@murumets-ee/create 0.1.13 → 0.1.15

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.mjs ADDED
@@ -0,0 +1,3481 @@
1
+ #!/usr/bin/env node
2
+ import*as e from"@clack/prompts";import t from"picocolors";import{dirname as n,join as r}from"node:path";import{execSync as i}from"node:child_process";import{appendFile as a,mkdir as o,readFile as s,rm as c,writeFile as l}from"node:fs/promises";var u=Object.defineProperty,d=(e,t)=>{let n={};for(var r in e)u(n,r,{get:e[r],enumerable:!0});return t||u(n,Symbol.toStringTag,{value:`Module`}),n};function f(e){let t={},n=e.slice(2);for(let e=0;e<n.length;e++){let r=n[e];if(r===`--mode`&&n[e+1]){let r=n[++e];(r===`single`||r===`multi`)&&(t.mode=r)}else r===`--install`?t.install=!0:r===`--no-install`?t.install=!1:!r.startsWith(`-`)&&!t.name&&(t.name=r)}return t}async function p(t){let n=f(process.argv),r=n.name??t;if(r&&n.mode&&n.install!==void 0)return{name:r,mode:n.mode,installDeps:n.install};e.intro(`@murumets-ee/create`);let i=r??await e.text({message:`Project name:`,placeholder:`my-project`,validate:e=>{if(!e)return`Project name is required`;if(!/^[a-z0-9-]+$/.test(e))return`Use lowercase letters, numbers, and hyphens only`}});if(e.isCancel(i))return e.cancel(`Cancelled.`),null;let a=n.mode??await e.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(e.isCancel(a))return e.cancel(`Cancelled.`),null;let o=n.install??await e.confirm({message:`Install dependencies?`,initialValue:!0});return e.isCancel(o)?(e.cancel(`Cancelled.`),null):{name:i,mode:a,installDeps:o}}var m=d({appendToFile:()=>y,deleteFiles:()=>_,mergePackageJson:()=>g,runCommand:()=>v,writeFile:()=>h});async function h(e,t){await o(n(e),{recursive:!0}),await l(e,t,`utf-8`)}async function g(e,t){let n=await s(e,`utf-8`),r=JSON.parse(n);t.type&&(r.type=t.type),t.dependencies&&(r.dependencies={...r.dependencies,...t.dependencies}),t.devDependencies&&(r.devDependencies={...r.devDependencies,...t.devDependencies}),t.scripts&&(r.scripts={...r.scripts,...t.scripts}),await l(e,`${JSON.stringify(r,null,2)}\n`,`utf-8`)}async function _(e,t){for(let n of t)await c(r(e,n),{force:!0,recursive:!0})}function v(e,t){return i(e,{cwd:t?.cwd,stdio:`pipe`,encoding:`utf-8`})}async function y(e,t){await a(e,t,`utf-8`)}async function b(e,t){await h(r(e,`lib/admin-config.ts`),`import { Media } from '@murumets-ee/media'
3
+ import {
4
+ Ticket,
5
+ TicketMessage,
6
+ TicketAttachment,
7
+ Department,
8
+ TicketTag,
9
+ } from '@murumets-ee/ticketing'
10
+ import { Article, Category } from '@/entities'
11
+
12
+ /** All entities available in the admin (drives sidebar nav + CRUD) */
13
+ export const allEntities = [
14
+ Article, Media, Category,
15
+ Ticket, TicketMessage, TicketAttachment, Department, TicketTag,
16
+ ]
17
+
18
+ /** Taxonomy entities keyed by name */
19
+ export const taxonomyVocabularies = {
20
+ category: Category,
21
+ department: Department,
22
+ ticket_tag: TicketTag,
23
+ }
24
+
25
+ /** Entities exposed via generic CRUD API handler */
26
+ export const crudEntities = [Article, Media, Ticket, TicketMessage, TicketAttachment]
27
+
28
+ /** Plugin resources for the permission catalog */
29
+ export const pluginResources = [
30
+ { resource: 'storage', actions: ['view', 'create', 'update', 'delete'] },
31
+ { resource: 'settings', actions: ['view', 'update'] },
32
+ { resource: 'audit-logs', actions: ['view'] },
33
+ { resource: 'permissions', actions: ['view', 'create', 'update', 'delete'] },
34
+ { resource: 'ticketing', actions: ['view', 'create', 'update', 'delete'] },
35
+ ]
36
+ `),await h(r(e,`lib/content-locale.ts`),`import { cookies } from 'next/headers'
37
+ import { hasLocale } from 'next-intl'
38
+ import { routing } from '@/i18n/routing'
39
+
40
+ /**
41
+ * Get the content editing locale from the cookie, falling back to the interface locale.
42
+ */
43
+ export async function getContentLocale(interfaceLocale: string): Promise<string> {
44
+ const jar = await cookies()
45
+ const raw = jar.get('content-locale')?.value
46
+ if (raw && hasLocale(routing.locales, raw)) return raw
47
+ return interfaceLocale
48
+ }
49
+ `),await h(r(e,`lib/with-admin-context.ts`),`import { getContentConfig } from '@murumets-ee/content/plugin'
50
+ import type { RequestContext } from '@murumets-ee/core'
51
+ import { runWithContextAsync } from '@murumets-ee/core'
52
+ import { getToolkitApp } from './app'
53
+ import { getContentLocale } from './content-locale'
54
+
55
+ export async function withAdminContext<T>(
56
+ interfaceLocale: string,
57
+ fn: (ctx: { locale: string; defaultLocale: string }) => Promise<T>,
58
+ ): Promise<T> {
59
+ const app = await getToolkitApp()
60
+ const contentLocale = await getContentLocale(interfaceLocale)
61
+ const { defaultLocale } = getContentConfig()
62
+
63
+ const context: RequestContext = {
64
+ locale: contentLocale,
65
+ defaultLocale,
66
+ app,
67
+ }
68
+
69
+ return runWithContextAsync(context, () => fn({ locale: contentLocale, defaultLocale }))
70
+ }
71
+ `),await h(r(e,`lib/load-roles.ts`),`import { buildInitialRoleDefinitions } from '@murumets-ee/auth'
72
+ import type { ToolkitApp } from '@murumets-ee/core'
73
+ import { createSettingsClient } from '@murumets-ee/settings'
74
+ import { permissionSettings } from '@/settings/permissions'
75
+
76
+ export async function loadRoles(
77
+ app: ToolkitApp,
78
+ ): Promise<Record<string, Record<string, string[]>>> {
79
+ const client = createSettingsClient(permissionSettings, { app })
80
+ const saved = await client.get('roles')
81
+
82
+ if (saved) return saved
83
+
84
+ // First run — seed built-in roles with zero permissions
85
+ const initial = buildInitialRoleDefinitions()
86
+ await client.set('roles', initial)
87
+ return initial
88
+ }
89
+ `),await h(r(e,`settings/permissions.ts`),`import { defineSettings, setting } from '@murumets-ee/settings'
90
+
91
+ export const permissionSettings = defineSettings({
92
+ namespace: 'permissions',
93
+ scope: 'global',
94
+ label: 'Permissions',
95
+ schema: {
96
+ roles: setting.json<Record<string, Record<string, string[]>>>(),
97
+ },
98
+ })
99
+ `),await h(r(e,`settings/site.ts`),`import { defineSettings, setting } from '@murumets-ee/settings'
100
+
101
+ export const siteSettings = defineSettings({
102
+ namespace: 'site',
103
+ scope: 'global',
104
+ label: 'Site Settings',
105
+ schema: {
106
+ siteName: setting.text({ default: 'My Site', label: 'Site Name', translatable: true }),
107
+ siteDescription: setting.text({ label: 'Site Description', translatable: true }),
108
+ maintenanceMode: setting.boolean({ default: false, label: 'Maintenance Mode' }),
109
+ postsPerPage: setting.number({
110
+ default: 10,
111
+ min: 1,
112
+ max: 100,
113
+ integer: true,
114
+ label: 'Posts Per Page',
115
+ }),
116
+ },
117
+ })
118
+ `),await h(r(e,`app/admin-layout.tsx`),`'use client'
119
+
120
+ import type { LinkComponent, SidebarNavGroup } from '@murumets-ee/admin-ui'
121
+ import { AdminShell } from '@murumets-ee/admin-ui'
122
+ import type { AdminNavGroup } from '@murumets-ee/admin-ui/server'
123
+ import {
124
+ Activity,
125
+ Building2,
126
+ FileText,
127
+ FolderTree,
128
+ Home,
129
+ Image,
130
+ ImageDown,
131
+ Inbox,
132
+ Kanban,
133
+ Lock,
134
+ PenTool,
135
+ ShieldCheck,
136
+ Tag,
137
+ Tags,
138
+ Ticket,
139
+ Users,
140
+ type LucideIcon,
141
+ } from 'lucide-react'
142
+ import type { ReactNode } from 'react'
143
+ import { Link, usePathname } from '@/i18n/navigation'
144
+
145
+ /** Map from icon name strings (from entity admin config) to Lucide components */
146
+ const ICON_MAP: Record<string, LucideIcon> = {
147
+ 'file-text': FileText,
148
+ 'folder-tree': FolderTree,
149
+ tags: Tags,
150
+ ticket: Ticket,
151
+ }
152
+
153
+ interface AdminLayoutProps {
154
+ children: ReactNode
155
+ defaultOpen?: boolean
156
+ headerActions?: ReactNode
157
+ sidebarFooter?: ReactNode
158
+ entityNavGroups?: AdminNavGroup[]
159
+ }
160
+
161
+ export function AdminLayout({
162
+ children,
163
+ defaultOpen,
164
+ headerActions,
165
+ sidebarFooter,
166
+ entityNavGroups,
167
+ }: AdminLayoutProps) {
168
+ const pathname = usePathname()
169
+
170
+ const dynamicGroups: SidebarNavGroup[] = (entityNavGroups ?? []).map((group) => ({
171
+ label: group.label,
172
+ items: group.items.map((item) => ({
173
+ label: item.label,
174
+ href: item.href,
175
+ icon: item.iconName ? ICON_MAP[item.iconName] : undefined,
176
+ })),
177
+ }))
178
+
179
+ const staticBefore: SidebarNavGroup = {
180
+ items: [
181
+ { label: 'Home', href: '/', icon: Home },
182
+ { label: 'Admin', href: '/admin', icon: PenTool },
183
+ ],
184
+ }
185
+
186
+ const supportGroup: SidebarNavGroup = {
187
+ label: 'Support',
188
+ items: [
189
+ { label: 'Tickets', href: '/admin/tickets', icon: Inbox },
190
+ { label: 'Board', href: '/admin/tickets/board', icon: Kanban },
191
+ { label: 'Departments', href: '/admin/departments', icon: Building2 },
192
+ { label: 'Ticket Tags', href: '/admin/ticket-tags', icon: Tag },
193
+ ],
194
+ }
195
+
196
+ const systemGroup: SidebarNavGroup = {
197
+ label: 'System',
198
+ items: [
199
+ { label: 'Users', href: '/admin/users', icon: Users },
200
+ { label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
201
+ { label: 'Permissions', href: '/admin/permissions', icon: Lock },
202
+ { label: 'Media', href: '/admin/media', icon: Image },
203
+ { label: 'Image Styles', href: '/admin/media/image-styles', icon: ImageDown },
204
+ { label: 'Activity', href: '/admin/activity', icon: Activity },
205
+ ],
206
+ }
207
+
208
+ const navGroups: SidebarNavGroup[] = [
209
+ staticBefore,
210
+ ...dynamicGroups,
211
+ supportGroup,
212
+ systemGroup,
213
+ ]
214
+
215
+ return (
216
+ <AdminShell
217
+ defaultOpen={defaultOpen}
218
+ sidebar={{
219
+ navGroups,
220
+ pathname,
221
+ Link: Link as unknown as LinkComponent,
222
+ logo: (
223
+ <div className="flex h-8 items-center gap-2 px-2">
224
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 truncate">
225
+ Admin
226
+ </span>
227
+ </div>
228
+ ),
229
+ footer: sidebarFooter,
230
+ }}
231
+ header={{
232
+ actions: headerActions,
233
+ }}
234
+ >
235
+ {children}
236
+ </AdminShell>
237
+ )
238
+ }
239
+ `),await h(r(e,`app/[locale]/(shell)/layout.tsx`),`import { buildAdminNav } from '@murumets-ee/admin-ui/server'
240
+ import { cookies, headers } from 'next/headers'
241
+ import { redirect } from 'next/navigation'
242
+ import { Suspense } from 'react'
243
+ import { allEntities } from '@/lib/admin-config'
244
+ import { auth } from '@/lib/auth'
245
+ import { AdminLayout } from '../../admin-layout'
246
+ import { ThemeToggle } from '../../theme-toggle'
247
+
248
+ async function DynamicShell({
249
+ children,
250
+ interfaceLocale,
251
+ }: {
252
+ children: React.ReactNode
253
+ interfaceLocale: string
254
+ }) {
255
+ const h = await headers()
256
+ const session = await auth.api.getSession({ headers: h })
257
+ if (!session?.user) {
258
+ redirect(\`/\${interfaceLocale}/auth/sign-in\`)
259
+ }
260
+
261
+ const jar = await cookies()
262
+ const sidebarOpen = jar.get('sidebar:state')?.value !== 'false'
263
+ const entityNavGroups = buildAdminNav(allEntities, { basePath: '/admin' })
264
+
265
+ return (
266
+ <AdminLayout
267
+ defaultOpen={sidebarOpen}
268
+ entityNavGroups={entityNavGroups}
269
+ sidebarFooter={
270
+ <div className="flex justify-center gap-2">
271
+ <ThemeToggle />
272
+ </div>
273
+ }
274
+ >
275
+ {children}
276
+ </AdminLayout>
277
+ )
278
+ }
279
+
280
+ export default async function ShellLayout({
281
+ children,
282
+ params,
283
+ }: {
284
+ children: React.ReactNode
285
+ params: Promise<{ locale: string }>
286
+ }) {
287
+ const { locale } = await params
288
+
289
+ return (
290
+ <Suspense>
291
+ <DynamicShell interfaceLocale={locale}>{children}</DynamicShell>
292
+ </Suspense>
293
+ )
294
+ }
295
+ `),await h(r(e,`app/[locale]/(shell)/admin/layout.tsx`),`import { headers } from 'next/headers'
296
+ import { redirect } from 'next/navigation'
297
+ import { auth } from '@/lib/auth'
298
+
299
+ const ALLOWED_ROLES = new Set(['admin', 'editor'])
300
+
301
+ export default async function AdminGuardLayout({
302
+ children,
303
+ params,
304
+ }: {
305
+ children: React.ReactNode
306
+ params: Promise<{ locale: string }>
307
+ }) {
308
+ const { locale } = await params
309
+ const h = await headers()
310
+ const session = await auth.api.getSession({ headers: h })
311
+
312
+ if (!session?.user) {
313
+ redirect(\`/\${locale}/auth/sign-in\`)
314
+ }
315
+
316
+ const role = (session.user as Record<string, unknown>).role as string | undefined
317
+ if (!role || !ALLOWED_ROLES.has(role)) {
318
+ redirect(\`/\${locale}\`)
319
+ }
320
+
321
+ return <>{children}</>
322
+ }
323
+ `),await h(r(e,`app/[locale]/(shell)/admin/page.tsx`),`import { setRequestLocale } from 'next-intl/server'
324
+
325
+ export default async function AdminDashboard({ params }: { params: Promise<{ locale: string }> }) {
326
+ const { locale } = await params
327
+ setRequestLocale(locale)
328
+
329
+ return (
330
+ <div className="p-6">
331
+ <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
332
+ <p className="text-muted-foreground">
333
+ Welcome to the admin panel. Use the sidebar to manage your content.
334
+ </p>
335
+ </div>
336
+ )
337
+ }
338
+ `),await h(r(e,`app/[locale]/(shell)/admin/[entity]/page.tsx`),`import { EntityList } from '@murumets-ee/admin-ui/entity-list'
339
+ import {
340
+ entityNameToSlug,
341
+ fetchEntityList,
342
+ getEntityLabel,
343
+ resolveEntityFromSlug,
344
+ toEntityMeta,
345
+ } from '@murumets-ee/admin-ui/server'
346
+ import { getContentConfig } from '@murumets-ee/content/plugin'
347
+ import { notFound } from 'next/navigation'
348
+ import { setRequestLocale } from 'next-intl/server'
349
+ import { allEntities, taxonomyVocabularies } from '@/lib/admin-config'
350
+ import { withAdminContext } from '@/lib/with-admin-context'
351
+
352
+ interface EntityListPageProps {
353
+ params: Promise<{ locale: string; entity: string }>
354
+ }
355
+
356
+ export default async function EntityListPage({ params }: EntityListPageProps) {
357
+ const { locale, entity: entitySlug } = await params
358
+ setRequestLocale(locale)
359
+
360
+ const entity = resolveEntityFromSlug(entitySlug, allEntities)
361
+ if (!entity) notFound()
362
+
363
+ const admin = entity.admin
364
+ const meta = toEntityMeta(entity)
365
+ const urlSlug = entityNameToSlug(entity.name)
366
+ const hasTranslatable = Object.values(entity.allFields).some((f) => f.translatable)
367
+ const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable')
368
+ const isTaxonomy = entity.name in taxonomyVocabularies
369
+
370
+ return withAdminContext(locale, async ({ locale: contentLocale, defaultLocale }) => {
371
+ let locales: Array<{ code: string; label: string }> | undefined
372
+ if (hasTranslatable) {
373
+ try {
374
+ const config = getContentConfig()
375
+ locales = config.locales
376
+ } catch {
377
+ // content plugin not available
378
+ }
379
+ }
380
+
381
+ const initialData = await fetchEntityList(entity, {
382
+ sortField: admin?.defaultSort ?? 'createdAt',
383
+ sortDirection: admin?.defaultSortDirection ?? 'desc',
384
+ limit: admin?.pageSize ?? 20,
385
+ locale: contentLocale,
386
+ includeTranslationStatus: hasTranslatable,
387
+ })
388
+
389
+ return (
390
+ <div className="p-6">
391
+ <div className="mb-6">
392
+ <h1 className="text-2xl font-bold">{getEntityLabel(entity)}</h1>
393
+ {admin?.description && (
394
+ <p className="text-sm text-muted-foreground">{admin.description}</p>
395
+ )}
396
+ </div>
397
+ <EntityList
398
+ entity={meta}
399
+ allowDelete
400
+ allowStatusToggle={isPublishable}
401
+ {...(isTaxonomy ? { apiBasePath: '/api/admin/taxonomy', entityPath: entity.name } : {})}
402
+ defaultSort={admin?.defaultSort ?? 'createdAt'}
403
+ defaultSortDirection={admin?.defaultSortDirection ?? 'desc'}
404
+ hiddenColumns={admin?.hiddenColumns}
405
+ pageSize={admin?.pageSize}
406
+ locale={contentLocale}
407
+ locales={locales}
408
+ defaultLocale={defaultLocale}
409
+ showTranslationStatus={hasTranslatable && !!locales}
410
+ searchPlaceholder={\`Search \${getEntityLabel(entity).toLowerCase()}...\`}
411
+ editHref={\`/\${locale}/admin/\${urlSlug}/:id\`}
412
+ createHref={admin?.disableCreate ? undefined : \`/\${locale}/admin/\${urlSlug}/new\`}
413
+ initialData={initialData}
414
+ />
415
+ </div>
416
+ )
417
+ })
418
+ }
419
+ `),await h(r(e,`app/[locale]/(shell)/admin/[entity]/[id]/page.tsx`),`import {
420
+ entityNameToSlug,
421
+ fetchReferenceOptions,
422
+ getBlocksFieldName,
423
+ getEntityLabelSingular,
424
+ getReferenceFields,
425
+ inferRootFields,
426
+ resolveEntityFromSlug,
427
+ toEntityMeta,
428
+ } from '@murumets-ee/admin-ui/server'
429
+ import { buildPermissionChecker } from '@murumets-ee/auth'
430
+ import { ContentClient } from '@murumets-ee/content/client'
431
+ import { LockService } from '@murumets-ee/content/lock'
432
+ import { createAdminClient } from '@murumets-ee/core/clients'
433
+ import { getApp } from '@murumets-ee/core'
434
+ import { prepareBlockEditor } from '@murumets-ee/editor/server'
435
+ import { headers } from 'next/headers'
436
+ import { notFound } from 'next/navigation'
437
+ import { setRequestLocale } from 'next-intl/server'
438
+ import { GenericBlockEditor } from '@murumets-ee/admin-ui/content-editor'
439
+ import { GenericEntityForm } from '@murumets-ee/admin-ui/entity-form'
440
+ import { allEntities } from '@/lib/admin-config'
441
+ import { auth } from '@/lib/auth'
442
+ import { loadRoles } from '@/lib/load-roles'
443
+ import { withAdminContext } from '@/lib/with-admin-context'
444
+
445
+ interface EditEntityPageProps {
446
+ params: Promise<{ locale: string; entity: string; id: string }>
447
+ }
448
+
449
+ export default async function EditEntityPage({ params }: EditEntityPageProps) {
450
+ const { locale, entity: entitySlug, id } = await params
451
+ setRequestLocale(locale)
452
+
453
+ const entity = resolveEntityFromSlug(entitySlug, allEntities)
454
+ if (!entity) notFound()
455
+
456
+ const blocksField = getBlocksFieldName(entity)
457
+ const urlSlug = entityNameToSlug(entity.name)
458
+
459
+ return withAdminContext(locale, async ({ locale: contentLocale, defaultLocale }) => {
460
+ const client = createAdminClient(entity)
461
+ const isDefaultLocale = contentLocale === defaultLocale
462
+ const isNonDefaultLocale = !isDefaultLocale
463
+
464
+ const isVersionable = entity.behaviors?.some((b) => b.name === 'versionable') ?? false
465
+ const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable') ?? false
466
+
467
+ let draft: { data: Record<string, unknown>; createdBy: string; createdByName: string | null; updatedAt: Date | string } | undefined
468
+ let canPublish = false
469
+ let lockResult: { acquired: boolean; lock?: { lockedBy: string; lockedByName: string | null; lockedAt: Date | string; expiresAt: Date | string } | null } | undefined
470
+ const enableLocking = isPublishable
471
+
472
+ if (isPublishable) {
473
+ try {
474
+ const app = getApp()
475
+ const contentClient = new ContentClient({ entity, db: app.db.readWrite })
476
+ const lockLocale = isDefaultLocale ? '_' : contentLocale
477
+
478
+ const draftEntry = await contentClient.getDraft(id, lockLocale)
479
+ if (draftEntry) {
480
+ draft = {
481
+ data: draftEntry.data,
482
+ createdBy: draftEntry.createdBy,
483
+ createdByName: draftEntry.createdByName,
484
+ updatedAt: draftEntry.updatedAt,
485
+ }
486
+ }
487
+
488
+ const session = await auth.api.getSession({ headers: await headers() })
489
+ if (session?.user) {
490
+ const role = (session.user as Record<string, unknown>).role as string | undefined
491
+ if (role) {
492
+ const roles = await loadRoles(app)
493
+ const checker = buildPermissionChecker(roles)
494
+ canPublish = checker(role, entity.name, 'publish')
495
+ }
496
+
497
+ const lockService = new LockService({ db: app.db.readWrite })
498
+ const result = await lockService.acquireLock(
499
+ entity.name, id, lockLocale,
500
+ { id: session.user.id, name: session.user.name ?? undefined },
501
+ )
502
+ if (result.acquired) {
503
+ lockResult = { acquired: true }
504
+ } else {
505
+ lockResult = { acquired: false, lock: result.lock }
506
+ }
507
+ }
508
+ } catch {
509
+ // Draft/permission/lock errors — proceed without
510
+ }
511
+ }
512
+
513
+ // Block editor entity
514
+ if (blocksField) {
515
+ const record = isDefaultLocale
516
+ ? await client.findById(id, { defaultLocale })
517
+ : await client.findById(id, { locale: contentLocale, defaultLocale })
518
+ if (!record) notFound()
519
+
520
+ const rootFields = inferRootFields(entity, blocksField)
521
+ const { blocks, initialData, mediaUrls, rootData, rootFieldDefs } =
522
+ await prepareBlockEditor(
523
+ entity as Parameters<typeof prepareBlockEditor>[0],
524
+ blocksField,
525
+ record,
526
+ { rootFields },
527
+ )
528
+
529
+ return (
530
+ <div className="flex h-[calc(100vh-3.5rem)] flex-col">
531
+ <div className="flex items-center gap-3 border-b px-6 py-3">
532
+ <h1 className="text-lg font-semibold">
533
+ {(record.title as string) ?? \`Untitled \${getEntityLabelSingular(entity)}\`}
534
+ </h1>
535
+ <span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
536
+ {contentLocale}
537
+ </span>
538
+ </div>
539
+ <div className="flex-1 overflow-hidden">
540
+ <GenericBlockEditor
541
+ key={contentLocale}
542
+ entityId={id}
543
+ entityName={entity.name}
544
+ blocksField={blocksField}
545
+ blocks={blocks}
546
+ initialData={initialData}
547
+ mediaUrls={mediaUrls}
548
+ rootData={rootData}
549
+ rootFieldDefs={rootFieldDefs}
550
+ saveLabel="Save"
551
+ locale={contentLocale}
552
+ defaultLocale={defaultLocale}
553
+ versionable={isVersionable}
554
+ isPublishable={isPublishable}
555
+ draft={draft}
556
+ canPublish={canPublish}
557
+ enableLocking={enableLocking}
558
+ lockResult={lockResult}
559
+ />
560
+ </div>
561
+ </div>
562
+ )
563
+ }
564
+
565
+ // Form entity
566
+ const [record, defaultLocaleData] = await Promise.all([
567
+ client.findById(id, { locale: contentLocale }),
568
+ isNonDefaultLocale ? client.findById(id) : Promise.resolve(null),
569
+ ])
570
+ if (!record) notFound()
571
+
572
+ const refFields = getReferenceFields(entity)
573
+ const referenceOptions: Record<string, Array<{ id: string; label: string }>> = {}
574
+
575
+ if (refFields.length > 0) {
576
+ const optionPromises = refFields.map(async ({ fieldName, entityName }) => {
577
+ const refEntity = allEntities.find((e) => e.name === entityName)
578
+ if (!refEntity) return { fieldName, options: [] }
579
+ const options = await fetchReferenceOptions(refEntity, { locale: contentLocale })
580
+ return { fieldName, options }
581
+ })
582
+ const results = await Promise.all(optionPromises)
583
+ for (const { fieldName, options } of results) {
584
+ referenceOptions[fieldName] = options
585
+ }
586
+ }
587
+
588
+ return (
589
+ <div className="p-6">
590
+ <div className="mb-6">
591
+ <h1 className="text-2xl font-bold">Edit {getEntityLabelSingular(entity)}</h1>
592
+ </div>
593
+ <GenericEntityForm
594
+ entity={toEntityMeta(entity)}
595
+ id={id}
596
+ locale={contentLocale}
597
+ defaultLocale={defaultLocale}
598
+ defaultLocaleData={
599
+ isNonDefaultLocale && defaultLocaleData
600
+ ? (defaultLocaleData as Record<string, unknown>)
601
+ : undefined
602
+ }
603
+ initialData={record as Record<string, unknown>}
604
+ referenceOptions={referenceOptions}
605
+ listUrl={\`/\${locale}/admin/\${urlSlug}\`}
606
+ versionable={isVersionable}
607
+ draft={draft}
608
+ canPublish={canPublish}
609
+ enableLocking={enableLocking}
610
+ lockResult={lockResult}
611
+ />
612
+ </div>
613
+ )
614
+ })
615
+ }
616
+ `),await h(r(e,`app/[locale]/(shell)/admin/[entity]/new/page.tsx`),`import {
617
+ entityNameToSlug,
618
+ fetchReferenceOptions,
619
+ getBlocksFieldName,
620
+ getEntityLabelSingular,
621
+ getReferenceFields,
622
+ resolveEntityFromSlug,
623
+ toEntityMeta,
624
+ } from '@murumets-ee/admin-ui/server'
625
+ import { createAdminClient } from '@murumets-ee/core/clients'
626
+ import { notFound, redirect } from 'next/navigation'
627
+ import { setRequestLocale } from 'next-intl/server'
628
+ import { GenericEntityForm } from '@murumets-ee/admin-ui/entity-form'
629
+ import { allEntities } from '@/lib/admin-config'
630
+ import { withAdminContext } from '@/lib/with-admin-context'
631
+
632
+ interface NewEntityPageProps {
633
+ params: Promise<{ locale: string; entity: string }>
634
+ }
635
+
636
+ export default async function NewEntityPage({ params }: NewEntityPageProps) {
637
+ const { locale, entity: entitySlug } = await params
638
+ setRequestLocale(locale)
639
+
640
+ const entity = resolveEntityFromSlug(entitySlug, allEntities)
641
+ if (!entity) notFound()
642
+ if (entity.admin?.disableCreate) notFound()
643
+
644
+ const blocksField = getBlocksFieldName(entity)
645
+ const urlSlug = entityNameToSlug(entity.name)
646
+
647
+ return withAdminContext(locale, async ({ locale: contentLocale }) => {
648
+ // Block editor entities: create a draft and redirect to the edit page
649
+ if (blocksField) {
650
+ const client = createAdminClient(entity)
651
+ const draft = await client.create({ title: 'Untitled' })
652
+ const newId = (draft as Record<string, unknown>).id as string
653
+ redirect(\`/\${locale}/admin/\${urlSlug}/\${newId}\`)
654
+ }
655
+
656
+ // Form entities: render the create form
657
+ const refFields = getReferenceFields(entity)
658
+ const referenceOptions: Record<string, Array<{ id: string; label: string }>> = {}
659
+
660
+ if (refFields.length > 0) {
661
+ const optionPromises = refFields.map(async ({ fieldName, entityName }) => {
662
+ const refEntity = allEntities.find((e) => e.name === entityName)
663
+ if (!refEntity) return { fieldName, options: [] }
664
+ const options = await fetchReferenceOptions(refEntity, { locale: contentLocale })
665
+ return { fieldName, options }
666
+ })
667
+ const results = await Promise.all(optionPromises)
668
+ for (const { fieldName, options } of results) {
669
+ referenceOptions[fieldName] = options
670
+ }
671
+ }
672
+
673
+ return (
674
+ <div className="p-6">
675
+ <div className="mb-6">
676
+ <h1 className="text-2xl font-bold">New {getEntityLabelSingular(entity)}</h1>
677
+ </div>
678
+ <GenericEntityForm
679
+ entity={toEntityMeta(entity)}
680
+ locale={contentLocale}
681
+ referenceOptions={referenceOptions}
682
+ listUrl={\`/\${locale}/admin/\${urlSlug}\`}
683
+ />
684
+ </div>
685
+ )
686
+ })
687
+ }
688
+ `),await h(r(e,`app/[locale]/(shell)/admin/media/page.tsx`),`import { EntityList } from '@murumets-ee/admin-ui/entity-list'
689
+ import { fetchEntityList, toEntityMeta } from '@murumets-ee/admin-ui/server'
690
+ import { Media } from '@murumets-ee/media'
691
+ import { getMediaClient } from '@murumets-ee/media/client'
692
+ import { setRequestLocale } from 'next-intl/server'
693
+
694
+ export default async function MediaListPage({ params }: { params: Promise<{ locale: string }> }) {
695
+ const { locale } = await params
696
+ setRequestLocale(locale)
697
+ const initialData = await fetchEntityList(Media, { sortField: 'createdAt' })
698
+
699
+ const mediaClient = await getMediaClient()
700
+ const ids = initialData.items.map((i) => i.id as string)
701
+ const thumbMap = await mediaClient.getVariantUrls(ids, 'thumbnail')
702
+ for (const item of initialData.items) {
703
+ item.thumbnailUrl = thumbMap.get(item.id as string) ?? ''
704
+ }
705
+
706
+ return (
707
+ <div className="p-6">
708
+ <div className="mb-6">
709
+ <h1 className="text-2xl font-bold">Media</h1>
710
+ <p className="text-sm text-muted-foreground">
711
+ Manage uploaded media files.
712
+ </p>
713
+ </div>
714
+ <EntityList
715
+ entity={toEntityMeta(Media)}
716
+ entityPath="media"
717
+ image={{ field: 'thumbnailUrl', size: 'sm', shape: 'rounded' }}
718
+ allowDelete
719
+ defaultSort="createdAt"
720
+ searchPlaceholder="Search media..."
721
+ editHref={\`/\${locale}/admin/media/:id\`}
722
+ initialData={initialData}
723
+ />
724
+ </div>
725
+ )
726
+ }
727
+ `),await h(r(e,`app/[locale]/(shell)/admin/media/[id]/page.tsx`),`import { toEntityMeta } from '@murumets-ee/admin-ui/server'
728
+ import { getCurrentApp } from '@murumets-ee/core'
729
+ import { createAdminClient } from '@murumets-ee/core/clients'
730
+ import { Media } from '@murumets-ee/media'
731
+ import { findMediaUsages } from '@murumets-ee/media/usage'
732
+ import { notFound } from 'next/navigation'
733
+ import { setRequestLocale } from 'next-intl/server'
734
+ import { withAdminContext } from '@/lib/with-admin-context'
735
+ import { MediaForm } from './media-form'
736
+ import { MediaUsagePanel } from './media-usage-panel'
737
+
738
+ interface MediaEditPageProps {
739
+ params: Promise<{ locale: string; id: string }>
740
+ }
741
+
742
+ export default async function MediaEditPage({ params }: MediaEditPageProps) {
743
+ const { locale, id } = await params
744
+ setRequestLocale(locale)
745
+
746
+ return withAdminContext(locale, async ({ locale: contentLocale }) => {
747
+ const app = getCurrentApp()!
748
+ const client = createAdminClient(Media)
749
+ const [mediaItem, usages] = await Promise.all([
750
+ client.findById(id, { locale: contentLocale }),
751
+ findMediaUsages(id, app.db.readWrite),
752
+ ])
753
+ if (!mediaItem) notFound()
754
+
755
+ return (
756
+ <div className="p-6">
757
+ <div className="mb-6">
758
+ <h1 className="text-2xl font-bold">Edit Media</h1>
759
+ </div>
760
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
761
+ <div className="lg:col-span-2">
762
+ <MediaForm
763
+ entity={toEntityMeta(Media)}
764
+ id={id}
765
+ locale={contentLocale}
766
+ initialData={mediaItem as Record<string, unknown>}
767
+ />
768
+ </div>
769
+ <div>
770
+ <MediaUsagePanel usages={usages} locale={locale} />
771
+ </div>
772
+ </div>
773
+ </div>
774
+ )
775
+ })
776
+ }
777
+ `),await h(r(e,`app/[locale]/(shell)/admin/media/[id]/media-form.tsx`),`'use client'
778
+
779
+ import { EntityForm } from '@murumets-ee/admin-ui/entity-form'
780
+ import type { EntityMeta } from '@murumets-ee/admin-ui/entity-list'
781
+ import { useRouter } from 'next/navigation'
782
+
783
+ interface MediaFormProps {
784
+ entity: EntityMeta
785
+ id: string
786
+ locale?: string
787
+ initialData?: Record<string, unknown>
788
+ }
789
+
790
+ export function MediaForm({ entity, id, locale, initialData }: MediaFormProps) {
791
+ const router = useRouter()
792
+
793
+ return (
794
+ <EntityForm
795
+ entity={entity}
796
+ id={id}
797
+ locale={locale}
798
+ initialData={initialData}
799
+ entityPath="media"
800
+ layout="two-column"
801
+ fields={['title', 'alt', 'description', 'filename', 'mimeType', 'size', 'mediaType']}
802
+ fieldOverrides={{
803
+ filename: { readOnly: true, description: 'Original upload filename (read-only)' },
804
+ mimeType: { readOnly: true, label: 'MIME Type' },
805
+ size: { readOnly: true, description: 'File size in bytes' },
806
+ mediaType: { readOnly: true, label: 'Media Type' },
807
+ alt: { label: 'Alt Text', description: 'Alternative text for accessibility' },
808
+ }}
809
+ onSuccess={() => router.push(\`/\${locale}/admin/media\`)}
810
+ onCancel={() => router.back()}
811
+ />
812
+ )
813
+ }
814
+ `),await h(r(e,`app/[locale]/(shell)/admin/media/[id]/media-usage-panel.tsx`),`import type { MediaUsage } from '@murumets-ee/media/usage'
815
+
816
+ interface MediaUsagePanelProps {
817
+ usages: MediaUsage[]
818
+ locale: string
819
+ }
820
+
821
+ export function MediaUsagePanel({ usages, locale }: MediaUsagePanelProps) {
822
+ return (
823
+ <div className="rounded-lg border bg-card p-4">
824
+ <h3 className="mb-3 text-sm font-semibold">Used by</h3>
825
+
826
+ {usages.length === 0 && (
827
+ <p className="text-sm text-muted-foreground">
828
+ This media item is not referenced by any entity.
829
+ </p>
830
+ )}
831
+
832
+ {usages.length > 0 && (
833
+ <ul className="space-y-2">
834
+ {usages.map((usage) => {
835
+ const entitySlug = \`\${usage.entityName}s\`
836
+ const href = \`/\${locale}/admin/\${entitySlug}/\${usage.entityId}\`
837
+
838
+ return (
839
+ <li key={\`\${usage.entityName}-\${usage.entityId}-\${usage.fieldName}\`}>
840
+ <a
841
+ href={href}
842
+ className="block rounded-md border px-3 py-2 text-sm hover:bg-muted transition-colors"
843
+ >
844
+ <span className="font-medium capitalize">{usage.entityName}</span>
845
+ <span className="mx-1.5 text-muted-foreground">&middot;</span>
846
+ <span className="text-muted-foreground">{usage.fieldName}</span>
847
+ {usage.context === 'block' && (
848
+ <span className="ml-1.5 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
849
+ block
850
+ </span>
851
+ )}
852
+ </a>
853
+ </li>
854
+ )
855
+ })}
856
+ </ul>
857
+ )}
858
+ </div>
859
+ )
860
+ }
861
+ `),await h(r(e,`app/[locale]/(shell)/admin/media/image-styles/page.tsx`),`import { imageStylesSettings } from '@murumets-ee/media'
862
+ import { ImageStylesManager } from '@murumets-ee/media/image-styles'
863
+ import { createSettingsClient } from '@murumets-ee/settings'
864
+ import { setRequestLocale } from 'next-intl/server'
865
+ import { getToolkitApp } from '@/lib/app'
866
+
867
+ async function loadImageStyles() {
868
+ const app = await getToolkitApp()
869
+ const client = createSettingsClient(imageStylesSettings, { app })
870
+ const styles = await client.get('imageStyles')
871
+ return styles ?? {}
872
+ }
873
+
874
+ export default async function ImageStylesPage({ params }: { params: Promise<{ locale: string }> }) {
875
+ const { locale } = await params
876
+ setRequestLocale(locale)
877
+
878
+ const initialStyles = await loadImageStyles()
879
+
880
+ return (
881
+ <div className="p-6 max-w-4xl">
882
+ <ImageStylesManager initialStyles={initialStyles} />
883
+ </div>
884
+ )
885
+ }
886
+ `),await h(r(e,`app/[locale]/(shell)/admin/users/page.tsx`),`import type { UsersInitialData } from '@murumets-ee/admin-ui/users'
887
+ import { getAuth } from '@murumets-ee/auth'
888
+ import { headers } from 'next/headers'
889
+ import { setRequestLocale } from 'next-intl/server'
890
+ import { getToolkitApp } from '@/lib/app'
891
+ import { UsersPage } from './users-page'
892
+
893
+ export default async function UsersAdminPage({ params }: { params: Promise<{ locale: string }> }) {
894
+ const { locale } = await params
895
+ setRequestLocale(locale)
896
+
897
+ await getToolkitApp()
898
+ const auth = getAuth()
899
+
900
+ const result = await auth.api.listUsers({
901
+ headers: await headers(),
902
+ query: { limit: 20, sortBy: 'createdAt', sortDirection: 'desc' },
903
+ })
904
+
905
+ const initialData: UsersInitialData = {
906
+ users: result.users.map((u) => ({
907
+ id: u.id,
908
+ name: u.name,
909
+ email: u.email,
910
+ emailVerified: u.emailVerified,
911
+ image: u.image ?? null,
912
+ createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : String(u.createdAt),
913
+ updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : String(u.updatedAt),
914
+ role: u.role ?? null,
915
+ banned: u.banned ?? null,
916
+ banReason: u.banReason ?? null,
917
+ banExpires:
918
+ u.banExpires instanceof Date
919
+ ? u.banExpires.toISOString()
920
+ : u.banExpires
921
+ ? String(u.banExpires)
922
+ : null,
923
+ })),
924
+ total: result.total,
925
+ }
926
+
927
+ return (
928
+ <div className="p-6">
929
+ <div className="mb-6">
930
+ <h1 className="text-2xl font-bold">Users</h1>
931
+ <p className="text-sm text-muted-foreground">Manage user accounts, roles, and access.</p>
932
+ </div>
933
+ <UsersPage initialData={initialData} />
934
+ </div>
935
+ )
936
+ }
937
+ `),await h(r(e,`app/[locale]/(shell)/admin/users/users-page.tsx`),`'use client'
938
+
939
+ import type { UsersInitialData } from '@murumets-ee/admin-ui/users'
940
+ import { UsersManagement } from '@murumets-ee/admin-ui/users'
941
+ import { createUsersApi } from '@murumets-ee/auth/client'
942
+ import { authClient } from '@/lib/auth-client'
943
+
944
+ const usersApi = createUsersApi(authClient)
945
+
946
+ export function UsersPage({ initialData }: { initialData: UsersInitialData }) {
947
+ const session = authClient.useSession()
948
+
949
+ return (
950
+ <UsersManagement
951
+ api={usersApi}
952
+ currentUserId={session.data?.user?.id}
953
+ roles={['admin', 'editor', 'viewer']}
954
+ initialData={initialData}
955
+ />
956
+ )
957
+ }
958
+ `),await h(r(e,`app/[locale]/(shell)/admin/permissions/page.tsx`),`import { PermissionsEditor } from '@murumets-ee/admin-ui/permissions'
959
+ import { BUILT_IN_ROLES, buildResourceCatalog } from '@murumets-ee/auth'
960
+ import { setRequestLocale } from 'next-intl/server'
961
+ import { allEntities, pluginResources } from '@/lib/admin-config'
962
+ import { getToolkitApp } from '@/lib/app'
963
+ import { loadRoles } from '@/lib/load-roles'
964
+
965
+ export default async function PermissionsPage({ params }: { params: Promise<{ locale: string }> }) {
966
+ const { locale } = await params
967
+ setRequestLocale(locale)
968
+
969
+ const app = await getToolkitApp()
970
+ const savedRoles = await loadRoles(app)
971
+ const statements = buildResourceCatalog(allEntities, pluginResources)
972
+
973
+ return (
974
+ <div className="p-6">
975
+ <div className="mb-6">
976
+ <h1 className="text-2xl font-bold">Permissions</h1>
977
+ <p className="text-sm text-muted-foreground">
978
+ Configure what each role can do. Admin always has full access.
979
+ </p>
980
+ </div>
981
+ <PermissionsEditor
982
+ statements={statements}
983
+ roles={Object.keys(savedRoles)}
984
+ builtInRoles={[...BUILT_IN_ROLES]}
985
+ initialPermissions={savedRoles}
986
+ />
987
+ </div>
988
+ )
989
+ }
990
+ `),await h(r(e,`app/[locale]/(shell)/admin/roles/page.tsx`),`import { RolesEditor } from '@murumets-ee/admin-ui/permissions'
991
+ import { BUILT_IN_ROLES } from '@murumets-ee/auth'
992
+ import { setRequestLocale } from 'next-intl/server'
993
+ import { getToolkitApp } from '@/lib/app'
994
+ import { loadRoles } from '@/lib/load-roles'
995
+
996
+ export default async function RolesPage({ params }: { params: Promise<{ locale: string }> }) {
997
+ const { locale } = await params
998
+ setRequestLocale(locale)
999
+
1000
+ const app = await getToolkitApp()
1001
+ const savedRoles = await loadRoles(app)
1002
+
1003
+ const roles = [
1004
+ { name: 'admin', builtIn: true, permissionCount: 0 },
1005
+ ...Object.entries(savedRoles).map(([name, perms]) => ({
1006
+ name,
1007
+ builtIn: (BUILT_IN_ROLES as readonly string[]).includes(name),
1008
+ permissionCount: Object.values(perms).flat().length,
1009
+ })),
1010
+ ]
1011
+
1012
+ return (
1013
+ <div className="p-6">
1014
+ <div className="mb-6">
1015
+ <h1 className="text-2xl font-bold">Roles</h1>
1016
+ <p className="text-sm text-muted-foreground">
1017
+ Manage user roles. Built-in roles cannot be deleted.
1018
+ </p>
1019
+ </div>
1020
+ <RolesEditor roles={roles} permissionsHref={\`/\${locale}/admin/permissions\`} />
1021
+ </div>
1022
+ )
1023
+ }
1024
+ `),await h(r(e,`app/[locale]/(shell)/admin/activity/page.tsx`),`import { AuditLog } from '@murumets-ee/admin-ui/audit-log'
1025
+ import { fetchAuditLogData } from '@murumets-ee/admin-ui/server'
1026
+ import { setRequestLocale } from 'next-intl/server'
1027
+ import { withAdminContext } from '@/lib/with-admin-context'
1028
+
1029
+ export default async function ActivityPage({ params }: { params: Promise<{ locale: string }> }) {
1030
+ const { locale } = await params
1031
+ setRequestLocale(locale)
1032
+
1033
+ return withAdminContext(locale, async () => {
1034
+ const initialData = await fetchAuditLogData()
1035
+
1036
+ return (
1037
+ <div className="p-6">
1038
+ <div className="mb-6">
1039
+ <h1 className="text-2xl font-bold">Activity Log</h1>
1040
+ <p className="text-sm text-muted-foreground">
1041
+ Track all content changes across your CMS.
1042
+ </p>
1043
+ </div>
1044
+ <AuditLog
1045
+ initialData={initialData}
1046
+ editHrefPattern={\`/\${locale}/admin/:entityType/:entityId\`}
1047
+ />
1048
+ </div>
1049
+ )
1050
+ })
1051
+ }
1052
+ `),await h(r(e,`app/[locale]/(shell)/admin/tickets/page.tsx`),`import { Ticket, Department, TicketTag } from '@murumets-ee/ticketing'
1053
+ import { headers } from 'next/headers'
1054
+ import { setRequestLocale } from 'next-intl/server'
1055
+ import { withAdminContext } from '@/lib/with-admin-context'
1056
+ import { auth } from '@/lib/auth'
1057
+ import { TicketsInboxClient } from './tickets-inbox-client'
1058
+ import { fetchEntityList } from '@murumets-ee/admin-ui/server'
1059
+ import type {
1060
+ TicketData,
1061
+ DepartmentData,
1062
+ TagData,
1063
+ } from '@murumets-ee/ticketing-ui'
1064
+
1065
+ interface TicketsPageProps {
1066
+ params: Promise<{ locale: string }>
1067
+ }
1068
+
1069
+ export default async function TicketsPage({ params }: TicketsPageProps) {
1070
+ const { locale } = await params
1071
+ setRequestLocale(locale)
1072
+
1073
+ const h = await headers()
1074
+ const session = await auth.api.getSession({ headers: h })
1075
+ const currentUserId = session?.user?.id ?? ''
1076
+
1077
+ return withAdminContext(locale, async () => {
1078
+ const ticketData = await fetchEntityList(Ticket, {
1079
+ sortField: 'lastReplyAt',
1080
+ sortDirection: 'desc',
1081
+ limit: 50,
1082
+ })
1083
+
1084
+ const departmentData = await fetchEntityList(Department, {
1085
+ sortField: 'name',
1086
+ sortDirection: 'asc',
1087
+ limit: 100,
1088
+ })
1089
+
1090
+ const tagData = await fetchEntityList(TicketTag, {
1091
+ sortField: 'name',
1092
+ sortDirection: 'asc',
1093
+ limit: 100,
1094
+ })
1095
+
1096
+ return (
1097
+ <TicketsInboxClient
1098
+ initialTickets={ticketData.items as unknown as TicketData[]}
1099
+ initialTotal={ticketData.total}
1100
+ initialDepartments={departmentData.items as unknown as DepartmentData[]}
1101
+ initialTags={tagData.items as unknown as TagData[]}
1102
+ currentUserId={currentUserId}
1103
+ locale={locale}
1104
+ />
1105
+ )
1106
+ })
1107
+ }
1108
+ `),await h(r(e,`app/[locale]/(shell)/admin/tickets/tickets-inbox-client.tsx`),`'use client'
1109
+
1110
+ import { QueryProvider } from '@murumets-ee/admin-ui'
1111
+ import { InboxProvider, TicketInbox } from '@murumets-ee/ticketing-ui/inbox'
1112
+ import type {
1113
+ TicketData,
1114
+ DepartmentData,
1115
+ TagData,
1116
+ } from '@murumets-ee/ticketing-ui'
1117
+
1118
+ interface TicketsInboxClientProps {
1119
+ initialTickets: TicketData[]
1120
+ initialTotal: number
1121
+ initialDepartments: DepartmentData[]
1122
+ initialTags: TagData[]
1123
+ currentUserId: string
1124
+ locale: string
1125
+ }
1126
+
1127
+ export function TicketsInboxClient({
1128
+ initialTickets,
1129
+ initialTotal,
1130
+ initialDepartments,
1131
+ initialTags,
1132
+ currentUserId,
1133
+ }: TicketsInboxClientProps) {
1134
+ return (
1135
+ <QueryProvider>
1136
+ <InboxProvider
1137
+ apiBasePath="/api/admin"
1138
+ initialTickets={initialTickets}
1139
+ initialTotal={initialTotal}
1140
+ initialDepartments={initialDepartments}
1141
+ initialTags={initialTags}
1142
+ currentUserId={currentUserId}
1143
+ >
1144
+ <div className="h-[calc(100vh-3.5rem)]">
1145
+ <TicketInbox currentUserId={currentUserId} />
1146
+ </div>
1147
+ </InboxProvider>
1148
+ </QueryProvider>
1149
+ )
1150
+ }
1151
+ `),await h(r(e,`app/[locale]/(shell)/admin/tickets/board/page.tsx`),`import { Ticket, Department } from '@murumets-ee/ticketing'
1152
+ import { setRequestLocale } from 'next-intl/server'
1153
+ import { withAdminContext } from '@/lib/with-admin-context'
1154
+ import { fetchEntityList } from '@murumets-ee/admin-ui/server'
1155
+ import { TicketBoardClient } from './ticket-board-client'
1156
+ import type { TicketData, DepartmentData } from '@murumets-ee/ticketing-ui'
1157
+
1158
+ interface TicketBoardPageProps {
1159
+ params: Promise<{ locale: string }>
1160
+ }
1161
+
1162
+ export default async function TicketBoardPage({ params }: TicketBoardPageProps) {
1163
+ const { locale } = await params
1164
+ setRequestLocale(locale)
1165
+
1166
+ return withAdminContext(locale, async () => {
1167
+ const ticketData = await fetchEntityList(Ticket, {
1168
+ sortField: 'lastReplyAt',
1169
+ sortDirection: 'desc',
1170
+ limit: 200,
1171
+ })
1172
+
1173
+ const departmentData = await fetchEntityList(Department, {
1174
+ sortField: 'name',
1175
+ sortDirection: 'asc',
1176
+ limit: 100,
1177
+ })
1178
+
1179
+ return (
1180
+ <div className="p-6 h-[calc(100vh-3.5rem)]">
1181
+ <div className="mb-4">
1182
+ <h1 className="text-2xl font-bold">Ticket Board</h1>
1183
+ <p className="text-sm text-muted-foreground">
1184
+ Kanban view of tickets by status
1185
+ </p>
1186
+ </div>
1187
+ <TicketBoardClient
1188
+ initialTickets={ticketData.items as unknown as TicketData[]}
1189
+ initialDepartments={departmentData.items as unknown as DepartmentData[]}
1190
+ locale={locale}
1191
+ />
1192
+ </div>
1193
+ )
1194
+ })
1195
+ }
1196
+ `),await h(r(e,`app/[locale]/(shell)/admin/tickets/board/ticket-board-client.tsx`),`'use client'
1197
+
1198
+ import { useMemo } from 'react'
1199
+ import { TicketBoard } from '@murumets-ee/ticketing-ui/board'
1200
+ import type { TicketData, DepartmentData } from '@murumets-ee/ticketing-ui'
1201
+ import { useRouter } from 'next/navigation'
1202
+
1203
+ interface TicketBoardClientProps {
1204
+ initialTickets: TicketData[]
1205
+ initialDepartments: DepartmentData[]
1206
+ locale: string
1207
+ }
1208
+
1209
+ export function TicketBoardClient({
1210
+ initialTickets,
1211
+ initialDepartments,
1212
+ locale,
1213
+ }: TicketBoardClientProps) {
1214
+ const router = useRouter()
1215
+ const departmentMap = useMemo(
1216
+ () => new Map(initialDepartments.map((d) => [d.id, d])),
1217
+ [initialDepartments],
1218
+ )
1219
+
1220
+ return (
1221
+ <TicketBoard
1222
+ tickets={initialTickets}
1223
+ departments={departmentMap}
1224
+ onTicketSelect={(ticket) => {
1225
+ router.push(\`/\${locale}/admin/tickets?selected=\${ticket.id}\`)
1226
+ }}
1227
+ className="h-[calc(100%-4rem)]"
1228
+ />
1229
+ )
1230
+ }
1231
+ `),await h(r(e,`app/api/ticketing/inbound/route.ts`),`/**
1232
+ * Resend inbound email webhook endpoint.
1233
+ *
1234
+ * Public endpoint (no session auth) — protected by webhook signature
1235
+ * verification (HMAC-SHA256). Lives outside the admin API handler
1236
+ * because the admin handler requires authentication.
1237
+ */
1238
+
1239
+ import { getToolkitApp } from '@/lib/app'
1240
+
1241
+ await getToolkitApp()
1242
+
1243
+ export async function POST(req: Request): Promise<Response> {
1244
+ const { handleInboundWebhook } = await import('@murumets-ee/ticketing')
1245
+ const { QueueClient } = await import('@murumets-ee/queue/client')
1246
+ const { getApp } = await import('@murumets-ee/core')
1247
+
1248
+ const db = getApp().db.readWrite
1249
+ const queueClient = new QueueClient({ db })
1250
+
1251
+ return handleInboundWebhook(req, (type, payload) => queueClient.enqueue(type, payload))
1252
+ }
1253
+ `),await h(r(e,`app/api/admin/[...path]/route.ts`),`import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'
1254
+ import { buildPermissionChecker, buildResourceCatalog } from '@murumets-ee/auth'
1255
+ import { permissionRoutes } from '@murumets-ee/auth/admin'
1256
+ import { ContentClient } from '@murumets-ee/content/client'
1257
+ import { LockService } from '@murumets-ee/content/lock'
1258
+ import type { PermissionChecker } from '@murumets-ee/core'
1259
+ import { getApp } from '@murumets-ee/core'
1260
+ import {
1261
+ AuditLogClient,
1262
+ createAuditDbWriter,
1263
+ createAuditLogger,
1264
+ createLogger,
1265
+ } from '@murumets-ee/logging'
1266
+ import { logRoutes } from '@murumets-ee/logging/admin'
1267
+ import { mediaRoutes } from '@murumets-ee/media/admin'
1268
+ import { createSettingsClient } from '@murumets-ee/settings'
1269
+ import { settingsRoutes } from '@murumets-ee/settings/admin'
1270
+ import { storageRoutes } from '@murumets-ee/storage/admin'
1271
+ import { taxonomyRoutes } from '@murumets-ee/taxonomy/admin'
1272
+ import { ticketingRoutes } from '@murumets-ee/ticketing/admin'
1273
+ import {
1274
+ allEntities,
1275
+ crudEntities,
1276
+ pluginResources,
1277
+ taxonomyVocabularies,
1278
+ } from '@/lib/admin-config'
1279
+ import { getToolkitApp } from '@/lib/app'
1280
+ import { auth } from '@/lib/auth'
1281
+ import { loadRoles } from '@/lib/load-roles'
1282
+ import { permissionSettings } from '@/settings/permissions'
1283
+ import { siteSettings } from '@/settings/site'
1284
+
1285
+ await getToolkitApp()
1286
+
1287
+ let _auditLogger: ReturnType<typeof createAuditLogger> | null = null
1288
+ let _checker: PermissionChecker | null = null
1289
+
1290
+ const routes = [
1291
+ mediaRoutes(),
1292
+ storageRoutes(),
1293
+ settingsRoutes(siteSettings),
1294
+ taxonomyRoutes(taxonomyVocabularies),
1295
+ ticketingRoutes(),
1296
+ logRoutes(() => new AuditLogClient(getApp().db.readWrite)),
1297
+ permissionRoutes({
1298
+ getStatements: () => buildResourceCatalog(allEntities, pluginResources),
1299
+ loadRoles: async () => loadRoles(getApp()),
1300
+ saveRoles: async (roles) => {
1301
+ const app = getApp()
1302
+ const client = createSettingsClient(permissionSettings, { app })
1303
+ await client.set('roles', roles)
1304
+ },
1305
+ onSave: () => {
1306
+ _checker = null
1307
+ },
1308
+ }),
1309
+ ]
1310
+
1311
+ const handler = createAdminApiHandler({
1312
+ authenticate: async (req) => {
1313
+ const session = await auth.api.getSession({ headers: req.headers })
1314
+ if (!session?.user) return null
1315
+ const role = (session.user as Record<string, unknown>).role as string | undefined
1316
+ return { id: session.user.id, role, name: session.user.name, email: session.user.email }
1317
+ },
1318
+ defaultLocale: 'en',
1319
+ entities: crudEntities,
1320
+ routes,
1321
+ loadPermissions: async () => {
1322
+ if (_checker) return _checker
1323
+ const roles = await loadRoles(getApp())
1324
+ _checker = buildPermissionChecker(roles)
1325
+ return _checker
1326
+ },
1327
+ auditLogger: {
1328
+ log: async (entry) => {
1329
+ if (!_auditLogger) {
1330
+ const app = getApp()
1331
+ _auditLogger = createAuditLogger({
1332
+ logger: createLogger({ name: 'audit' }),
1333
+ dbWriter: createAuditDbWriter(app.db.readWrite),
1334
+ })
1335
+ }
1336
+ return _auditLogger.log(entry)
1337
+ },
1338
+ },
1339
+ contentClientFactory: (entity) =>
1340
+ new ContentClient({ entity, db: getApp().db.readWrite }),
1341
+ lockServiceFactory: () =>
1342
+ new LockService({ db: getApp().db.readWrite }),
1343
+ })
1344
+
1345
+ export const { GET, POST, PATCH, DELETE } = handler
1346
+ `)}async function x(e,t){await h(r(e,`lib/auth.ts`),`import { getToolkitApp } from './app'
1347
+ import { getAuth } from '@murumets-ee/auth'
1348
+
1349
+ export async function getAuthInstance() {
1350
+ await getToolkitApp()
1351
+ return getAuth()
1352
+ }
1353
+ `),await h(r(e,`lib/auth-client.ts`),`import { createClient } from '@murumets-ee/auth/client'
1354
+
1355
+ export const authClient = createClient()
1356
+ `),await h(r(e,`app/api/auth/[...all]/route.ts`),`import { toNextJsHandler } from 'better-auth/next-js'
1357
+ import { getAuthInstance } from '../../../../lib/auth'
1358
+
1359
+ let _handler: ReturnType<typeof toNextJsHandler> | null = null
1360
+
1361
+ async function handler() {
1362
+ if (!_handler) {
1363
+ const auth = await getAuthInstance()
1364
+ _handler = toNextJsHandler(auth)
1365
+ }
1366
+ return _handler
1367
+ }
1368
+
1369
+ export async function GET(req: Request) {
1370
+ const h = await handler()
1371
+ return h.GET(req)
1372
+ }
1373
+
1374
+ export async function POST(req: Request) {
1375
+ const h = await handler()
1376
+ return h.POST(req)
1377
+ }
1378
+ `),await h(r(e,`app/[locale]/auth/layout.tsx`),`import { AuthProviders } from './providers'
1379
+ import type { ReactNode } from 'react'
1380
+
1381
+ export default function AuthLayout({ children }: { children: ReactNode }) {
1382
+ return (
1383
+ <AuthProviders>
1384
+ <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
1385
+ {children}
1386
+ </div>
1387
+ </AuthProviders>
1388
+ )
1389
+ }
1390
+ `),await h(r(e,`app/[locale]/auth/providers.tsx`),`'use client'
1391
+
1392
+ import { AuthUIProvider } from '@murumets-ee/auth-ui'
1393
+ import { authClient } from '@/lib/auth-client'
1394
+ import Link from 'next/link'
1395
+ import { useRouter } from 'next/navigation'
1396
+ import { useLocale } from 'next-intl'
1397
+ import type { ReactNode } from 'react'
1398
+
1399
+ export function AuthProviders({ children }: { children: ReactNode }) {
1400
+ const router = useRouter()
1401
+ const locale = useLocale()
1402
+
1403
+ return (
1404
+ <AuthUIProvider
1405
+ authClient={authClient as never}
1406
+ basePath="/auth"
1407
+ redirectTo="/"
1408
+ Link={Link}
1409
+ navigate={(url) => router.push(url)}
1410
+ resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
1411
+ >
1412
+ {children}
1413
+ </AuthUIProvider>
1414
+ )
1415
+ }
1416
+ `),await h(r(e,`app/[locale]/auth/sign-in/page.tsx`),`import { SignInForm } from '@murumets-ee/auth-ui'
1417
+
1418
+ export default function SignInPage() {
1419
+ return <SignInForm />
1420
+ }
1421
+ `),await h(r(e,`app/[locale]/auth/sign-up/page.tsx`),`import { SignUpForm } from '@murumets-ee/auth-ui'
1422
+
1423
+ export default function SignUpPage() {
1424
+ return <SignUpForm />
1425
+ }
1426
+ `),await h(r(e,`app/[locale]/auth/forgot-password/page.tsx`),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
1427
+
1428
+ export default function ForgotPasswordPage() {
1429
+ return <ForgotPasswordForm />
1430
+ }
1431
+ `),await h(r(e,`app/[locale]/auth/reset-password/page.tsx`),`import { Suspense } from 'react'
1432
+ import { ResetPasswordContent } from './content'
1433
+
1434
+ export default function ResetPasswordPage() {
1435
+ return (
1436
+ <Suspense>
1437
+ <ResetPasswordContent />
1438
+ </Suspense>
1439
+ )
1440
+ }
1441
+ `),await h(r(e,`app/[locale]/auth/reset-password/content.tsx`),`'use client'
1442
+
1443
+ import { useSearchParams } from 'next/navigation'
1444
+ import { ResetPasswordForm } from '@murumets-ee/auth-ui'
1445
+
1446
+ export function ResetPasswordContent() {
1447
+ const searchParams = useSearchParams()
1448
+ const token = searchParams.get('token')
1449
+
1450
+ if (!token) {
1451
+ return (
1452
+ <div className="text-center">
1453
+ <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
1454
+ Invalid or expired link
1455
+ </h1>
1456
+ <p className="mt-2 text-zinc-500">
1457
+ Please request a new password reset.
1458
+ </p>
1459
+ </div>
1460
+ )
1461
+ }
1462
+
1463
+ return <ResetPasswordForm token={token} />
1464
+ }
1465
+ `),await h(r(e,`app/[locale]/setup/page.tsx`),`import { redirect } from 'next/navigation'
1466
+ import { sql } from 'drizzle-orm'
1467
+ import { getToolkitApp } from '@/lib/app'
1468
+ import { SetupForm } from './form'
1469
+
1470
+ export default async function SetupPage() {
1471
+ const app = await getToolkitApp()
1472
+ const result = await app.db.readOnly.execute<{ count: string }>(
1473
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1474
+ )
1475
+
1476
+ if (Number(result[0]?.count) > 0) {
1477
+ redirect('/')
1478
+ }
1479
+
1480
+ return (
1481
+ <div className="max-w-md mx-auto p-8">
1482
+ <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
1483
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
1484
+ No users found. Create the first admin account.
1485
+ </p>
1486
+ <SetupForm />
1487
+ </div>
1488
+ )
1489
+ }
1490
+ `),await h(r(e,`app/[locale]/setup/form.tsx`),`'use client'
1491
+
1492
+ import { useState } from 'react'
1493
+ import { createFirstAdmin } from './actions'
1494
+
1495
+ export function SetupForm() {
1496
+ const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
1497
+ const [loading, setLoading] = useState(false)
1498
+
1499
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
1500
+ e.preventDefault()
1501
+ setLoading(true)
1502
+ setResult(null)
1503
+ const res = await createFirstAdmin(new FormData(e.currentTarget))
1504
+ setResult(res)
1505
+ setLoading(false)
1506
+ }
1507
+
1508
+ if (result?.ok) {
1509
+ return (
1510
+ <div className="space-y-2">
1511
+ <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
1512
+ <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
1513
+ Sign in &rarr;
1514
+ </a>
1515
+ </div>
1516
+ )
1517
+ }
1518
+
1519
+ return (
1520
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
1521
+ <label className="text-sm font-medium">
1522
+ Name
1523
+ <input
1524
+ name="name"
1525
+ required
1526
+ 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"
1527
+ />
1528
+ </label>
1529
+ <label className="text-sm font-medium">
1530
+ Email
1531
+ <input
1532
+ name="email"
1533
+ type="email"
1534
+ required
1535
+ 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"
1536
+ />
1537
+ </label>
1538
+ <label className="text-sm font-medium">
1539
+ Password (min 8 chars)
1540
+ <input
1541
+ name="password"
1542
+ type="password"
1543
+ required
1544
+ minLength={8}
1545
+ 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"
1546
+ />
1547
+ </label>
1548
+
1549
+ {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
1550
+
1551
+ <button
1552
+ type="submit"
1553
+ disabled={loading}
1554
+ className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
1555
+ >
1556
+ {loading ? 'Creating...' : 'Create Admin'}
1557
+ </button>
1558
+ </form>
1559
+ )
1560
+ }
1561
+ `),await h(r(e,`app/[locale]/setup/actions.ts`),`'use server'
1562
+
1563
+ import { sql } from 'drizzle-orm'
1564
+ import { getToolkitApp } from '@/lib/app'
1565
+ import { getAuth } from '@murumets-ee/auth'
1566
+
1567
+ /**
1568
+ * Create the first admin user. Locked down:
1569
+ * - Advisory lock prevents race conditions (two simultaneous requests)
1570
+ * - User count check inside the lock ensures only one admin can be created
1571
+ * - Once any user exists, this action permanently refuses
1572
+ */
1573
+ export async function createFirstAdmin(formData: FormData) {
1574
+ const email = formData.get('email') as string
1575
+ const password = formData.get('password') as string
1576
+ const name = formData.get('name') as string
1577
+
1578
+ if (!email || !password || !name) {
1579
+ return { error: 'All fields are required' }
1580
+ }
1581
+
1582
+ if (password.length < 8) {
1583
+ return { error: 'Password must be at least 8 characters' }
1584
+ }
1585
+
1586
+ const app = await getToolkitApp()
1587
+
1588
+ // Acquire advisory lock + check atomically in a transaction
1589
+ const canCreate = await app.db.readWrite.transaction(async (tx) => {
1590
+ // Advisory lock 1 = "setup lock". Blocks concurrent setup attempts.
1591
+ await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
1592
+
1593
+ const result = await tx.execute<{ count: string }>(
1594
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1595
+ )
1596
+ return Number(result[0]?.count) === 0
1597
+ })
1598
+
1599
+ if (!canCreate) {
1600
+ return { error: 'Admin user already exists. Setup is complete.' }
1601
+ }
1602
+
1603
+ const auth = getAuth()
1604
+
1605
+ const adminUser = await auth.api.signUpEmail({
1606
+ body: { email, password, name },
1607
+ })
1608
+
1609
+ await app.db.readWrite.execute(
1610
+ sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
1611
+ )
1612
+
1613
+ return { ok: true, email: adminUser.user.email }
1614
+ }
1615
+ `)}async function S(e,t){let{name:n}=t;await h(r(e,`.env.example`),`DATABASE_URL=postgresql://${n}:${n}_dev_password@localhost:5432/${n}_dev
1616
+ BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
1617
+ BETTER_AUTH_URL=http://localhost:3000
1618
+ LOG_LEVEL=debug
1619
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
1620
+ # Queue worker — by default runs embedded in the web process.
1621
+ # Set to "false" in production and run "pnpm worker" as a separate process.
1622
+ # QUEUE_WORKER=false
1623
+ RESEND_API_KEY=
1624
+ RESEND_WEBHOOK_SECRET=
1625
+ MAIL_FROM=noreply@example.com
1626
+ CSAT_SECRET=
1627
+ `),await h(r(e,`.dockerignore`),`# Dependencies (installed inside Docker)
1628
+ node_modules/
1629
+ .pnpm-store/
1630
+
1631
+ # Build outputs (rebuilt inside Docker)
1632
+ .next/
1633
+ out/
1634
+ dist/
1635
+
1636
+ # Git
1637
+ .git/
1638
+ .gitignore
1639
+
1640
+ # Environment files (pass via docker run --env-file)
1641
+ .env
1642
+ .env.*
1643
+ !.env.example
1644
+
1645
+ # Docker files
1646
+ Dockerfile*
1647
+ .dockerignore
1648
+ docker-compose*.yml
1649
+
1650
+ # Tests
1651
+ coverage/
1652
+ **/*.test.*
1653
+ **/*.spec.*
1654
+ **/vitest.config.*
1655
+ docker-compose.test.yml
1656
+
1657
+ # Development tooling
1658
+ .vscode/
1659
+ .idea/
1660
+ .turbo/
1661
+ .cache/
1662
+ *.tsbuildinfo
1663
+
1664
+ # Documentation
1665
+ *.md
1666
+ docs/
1667
+
1668
+ # OS files
1669
+ .DS_Store
1670
+ Thumbs.db
1671
+ `),await h(r(e,`Dockerfile`),`# --- Base image ---
1672
+ # Node 22 LTS (Debian slim — glibc required for sharp image optimization)
1673
+ FROM node:22-slim AS base
1674
+
1675
+ # --- Stage 1: Install dependencies ---
1676
+ FROM base AS deps
1677
+ WORKDIR /app
1678
+ RUN corepack enable pnpm
1679
+ COPY package.json pnpm-lock.yaml ./
1680
+ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \\
1681
+ pnpm install --frozen-lockfile
1682
+
1683
+ # --- Stage 2: Build the application ---
1684
+ FROM base AS builder
1685
+ WORKDIR /app
1686
+ RUN corepack enable pnpm
1687
+ COPY --from=deps /app/node_modules ./node_modules
1688
+ COPY . .
1689
+ ENV NODE_ENV=production
1690
+ ENV NEXT_TELEMETRY_DISABLED=1
1691
+ # Dummy env vars so toolkit.config.ts doesn't throw at build time (no DB connection needed)
1692
+ ENV DATABASE_URL=postgresql://build:build@localhost:5432/build
1693
+ ENV BETTER_AUTH_SECRET=build-secret-not-used-at-runtime
1694
+ RUN pnpm build
1695
+
1696
+ # --- Stage 3: Production runner ---
1697
+ FROM base AS runner
1698
+ WORKDIR /app
1699
+ ENV NODE_ENV=production
1700
+ ENV PORT=3000
1701
+ ENV HOSTNAME=0.0.0.0
1702
+ ENV NEXT_TELEMETRY_DISABLED=1
1703
+
1704
+ # Copy standalone output (includes server.js + traced node_modules)
1705
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
1706
+ # Copy static assets (JS/CSS chunks — excluded from standalone trace)
1707
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
1708
+ # Copy public assets (favicon, robots.txt, images)
1709
+ COPY --from=builder --chown=node:node /app/public ./public
1710
+ # Copy migrations (applied at app startup by the toolkit migration runner)
1711
+ COPY --from=builder --chown=node:node /app/migrations ./migrations
1712
+
1713
+ # Run as non-root user
1714
+ USER node
1715
+ EXPOSE 3000
1716
+ CMD ["node", "server.js"]
1717
+ `),await h(r(e,`docker-compose.yml`),`services:
1718
+ postgres:
1719
+ image: postgres:17-alpine
1720
+ container_name: ${n}-postgres
1721
+ restart: unless-stopped
1722
+ environment:
1723
+ POSTGRES_USER: ${n}
1724
+ POSTGRES_PASSWORD: ${n}_dev_password
1725
+ POSTGRES_DB: ${n}_dev
1726
+ ports:
1727
+ - "5432:5432"
1728
+ volumes:
1729
+ - postgres_data:/var/lib/postgresql/data
1730
+ healthcheck:
1731
+ test: ["CMD-SHELL", "pg_isready -U ${n} -d ${n}_dev"]
1732
+ interval: 10s
1733
+ timeout: 5s
1734
+ retries: 5
1735
+
1736
+ volumes:
1737
+ postgres_data:
1738
+ name: ${n}_postgres_data
1739
+ `),await h(r(e,`docker-compose.prod.yml`),`services:
1740
+ app:
1741
+ build: .
1742
+ restart: unless-stopped
1743
+ env_file: .env
1744
+ depends_on:
1745
+ postgres:
1746
+ condition: service_healthy
1747
+ ports:
1748
+ - "3000:3000"
1749
+ networks:
1750
+ - internal
1751
+
1752
+ postgres:
1753
+ image: postgres:17-alpine
1754
+ restart: unless-stopped
1755
+ environment:
1756
+ POSTGRES_USER: ${n}
1757
+ POSTGRES_PASSWORD: \${DB_PASSWORD}
1758
+ POSTGRES_DB: ${n}
1759
+ volumes:
1760
+ - postgres_data:/var/lib/postgresql/data
1761
+ healthcheck:
1762
+ test: ["CMD-SHELL", "pg_isready -U ${n} -d ${n}"]
1763
+ interval: 10s
1764
+ timeout: 5s
1765
+ retries: 5
1766
+ networks:
1767
+ - internal
1768
+
1769
+ volumes:
1770
+ postgres_data:
1771
+
1772
+ networks:
1773
+ internal:
1774
+ `),await y(r(e,`.gitignore`),`
1775
+ # Toolkit generated files
1776
+ generated/
1777
+
1778
+ # Environment
1779
+ .env*
1780
+ !.env.example
1781
+ `)}async function C(e,t){await h(r(e,`entities/index.ts`),`export { Article } from './article'
1782
+ export { Category } from './category'
1783
+ `),await h(r(e,`entities/article.ts`),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
1784
+
1785
+ export const Article = defineEntity({
1786
+ name: 'article',
1787
+ fields: {
1788
+ title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
1789
+ slug: field.slug({ from: 'title', unique: true }),
1790
+ excerpt: field.text({ maxLength: 500, translatable: true }),
1791
+ body: field.richtext(),
1792
+ viewCount: field.number({ default: 0, integer: true }),
1793
+ featured: field.boolean({ default: false }),
1794
+ publishDate: field.date(),
1795
+ contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
1796
+ category: field.reference({ entity: 'category', required: false }),
1797
+ tags: field.reference({ entity: 'category', cardinality: 'many' }),
1798
+ coverImage: field.media({ accept: ['image/*'] }),
1799
+ },
1800
+ behaviors: [
1801
+ behavior.publishable(),
1802
+ behavior.auditable(),
1803
+ behavior.sluggable('title'),
1804
+ behavior.revisionable(),
1805
+ ],
1806
+ scope: 'global',
1807
+ access: {
1808
+ view: 'public',
1809
+ create: 'group.editor',
1810
+ update: 'group.editor',
1811
+ delete: 'group.admin',
1812
+ },
1813
+ })
1814
+ `),await h(r(e,`entities/category.ts`),`import { defineEntity, field } from '@murumets-ee/entity'
1815
+
1816
+ export const Category = defineEntity({
1817
+ name: 'category',
1818
+ fields: {
1819
+ name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
1820
+ slug: field.slug({ from: 'name', unique: true }),
1821
+ description: field.text({ maxLength: 500, translatable: true }),
1822
+ },
1823
+ scope: 'global',
1824
+ access: {
1825
+ view: 'public',
1826
+ create: 'group.editor',
1827
+ update: 'group.editor',
1828
+ delete: 'group.admin',
1829
+ },
1830
+ })
1831
+ `)}async function w(e,t){await h(r(e,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
1832
+
1833
+ export const routing = defineRouting({
1834
+ locales: ['en'],
1835
+ defaultLocale: 'en',
1836
+ })
1837
+ `),await h(r(e,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
1838
+ import { hasLocale } from 'next-intl'
1839
+ import { routing } from './routing'
1840
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
1841
+
1842
+ export default getRequestConfig(async ({ requestLocale }) => {
1843
+ const requested = await requestLocale
1844
+ const locale = hasLocale(routing.locales, requested)
1845
+ ? requested
1846
+ : routing.defaultLocale
1847
+
1848
+ const authMessages = await getAuthMessages(locale)
1849
+
1850
+ return {
1851
+ locale,
1852
+ messages: {
1853
+ ...authMessages,
1854
+ },
1855
+ }
1856
+ })
1857
+ `)}const T={myorgCore:`0.1.0`,myorgDb:`0.1.0`,myorgEntity:`0.1.0`,myorgLogging:`0.1.0`,myorgAuth:`0.1.0`,myorgAuthUi:`0.1.0`,myorgAdminUi:`0.1.0`,myorgContent:`0.1.0`,myorgContentApi:`0.1.0`,myorgSettings:`0.1.0`,myorgStorage:`0.1.0`,myorgMedia:`0.1.0`,myorgTaxonomy:`0.1.0`,myorgQueue:`0.1.0`,myorgMail:`0.1.0`,myorgTicketing:`0.1.0`,myorgTicketingUi:`0.1.0`,myorgEditor:`0.1.0`,myorgBlocks:`0.1.0`,myorgTokens:`0.1.0`,myorgUi:`0.1.0`,myorgCli:`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`,tanstackReactQuery:`5.60.5`,tanstackReactTable:`8.21.3`,drizzleKit:`0.31.10`,tsx:`4.19.2`,babelReactCompiler:`1.0.0`};async function E(e,t,n){await D(e,t),n?.(`Workspace root created`),n?.(`Creating admin app...`),await k(e,t),n?.(`Admin app created`),n?.(`Creating web app...`),await A(e,t),n?.(`Web app created`),await O(e,t),n?.(`Shared config package created`)}async function D(e,t){let{name:n}=t;await o(e,{recursive:!0}),await h(r(e,`pnpm-workspace.yaml`),`packages:
1858
+ - 'packages/*'
1859
+ - 'apps/*'
1860
+
1861
+ ignoredBuiltDependencies:
1862
+ - sharp
1863
+ - unrs-resolver
1864
+ `),await h(r(e,`package.json`),`${JSON.stringify({name:`@${n}/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:`^${T.tsx}`,"drizzle-kit":`^${T.drizzleKit}`}},null,2)}\n`),await h(r(e,`.gitignore`),`# dependencies
1865
+ node_modules/
1866
+
1867
+ # build
1868
+ dist/
1869
+ .next/
1870
+ .turbo/
1871
+
1872
+ # env
1873
+ .env*
1874
+ !.env.example
1875
+
1876
+ # toolkit generated
1877
+ generated/
1878
+
1879
+ # IDE
1880
+ .vscode/
1881
+ .idea/
1882
+
1883
+ # OS
1884
+ .DS_Store
1885
+ Thumbs.db
1886
+
1887
+ # logs
1888
+ *.log
1889
+
1890
+ # migrations meta
1891
+ migrations/
1892
+
1893
+ # turbo
1894
+ .turbo/
1895
+ `),await h(r(e,`.env.example`),`DATABASE_URL=postgresql://${n}:${n}_dev_password@localhost:5432/${n}_dev
1896
+ BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
1897
+ BETTER_AUTH_URL=http://localhost:3000
1898
+ LOG_LEVEL=debug
1899
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
1900
+ QUEUE_WORKER=true
1901
+ RESEND_API_KEY=
1902
+ RESEND_WEBHOOK_SECRET=
1903
+ MAIL_FROM=noreply@example.com
1904
+ CSAT_SECRET=
1905
+ `),await h(r(e,`docker-compose.yml`),`services:
1906
+ postgres:
1907
+ image: postgres:17-alpine
1908
+ container_name: ${n}-postgres
1909
+ restart: unless-stopped
1910
+ environment:
1911
+ POSTGRES_USER: ${n}
1912
+ POSTGRES_PASSWORD: ${n}_dev_password
1913
+ POSTGRES_DB: ${n}_dev
1914
+ ports:
1915
+ - "5432:5432"
1916
+ volumes:
1917
+ - postgres_data:/var/lib/postgresql/data
1918
+ healthcheck:
1919
+ test: ["CMD-SHELL", "pg_isready -U ${n} -d ${n}_dev"]
1920
+ interval: 10s
1921
+ timeout: 5s
1922
+ retries: 5
1923
+
1924
+ volumes:
1925
+ postgres_data:
1926
+ name: ${n}_postgres_data
1927
+ `)}async function O(e,t){let{name:n}=t,i=r(e,`packages/config`);await h(r(i,`package.json`),`${JSON.stringify({name:`@${n}/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":`^${T.myorgCore}`,"@murumets-ee/db":`^${T.myorgDb}`,"@murumets-ee/entity":`^${T.myorgEntity}`,"@murumets-ee/logging":`^${T.myorgLogging}`,"@murumets-ee/auth":`^${T.myorgAuth}`,"better-auth":`^${T.betterAuth}`,"drizzle-orm":`^${T.drizzleOrm}`,postgres:`^${T.postgres}`,zod:`^${T.zod}`}},null,2)}\n`),await h(r(i,`tsconfig.json`),`${JSON.stringify({compilerOptions:{target:`ES2022`,module:`ESNext`,moduleResolution:`Bundler`,strict:!0,esModuleInterop:!0,skipLibCheck:!0,resolveJsonModule:!0},include:[`**/*.ts`]},null,2)}\n`),await h(r(i,`toolkit.config.ts`),`import { auth } from '@murumets-ee/auth/plugin'
1928
+ import { content } from '@murumets-ee/content/plugin'
1929
+ import { defineConfig } from '@murumets-ee/core'
1930
+ import { logging } from '@murumets-ee/logging/plugin'
1931
+ import { mail, ResendMailProvider } from '@murumets-ee/mail'
1932
+ import { media } from '@murumets-ee/media/plugin'
1933
+ import { queue } from '@murumets-ee/queue/plugin'
1934
+ import { settings } from '@murumets-ee/settings/plugin'
1935
+ import { storage } from '@murumets-ee/storage/plugin'
1936
+ import { taxonomy } from '@murumets-ee/taxonomy/plugin'
1937
+ import { ticketing } from '@murumets-ee/ticketing/plugin'
1938
+ import { Article, Category } from './entities'
1939
+ import * as authSchema from './generated/auth-schema'
1940
+
1941
+ if (!process.env.DATABASE_URL) {
1942
+ throw new Error('DATABASE_URL environment variable is required')
1943
+ }
1944
+
1945
+ export default defineConfig({
1946
+ db: {
1947
+ url: process.env.DATABASE_URL,
1948
+ poolMin: 2,
1949
+ poolMax: 10,
1950
+ },
1951
+ logging: {
1952
+ level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
1953
+ name: '${n}',
1954
+ },
1955
+ entities: [Category, Article],
1956
+ plugins: [
1957
+ auth({ providers: ['email'], schema: authSchema }),
1958
+ content({
1959
+ locales: [{ code: 'en', label: 'English' }],
1960
+ defaultLocale: 'en',
1961
+ }),
1962
+ logging(),
1963
+ settings(),
1964
+ storage(),
1965
+ media(),
1966
+ taxonomy(),
1967
+ queue(),
1968
+ mail({
1969
+ provider: process.env.RESEND_API_KEY
1970
+ ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
1971
+ : undefined,
1972
+ defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
1973
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
1974
+ }),
1975
+ ticketing({
1976
+ csatSecret: process.env.CSAT_SECRET,
1977
+ }),
1978
+ ],
1979
+ projectRoot: import.meta.dirname,
1980
+ })
1981
+ `),await h(r(i,`auth.config.ts`),`import { betterAuth } from 'better-auth'
1982
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
1983
+ import { admin } from 'better-auth/plugins'
1984
+ import { organization } from 'better-auth/plugins/organization'
1985
+ import { drizzle } from 'drizzle-orm/postgres-js'
1986
+ import postgres from 'postgres'
1987
+
1988
+ const sql = postgres(process.env.DATABASE_URL!)
1989
+ const db = drizzle(sql)
1990
+
1991
+ export const auth = betterAuth({
1992
+ database: drizzleAdapter(db, { provider: 'pg' }),
1993
+ emailAndPassword: { enabled: true },
1994
+ plugins: [
1995
+ admin(),
1996
+ organization(),
1997
+ ],
1998
+ })
1999
+ `),await h(r(i,`drizzle.config.ts`),`import type { Config } from 'drizzle-kit'
2000
+
2001
+ if (!process.env.DATABASE_URL) {
2002
+ throw new Error('DATABASE_URL environment variable is required')
2003
+ }
2004
+
2005
+ export default {
2006
+ schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
2007
+ out: './migrations',
2008
+ dialect: 'postgresql',
2009
+ dbCredentials: {
2010
+ url: process.env.DATABASE_URL,
2011
+ },
2012
+ } satisfies Config
2013
+ `),await h(r(i,`entities/index.ts`),`export { Article } from './article'
2014
+ export { Category } from './category'
2015
+ `),await h(r(i,`entities/article.ts`),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
2016
+
2017
+ export const Article = defineEntity({
2018
+ name: 'article',
2019
+ fields: {
2020
+ title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
2021
+ slug: field.slug({ from: 'title', unique: true }),
2022
+ excerpt: field.text({ maxLength: 500, translatable: true }),
2023
+ body: field.richtext(),
2024
+ viewCount: field.number({ default: 0, integer: true }),
2025
+ featured: field.boolean({ default: false }),
2026
+ publishDate: field.date(),
2027
+ contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
2028
+ category: field.reference({ entity: 'category', required: false }),
2029
+ tags: field.reference({ entity: 'category', cardinality: 'many' }),
2030
+ coverImage: field.media({ accept: ['image/*'] }),
2031
+ },
2032
+ behaviors: [
2033
+ behavior.publishable(),
2034
+ behavior.auditable(),
2035
+ behavior.sluggable('title'),
2036
+ behavior.revisionable(),
2037
+ ],
2038
+ scope: 'global',
2039
+ access: {
2040
+ view: 'public',
2041
+ create: 'group.editor',
2042
+ update: 'group.editor',
2043
+ delete: 'group.admin',
2044
+ },
2045
+ })
2046
+ `),await h(r(i,`entities/category.ts`),`import { defineEntity, field } from '@murumets-ee/entity'
2047
+
2048
+ export const Category = defineEntity({
2049
+ name: 'category',
2050
+ fields: {
2051
+ name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
2052
+ slug: field.slug({ from: 'name', unique: true }),
2053
+ description: field.text({ maxLength: 500, translatable: true }),
2054
+ },
2055
+ scope: 'global',
2056
+ access: {
2057
+ view: 'public',
2058
+ create: 'group.editor',
2059
+ update: 'group.editor',
2060
+ delete: 'group.admin',
2061
+ },
2062
+ })
2063
+ `),await h(r(i,`lib/app.ts`),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
2064
+ import config from '../toolkit.config'
2065
+
2066
+ let appInstance: ToolkitApp | null = null
2067
+
2068
+ export async function getToolkitApp(): Promise<ToolkitApp> {
2069
+ if (!appInstance) {
2070
+ appInstance = await createApp(config)
2071
+ setApp(appInstance)
2072
+ }
2073
+ return appInstance
2074
+ }
2075
+ `),await h(r(i,`lib/auth.ts`),`import { getToolkitApp } from './app'
2076
+ import { getAuth } from '@murumets-ee/auth'
2077
+
2078
+ export async function getAuthInstance() {
2079
+ await getToolkitApp()
2080
+ return getAuth()
2081
+ }
2082
+ `),await h(r(i,`lib/auth-client.ts`),`import { createClient } from '@murumets-ee/auth/client'
2083
+
2084
+ export const authClient = createClient()
2085
+ `),await h(r(i,`generated/auth-schema.ts`),`// This file is generated by better-auth CLI.
2086
+ // Run: npx @better-auth/cli generate --config auth.config.ts -y
2087
+ export {}
2088
+ `),await h(r(i,`scripts/generate-schema.ts`),`/**
2089
+ * Generate Drizzle schemas from entity definitions
2090
+ */
2091
+
2092
+ import { mkdir, writeFile } from 'node:fs/promises'
2093
+ import { join } from 'node:path'
2094
+ import { generateSchemaCode, generateTranslationSchemaCode } from '@murumets-ee/entity'
2095
+ import config from '../toolkit.config'
2096
+
2097
+ async function generateSchemas() {
2098
+ console.log('Generating Drizzle schemas from entity definitions...')
2099
+
2100
+ const schemaDir = join(import.meta.dirname, '..', 'generated')
2101
+ await mkdir(schemaDir, { recursive: true })
2102
+
2103
+ const imports: string[] = []
2104
+ const schemas: string[] = []
2105
+ let hasTranslations = false
2106
+
2107
+ for (const entity of config.entities) {
2108
+ console.log(\` - Generating schema for \${entity.name}\`)
2109
+
2110
+ const schemaCode = generateSchemaCode(entity)
2111
+ schemas.push(\`\\n// \${entity.name} table\`)
2112
+ schemas.push(schemaCode)
2113
+
2114
+ const translationCode = generateTranslationSchemaCode(entity)
2115
+ if (translationCode) {
2116
+ hasTranslations = true
2117
+ schemas.push(\`\\n// \${entity.name} translations\`)
2118
+ schemas.push(translationCode)
2119
+ }
2120
+ }
2121
+
2122
+ const pgCoreTypes = ['pgTable', 'varchar', 'text', 'boolean', 'timestamp', 'integer', 'doublePrecision', 'jsonb', 'uuid', 'index']
2123
+ if (hasTranslations) pgCoreTypes.push('unique')
2124
+ const pgCoreImports = \`import { \${pgCoreTypes.join(', ')} } from 'drizzle-orm/pg-core'\\n\`
2125
+ imports.push(pgCoreImports)
2126
+
2127
+ const schemaFile = join(schemaDir, 'schema.ts')
2128
+ await writeFile(schemaFile, [...imports, ...schemas].join('\\n'))
2129
+
2130
+ console.log(\`\\nSchemas written to: \${schemaFile}\`)
2131
+ console.log('Run \\\`pnpm db:migrate:generate\\\` to create migrations')
2132
+ }
2133
+
2134
+ generateSchemas().catch(console.error)
2135
+ `),await h(r(i,`scripts/migrate.ts`),`/**
2136
+ * Run pending migrations
2137
+ */
2138
+
2139
+ import { createDbClient, runMigrations } from '@murumets-ee/db'
2140
+
2141
+ async function migrate() {
2142
+ console.log('Running migrations...')
2143
+
2144
+ try {
2145
+ if (!process.env.DATABASE_URL) {
2146
+ throw new Error('DATABASE_URL environment variable is required')
2147
+ }
2148
+
2149
+ const db = createDbClient({ url: process.env.DATABASE_URL })
2150
+ await runMigrations(db, import.meta.dirname + '/..')
2151
+
2152
+ console.log('Migrations completed successfully')
2153
+ process.exit(0)
2154
+ } catch (error) {
2155
+ console.error('Migration failed:', error)
2156
+ process.exit(1)
2157
+ }
2158
+ }
2159
+
2160
+ migrate()
2161
+ `),await h(r(i,`scripts/reset-db.ts`),`/**
2162
+ * Reset database (DROP all tables)
2163
+ * WARNING: Only for development
2164
+ */
2165
+
2166
+ import postgres from 'postgres'
2167
+
2168
+ async function resetDb() {
2169
+ const DATABASE_URL = process.env.DATABASE_URL
2170
+
2171
+ if (!DATABASE_URL) {
2172
+ throw new Error('DATABASE_URL not set')
2173
+ }
2174
+
2175
+ console.warn('WARNING: This will DROP ALL TABLES')
2176
+ console.log('Database:', DATABASE_URL.split('@')[1])
2177
+
2178
+ const sql = postgres(DATABASE_URL)
2179
+
2180
+ try {
2181
+ await sql\`
2182
+ DROP SCHEMA public CASCADE;
2183
+ CREATE SCHEMA public;
2184
+ GRANT ALL ON SCHEMA public TO PUBLIC;
2185
+ \`
2186
+
2187
+ console.log('Database reset complete')
2188
+ } finally {
2189
+ await sql.end()
2190
+ }
2191
+ }
2192
+
2193
+ resetDb().catch(console.error)
2194
+ `)}async function k(e,t){let{name:n}=t,i=r(e,`apps/admin`);await o(r(e,`apps`),{recursive:!0}),v(`pnpm create next-app@16 ${i} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await _(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await g(r(i,`package.json`),{dependencies:{[`@${n}/config`]:`workspace:*`,"@murumets-ee/auth-ui":`^${T.myorgAuthUi}`,"better-auth":`^${T.betterAuth}`,"next-intl":`^${T.nextIntl}`,"next-themes":`^${T.nextThemes}`,"lucide-react":`^${T.lucideReact}`,"react-hook-form":`^${T.reactHookForm}`,"@hookform/resolvers":`^${T.hookformResolvers}`,zod:`^${T.zod}`},devDependencies:{"babel-plugin-react-compiler":T.babelReactCompiler}});let{appendToFile:a}=await Promise.resolve().then(()=>m);await a(r(i,`.gitignore`),`
2195
+ # Environment
2196
+ .env*
2197
+ !.env.example
2198
+ `),await h(r(i,`next.config.ts`),`import type { NextConfig } from 'next'
2199
+ import createNextIntlPlugin from 'next-intl/plugin'
2200
+
2201
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
2202
+
2203
+ const nextConfig: NextConfig = {
2204
+ reactCompiler: true,
2205
+ transpilePackages: [
2206
+ '@${n}/config',
2207
+ '@murumets-ee/core',
2208
+ '@murumets-ee/entity',
2209
+ '@murumets-ee/db',
2210
+ '@murumets-ee/logging',
2211
+ '@murumets-ee/auth-ui',
2212
+ ],
2213
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
2214
+ experimental: {
2215
+ serverActions: {
2216
+ bodySizeLimit: '2mb',
2217
+ },
2218
+ },
2219
+ }
2220
+
2221
+ export default withNextIntl(nextConfig)
2222
+ `),await h(r(i,`proxy.ts`),`import { NextRequest, NextResponse } from 'next/server'
2223
+ import { getSessionCookie } from 'better-auth/cookies'
2224
+ import createMiddleware from 'next-intl/middleware'
2225
+ import { routing } from './i18n/routing'
2226
+
2227
+ const intlMiddleware = createMiddleware(routing)
2228
+
2229
+ const protectedPaths = ['/setup']
2230
+
2231
+ export function proxy(request: NextRequest) {
2232
+ const { pathname } = request.nextUrl
2233
+
2234
+ const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
2235
+ const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
2236
+
2237
+ if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
2238
+ const session = getSessionCookie(request)
2239
+ if (!session) {
2240
+ const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
2241
+ return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
2242
+ }
2243
+ }
2244
+
2245
+ return intlMiddleware(request)
2246
+ }
2247
+
2248
+ export const config = {
2249
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
2250
+ }
2251
+ `),await h(r(i,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
2252
+
2253
+ export const routing = defineRouting({
2254
+ locales: ['en'],
2255
+ defaultLocale: 'en',
2256
+ })
2257
+ `),await h(r(i,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
2258
+ import { hasLocale } from 'next-intl'
2259
+ import { routing } from './routing'
2260
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
2261
+
2262
+ export default getRequestConfig(async ({ requestLocale }) => {
2263
+ const requested = await requestLocale
2264
+ const locale = hasLocale(routing.locales, requested)
2265
+ ? requested
2266
+ : routing.defaultLocale
2267
+
2268
+ const authMessages = await getAuthMessages(locale)
2269
+
2270
+ return {
2271
+ locale,
2272
+ messages: {
2273
+ ...authMessages,
2274
+ },
2275
+ }
2276
+ })
2277
+ `),await h(r(i,`app/globals.css`),`@import "tailwindcss";
2278
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
2279
+
2280
+ @custom-variant dark (&:where(.dark, .dark *));
2281
+
2282
+ :root {
2283
+ --background: #ffffff;
2284
+ --foreground: #171717;
2285
+ }
2286
+
2287
+ @theme inline {
2288
+ --color-background: var(--background);
2289
+ --color-foreground: var(--foreground);
2290
+ --font-sans: var(--font-geist-sans);
2291
+ --font-mono: var(--font-geist-mono);
2292
+ }
2293
+
2294
+ .dark {
2295
+ --background: #0a0a0a;
2296
+ --foreground: #ededed;
2297
+ }
2298
+
2299
+ body {
2300
+ background: var(--background);
2301
+ color: var(--foreground);
2302
+ font-family: Arial, Helvetica, sans-serif;
2303
+ }
2304
+ `),await h(r(i,`app/theme-provider.tsx`),`'use client'
2305
+
2306
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
2307
+ import type { ReactNode } from 'react'
2308
+
2309
+ export function ThemeProvider({ children }: { children: ReactNode }) {
2310
+ return (
2311
+ <NextThemesProvider
2312
+ attribute="class"
2313
+ defaultTheme="system"
2314
+ enableSystem
2315
+ disableTransitionOnChange
2316
+ >
2317
+ {children}
2318
+ </NextThemesProvider>
2319
+ )
2320
+ }
2321
+ `),await h(r(i,`app/theme-toggle.tsx`),`'use client'
2322
+
2323
+ import { useTheme } from 'next-themes'
2324
+ import { useState, useEffect } from 'react'
2325
+ import { Sun, Moon } from 'lucide-react'
2326
+
2327
+ export function ThemeToggle() {
2328
+ const [mounted, setMounted] = useState(false)
2329
+ const { resolvedTheme, setTheme } = useTheme()
2330
+
2331
+ useEffect(() => { setMounted(true) }, [])
2332
+
2333
+ if (!mounted) {
2334
+ return <div className="h-8 w-8" />
2335
+ }
2336
+
2337
+ return (
2338
+ <button
2339
+ type="button"
2340
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
2341
+ 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"
2342
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
2343
+ >
2344
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
2345
+ </button>
2346
+ )
2347
+ }
2348
+ `),await h(r(i,`app/nav-header.tsx`),`'use client'
2349
+
2350
+ import Link from 'next/link'
2351
+ import { usePathname } from 'next/navigation'
2352
+ import { ThemeToggle } from './theme-toggle'
2353
+
2354
+ const links = [
2355
+ { href: '/', label: 'Home' },
2356
+ { href: '/auth/sign-in', label: 'Sign In' },
2357
+ { href: '/setup', label: 'Setup' },
2358
+ ]
2359
+
2360
+ export function NavHeader() {
2361
+ const pathname = usePathname()
2362
+
2363
+ function isActive(href: string) {
2364
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
2365
+ }
2366
+
2367
+ const linkClass = (href: string) =>
2368
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
2369
+ isActive(href)
2370
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
2371
+ : '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'
2372
+ }\`
2373
+
2374
+ return (
2375
+ <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">
2376
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
2377
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
2378
+ ${n} Admin
2379
+ </span>
2380
+ {links.map(({ href, label }) => (
2381
+ <Link key={href} href={href} className={linkClass(href)}>
2382
+ {label}
2383
+ </Link>
2384
+ ))}
2385
+ <div className="ml-auto">
2386
+ <ThemeToggle />
2387
+ </div>
2388
+ </nav>
2389
+ </header>
2390
+ )
2391
+ }
2392
+ `),await h(r(i,`app/layout.tsx`),`import './globals.css'
2393
+
2394
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
2395
+ return children
2396
+ }
2397
+ `),await h(r(i,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
2398
+ import { Geist, Geist_Mono } from 'next/font/google'
2399
+ import { NextIntlClientProvider } from 'next-intl'
2400
+ import { getMessages } from 'next-intl/server'
2401
+ import { notFound } from 'next/navigation'
2402
+ import { routing } from '@/i18n/routing'
2403
+ import { ThemeProvider } from '../theme-provider'
2404
+ import { NavHeader } from '../nav-header'
2405
+
2406
+ const geistSans = Geist({
2407
+ variable: '--font-geist-sans',
2408
+ subsets: ['latin'],
2409
+ })
2410
+
2411
+ const geistMono = Geist_Mono({
2412
+ variable: '--font-geist-mono',
2413
+ subsets: ['latin'],
2414
+ })
2415
+
2416
+ export const metadata: Metadata = {
2417
+ title: '${n} Admin',
2418
+ description: 'Built with Lumi CMS Toolkit',
2419
+ }
2420
+
2421
+ export default async function LocaleLayout({
2422
+ children,
2423
+ params,
2424
+ }: {
2425
+ children: React.ReactNode
2426
+ params: Promise<{ locale: string }>
2427
+ }) {
2428
+ const { locale } = await params
2429
+ if (!routing.locales.includes(locale as any)) notFound()
2430
+
2431
+ const messages = await getMessages()
2432
+
2433
+ return (
2434
+ <html lang={locale} suppressHydrationWarning>
2435
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
2436
+ <ThemeProvider>
2437
+ <NextIntlClientProvider locale={locale} messages={messages}>
2438
+ <NavHeader />
2439
+ {children}
2440
+ </NextIntlClientProvider>
2441
+ </ThemeProvider>
2442
+ </body>
2443
+ </html>
2444
+ )
2445
+ }
2446
+ `),await h(r(i,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
2447
+ import { Article, Category } from '@${n}/config/entities'
2448
+ import { getToolkitApp } from '@${n}/config/app'
2449
+
2450
+ export default async function HomePage() {
2451
+ await getToolkitApp()
2452
+ const articles = createQueryClient(Article)
2453
+ const categories = createQueryClient(Category)
2454
+
2455
+ const [articleList, categoryList] = await Promise.all([
2456
+ articles.findMany({ limit: 10 }),
2457
+ categories.findMany({}),
2458
+ ])
2459
+
2460
+ return (
2461
+ <div className="min-h-screen p-8">
2462
+ <div className="max-w-4xl mx-auto">
2463
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${n}</h1>
2464
+
2465
+ <section className="mb-12">
2466
+ <h2 className="text-2xl font-semibold mb-4">
2467
+ Categories ({categoryList.length})
2468
+ </h2>
2469
+ <div className="grid gap-4 md:grid-cols-2">
2470
+ {categoryList.map((cat) => (
2471
+ <div
2472
+ key={cat.id}
2473
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
2474
+ >
2475
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
2476
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
2477
+ {cat.description}
2478
+ </p>
2479
+ </div>
2480
+ ))}
2481
+ </div>
2482
+ </section>
2483
+
2484
+ <section>
2485
+ <h2 className="text-2xl font-semibold mb-4">
2486
+ Articles ({articleList.length})
2487
+ </h2>
2488
+ {articleList.length === 0 ? (
2489
+ <p className="text-zinc-500">No published articles yet.</p>
2490
+ ) : (
2491
+ <div className="space-y-4">
2492
+ {articleList.map((article) => (
2493
+ <div
2494
+ key={article.id}
2495
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
2496
+ >
2497
+ <h3 className="text-xl font-bold">{article.title}</h3>
2498
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
2499
+ {article.excerpt}
2500
+ </p>
2501
+ </div>
2502
+ ))}
2503
+ </div>
2504
+ )}
2505
+ </section>
2506
+ </div>
2507
+ </div>
2508
+ )
2509
+ }
2510
+ `),await h(r(i,`app/api/auth/[...all]/route.ts`),`import { toNextJsHandler } from 'better-auth/next-js'
2511
+ import { getAuthInstance } from '@${n}/config/auth'
2512
+
2513
+ let _handler: ReturnType<typeof toNextJsHandler> | null = null
2514
+
2515
+ async function handler() {
2516
+ if (!_handler) {
2517
+ const auth = await getAuthInstance()
2518
+ _handler = toNextJsHandler(auth)
2519
+ }
2520
+ return _handler
2521
+ }
2522
+
2523
+ export async function GET(req: Request) {
2524
+ return (await handler()).GET(req)
2525
+ }
2526
+
2527
+ export async function POST(req: Request) {
2528
+ return (await handler()).POST(req)
2529
+ }
2530
+ `),await h(r(i,`app/[locale]/auth/layout.tsx`),`import { AuthProviders } from './providers'
2531
+ import type { ReactNode } from 'react'
2532
+
2533
+ export default function AuthLayout({ children }: { children: ReactNode }) {
2534
+ return (
2535
+ <AuthProviders>
2536
+ <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
2537
+ {children}
2538
+ </div>
2539
+ </AuthProviders>
2540
+ )
2541
+ }
2542
+ `),await h(r(i,`app/[locale]/auth/providers.tsx`),`'use client'
2543
+
2544
+ import { AuthUIProvider } from '@murumets-ee/auth-ui'
2545
+ import { authClient } from '@${n}/config/auth-client'
2546
+ import Link from 'next/link'
2547
+ import { useRouter } from 'next/navigation'
2548
+ import { useLocale } from 'next-intl'
2549
+ import type { ReactNode } from 'react'
2550
+
2551
+ export function AuthProviders({ children }: { children: ReactNode }) {
2552
+ const router = useRouter()
2553
+ const locale = useLocale()
2554
+
2555
+ return (
2556
+ <AuthUIProvider
2557
+ authClient={authClient as never}
2558
+ basePath="/auth"
2559
+ redirectTo="/"
2560
+ Link={Link}
2561
+ navigate={(url) => router.push(url)}
2562
+ resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
2563
+ >
2564
+ {children}
2565
+ </AuthUIProvider>
2566
+ )
2567
+ }
2568
+ `),await h(r(i,`app/[locale]/auth/sign-in/page.tsx`),`import { SignInForm } from '@murumets-ee/auth-ui'
2569
+
2570
+ export default function SignInPage() {
2571
+ return <SignInForm />
2572
+ }
2573
+ `),await h(r(i,`app/[locale]/auth/sign-up/page.tsx`),`import { SignUpForm } from '@murumets-ee/auth-ui'
2574
+
2575
+ export default function SignUpPage() {
2576
+ return <SignUpForm />
2577
+ }
2578
+ `),await h(r(i,`app/[locale]/auth/forgot-password/page.tsx`),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
2579
+
2580
+ export default function ForgotPasswordPage() {
2581
+ return <ForgotPasswordForm />
2582
+ }
2583
+ `),await h(r(i,`app/[locale]/auth/reset-password/page.tsx`),`import { Suspense } from 'react'
2584
+ import { ResetPasswordContent } from './content'
2585
+
2586
+ export default function ResetPasswordPage() {
2587
+ return (
2588
+ <Suspense>
2589
+ <ResetPasswordContent />
2590
+ </Suspense>
2591
+ )
2592
+ }
2593
+ `),await h(r(i,`app/[locale]/auth/reset-password/content.tsx`),`'use client'
2594
+
2595
+ import { useSearchParams } from 'next/navigation'
2596
+ import { ResetPasswordForm } from '@murumets-ee/auth-ui'
2597
+
2598
+ export function ResetPasswordContent() {
2599
+ const searchParams = useSearchParams()
2600
+ const token = searchParams.get('token')
2601
+
2602
+ if (!token) {
2603
+ return (
2604
+ <div className="text-center">
2605
+ <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
2606
+ Invalid or expired link
2607
+ </h1>
2608
+ <p className="mt-2 text-zinc-500">
2609
+ Please request a new password reset.
2610
+ </p>
2611
+ </div>
2612
+ )
2613
+ }
2614
+
2615
+ return <ResetPasswordForm token={token} />
2616
+ }
2617
+ `),await h(r(i,`app/[locale]/setup/page.tsx`),`import { redirect } from 'next/navigation'
2618
+ import { sql } from 'drizzle-orm'
2619
+ import { getToolkitApp } from '@${n}/config/app'
2620
+ import { SetupForm } from './form'
2621
+
2622
+ export default async function SetupPage() {
2623
+ const app = await getToolkitApp()
2624
+ const result = await app.db.readOnly.execute<{ count: string }>(
2625
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
2626
+ )
2627
+
2628
+ if (Number(result[0]?.count) > 0) {
2629
+ redirect('/')
2630
+ }
2631
+
2632
+ return (
2633
+ <div className="max-w-md mx-auto p-8">
2634
+ <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
2635
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
2636
+ No users found. Create the first admin account.
2637
+ </p>
2638
+ <SetupForm />
2639
+ </div>
2640
+ )
2641
+ }
2642
+ `),await h(r(i,`app/[locale]/setup/form.tsx`),`'use client'
2643
+
2644
+ import { useState } from 'react'
2645
+ import { createFirstAdmin } from './actions'
2646
+
2647
+ export function SetupForm() {
2648
+ const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
2649
+ const [loading, setLoading] = useState(false)
2650
+
2651
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
2652
+ e.preventDefault()
2653
+ setLoading(true)
2654
+ setResult(null)
2655
+ const res = await createFirstAdmin(new FormData(e.currentTarget))
2656
+ setResult(res)
2657
+ setLoading(false)
2658
+ }
2659
+
2660
+ if (result?.ok) {
2661
+ return (
2662
+ <div className="space-y-2">
2663
+ <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
2664
+ <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
2665
+ Sign in &rarr;
2666
+ </a>
2667
+ </div>
2668
+ )
2669
+ }
2670
+
2671
+ return (
2672
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
2673
+ <label className="text-sm font-medium">
2674
+ Name
2675
+ <input
2676
+ name="name"
2677
+ required
2678
+ 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"
2679
+ />
2680
+ </label>
2681
+ <label className="text-sm font-medium">
2682
+ Email
2683
+ <input
2684
+ name="email"
2685
+ type="email"
2686
+ required
2687
+ 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"
2688
+ />
2689
+ </label>
2690
+ <label className="text-sm font-medium">
2691
+ Password (min 8 chars)
2692
+ <input
2693
+ name="password"
2694
+ type="password"
2695
+ required
2696
+ minLength={8}
2697
+ 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"
2698
+ />
2699
+ </label>
2700
+
2701
+ {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
2702
+
2703
+ <button
2704
+ type="submit"
2705
+ disabled={loading}
2706
+ className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
2707
+ >
2708
+ {loading ? 'Creating...' : 'Create Admin'}
2709
+ </button>
2710
+ </form>
2711
+ )
2712
+ }
2713
+ `),await h(r(i,`app/[locale]/setup/actions.ts`),`'use server'
2714
+
2715
+ import { sql } from 'drizzle-orm'
2716
+ import { getToolkitApp } from '@${n}/config/app'
2717
+ import { getAuth } from '@murumets-ee/auth'
2718
+
2719
+ export async function createFirstAdmin(formData: FormData) {
2720
+ const email = formData.get('email') as string
2721
+ const password = formData.get('password') as string
2722
+ const name = formData.get('name') as string
2723
+
2724
+ if (!email || !password || !name) {
2725
+ return { error: 'All fields are required' }
2726
+ }
2727
+
2728
+ if (password.length < 8) {
2729
+ return { error: 'Password must be at least 8 characters' }
2730
+ }
2731
+
2732
+ const app = await getToolkitApp()
2733
+
2734
+ const canCreate = await app.db.readWrite.transaction(async (tx) => {
2735
+ await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
2736
+
2737
+ const result = await tx.execute<{ count: string }>(
2738
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
2739
+ )
2740
+ return Number(result[0]?.count) === 0
2741
+ })
2742
+
2743
+ if (!canCreate) {
2744
+ return { error: 'Admin user already exists. Setup is complete.' }
2745
+ }
2746
+
2747
+ const auth = getAuth()
2748
+
2749
+ const adminUser = await auth.api.signUpEmail({
2750
+ body: { email, password, name },
2751
+ })
2752
+
2753
+ await app.db.readWrite.execute(
2754
+ sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
2755
+ )
2756
+
2757
+ return { ok: true, email: adminUser.user.email }
2758
+ }
2759
+ `)}async function A(e,t){let{name:n}=t,i=r(e,`apps/web`);v(`pnpm create next-app@16 ${i} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await _(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await g(r(i,`package.json`),{dependencies:{[`@${n}/config`]:`workspace:*`,"@murumets-ee/core":`^${T.myorgCore}`,"@murumets-ee/auth-ui":`^${T.myorgAuthUi}`,"next-intl":`^${T.nextIntl}`,"next-themes":`^${T.nextThemes}`,"lucide-react":`^${T.lucideReact}`},devDependencies:{"babel-plugin-react-compiler":T.babelReactCompiler}});let{appendToFile:a}=await Promise.resolve().then(()=>m);await a(r(i,`.gitignore`),`
2760
+ # Environment
2761
+ .env*
2762
+ !.env.example
2763
+ `),await h(r(i,`next.config.ts`),`import type { NextConfig } from 'next'
2764
+ import createNextIntlPlugin from 'next-intl/plugin'
2765
+
2766
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
2767
+
2768
+ const nextConfig: NextConfig = {
2769
+ reactCompiler: true,
2770
+ transpilePackages: [
2771
+ '@${n}/config',
2772
+ '@murumets-ee/core',
2773
+ '@murumets-ee/entity',
2774
+ '@murumets-ee/db',
2775
+ '@murumets-ee/logging',
2776
+ '@murumets-ee/auth-ui',
2777
+ ],
2778
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
2779
+ }
2780
+
2781
+ export default withNextIntl(nextConfig)
2782
+ `),await h(r(i,`proxy.ts`),`import createMiddleware from 'next-intl/middleware'
2783
+ import { routing } from './i18n/routing'
2784
+
2785
+ export default createMiddleware(routing)
2786
+
2787
+ export const config = {
2788
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
2789
+ }
2790
+ `),await h(r(i,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
2791
+
2792
+ export const routing = defineRouting({
2793
+ locales: ['en'],
2794
+ defaultLocale: 'en',
2795
+ })
2796
+ `),await h(r(i,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
2797
+ import { hasLocale } from 'next-intl'
2798
+ import { routing } from './routing'
2799
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
2800
+
2801
+ export default getRequestConfig(async ({ requestLocale }) => {
2802
+ const requested = await requestLocale
2803
+ const locale = hasLocale(routing.locales, requested)
2804
+ ? requested
2805
+ : routing.defaultLocale
2806
+
2807
+ const authMessages = await getAuthMessages(locale)
2808
+
2809
+ return {
2810
+ locale,
2811
+ messages: {
2812
+ ...authMessages,
2813
+ },
2814
+ }
2815
+ })
2816
+ `),await h(r(i,`app/globals.css`),`@import "tailwindcss";
2817
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
2818
+
2819
+ @custom-variant dark (&:where(.dark, .dark *));
2820
+
2821
+ :root {
2822
+ --background: #ffffff;
2823
+ --foreground: #171717;
2824
+ }
2825
+
2826
+ @theme inline {
2827
+ --color-background: var(--background);
2828
+ --color-foreground: var(--foreground);
2829
+ --font-sans: var(--font-geist-sans);
2830
+ --font-mono: var(--font-geist-mono);
2831
+ }
2832
+
2833
+ .dark {
2834
+ --background: #0a0a0a;
2835
+ --foreground: #ededed;
2836
+ }
2837
+
2838
+ body {
2839
+ background: var(--background);
2840
+ color: var(--foreground);
2841
+ font-family: Arial, Helvetica, sans-serif;
2842
+ }
2843
+ `),await h(r(i,`app/theme-provider.tsx`),`'use client'
2844
+
2845
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
2846
+ import type { ReactNode } from 'react'
2847
+
2848
+ export function ThemeProvider({ children }: { children: ReactNode }) {
2849
+ return (
2850
+ <NextThemesProvider
2851
+ attribute="class"
2852
+ defaultTheme="system"
2853
+ enableSystem
2854
+ disableTransitionOnChange
2855
+ >
2856
+ {children}
2857
+ </NextThemesProvider>
2858
+ )
2859
+ }
2860
+ `),await h(r(i,`app/theme-toggle.tsx`),`'use client'
2861
+
2862
+ import { useTheme } from 'next-themes'
2863
+ import { useState, useEffect } from 'react'
2864
+ import { Sun, Moon } from 'lucide-react'
2865
+
2866
+ export function ThemeToggle() {
2867
+ const [mounted, setMounted] = useState(false)
2868
+ const { resolvedTheme, setTheme } = useTheme()
2869
+
2870
+ useEffect(() => { setMounted(true) }, [])
2871
+
2872
+ if (!mounted) {
2873
+ return <div className="h-8 w-8" />
2874
+ }
2875
+
2876
+ return (
2877
+ <button
2878
+ type="button"
2879
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
2880
+ 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"
2881
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
2882
+ >
2883
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
2884
+ </button>
2885
+ )
2886
+ }
2887
+ `),await h(r(i,`app/nav-header.tsx`),`'use client'
2888
+
2889
+ import Link from 'next/link'
2890
+ import { usePathname } from 'next/navigation'
2891
+ import { ThemeToggle } from './theme-toggle'
2892
+
2893
+ const links = [
2894
+ { href: '/', label: 'Home' },
2895
+ ]
2896
+
2897
+ export function NavHeader() {
2898
+ const pathname = usePathname()
2899
+
2900
+ function isActive(href: string) {
2901
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
2902
+ }
2903
+
2904
+ const linkClass = (href: string) =>
2905
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
2906
+ isActive(href)
2907
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
2908
+ : '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'
2909
+ }\`
2910
+
2911
+ return (
2912
+ <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">
2913
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
2914
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
2915
+ ${n}
2916
+ </span>
2917
+ {links.map(({ href, label }) => (
2918
+ <Link key={href} href={href} className={linkClass(href)}>
2919
+ {label}
2920
+ </Link>
2921
+ ))}
2922
+ <div className="ml-auto">
2923
+ <ThemeToggle />
2924
+ </div>
2925
+ </nav>
2926
+ </header>
2927
+ )
2928
+ }
2929
+ `),await h(r(i,`app/layout.tsx`),`import './globals.css'
2930
+
2931
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
2932
+ return children
2933
+ }
2934
+ `),await h(r(i,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
2935
+ import { Geist, Geist_Mono } from 'next/font/google'
2936
+ import { NextIntlClientProvider } from 'next-intl'
2937
+ import { getMessages } from 'next-intl/server'
2938
+ import { notFound } from 'next/navigation'
2939
+ import { routing } from '@/i18n/routing'
2940
+ import { ThemeProvider } from '../theme-provider'
2941
+ import { NavHeader } from '../nav-header'
2942
+
2943
+ const geistSans = Geist({
2944
+ variable: '--font-geist-sans',
2945
+ subsets: ['latin'],
2946
+ })
2947
+
2948
+ const geistMono = Geist_Mono({
2949
+ variable: '--font-geist-mono',
2950
+ subsets: ['latin'],
2951
+ })
2952
+
2953
+ export const metadata: Metadata = {
2954
+ title: '${n}',
2955
+ description: 'Built with Lumi CMS Toolkit',
2956
+ }
2957
+
2958
+ export default async function LocaleLayout({
2959
+ children,
2960
+ params,
2961
+ }: {
2962
+ children: React.ReactNode
2963
+ params: Promise<{ locale: string }>
2964
+ }) {
2965
+ const { locale } = await params
2966
+ if (!routing.locales.includes(locale as any)) notFound()
2967
+
2968
+ const messages = await getMessages()
2969
+
2970
+ return (
2971
+ <html lang={locale} suppressHydrationWarning>
2972
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
2973
+ <ThemeProvider>
2974
+ <NextIntlClientProvider locale={locale} messages={messages}>
2975
+ <NavHeader />
2976
+ {children}
2977
+ </NextIntlClientProvider>
2978
+ </ThemeProvider>
2979
+ </body>
2980
+ </html>
2981
+ )
2982
+ }
2983
+ `),await h(r(i,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
2984
+ import { Article, Category } from '@${n}/config/entities'
2985
+ import { getToolkitApp } from '@${n}/config/app'
2986
+
2987
+ export default async function HomePage() {
2988
+ await getToolkitApp()
2989
+ const articles = createQueryClient(Article)
2990
+ const categories = createQueryClient(Category)
2991
+
2992
+ const [articleList, categoryList] = await Promise.all([
2993
+ articles.findMany({ limit: 10 }),
2994
+ categories.findMany({}),
2995
+ ])
2996
+
2997
+ return (
2998
+ <div className="min-h-screen p-8">
2999
+ <div className="max-w-4xl mx-auto">
3000
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${n}</h1>
3001
+
3002
+ <section className="mb-12">
3003
+ <h2 className="text-2xl font-semibold mb-4">
3004
+ Categories ({categoryList.length})
3005
+ </h2>
3006
+ <div className="grid gap-4 md:grid-cols-2">
3007
+ {categoryList.map((cat) => (
3008
+ <div
3009
+ key={cat.id}
3010
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
3011
+ >
3012
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
3013
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
3014
+ {cat.description}
3015
+ </p>
3016
+ </div>
3017
+ ))}
3018
+ </div>
3019
+ </section>
3020
+
3021
+ <section>
3022
+ <h2 className="text-2xl font-semibold mb-4">
3023
+ Articles ({articleList.length})
3024
+ </h2>
3025
+ {articleList.length === 0 ? (
3026
+ <p className="text-zinc-500">No published articles yet.</p>
3027
+ ) : (
3028
+ <div className="space-y-4">
3029
+ {articleList.map((article) => (
3030
+ <div
3031
+ key={article.id}
3032
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
3033
+ >
3034
+ <h3 className="text-xl font-bold">{article.title}</h3>
3035
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
3036
+ {article.excerpt}
3037
+ </p>
3038
+ </div>
3039
+ ))}
3040
+ </div>
3041
+ )}
3042
+ </section>
3043
+ </div>
3044
+ </div>
3045
+ )
3046
+ }
3047
+ `)}async function j(e,t){let{name:n}=t;await h(r(e,`next.config.ts`),`import type { NextConfig } from 'next'
3048
+ import createNextIntlPlugin from 'next-intl/plugin'
3049
+
3050
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
3051
+
3052
+ const nextConfig: NextConfig = {
3053
+ output: 'standalone',
3054
+ reactCompiler: true,
3055
+ transpilePackages: [
3056
+ '@murumets-ee/core', '@murumets-ee/entity', '@murumets-ee/db',
3057
+ '@murumets-ee/logging', '@murumets-ee/auth', '@murumets-ee/auth-ui',
3058
+ '@murumets-ee/admin-ui', '@murumets-ee/content', '@murumets-ee/settings',
3059
+ '@murumets-ee/storage', '@murumets-ee/media', '@murumets-ee/taxonomy',
3060
+ '@murumets-ee/queue', '@murumets-ee/mail', '@murumets-ee/ticketing',
3061
+ '@murumets-ee/ticketing-ui', '@murumets-ee/tokens', '@murumets-ee/ui',
3062
+ ],
3063
+ serverExternalPackages: ['drizzle-orm', 'postgres', 'pino', 'file-type'],
3064
+ experimental: {
3065
+ serverActions: {
3066
+ bodySizeLimit: '2mb',
3067
+ },
3068
+ },
3069
+ }
3070
+
3071
+ export default withNextIntl(nextConfig)
3072
+ `),await h(r(e,`proxy.ts`),`import { NextRequest, NextResponse } from 'next/server'
3073
+ import { getSessionCookie } from 'better-auth/cookies'
3074
+ import createMiddleware from 'next-intl/middleware'
3075
+ import { routing } from './i18n/routing'
3076
+
3077
+ const intlMiddleware = createMiddleware(routing)
3078
+
3079
+ const protectedPaths = ['/setup']
3080
+
3081
+ export function proxy(request: NextRequest) {
3082
+ const { pathname } = request.nextUrl
3083
+
3084
+ // Strip locale prefix to check actual path
3085
+ const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
3086
+ const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
3087
+
3088
+ // Auth check for protected paths
3089
+ if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
3090
+ const session = getSessionCookie(request)
3091
+ if (!session) {
3092
+ const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
3093
+ return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
3094
+ }
3095
+ }
3096
+
3097
+ // Locale routing
3098
+ return intlMiddleware(request)
3099
+ }
3100
+
3101
+ export const config = {
3102
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
3103
+ }
3104
+ `),await h(r(e,`app/layout.tsx`),`import './globals.css'
3105
+
3106
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
3107
+ return children
3108
+ }
3109
+ `),await h(r(e,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
3110
+ import { Geist, Geist_Mono } from 'next/font/google'
3111
+ import { NextIntlClientProvider } from 'next-intl'
3112
+ import { getMessages } from 'next-intl/server'
3113
+ import { notFound } from 'next/navigation'
3114
+ import { routing } from '@/i18n/routing'
3115
+ import { ThemeProvider } from '../theme-provider'
3116
+ import { NavHeader } from '../nav-header'
3117
+
3118
+ const geistSans = Geist({
3119
+ variable: '--font-geist-sans',
3120
+ subsets: ['latin'],
3121
+ })
3122
+
3123
+ const geistMono = Geist_Mono({
3124
+ variable: '--font-geist-mono',
3125
+ subsets: ['latin'],
3126
+ })
3127
+
3128
+ export const metadata: Metadata = {
3129
+ title: '${n}',
3130
+ description: 'Built with Lumi CMS Toolkit',
3131
+ }
3132
+
3133
+ export default async function LocaleLayout({
3134
+ children,
3135
+ params,
3136
+ }: {
3137
+ children: React.ReactNode
3138
+ params: Promise<{ locale: string }>
3139
+ }) {
3140
+ const { locale } = await params
3141
+ if (!routing.locales.includes(locale as any)) notFound()
3142
+
3143
+ const messages = await getMessages()
3144
+
3145
+ return (
3146
+ <html lang={locale} suppressHydrationWarning>
3147
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
3148
+ <ThemeProvider>
3149
+ <NextIntlClientProvider locale={locale} messages={messages}>
3150
+ <NavHeader />
3151
+ {children}
3152
+ </NextIntlClientProvider>
3153
+ </ThemeProvider>
3154
+ </body>
3155
+ </html>
3156
+ )
3157
+ }
3158
+ `),await h(r(e,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
3159
+ import { Article, Category } from '@/entities'
3160
+ import { getToolkitApp } from '@/lib/app'
3161
+
3162
+ export default async function HomePage() {
3163
+ await getToolkitApp()
3164
+ const articles = createQueryClient(Article)
3165
+ const categories = createQueryClient(Category)
3166
+
3167
+ const [articleList, categoryList] = await Promise.all([
3168
+ articles.findMany({ limit: 10 }),
3169
+ categories.findMany({}),
3170
+ ])
3171
+
3172
+ return (
3173
+ <div className="min-h-screen p-8">
3174
+ <div className="max-w-4xl mx-auto">
3175
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${n}</h1>
3176
+
3177
+ <section className="mb-12">
3178
+ <h2 className="text-2xl font-semibold mb-4">
3179
+ Categories ({categoryList.length})
3180
+ </h2>
3181
+ <div className="grid gap-4 md:grid-cols-2">
3182
+ {categoryList.map((cat) => (
3183
+ <div
3184
+ key={cat.id}
3185
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
3186
+ >
3187
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
3188
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
3189
+ {cat.description}
3190
+ </p>
3191
+ </div>
3192
+ ))}
3193
+ </div>
3194
+ </section>
3195
+
3196
+ <section>
3197
+ <h2 className="text-2xl font-semibold mb-4">
3198
+ Articles ({articleList.length})
3199
+ </h2>
3200
+ {articleList.length === 0 ? (
3201
+ <p className="text-zinc-500">No published articles yet.</p>
3202
+ ) : (
3203
+ <div className="space-y-4">
3204
+ {articleList.map((article) => (
3205
+ <div
3206
+ key={article.id}
3207
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
3208
+ >
3209
+ <h3 className="text-xl font-bold">{article.title}</h3>
3210
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
3211
+ {article.excerpt}
3212
+ </p>
3213
+ </div>
3214
+ ))}
3215
+ </div>
3216
+ )}
3217
+ </section>
3218
+ </div>
3219
+ </div>
3220
+ )
3221
+ }
3222
+ `)}async function M(e,t){await h(r(e,`scripts/generate-schema.ts`),`import { execSync } from 'node:child_process'
3223
+ execSync('npx @murumets-ee/cli generate', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3224
+ `),await h(r(e,`scripts/migrate.ts`),`import { execSync } from 'node:child_process'
3225
+ execSync('npx @murumets-ee/cli migrate', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3226
+ `),await h(r(e,`scripts/reset-db.ts`),`import { execSync } from 'node:child_process'
3227
+ execSync('npx @murumets-ee/cli reset --force', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3228
+ `),await h(r(e,`scripts/worker.ts`),`/**
3229
+ * Standalone queue worker process.
3230
+ *
3231
+ * Run separately from the Next.js web server:
3232
+ * pnpm worker (or: tsx --env-file=.env scripts/worker.ts)
3233
+ *
3234
+ * The web server should set QUEUE_WORKER=false so it only enqueues jobs.
3235
+ * This process handles job execution.
3236
+ */
3237
+
3238
+ import { createApp, setApp } from '@murumets-ee/core'
3239
+ import config from '../toolkit.config'
3240
+
3241
+ process.env.QUEUE_WORKER = 'true'
3242
+
3243
+ const app = await createApp(config)
3244
+ setApp(app)
3245
+
3246
+ app.logger.info('Queue worker process running. Press Ctrl+C to stop.')
3247
+
3248
+ const shutdown = async () => {
3249
+ app.logger.info('Shutting down queue worker...')
3250
+
3251
+ const worker = (globalThis as Record<symbol, unknown>)[
3252
+ Symbol.for('@murumets-ee/queue:worker')
3253
+ ] as { stop: () => Promise<void> } | undefined
3254
+
3255
+ if (worker) {
3256
+ await worker.stop()
3257
+ }
3258
+
3259
+ process.exit(0)
3260
+ }
3261
+
3262
+ process.on('SIGINT', shutdown)
3263
+ process.on('SIGTERM', shutdown)
3264
+ `)}async function N(e,t){let{name:n}=t;await h(r(e,`app/globals.css`),`@import "tailwindcss";
3265
+ @source "../node_modules/@murumets-ee/auth-ui/dist";
3266
+
3267
+ @custom-variant dark (&:where(.dark, .dark *));
3268
+
3269
+ :root {
3270
+ --background: #ffffff;
3271
+ --foreground: #171717;
3272
+ }
3273
+
3274
+ @theme inline {
3275
+ --color-background: var(--background);
3276
+ --color-foreground: var(--foreground);
3277
+ --font-sans: var(--font-geist-sans);
3278
+ --font-mono: var(--font-geist-mono);
3279
+ }
3280
+
3281
+ .dark {
3282
+ --background: #0a0a0a;
3283
+ --foreground: #ededed;
3284
+ }
3285
+
3286
+ body {
3287
+ background: var(--background);
3288
+ color: var(--foreground);
3289
+ font-family: Arial, Helvetica, sans-serif;
3290
+ }
3291
+ `),await h(r(e,`app/theme-provider.tsx`),`'use client'
3292
+
3293
+ import { ThemeProvider as NextThemesProvider } from 'next-themes'
3294
+ import type { ReactNode } from 'react'
3295
+
3296
+ export function ThemeProvider({ children }: { children: ReactNode }) {
3297
+ return (
3298
+ <NextThemesProvider
3299
+ attribute="class"
3300
+ defaultTheme="system"
3301
+ enableSystem
3302
+ disableTransitionOnChange
3303
+ >
3304
+ {children}
3305
+ </NextThemesProvider>
3306
+ )
3307
+ }
3308
+ `),await h(r(e,`app/theme-toggle.tsx`),`'use client'
3309
+
3310
+ import { useTheme } from 'next-themes'
3311
+ import { useState, useEffect } from 'react'
3312
+ import { Sun, Moon } from 'lucide-react'
3313
+
3314
+ export function ThemeToggle() {
3315
+ const [mounted, setMounted] = useState(false)
3316
+ const { resolvedTheme, setTheme } = useTheme()
3317
+
3318
+ useEffect(() => { setMounted(true) }, [])
3319
+
3320
+ if (!mounted) {
3321
+ return <div className="h-8 w-8" />
3322
+ }
3323
+
3324
+ return (
3325
+ <button
3326
+ type="button"
3327
+ onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
3328
+ 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"
3329
+ aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
3330
+ >
3331
+ {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
3332
+ </button>
3333
+ )
3334
+ }
3335
+ `),await h(r(e,`app/nav-header.tsx`),`'use client'
3336
+
3337
+ import Link from 'next/link'
3338
+ import { usePathname } from 'next/navigation'
3339
+ import { ThemeToggle } from './theme-toggle'
3340
+
3341
+ const links = [
3342
+ { href: '/', label: 'Home' },
3343
+ { href: '/auth/sign-in', label: 'Sign In' },
3344
+ { href: '/setup', label: 'Setup' },
3345
+ ]
3346
+
3347
+ export function NavHeader() {
3348
+ const pathname = usePathname()
3349
+
3350
+ function isActive(href: string) {
3351
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
3352
+ }
3353
+
3354
+ const linkClass = (href: string) =>
3355
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
3356
+ isActive(href)
3357
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
3358
+ : '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'
3359
+ }\`
3360
+
3361
+ return (
3362
+ <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">
3363
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
3364
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
3365
+ ${n}
3366
+ </span>
3367
+ {links.map(({ href, label }) => (
3368
+ <Link key={href} href={href} className={linkClass(href)}>
3369
+ {label}
3370
+ </Link>
3371
+ ))}
3372
+ <div className="ml-auto">
3373
+ <ThemeToggle />
3374
+ </div>
3375
+ </nav>
3376
+ </header>
3377
+ )
3378
+ }
3379
+ `)}async function P(e,t){let{name:n}=t;await h(r(e,`toolkit.config.ts`),`import { auth } from '@murumets-ee/auth/plugin'
3380
+ import { content } from '@murumets-ee/content/plugin'
3381
+ import { defineConfig } from '@murumets-ee/core'
3382
+ import { logging } from '@murumets-ee/logging/plugin'
3383
+ import { mail, ResendMailProvider } from '@murumets-ee/mail'
3384
+ import { media } from '@murumets-ee/media/plugin'
3385
+ import { queue } from '@murumets-ee/queue/plugin'
3386
+ import { settings } from '@murumets-ee/settings/plugin'
3387
+ import { storage } from '@murumets-ee/storage/plugin'
3388
+ import { taxonomy } from '@murumets-ee/taxonomy/plugin'
3389
+ import { ticketing } from '@murumets-ee/ticketing/plugin'
3390
+ import { Article, Category } from './entities'
3391
+ import * as authSchema from './generated/auth-schema'
3392
+
3393
+ if (!process.env.DATABASE_URL) {
3394
+ throw new Error('DATABASE_URL environment variable is required')
3395
+ }
3396
+
3397
+ export default defineConfig({
3398
+ db: {
3399
+ url: process.env.DATABASE_URL,
3400
+ poolMin: 2,
3401
+ poolMax: 10,
3402
+ },
3403
+ logging: {
3404
+ level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
3405
+ name: '${n}',
3406
+ },
3407
+ entities: [Category, Article],
3408
+ plugins: [
3409
+ auth({ providers: ['email'], schema: authSchema }),
3410
+ content({
3411
+ locales: [{ code: 'en', label: 'English' }],
3412
+ defaultLocale: 'en',
3413
+ }),
3414
+ logging(),
3415
+ settings(),
3416
+ storage(),
3417
+ media(),
3418
+ taxonomy(),
3419
+ queue(),
3420
+ mail({
3421
+ provider: process.env.RESEND_API_KEY
3422
+ ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
3423
+ : undefined,
3424
+ defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
3425
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
3426
+ }),
3427
+ ticketing({
3428
+ csatSecret: process.env.CSAT_SECRET,
3429
+ }),
3430
+ ],
3431
+ projectRoot: import.meta.dirname,
3432
+ })
3433
+ `),await h(r(e,`lib/app.ts`),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
3434
+ import config from '../toolkit.config'
3435
+
3436
+ let appInstance: ToolkitApp | null = null
3437
+
3438
+ export async function getToolkitApp(): Promise<ToolkitApp> {
3439
+ if (!appInstance) {
3440
+ appInstance = await createApp(config)
3441
+ setApp(appInstance)
3442
+ }
3443
+ return appInstance
3444
+ }
3445
+ `),await h(r(e,`drizzle.config.ts`),`import type { Config } from 'drizzle-kit'
3446
+
3447
+ if (!process.env.DATABASE_URL) {
3448
+ throw new Error('DATABASE_URL environment variable is required')
3449
+ }
3450
+
3451
+ export default {
3452
+ schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
3453
+ out: './migrations',
3454
+ dialect: 'postgresql',
3455
+ dbCredentials: {
3456
+ url: process.env.DATABASE_URL,
3457
+ },
3458
+ } satisfies Config
3459
+ `),await h(r(e,`auth.config.ts`),`import { betterAuth } from 'better-auth'
3460
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
3461
+ import { admin } from 'better-auth/plugins'
3462
+ import { organization } from 'better-auth/plugins/organization'
3463
+ import { drizzle } from 'drizzle-orm/postgres-js'
3464
+ import postgres from 'postgres'
3465
+
3466
+ const sql = postgres(process.env.DATABASE_URL!)
3467
+ const db = drizzle(sql)
3468
+
3469
+ export const auth = betterAuth({
3470
+ database: drizzleAdapter(db, { provider: 'pg' }),
3471
+ emailAndPassword: { enabled: true },
3472
+ plugins: [
3473
+ admin(),
3474
+ organization(),
3475
+ ],
3476
+ })
3477
+ `),await h(r(e,`generated/auth-schema.ts`),`// This file is generated by better-auth CLI.
3478
+ // Run: npx @better-auth/cli generate --config auth.config.ts -y
3479
+ export {}
3480
+ `)}async function F(e,t){e.mode===`single`?await I(e,t):await L(e,t)}async function I(e,t){let n=r(process.cwd(),e.name);t?.(`Creating Next.js app...`),v(`pnpm create next-app@16 ${e.name} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await _(n,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`]),await g(r(n,`package.json`),{type:`module`,dependencies:{"@murumets-ee/core":`^${T.myorgCore}`,"@murumets-ee/db":`^${T.myorgDb}`,"@murumets-ee/entity":`^${T.myorgEntity}`,"@murumets-ee/logging":`^${T.myorgLogging}`,"@murumets-ee/auth":`^${T.myorgAuth}`,"@murumets-ee/auth-ui":`^${T.myorgAuthUi}`,"@murumets-ee/admin-ui":`^${T.myorgAdminUi}`,"@murumets-ee/content":`^${T.myorgContent}`,"@murumets-ee/settings":`^${T.myorgSettings}`,"@murumets-ee/storage":`^${T.myorgStorage}`,"@murumets-ee/media":`^${T.myorgMedia}`,"@murumets-ee/taxonomy":`^${T.myorgTaxonomy}`,"@murumets-ee/queue":`^${T.myorgQueue}`,"@murumets-ee/mail":`^${T.myorgMail}`,"@murumets-ee/ticketing":`^${T.myorgTicketing}`,"@murumets-ee/ticketing-ui":`^${T.myorgTicketingUi}`,"@murumets-ee/tokens":`^${T.myorgTokens}`,"@murumets-ee/editor":`^${T.myorgEditor}`,"@murumets-ee/blocks":`^${T.myorgBlocks}`,"@murumets-ee/ui":`^${T.myorgUi}`,"better-auth":`^${T.betterAuth}`,"drizzle-orm":`^${T.drizzleOrm}`,"next-intl":`^${T.nextIntl}`,"next-themes":`^${T.nextThemes}`,"lucide-react":`^${T.lucideReact}`,postgres:`^${T.postgres}`,"@tanstack/react-query":`^${T.tanstackReactQuery}`,"@tanstack/react-table":`^${T.tanstackReactTable}`,"react-hook-form":`^${T.reactHookForm}`,"@hookform/resolvers":`^${T.hookformResolvers}`,zod:`^${T.zod}`},devDependencies:{"drizzle-kit":`^${T.drizzleKit}`,tsx:`^${T.tsx}`,"babel-plugin-react-compiler":T.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`,worker:`tsx --env-file=.env scripts/worker.ts`}}),t?.(`Adding toolkit files...`),await S(n,e),await C(n,e),await P(n,e),await x(n,e),await w(n,e),await j(n,e),await N(n,e),await b(n,e),await M(n,e),e.installDeps&&(t?.(`Installing dependencies...`),v(`pnpm install`,{cwd:n}))}async function L(e,t){let n=r(process.cwd(),e.name);t?.(`Creating workspace...`),await E(n,e,t),e.installDeps&&(t?.(`Installing dependencies...`),v(`pnpm install`,{cwd:n}))}async function R(){let n=process.argv[2],r=await p(n);if(!r)return;let i=e.spinner();try{i.start(`Creating Next.js app...`),await F(r,e=>{i.stop(`${t.green(`✓`)} Done`),i.start(e)}),i.stop(`${t.green(`✓`)} Done`);let n=[`cd ${r.name}`,`cp .env.example .env`,`docker compose up -d`,`npx @murumets-ee/cli setup`,`pnpm db:migrate`,`pnpm dev`,`# Visit /setup to create your first admin user`];e.note(n.join(`
3481
+ `),`Next steps`),e.outro(`${t.green(`Success!`)} Your project is ready.`)}catch(n){i.stop(t.red(`Failed.`)),e.log.error(n instanceof Error?n.message:String(n)),process.exit(1)}}R();export{};