create-raiko 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +21 -2
- package/index.mjs +141 -0
- package/package.json +32 -3
- package/prompt.mjs +110 -0
- package/templates-common.mjs +439 -0
- package/templates.mjs +632 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
// Files shared by every frontend: config, database, auth, email, i18n, deploy.
|
|
2
|
+
export const V = {
|
|
3
|
+
raiko: '^1.0.1', typescript: '5.6.3', bunTypes: '1.1.13', nodeTypes: '22.9.0', tsx: '4.19.2', esbuild: '0.24.0', vite: '5.4.11', terser: '5.36.0',
|
|
4
|
+
kastor: '0.2.0', prisma: '5.22.0', drizzle: '0.38.3', drizzleKit: '0.30.1', betterSqlite: '11.7.0', pg: '8.13.1', postgresJs: '3.4.5',
|
|
5
|
+
glyphfox: '1.3.0', lucide: '0.469.0', heroicons: '2.2.0', motion: '11.15.0', nodemailer: '6.9.16',
|
|
6
|
+
}
|
|
7
|
+
export const LOCALES = { en: 'English', 'pt-BR': 'Portugues', es: 'Espanol', fr: 'Francais', de: 'Deutsch' }
|
|
8
|
+
const T = {
|
|
9
|
+
en: { title: 'Welcome to Raiko', subtitle: 'Edit app/page.tsx and save to reload.', docs: 'Read the docs', count: 'Clicked {count} times' },
|
|
10
|
+
'pt-BR': { title: 'Bem vindo ao Raiko', subtitle: 'Edite app/page.tsx e salve para recarregar.', docs: 'Ler a documentacao', count: 'Clicou {count} vezes' },
|
|
11
|
+
es: { title: 'Bienvenido a Raiko', subtitle: 'Edita app/page.tsx y guarda para recargar.', docs: 'Leer la documentacion', count: 'Pulsado {count} veces' },
|
|
12
|
+
fr: { title: 'Bienvenue sur Raiko', subtitle: 'Modifiez app/page.tsx et enregistrez pour recharger.', docs: 'Lire la documentation', count: 'Clique {count} fois' },
|
|
13
|
+
de: { title: 'Willkommen bei Raiko', subtitle: 'Bearbeite app/page.tsx und speichere zum Neuladen.', docs: 'Dokumentation lesen', count: '{count} mal geklickt' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const isVite = (o) => o.frontend !== 'raiko'
|
|
17
|
+
export const run = (o, s) => (o.pm === 'npm' ? `npm run ${s}` : `${o.pm} ${o.pm === 'yarn' ? '' : 'run '}${s}`.replace(' ', ' '))
|
|
18
|
+
export const exec = (o, s) => (o.pm === 'bun' ? `bunx ${s}` : o.pm === 'pnpm' ? `pnpm dlx ${s}` : `npx ${s}`)
|
|
19
|
+
const json = (v) => JSON.stringify(v, null, 2) + '\n'
|
|
20
|
+
|
|
21
|
+
export function dbFiles(o) {
|
|
22
|
+
const f = {}
|
|
23
|
+
const sqlite = o.db === 'sqlite'
|
|
24
|
+
if (o.orm === 'kastor') {
|
|
25
|
+
f['db/schema.ts'] = `import { table, t } from 'kastor-orm'
|
|
26
|
+
|
|
27
|
+
export const users = table('users', {
|
|
28
|
+
id: t.id(),
|
|
29
|
+
email: t.string(255).unique(),
|
|
30
|
+
name: t.string().nullable(),
|
|
31
|
+
role: t.enum([${Object.keys(o.roles).map((r) => `'${r}'`).join(', ')}, 'user']).default('user'),
|
|
32
|
+
passwordHash: t.string().nullable(),
|
|
33
|
+
totpSecret: t.string().nullable(),
|
|
34
|
+
createdAt: t.datetime().defaultNow(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export const posts = table('posts', {
|
|
38
|
+
id: t.id(),
|
|
39
|
+
title: t.string(200),
|
|
40
|
+
body: t.text().nullable(),
|
|
41
|
+
published: t.boolean().default(false),
|
|
42
|
+
createdAt: t.datetime().defaultNow(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export const schema = { users, posts }
|
|
46
|
+
`
|
|
47
|
+
f['db/index.ts'] = sqlite
|
|
48
|
+
? `import { kastor } from 'kastor-orm'
|
|
49
|
+
import { sqlite } from 'kastor-orm/sqlite'
|
|
50
|
+
import { schema } from './schema'
|
|
51
|
+
|
|
52
|
+
// bun:sqlite on Bun, better-sqlite3 on Node
|
|
53
|
+
const Database = (globalThis as any).Bun ? (await import('bun:sqlite' as string)).Database : (await import('better-sqlite3' as string)).default
|
|
54
|
+
export const db = kastor({ adapter: sqlite(new Database(process.env.DATABASE_URL ?? 'app.db')), schema })
|
|
55
|
+
`
|
|
56
|
+
: `import pg from 'pg'
|
|
57
|
+
import { kastor } from 'kastor-orm'
|
|
58
|
+
import { postgres } from 'kastor-orm/postgres'
|
|
59
|
+
import { schema } from './schema'
|
|
60
|
+
|
|
61
|
+
export const db = kastor({ adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })), schema })
|
|
62
|
+
`
|
|
63
|
+
}
|
|
64
|
+
if (o.orm === 'prisma') {
|
|
65
|
+
f['prisma/schema.prisma'] = `generator client {
|
|
66
|
+
provider = "prisma-client-js"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
datasource db {
|
|
70
|
+
provider = "${sqlite ? 'sqlite' : 'postgresql'}"
|
|
71
|
+
url = env("DATABASE_URL")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
model User {
|
|
75
|
+
id Int @id @default(autoincrement())
|
|
76
|
+
email String @unique
|
|
77
|
+
name String?
|
|
78
|
+
role String @default("user")
|
|
79
|
+
passwordHash String?
|
|
80
|
+
totpSecret String?
|
|
81
|
+
createdAt DateTime @default(now())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
model Post {
|
|
85
|
+
id Int @id @default(autoincrement())
|
|
86
|
+
title String
|
|
87
|
+
body String?
|
|
88
|
+
published Boolean @default(false)
|
|
89
|
+
createdAt DateTime @default(now())
|
|
90
|
+
}
|
|
91
|
+
`
|
|
92
|
+
f['db/index.ts'] = `import { PrismaClient } from '@prisma/client'
|
|
93
|
+
|
|
94
|
+
const g = globalThis as unknown as { prisma?: PrismaClient }
|
|
95
|
+
export const prisma = (g.prisma ??= new PrismaClient())
|
|
96
|
+
// same names as the other templates, so the rest of the app does not care which ORM you picked
|
|
97
|
+
export const db = { users: prisma.user, posts: prisma.post }
|
|
98
|
+
`
|
|
99
|
+
}
|
|
100
|
+
if (o.orm === 'drizzle') {
|
|
101
|
+
f['db/schema.ts'] = sqlite
|
|
102
|
+
? `import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core'
|
|
103
|
+
|
|
104
|
+
export const users = sqliteTable('users', {
|
|
105
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
106
|
+
email: text('email').notNull().unique(),
|
|
107
|
+
name: text('name'),
|
|
108
|
+
role: text('role').notNull().default('user'),
|
|
109
|
+
passwordHash: text('password_hash'),
|
|
110
|
+
totpSecret: text('totp_secret'),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
export const posts = sqliteTable('posts', {
|
|
114
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
115
|
+
title: text('title').notNull(),
|
|
116
|
+
body: text('body'),
|
|
117
|
+
published: integer('published', { mode: 'boolean' }).notNull().default(false),
|
|
118
|
+
})
|
|
119
|
+
`
|
|
120
|
+
: `import { pgTable, serial, text, boolean } from 'drizzle-orm/pg-core'
|
|
121
|
+
|
|
122
|
+
export const users = pgTable('users', {
|
|
123
|
+
id: serial('id').primaryKey(),
|
|
124
|
+
email: text('email').notNull().unique(),
|
|
125
|
+
name: text('name'),
|
|
126
|
+
role: text('role').notNull().default('user'),
|
|
127
|
+
passwordHash: text('password_hash'),
|
|
128
|
+
totpSecret: text('totp_secret'),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
export const posts = pgTable('posts', {
|
|
132
|
+
id: serial('id').primaryKey(),
|
|
133
|
+
title: text('title').notNull(),
|
|
134
|
+
body: text('body'),
|
|
135
|
+
published: boolean('published').notNull().default(false),
|
|
136
|
+
})
|
|
137
|
+
`
|
|
138
|
+
f['db/index.ts'] = sqlite
|
|
139
|
+
? `import * as schema from './schema'
|
|
140
|
+
|
|
141
|
+
const bun = !!(globalThis as any).Bun
|
|
142
|
+
const url = process.env.DATABASE_URL ?? 'app.db'
|
|
143
|
+
export const db = bun
|
|
144
|
+
? (await import('drizzle-orm/bun-sqlite')).drizzle(new (await import('bun:sqlite' as string)).Database(url), { schema })
|
|
145
|
+
: (await import('drizzle-orm/better-sqlite3' as string)).drizzle(new (await import('better-sqlite3' as string)).default(url), { schema })
|
|
146
|
+
export { schema }
|
|
147
|
+
`
|
|
148
|
+
: `import postgres from 'postgres'
|
|
149
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
150
|
+
import * as schema from './schema'
|
|
151
|
+
|
|
152
|
+
export const db = drizzle(postgres(process.env.DATABASE_URL!), { schema })
|
|
153
|
+
export { schema }
|
|
154
|
+
`
|
|
155
|
+
f['drizzle.config.ts'] = `import { defineConfig } from 'drizzle-kit'
|
|
156
|
+
|
|
157
|
+
export default defineConfig({ schema: './db/schema.ts', out: './db/migrations', dialect: '${sqlite ? 'sqlite' : 'postgresql'}', dbCredentials: { url: process.env.DATABASE_URL ?? 'app.db' } })
|
|
158
|
+
`
|
|
159
|
+
}
|
|
160
|
+
return f
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The single config file the user edits later: auth, 2FA, email, i18n, admin roles. */
|
|
164
|
+
export function configFile(o) {
|
|
165
|
+
const lines = [`/**
|
|
166
|
+
* Everything the installer asked lives here. Change it any time.
|
|
167
|
+
* Secrets come from .env (copy .env.example).
|
|
168
|
+
*/
|
|
169
|
+
import { defineConfig } from './shared/config'
|
|
170
|
+
|
|
171
|
+
export default defineConfig({
|
|
172
|
+
name: ${JSON.stringify(o.name)},
|
|
173
|
+
url: process.env.APP_URL ?? 'http://localhost:3000',
|
|
174
|
+
`]
|
|
175
|
+
lines.push(` auth: {
|
|
176
|
+
enabled: ${o.auth.length > 0},
|
|
177
|
+
providers: ${JSON.stringify(o.auth)},
|
|
178
|
+
/** TOTP two factor (Google Authenticator, Authy, 1Password) */
|
|
179
|
+
twoFactor: ${o.twofa},
|
|
180
|
+
},
|
|
181
|
+
email: {
|
|
182
|
+
/** resend | postmark | sendgrid | smtp | console */
|
|
183
|
+
provider: ${JSON.stringify(o.email === 'none' ? 'console' : o.email)},
|
|
184
|
+
from: process.env.MAIL_FROM ?? 'hello@example.com',
|
|
185
|
+
},
|
|
186
|
+
i18n: {
|
|
187
|
+
enabled: ${o.i18n},
|
|
188
|
+
locales: ${JSON.stringify(o.i18n ? Object.keys(LOCALES) : ['en'])},
|
|
189
|
+
default: 'en',
|
|
190
|
+
},
|
|
191
|
+
admin: {
|
|
192
|
+
enabled: ${o.admin},
|
|
193
|
+
path: '/admin',
|
|
194
|
+
/**
|
|
195
|
+
* Permissions per role and resource. Actions: list, read, create, update, delete.
|
|
196
|
+
* '*' allows all five. A role missing here cannot open the admin at all.
|
|
197
|
+
*/
|
|
198
|
+
roles: ${JSON.stringify(o.roles, null, 2).replace(/\n/g, '\n ')},
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
`)
|
|
202
|
+
return lines.join('')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function sharedFiles(o) {
|
|
206
|
+
return {
|
|
207
|
+
'shared/config.ts': `export type Action = 'list' | 'read' | 'create' | 'update' | 'delete'
|
|
208
|
+
export interface AppConfig {
|
|
209
|
+
name: string
|
|
210
|
+
url: string
|
|
211
|
+
auth: { enabled: boolean; providers: string[]; twoFactor: boolean }
|
|
212
|
+
email: { provider: 'resend' | 'postmark' | 'sendgrid' | 'smtp' | 'console'; from: string }
|
|
213
|
+
i18n: { enabled: boolean; locales: string[]; default: string }
|
|
214
|
+
admin: { enabled: boolean; path: string; roles: Record<string, Record<string, Action[] | '*'>> }
|
|
215
|
+
}
|
|
216
|
+
export const defineConfig = (c: AppConfig) => c
|
|
217
|
+
`,
|
|
218
|
+
'shared/schemas.ts': `/**
|
|
219
|
+
* Shared between backend and frontend: one schema, validated on the server,
|
|
220
|
+
* typed on the client.
|
|
221
|
+
*/
|
|
222
|
+
import { t, type Static } from 'raiko.js'
|
|
223
|
+
|
|
224
|
+
export const NewPost = t.Object({
|
|
225
|
+
title: t.String({ minLength: 1, maxLength: 200 }),
|
|
226
|
+
body: t.Optional(t.String({ maxLength: 10_000 })),
|
|
227
|
+
})
|
|
228
|
+
export type NewPost = Static<typeof NewPost>
|
|
229
|
+
|
|
230
|
+
export const ChatMessage = t.Object({ text: t.String({ minLength: 1, maxLength: 500 }) })
|
|
231
|
+
export type ChatMessage = Static<typeof ChatMessage>
|
|
232
|
+
`,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Server pieces used by both kinds of templates. Returns code to paste into the server entry. */
|
|
237
|
+
export function serverPieces(o) {
|
|
238
|
+
const imports = [`import { Raiko, isMain } from 'raiko.js'`, `import { cors, secureHeaders, logger, rateLimit, health, openapi } from 'raiko.js/plugins'`, `import config from '${o.serverDepth}raiko.config'`]
|
|
239
|
+
const uses = [` .use(logger())`, ` .use(secureHeaders({ hsts: process.env.NODE_ENV === 'production' }))`, ` .use(cors({ origin: [config.url], credentials: true }))`, ` .use(rateLimit({ max: 300, window: 60_000 }))`, ` .use(health())`]
|
|
240
|
+
if (o.orm !== 'none') imports.push(`import { db } from '${o.serverDepth}db'`)
|
|
241
|
+
if (o.auth.length) {
|
|
242
|
+
imports.push(`import { auth } from '${o.serverDepth}server/auth'`)
|
|
243
|
+
uses.push(` .use(auth.plugin)`)
|
|
244
|
+
}
|
|
245
|
+
if (o.email !== 'none' || o.auth.length) {
|
|
246
|
+
imports.push(`import { mailer } from '${o.serverDepth}emails'`)
|
|
247
|
+
uses.push(` .use(process.env.NODE_ENV !== 'production' ? mailer.preview() : new Raiko())`)
|
|
248
|
+
}
|
|
249
|
+
if (o.i18n) {
|
|
250
|
+
imports.push(`import { i18n } from '${o.serverDepth}locales'`)
|
|
251
|
+
uses.push(` .use(i18n.plugin())`)
|
|
252
|
+
}
|
|
253
|
+
if (o.admin) {
|
|
254
|
+
imports.push(`import { adminPanel } from '${o.serverDepth}server/admin'`)
|
|
255
|
+
uses.push(` .use(adminPanel)`)
|
|
256
|
+
}
|
|
257
|
+
return { imports, uses }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function authFiles(o) {
|
|
261
|
+
if (!o.auth.length) return {}
|
|
262
|
+
const providers = o.auth.filter((p) => p !== 'credentials').map((p) => `${p[0].toUpperCase()}${p.slice(1).replace('github', 'itHub').replace(/^ithub/, 'itHub')}`)
|
|
263
|
+
const names = { github: 'GitHub', google: 'Google', discord: 'Discord', gitlab: 'GitLab' }
|
|
264
|
+
const imports = ['raikoAuth', ...o.auth.map((p) => (p === 'credentials' ? 'Credentials' : names[p])), ...(o.auth.includes('credentials') ? ['verifyPassword'] : [])]
|
|
265
|
+
const list = o.auth.map((p) =>
|
|
266
|
+
p === 'credentials'
|
|
267
|
+
? ` Credentials({
|
|
268
|
+
async authorize({ email, password }) {
|
|
269
|
+
${o.orm !== 'none' ? ` const user = await db.users.findUnique({ where: { email: String(email) } })
|
|
270
|
+
if (!user?.passwordHash || !(await verifyPassword(String(password), user.passwordHash))) return null
|
|
271
|
+
return { id: String(user.id), email: user.email, name: user.name, role: user.role }` : ` // no database picked: replace this with your own user lookup
|
|
272
|
+
if (email === 'demo@raiko.dev' && password === 'raiko-demo') return { id: '1', email: 'demo@raiko.dev', role: 'admin' }
|
|
273
|
+
return null`}
|
|
274
|
+
},
|
|
275
|
+
}),`
|
|
276
|
+
: ` ${names[p]}({ clientId: process.env.${p.toUpperCase()}_CLIENT_ID!, clientSecret: process.env.${p.toUpperCase()}_CLIENT_SECRET! }),`,
|
|
277
|
+
)
|
|
278
|
+
void providers
|
|
279
|
+
return {
|
|
280
|
+
'server/auth.ts': `import { ${imports.join(', ')} } from 'raiko.js/auth'
|
|
281
|
+
${o.orm !== 'none' ? "import { db } from '../db'\n" : ''}import config from '../raiko.config'
|
|
282
|
+
|
|
283
|
+
export const auth = raikoAuth({
|
|
284
|
+
// a missing secret in production would let anyone forge sessions, so it refuses to start
|
|
285
|
+
// the fallback exists only while developing; anywhere else a missing secret refuses to start,
|
|
286
|
+
// because a known secret would let anyone forge an admin session
|
|
287
|
+
// the installer wrote a random AUTH_SECRET to .env; a missing one refuses to start,
|
|
288
|
+
// because a known secret would let anyone forge an admin session
|
|
289
|
+
secret:
|
|
290
|
+
process.env.AUTH_SECRET ||
|
|
291
|
+
(() => {
|
|
292
|
+
throw new Error('AUTH_SECRET is not set. Generate one with: npx raiko secret')
|
|
293
|
+
})(),
|
|
294
|
+
url: config.url,
|
|
295
|
+
pages: { signIn: '/login', twoFactor: '/login/2fa' },
|
|
296
|
+
providers: [
|
|
297
|
+
${list.join('\n')}
|
|
298
|
+
],${o.twofa ? `
|
|
299
|
+
twoFactor: config.auth.twoFactor
|
|
300
|
+
? {
|
|
301
|
+
// return the user's TOTP secret when they turned 2FA on
|
|
302
|
+
getSecret: async (user) => ${o.orm !== 'none' ? `(await db.users.findUnique({ where: { id: Number(user.id) } }))?.totpSecret ?? null` : 'null'},
|
|
303
|
+
}
|
|
304
|
+
: undefined,` : ''}
|
|
305
|
+
})
|
|
306
|
+
`,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function emailFiles(o) {
|
|
311
|
+
if (o.email === 'none' && !o.auth.length) return {}
|
|
312
|
+
const transport = { resend: `resend({ apiKey: process.env.RESEND_API_KEY! })`, postmark: `postmark({ serverToken: process.env.POSTMARK_TOKEN! })`, sendgrid: `sendgrid({ apiKey: process.env.SENDGRID_API_KEY! })`, smtp: `smtp({ host: process.env.SMTP_HOST!, port: Number(process.env.SMTP_PORT ?? 587), user: process.env.SMTP_USER, pass: process.env.SMTP_PASS })`, none: `consoleTransport()` }[o.email]
|
|
313
|
+
const fn = { resend: 'resend', postmark: 'postmark', sendgrid: 'sendgrid', smtp: 'smtp', none: 'consoleTransport' }[o.email]
|
|
314
|
+
return {
|
|
315
|
+
'emails/welcome.tsx': `/** @jsxImportSource raiko.js */
|
|
316
|
+
/** Preview it at /_raiko/mail/welcome while running the dev server. */
|
|
317
|
+
export default function Welcome({ name }: { name: string }) {
|
|
318
|
+
return (
|
|
319
|
+
<div style={{ fontFamily: 'system-ui, sans-serif', maxWidth: '520px', margin: '0 auto', padding: '32px' }}>
|
|
320
|
+
<h1 style={{ fontSize: '22px' }}>Welcome, {name}</h1>
|
|
321
|
+
<p>Your account is ready.</p>
|
|
322
|
+
<a href="https://example.com" style={{ background: '#ffd23f', color: '#0a0a0b', padding: '10px 18px', borderRadius: '6px', textDecoration: 'none', fontWeight: 600 }}>Open the app</a>
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
Welcome.subject = (p: { name: string }) => \`Welcome, \${p.name}\`
|
|
327
|
+
Welcome.preview = { name: 'Ana' }
|
|
328
|
+
`,
|
|
329
|
+
'emails/index.ts': `import { createMailer, ${fn}${fn !== 'consoleTransport' ? ', consoleTransport' : ''} } from 'raiko.js/mail'
|
|
330
|
+
import config from '../raiko.config'
|
|
331
|
+
import Welcome from './welcome'
|
|
332
|
+
|
|
333
|
+
export const mailer = createMailer({
|
|
334
|
+
from: config.email.from,
|
|
335
|
+
// print emails in development, send them in production
|
|
336
|
+
transport: process.env.NODE_ENV === 'production' ? ${transport} : consoleTransport(),
|
|
337
|
+
templates: { welcome: Welcome },
|
|
338
|
+
})
|
|
339
|
+
`,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function i18nFiles(o) {
|
|
344
|
+
if (!o.i18n) return {}
|
|
345
|
+
const f = {}
|
|
346
|
+
for (const l of Object.keys(LOCALES)) f[`locales/${l}.ts`] = `export default ${JSON.stringify(T[l], null, 2)} as const\n`
|
|
347
|
+
f['locales/index.ts'] = `import { createI18n } from 'raiko.js/i18n'
|
|
348
|
+
${Object.keys(LOCALES).map((l) => `import ${l.replace('-', '')} from './${l}'`).join('\n')}
|
|
349
|
+
|
|
350
|
+
export const i18n = createI18n({ ${Object.keys(LOCALES).map((l) => (l.includes('-') ? `'${l}': ${l.replace('-', '')}` : l)).join(', ')} }, { fallback: 'en', cookie: 'locale' })
|
|
351
|
+
`
|
|
352
|
+
return f
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function adminFiles(o) {
|
|
356
|
+
if (!o.admin) return {}
|
|
357
|
+
const adapter = o.orm === 'drizzle' ? `drizzleAdapter(db, schema.users, { id: schema.users.id, searchable: [schema.users.email] })` : `modelAdapter(db.users, { searchable: ['email', 'name'] })`
|
|
358
|
+
const adapter2 = o.orm === 'drizzle' ? `drizzleAdapter(db, schema.posts, { id: schema.posts.id, searchable: [schema.posts.title] })` : `modelAdapter(db.posts, { searchable: ['title'] })`
|
|
359
|
+
const perm = (res) => `Object.fromEntries(Object.entries(config.admin.roles).map(([role, r]) => [role, r.${res} ?? []]))`
|
|
360
|
+
return {
|
|
361
|
+
'server/admin.ts': `import { admin, ${o.orm === 'drizzle' ? 'drizzleAdapter' : 'modelAdapter'} } from 'raiko.js/admin'
|
|
362
|
+
import { db${o.orm === 'drizzle' ? ', schema' : ''} } from '../db'
|
|
363
|
+
import config from '../raiko.config'
|
|
364
|
+
|
|
365
|
+
/** Permissions come from raiko.config.ts, edit them there. */
|
|
366
|
+
export const adminPanel = admin({
|
|
367
|
+
path: config.admin.path,
|
|
368
|
+
title: \`\${config.name} admin\`,
|
|
369
|
+
${o.auth.length ? `auth: ({ auth }: any) => auth?.user?.role ?? null,` : `// no auth picked: everyone is admin in development, nobody in production
|
|
370
|
+
auth: () => (process.env.NODE_ENV === 'production' ? null : 'admin'),`}
|
|
371
|
+
signIn: '/login',
|
|
372
|
+
resources: {
|
|
373
|
+
users: { adapter: ${adapter}, fields: ['id', 'email', 'name', 'role'], hidden: ['passwordHash', 'totpSecret'], permissions: ${perm('users')} },
|
|
374
|
+
posts: { adapter: ${adapter2}, fields: ['id', 'title', 'published'], permissions: ${perm('posts')} },
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
`,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function deployFiles(o) {
|
|
382
|
+
const f = {}
|
|
383
|
+
const out = isVite(o) ? 'dist' : 'public'
|
|
384
|
+
if (o.deploy === 'vercel') {
|
|
385
|
+
f['api/index.ts'] = `import app from '../${isVite(o) ? 'server/index' : 'server'}'\n\nexport default { fetch: app.fetch }\n`
|
|
386
|
+
f['vercel.json'] = json({
|
|
387
|
+
$schema: 'https://openapi.vercel.sh/vercel.json',
|
|
388
|
+
buildCommand: isVite(o) ? 'npx raiko manifest && npx vite build' : 'npx raiko manifest',
|
|
389
|
+
outputDirectory: out,
|
|
390
|
+
rewrites: isVite(o) ? [{ source: '/(api|auth|admin|docs|_raiko|health)(.*)', destination: '/api' }, { source: '/(.*)', destination: '/index.html' }] : [{ source: '/(.*)', destination: '/api' }],
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
if (o.deploy === 'cloudflare') {
|
|
394
|
+
f['worker.ts'] = `import app from './${isVite(o) ? 'server/index' : 'server'}'\n\nexport default { fetch: app.fetch }\n`
|
|
395
|
+
f['wrangler.toml'] = `name = ${JSON.stringify(o.name)}\nmain = "worker.ts"\n# 2025-04-01 or later fills process.env from vars and secrets: npx wrangler secret put AUTH_SECRET\ncompatibility_date = "2025-04-01"\ncompatibility_flags = ["nodejs_compat"]\n\n[vars]\nNODE_ENV = "production"\n\n[assets]\ndirectory = "./${out}"\n${isVite(o) ? 'not_found_handling = "single-page-application"\n' : ''}`
|
|
396
|
+
}
|
|
397
|
+
if (o.deploy === 'docker' || o.deploy === 'node') {
|
|
398
|
+
const assets = isVite(o) ? 'dist' : 'public'
|
|
399
|
+
f['Dockerfile'] = `# small production image, Bun runtime, runs as a non root user
|
|
400
|
+
FROM oven/bun:1.1-alpine AS build
|
|
401
|
+
WORKDIR /app
|
|
402
|
+
COPY package.json ./
|
|
403
|
+
RUN bun install
|
|
404
|
+
COPY . .
|
|
405
|
+
RUN bun run build && rm -rf node_modules && bun install --production
|
|
406
|
+
|
|
407
|
+
FROM oven/bun:1.1-alpine
|
|
408
|
+
WORKDIR /app
|
|
409
|
+
ENV NODE_ENV=production
|
|
410
|
+
# only what runs: no source, no .env files, no local databases
|
|
411
|
+
COPY --from=build --chown=bun:bun /app/package.json ./
|
|
412
|
+
COPY --from=build --chown=bun:bun /app/node_modules ./node_modules
|
|
413
|
+
COPY --from=build --chown=bun:bun /app/.raiko/out ./.raiko/out
|
|
414
|
+
COPY --from=build --chown=bun:bun /app/${assets} ./${assets}
|
|
415
|
+
USER bun
|
|
416
|
+
EXPOSE 3000
|
|
417
|
+
CMD ["bun", "run", "start"]
|
|
418
|
+
`
|
|
419
|
+
f['.dockerignore'] = 'node_modules\n.git\n.env\n.env.*\n!.env.example\n*.db\n*.db-journal\n*.sqlite\n.raiko\ndist\n*.log\n.vercel\n.wrangler\n'
|
|
420
|
+
}
|
|
421
|
+
return f
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function envExample(o) {
|
|
425
|
+
const l = ['APP_URL=http://localhost:3000', 'PORT=3000']
|
|
426
|
+
if (o.orm !== 'none') l.push(o.db === 'sqlite' ? (o.orm === 'prisma' ? 'DATABASE_URL=file:./app.db' : 'DATABASE_URL=app.db') : 'DATABASE_URL=postgres://user:pass@localhost:5432/app')
|
|
427
|
+
if (o.auth.length) l.push('# generate with: npx raiko secret', 'AUTH_SECRET=')
|
|
428
|
+
for (const p of o.auth.filter((p) => p !== 'credentials')) l.push(`${p.toUpperCase()}_CLIENT_ID=`, `${p.toUpperCase()}_CLIENT_SECRET=`)
|
|
429
|
+
if (o.email !== 'none') l.push('MAIL_FROM=hello@example.com')
|
|
430
|
+
if (o.email === 'resend') l.push('RESEND_API_KEY=')
|
|
431
|
+
if (o.email === 'postmark') l.push('POSTMARK_TOKEN=')
|
|
432
|
+
if (o.email === 'sendgrid') l.push('SENDGRID_API_KEY=')
|
|
433
|
+
if (o.email === 'smtp') l.push('SMTP_HOST=', 'SMTP_PORT=587', 'SMTP_USER=', 'SMTP_PASS=')
|
|
434
|
+
return l.join('\n') + '\n'
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function gitignore() {
|
|
438
|
+
return 'node_modules\ndist\n.raiko\n.env\n.env.*\n!.env.example\n*.log\n*.db\n.vercel\n.wrangler\n.DS_Store\n'
|
|
439
|
+
}
|