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