@murumets-ee/create 0.1.20 → 0.3.0

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