@beechcms/cms 0.6.0-preview.3 → 0.6.0-preview.4
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/bin/cli.mjs +270 -231
- package/bin/create.mjs +446 -446
- package/bin/templates/blog.ts +35 -35
- package/bin/templates/commerce.ts +29 -29
- package/bin/templates/contact.ts +23 -23
- package/bin/templates/empty.ts +41 -41
- package/bin/templates/gallery.ts +16 -16
- package/bin/templates/tasks.ts +12 -12
- package/package.json +8 -2
package/bin/create.mjs
CHANGED
|
@@ -1,447 +1,447 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @ts-check
|
|
3
|
-
|
|
4
|
-
import * as p from '@clack/prompts'
|
|
5
|
-
import pc from 'picocolors'
|
|
6
|
-
import { execSync } from 'node:child_process'
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
-
import { resolve, join } from 'node:path'
|
|
9
|
-
import { randomBytes } from 'node:crypto'
|
|
10
|
-
import { fileURLToPath } from 'node:url'
|
|
11
|
-
import { dirname } from 'node:path'
|
|
12
|
-
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
-
|
|
15
|
-
// ── Template registry ─────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
const TEMPLATES = {
|
|
18
|
-
blog: {
|
|
19
|
-
label: 'Blog',
|
|
20
|
-
hint: 'posts with rich text, cover image, tags and authors',
|
|
21
|
-
file: 'blog.ts',
|
|
22
|
-
registryEntries: ['posts: POST_SEED', 'authors: AUTHOR_SEED'],
|
|
23
|
-
},
|
|
24
|
-
gallery: {
|
|
25
|
-
label: 'Gallery',
|
|
26
|
-
hint: 'media items with image, tags and featured flag',
|
|
27
|
-
file: 'gallery.ts',
|
|
28
|
-
registryEntries: ['gallery: GALLERY_SEED'],
|
|
29
|
-
},
|
|
30
|
-
contact: {
|
|
31
|
-
label: 'Contact',
|
|
32
|
-
hint: 'public form submissions with masked email and read status',
|
|
33
|
-
file: 'contact.ts',
|
|
34
|
-
registryEntries: ['messages: MESSAGE_SEED'],
|
|
35
|
-
},
|
|
36
|
-
commerce: {
|
|
37
|
-
label: 'Commerce',
|
|
38
|
-
hint: 'e-commerce product catalog with prices, inventory and ratings',
|
|
39
|
-
file: 'commerce.ts',
|
|
40
|
-
registryEntries: ['products: PRODUCT_SEED', 'reviews: REVIEW_SEED'],
|
|
41
|
-
},
|
|
42
|
-
tasks: {
|
|
43
|
-
label: 'Tasks',
|
|
44
|
-
hint: 'project management tasks with progress slider',
|
|
45
|
-
file: 'tasks.ts',
|
|
46
|
-
registryEntries: ['tasks: TASK_SEED'],
|
|
47
|
-
},
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function readTemplate(filename) {
|
|
51
|
-
return readFileSync(join(__dirname, 'templates', filename), 'utf8')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function buildSeedsFile(selectedKeys) {
|
|
55
|
-
const header = `import type { Seed } from '@beechcms/core'\n\n`
|
|
56
|
-
|
|
57
|
-
if (selectedKeys.length === 0) {
|
|
58
|
-
const example = readFileSync(join(__dirname, 'templates', 'empty.ts'), 'utf8')
|
|
59
|
-
return (
|
|
60
|
-
header +
|
|
61
|
-
example +
|
|
62
|
-
'\nexport const SEED_REGISTRY: Record<string, Seed> = {}\n\n' +
|
|
63
|
-
'export function getSeed(slug: string): Seed | null {\n' +
|
|
64
|
-
' return SEED_REGISTRY[slug] ?? null\n' +
|
|
65
|
-
'}\n'
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const blocks = selectedKeys.map((key) =>
|
|
70
|
-
readFileSync(join(__dirname, 'templates', TEMPLATES[key].file), 'utf8')
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
const registryEntries = selectedKeys.flatMap((key) => TEMPLATES[key].registryEntries)
|
|
74
|
-
const registry =
|
|
75
|
-
'export const SEED_REGISTRY: Record<string, Seed> = {\n' +
|
|
76
|
-
registryEntries.map((e) => ` ${e},`).join('\n') +
|
|
77
|
-
'\n}\n'
|
|
78
|
-
|
|
79
|
-
const getSeed =
|
|
80
|
-
'\nexport function getSeed(slug: string): Seed | null {\n' +
|
|
81
|
-
' return SEED_REGISTRY[slug] ?? null\n' +
|
|
82
|
-
'}\n'
|
|
83
|
-
|
|
84
|
-
return header + blocks.join('\n') + '\n' + registry + getSeed
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
function generateSecret(bytes = 32) {
|
|
90
|
-
return randomBytes(bytes).toString('hex')
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function writeFile(path, content) {
|
|
94
|
-
writeFileSync(path, content, 'utf8')
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function buildWorkerTs() {
|
|
98
|
-
return `/// <reference types="@cloudflare/workers-types" />
|
|
99
|
-
import { createBeechApp } from '@beechcms/api'
|
|
100
|
-
import { SEED_REGISTRY } from './seeds'
|
|
101
|
-
|
|
102
|
-
export default createBeechApp({ seeds: Object.values(SEED_REGISTRY) })
|
|
103
|
-
`
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function buildSeedsTs(selectedKeys) {
|
|
107
|
-
return buildSeedsFile(selectedKeys)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function buildPackageJson(name) {
|
|
111
|
-
return JSON.stringify({
|
|
112
|
-
name,
|
|
113
|
-
version: '0.1.0',
|
|
114
|
-
private: true,
|
|
115
|
-
type: 'module',
|
|
116
|
-
scripts: {
|
|
117
|
-
dev: 'wrangler dev --port 8789',
|
|
118
|
-
deploy: 'wrangler deploy --minify',
|
|
119
|
-
'seed:load': 'npx beech seed:load',
|
|
120
|
-
'seed:load:local': 'npx beech seed:load --local',
|
|
121
|
-
'db:migrate:local': 'wrangler d1 migrations apply ' + name + '-db --local',
|
|
122
|
-
'db:reset:local': 'node -e "require(\'fs\').rmSync(\'.wrangler/state\',{recursive:true,force:true})" && npm run db:migrate:local',
|
|
123
|
-
},
|
|
124
|
-
dependencies: {
|
|
125
|
-
'@beechcms/api': '^0.5.0',
|
|
126
|
-
'@beechcms/core': '^0.5.0',
|
|
127
|
-
},
|
|
128
|
-
devDependencies: {
|
|
129
|
-
'@cloudflare/workers-types': '^4.0.0',
|
|
130
|
-
wrangler: '^4.0.0',
|
|
131
|
-
typescript: '^5.0.0',
|
|
132
|
-
},
|
|
133
|
-
}, null, 2) + '\n'
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function buildWranglerJsonc(cfg) {
|
|
137
|
-
return `{
|
|
138
|
-
"name": "${cfg.name}-api",
|
|
139
|
-
"main": "worker.ts",
|
|
140
|
-
"compatibility_date": "2025-01-01",
|
|
141
|
-
|
|
142
|
-
"vars": {
|
|
143
|
-
"JWT_SECRET": "${cfg.jwtSecret}",
|
|
144
|
-
"CORS_ORIGINS": "${cfg.corsOrigins}",
|
|
145
|
-
"PUBLIC_READ_API_KEY": "${cfg.publicReadKey}",
|
|
146
|
-
"PUBLIC_WRITE_API_KEY": "${cfg.publicWriteKey}",
|
|
147
|
-
"APP_URL": "${cfg.appUrl || 'http://localhost:5173'}"
|
|
148
|
-
},
|
|
149
|
-
|
|
150
|
-
"assets": {
|
|
151
|
-
"binding": "ASSETS",
|
|
152
|
-
"directory": "node_modules/@beechcms/api/assets/dashboard"
|
|
153
|
-
},
|
|
154
|
-
|
|
155
|
-
"d1_databases": [
|
|
156
|
-
{
|
|
157
|
-
"binding": "DB",
|
|
158
|
-
"database_name": "${cfg.d1Name}",
|
|
159
|
-
"database_id": "${cfg.d1Id}",
|
|
160
|
-
"migrations_dir": "node_modules/@beechcms/api/migrations"
|
|
161
|
-
}
|
|
162
|
-
],
|
|
163
|
-
|
|
164
|
-
"r2_buckets": [
|
|
165
|
-
{
|
|
166
|
-
"binding": "MEDIA_BUCKET",
|
|
167
|
-
"bucket_name": "${cfg.r2Bucket}"
|
|
168
|
-
}
|
|
169
|
-
]
|
|
170
|
-
}
|
|
171
|
-
`
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function buildDevVars(cloudflare) {
|
|
175
|
-
if (cloudflare) {
|
|
176
|
-
return [
|
|
177
|
-
`R2_ACCESS_KEY_ID=${cloudflare.r2AccessKey}`,
|
|
178
|
-
`R2_SECRET_ACCESS_KEY=${cloudflare.r2SecretKey}`,
|
|
179
|
-
`R2_ENDPOINT=${cloudflare.r2Endpoint}`,
|
|
180
|
-
`R2_BUCKET_NAME=${cloudflare.r2Bucket}`,
|
|
181
|
-
].join('\n') + '\n'
|
|
182
|
-
}
|
|
183
|
-
return [
|
|
184
|
-
'# R2 credentials — only needed if you want production-like S3 media uploads locally.',
|
|
185
|
-
'# For local development, media uploads work automatically via the Miniflare R2 binding.',
|
|
186
|
-
'# Fill these in only when testing production media behaviour:',
|
|
187
|
-
'# Guide: https://developers.cloudflare.com/r2/api/s3/tokens/',
|
|
188
|
-
'R2_ACCESS_KEY_ID=',
|
|
189
|
-
'R2_SECRET_ACCESS_KEY=',
|
|
190
|
-
'R2_ENDPOINT=https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com',
|
|
191
|
-
'R2_BUCKET_NAME=',
|
|
192
|
-
].join('\n') + '\n'
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function buildTsConfig() {
|
|
196
|
-
return JSON.stringify({
|
|
197
|
-
compilerOptions: {
|
|
198
|
-
target: 'ES2022',
|
|
199
|
-
module: 'ES2022',
|
|
200
|
-
moduleResolution: 'bundler',
|
|
201
|
-
strict: true,
|
|
202
|
-
types: ['@cloudflare/workers-types'],
|
|
203
|
-
},
|
|
204
|
-
include: ['*.ts'],
|
|
205
|
-
}, null, 2) + '\n'
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ── Cloudflare prompts ────────────────────────────────────────────────────────
|
|
209
|
-
|
|
210
|
-
async function askCloudflareConfig(name) {
|
|
211
|
-
p.note(
|
|
212
|
-
[
|
|
213
|
-
'You will need a free Cloudflare account with:',
|
|
214
|
-
'',
|
|
215
|
-
` D1 database → ${pc.cyan('npx wrangler d1 create ' + name + '-db')}`,
|
|
216
|
-
` R2 bucket → ${pc.cyan('npx wrangler r2 bucket create ' + name + '-media')}`,
|
|
217
|
-
'',
|
|
218
|
-
'Docs: https://developers.cloudflare.com/d1/',
|
|
219
|
-
' https://developers.cloudflare.com/r2/',
|
|
220
|
-
].join('\n'),
|
|
221
|
-
'Prerequisites'
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
const accountId = await p.text({
|
|
225
|
-
message: 'Cloudflare Account ID',
|
|
226
|
-
hint: 'dash.cloudflare.com → right sidebar → "Account ID"',
|
|
227
|
-
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
228
|
-
})
|
|
229
|
-
if (p.isCancel(accountId)) return null
|
|
230
|
-
|
|
231
|
-
const d1Name = await p.text({
|
|
232
|
-
message: 'D1 Database name',
|
|
233
|
-
initialValue: `${name}-db`,
|
|
234
|
-
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
235
|
-
})
|
|
236
|
-
if (p.isCancel(d1Name)) return null
|
|
237
|
-
|
|
238
|
-
const d1Id = await p.text({
|
|
239
|
-
message: 'D1 Database ID',
|
|
240
|
-
placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
|
241
|
-
hint: `Run: npx wrangler d1 create ${d1Name} — copy the "database_id" from the output`,
|
|
242
|
-
validate: (v) => { if (!v.trim()) return 'Required — create the D1 database first and paste its ID here' },
|
|
243
|
-
})
|
|
244
|
-
if (p.isCancel(d1Id)) return null
|
|
245
|
-
|
|
246
|
-
const r2Bucket = await p.text({
|
|
247
|
-
message: 'R2 Bucket name',
|
|
248
|
-
initialValue: `${name}-media`,
|
|
249
|
-
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
250
|
-
})
|
|
251
|
-
if (p.isCancel(r2Bucket)) return null
|
|
252
|
-
|
|
253
|
-
p.note(
|
|
254
|
-
[
|
|
255
|
-
'Create an R2 API token:',
|
|
256
|
-
' Cloudflare Dashboard → R2 → "Manage R2 API Tokens"',
|
|
257
|
-
` → Create Token → Object Read & Write → bucket: ${r2Bucket}`,
|
|
258
|
-
].join('\n'),
|
|
259
|
-
'R2 credentials'
|
|
260
|
-
)
|
|
261
|
-
|
|
262
|
-
const r2AccessKey = await p.text({
|
|
263
|
-
message: 'R2 Access Key ID',
|
|
264
|
-
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
265
|
-
})
|
|
266
|
-
if (p.isCancel(r2AccessKey)) return null
|
|
267
|
-
|
|
268
|
-
const r2SecretKey = await p.password({
|
|
269
|
-
message: 'R2 Secret Access Key',
|
|
270
|
-
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
271
|
-
})
|
|
272
|
-
if (p.isCancel(r2SecretKey)) return null
|
|
273
|
-
|
|
274
|
-
const appUrl = await p.text({
|
|
275
|
-
message: 'Production dashboard URL (for CORS)',
|
|
276
|
-
placeholder: `https://cms.${name}.com`,
|
|
277
|
-
hint: 'Leave empty to configure later in wrangler.jsonc',
|
|
278
|
-
})
|
|
279
|
-
if (p.isCancel(appUrl)) return null
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
accountId: accountId.trim(),
|
|
283
|
-
d1Name: d1Name.trim(),
|
|
284
|
-
d1Id: d1Id.trim(),
|
|
285
|
-
r2Bucket: r2Bucket.trim(),
|
|
286
|
-
r2AccessKey: r2AccessKey.trim(),
|
|
287
|
-
r2SecretKey: r2SecretKey.trim(),
|
|
288
|
-
r2Endpoint: `https://${accountId.trim()}.r2.cloudflarestorage.com`,
|
|
289
|
-
appUrl: appUrl?.trim() ?? '',
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
294
|
-
|
|
295
|
-
async function main() {
|
|
296
|
-
const argv = process.argv.slice(2)
|
|
297
|
-
const silent = argv.includes('--yes') || argv.includes('-y') || !process.stdout.isTTY
|
|
298
|
-
|
|
299
|
-
console.log()
|
|
300
|
-
p.intro(pc.bgGreen(pc.black(' @beechcms/cms ')))
|
|
301
|
-
|
|
302
|
-
let name, selectedTemplates, cloudflare
|
|
303
|
-
|
|
304
|
-
if (silent) {
|
|
305
|
-
// Non-interactive: use first positional arg or default name, skip Cloudflare
|
|
306
|
-
const positional = argv.find((a) => !a.startsWith('-'))
|
|
307
|
-
name = positional ?? 'my-beech-project'
|
|
308
|
-
const withExamples = argv.includes('--with-examples') || argv.includes('--examples')
|
|
309
|
-
selectedTemplates = withExamples ? ['blog'] : []
|
|
310
|
-
cloudflare = null
|
|
311
|
-
const examplesNote = withExamples ? ' (with blog example content types)' : ''
|
|
312
|
-
console.log(pc.dim(` Running in non-interactive mode. Project name: ${name}${examplesNote}`))
|
|
313
|
-
} else {
|
|
314
|
-
// Project name
|
|
315
|
-
const projectName = await p.text({
|
|
316
|
-
message: 'Project name',
|
|
317
|
-
placeholder: 'my-website',
|
|
318
|
-
validate: (v) => {
|
|
319
|
-
if (!v.trim()) return 'Required'
|
|
320
|
-
if (!/^[a-z0-9][a-z0-9-]*$/.test(v.trim())) return 'Lowercase letters, numbers and hyphens only'
|
|
321
|
-
},
|
|
322
|
-
})
|
|
323
|
-
if (p.isCancel(projectName)) { p.cancel('Cancelled'); process.exit(0) }
|
|
324
|
-
name = projectName.trim()
|
|
325
|
-
|
|
326
|
-
// Content types
|
|
327
|
-
const tmpl = await p.multiselect({
|
|
328
|
-
message: 'Which content types do you need?',
|
|
329
|
-
hint: 'Space to select, Enter to confirm. You can add more later in seeds.ts',
|
|
330
|
-
options: [
|
|
331
|
-
{ value: 'blog', label: 'Blog', hint: 'posts with rich text, cover image, tags and authors' },
|
|
332
|
-
{ value: 'gallery', label: 'Gallery', hint: 'media items with image, tags and featured flag' },
|
|
333
|
-
{ value: 'contact', label: 'Contact', hint: 'public form submissions with masked email and read status' },
|
|
334
|
-
{ value: 'commerce',label: 'Commerce',hint: 'e-commerce product catalog with prices, inventory and ratings' },
|
|
335
|
-
{ value: 'tasks', label: 'Tasks', hint: 'project management tasks with progress slider' },
|
|
336
|
-
],
|
|
337
|
-
required: false,
|
|
338
|
-
})
|
|
339
|
-
if (p.isCancel(tmpl)) { p.cancel('Cancelled'); process.exit(0) }
|
|
340
|
-
selectedTemplates = tmpl
|
|
341
|
-
|
|
342
|
-
// Cloudflare now or later?
|
|
343
|
-
const configureNow = await p.confirm({
|
|
344
|
-
message: 'Configure Cloudflare credentials now?',
|
|
345
|
-
hint: 'Choose "No" to scaffold the project and fill in the values later',
|
|
346
|
-
initialValue: true,
|
|
347
|
-
})
|
|
348
|
-
if (p.isCancel(configureNow)) { p.cancel('Cancelled'); process.exit(0) }
|
|
349
|
-
|
|
350
|
-
if (configureNow) {
|
|
351
|
-
cloudflare = await askCloudflareConfig(name)
|
|
352
|
-
if (!cloudflare) { p.cancel('Cancelled'); process.exit(0) }
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const targetDir = resolve(process.cwd(), name)
|
|
357
|
-
if (existsSync(targetDir)) {
|
|
358
|
-
p.cancel(`Directory '${name}' already exists. Choose a different name or delete the folder.`)
|
|
359
|
-
process.exit(1)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const jwtSecret = generateSecret(32)
|
|
363
|
-
const publicReadKey = generateSecret(16)
|
|
364
|
-
const publicWriteKey = generateSecret(16)
|
|
365
|
-
|
|
366
|
-
const corsOrigins = cloudflare
|
|
367
|
-
? ['http://localhost:5173', 'http://localhost:5174', cloudflare.appUrl]
|
|
368
|
-
.filter(Boolean).join(',')
|
|
369
|
-
: 'http://localhost:5173,http://localhost:5174'
|
|
370
|
-
|
|
371
|
-
// Scaffold
|
|
372
|
-
const s = p.spinner()
|
|
373
|
-
s.start('Scaffolding project…')
|
|
374
|
-
|
|
375
|
-
mkdirSync(targetDir, { recursive: true })
|
|
376
|
-
|
|
377
|
-
writeFile(join(targetDir, 'seeds.ts'), buildSeedsTs(selectedTemplates))
|
|
378
|
-
writeFile(join(targetDir, 'worker.ts'), buildWorkerTs())
|
|
379
|
-
writeFile(join(targetDir, 'package.json'), buildPackageJson(name))
|
|
380
|
-
writeFile(join(targetDir, 'tsconfig.json'), buildTsConfig())
|
|
381
|
-
writeFile(join(targetDir, 'wrangler.jsonc'), buildWranglerJsonc({
|
|
382
|
-
name,
|
|
383
|
-
d1Name: cloudflare?.d1Name ?? `${name}-db`,
|
|
384
|
-
d1Id: cloudflare?.d1Id ?? 'FILL_IN_YOUR_D1_DATABASE_ID',
|
|
385
|
-
r2Bucket: cloudflare?.r2Bucket ?? `${name}-media`,
|
|
386
|
-
jwtSecret,
|
|
387
|
-
corsOrigins,
|
|
388
|
-
publicReadKey,
|
|
389
|
-
publicWriteKey,
|
|
390
|
-
appUrl: cloudflare?.appUrl ?? '',
|
|
391
|
-
}))
|
|
392
|
-
writeFile(join(targetDir, '.dev.vars'), buildDevVars(cloudflare))
|
|
393
|
-
writeFile(join(targetDir, '.gitignore'), '.wrangler\nnode_modules\n.dev.vars\ndist\n')
|
|
394
|
-
|
|
395
|
-
s.stop('Project scaffolded')
|
|
396
|
-
|
|
397
|
-
// Init git
|
|
398
|
-
s.start('Initialising git repository…')
|
|
399
|
-
try {
|
|
400
|
-
execSync(`git -C "${targetDir}" init -q`, { stdio: 'pipe' })
|
|
401
|
-
execSync(`git -C "${targetDir}" add -A`, { stdio: 'pipe' })
|
|
402
|
-
execSync(`git -C "${targetDir}" commit -q -m "feat: initialise BeechCMS project"`, { stdio: 'pipe' })
|
|
403
|
-
s.stop('Git initialised')
|
|
404
|
-
} catch {
|
|
405
|
-
s.stop('Git skipped (not available)')
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const pendingConfig = !cloudflare
|
|
409
|
-
const step = (n) => pc.bold(String(n + (pendingConfig ? 1 : 0)))
|
|
410
|
-
console.log()
|
|
411
|
-
p.note(
|
|
412
|
-
[
|
|
413
|
-
`${pc.bold('1. Enter the project')}`,
|
|
414
|
-
` ${pc.cyan('cd ' + name)}`,
|
|
415
|
-
'',
|
|
416
|
-
`${pc.bold('2. Install dependencies')}`,
|
|
417
|
-
` ${pc.cyan('npm install')}`,
|
|
418
|
-
'',
|
|
419
|
-
...(pendingConfig ? [
|
|
420
|
-
`${pc.bold('3. Complete Cloudflare configuration')} ${pc.yellow('← pending')}`,
|
|
421
|
-
` Edit ${pc.underline('wrangler.jsonc')} → fill in ${pc.yellow('database_id')} (D1) and ${pc.yellow('bucket_name')} (R2)`,
|
|
422
|
-
` Guide: https://developers.cloudflare.com/d1/`,
|
|
423
|
-
` ${pc.dim('Note: media uploads work locally without R2 credentials (.dev.vars optional)')}`,
|
|
424
|
-
'',
|
|
425
|
-
] : []),
|
|
426
|
-
`${step(3)}. Run local migrations`,
|
|
427
|
-
` ${pc.cyan('npm run db:migrate:local')}`,
|
|
428
|
-
'',
|
|
429
|
-
`${step(4)}. Start the dev server`,
|
|
430
|
-
` ${pc.cyan('npx wrangler dev')}`,
|
|
431
|
-
` Then open: ${pc.underline('http://localhost:8789/admin')}`,
|
|
432
|
-
'',
|
|
433
|
-
`${step(5)}. Deploy to production`,
|
|
434
|
-
` ${pc.cyan('npm run deploy')}`,
|
|
435
|
-
'',
|
|
436
|
-
`${pc.dim('Your content types are defined in seeds.ts')}`,
|
|
437
|
-
`${pc.dim('JWT secret and API keys have been auto-generated.')}`,
|
|
438
|
-
].join('\n'),
|
|
439
|
-
'Next steps'
|
|
440
|
-
)
|
|
441
|
-
p.outro(pc.green(`✔ BeechCMS project ready — ${name}/`))
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
main().catch((err) => {
|
|
445
|
-
console.error(err)
|
|
446
|
-
process.exit(1)
|
|
447
|
-
})
|
|
2
|
+
// @ts-check
|
|
3
|
+
|
|
4
|
+
import * as p from '@clack/prompts'
|
|
5
|
+
import pc from 'picocolors'
|
|
6
|
+
import { execSync } from 'node:child_process'
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { resolve, join } from 'node:path'
|
|
9
|
+
import { randomBytes } from 'node:crypto'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { dirname } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
|
|
15
|
+
// ── Template registry ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const TEMPLATES = {
|
|
18
|
+
blog: {
|
|
19
|
+
label: 'Blog',
|
|
20
|
+
hint: 'posts with rich text, cover image, tags and authors',
|
|
21
|
+
file: 'blog.ts',
|
|
22
|
+
registryEntries: ['posts: POST_SEED', 'authors: AUTHOR_SEED'],
|
|
23
|
+
},
|
|
24
|
+
gallery: {
|
|
25
|
+
label: 'Gallery',
|
|
26
|
+
hint: 'media items with image, tags and featured flag',
|
|
27
|
+
file: 'gallery.ts',
|
|
28
|
+
registryEntries: ['gallery: GALLERY_SEED'],
|
|
29
|
+
},
|
|
30
|
+
contact: {
|
|
31
|
+
label: 'Contact',
|
|
32
|
+
hint: 'public form submissions with masked email and read status',
|
|
33
|
+
file: 'contact.ts',
|
|
34
|
+
registryEntries: ['messages: MESSAGE_SEED'],
|
|
35
|
+
},
|
|
36
|
+
commerce: {
|
|
37
|
+
label: 'Commerce',
|
|
38
|
+
hint: 'e-commerce product catalog with prices, inventory and ratings',
|
|
39
|
+
file: 'commerce.ts',
|
|
40
|
+
registryEntries: ['products: PRODUCT_SEED', 'reviews: REVIEW_SEED'],
|
|
41
|
+
},
|
|
42
|
+
tasks: {
|
|
43
|
+
label: 'Tasks',
|
|
44
|
+
hint: 'project management tasks with progress slider',
|
|
45
|
+
file: 'tasks.ts',
|
|
46
|
+
registryEntries: ['tasks: TASK_SEED'],
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readTemplate(filename) {
|
|
51
|
+
return readFileSync(join(__dirname, 'templates', filename), 'utf8')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildSeedsFile(selectedKeys) {
|
|
55
|
+
const header = `import type { Seed } from '@beechcms/core'\n\n`
|
|
56
|
+
|
|
57
|
+
if (selectedKeys.length === 0) {
|
|
58
|
+
const example = readFileSync(join(__dirname, 'templates', 'empty.ts'), 'utf8')
|
|
59
|
+
return (
|
|
60
|
+
header +
|
|
61
|
+
example +
|
|
62
|
+
'\nexport const SEED_REGISTRY: Record<string, Seed> = {}\n\n' +
|
|
63
|
+
'export function getSeed(slug: string): Seed | null {\n' +
|
|
64
|
+
' return SEED_REGISTRY[slug] ?? null\n' +
|
|
65
|
+
'}\n'
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const blocks = selectedKeys.map((key) =>
|
|
70
|
+
readFileSync(join(__dirname, 'templates', TEMPLATES[key].file), 'utf8')
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const registryEntries = selectedKeys.flatMap((key) => TEMPLATES[key].registryEntries)
|
|
74
|
+
const registry =
|
|
75
|
+
'export const SEED_REGISTRY: Record<string, Seed> = {\n' +
|
|
76
|
+
registryEntries.map((e) => ` ${e},`).join('\n') +
|
|
77
|
+
'\n}\n'
|
|
78
|
+
|
|
79
|
+
const getSeed =
|
|
80
|
+
'\nexport function getSeed(slug: string): Seed | null {\n' +
|
|
81
|
+
' return SEED_REGISTRY[slug] ?? null\n' +
|
|
82
|
+
'}\n'
|
|
83
|
+
|
|
84
|
+
return header + blocks.join('\n') + '\n' + registry + getSeed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function generateSecret(bytes = 32) {
|
|
90
|
+
return randomBytes(bytes).toString('hex')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeFile(path, content) {
|
|
94
|
+
writeFileSync(path, content, 'utf8')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildWorkerTs() {
|
|
98
|
+
return `/// <reference types="@cloudflare/workers-types" />
|
|
99
|
+
import { createBeechApp } from '@beechcms/api'
|
|
100
|
+
import { SEED_REGISTRY } from './seeds'
|
|
101
|
+
|
|
102
|
+
export default createBeechApp({ seeds: Object.values(SEED_REGISTRY) })
|
|
103
|
+
`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildSeedsTs(selectedKeys) {
|
|
107
|
+
return buildSeedsFile(selectedKeys)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildPackageJson(name) {
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
name,
|
|
113
|
+
version: '0.1.0',
|
|
114
|
+
private: true,
|
|
115
|
+
type: 'module',
|
|
116
|
+
scripts: {
|
|
117
|
+
dev: 'wrangler dev --port 8789',
|
|
118
|
+
deploy: 'wrangler deploy --minify',
|
|
119
|
+
'seed:load': 'npx beech seed:load',
|
|
120
|
+
'seed:load:local': 'npx beech seed:load --local',
|
|
121
|
+
'db:migrate:local': 'wrangler d1 migrations apply ' + name + '-db --local',
|
|
122
|
+
'db:reset:local': 'node -e "require(\'fs\').rmSync(\'.wrangler/state\',{recursive:true,force:true})" && npm run db:migrate:local',
|
|
123
|
+
},
|
|
124
|
+
dependencies: {
|
|
125
|
+
'@beechcms/api': '^0.5.0',
|
|
126
|
+
'@beechcms/core': '^0.5.0',
|
|
127
|
+
},
|
|
128
|
+
devDependencies: {
|
|
129
|
+
'@cloudflare/workers-types': '^4.0.0',
|
|
130
|
+
wrangler: '^4.0.0',
|
|
131
|
+
typescript: '^5.0.0',
|
|
132
|
+
},
|
|
133
|
+
}, null, 2) + '\n'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildWranglerJsonc(cfg) {
|
|
137
|
+
return `{
|
|
138
|
+
"name": "${cfg.name}-api",
|
|
139
|
+
"main": "worker.ts",
|
|
140
|
+
"compatibility_date": "2025-01-01",
|
|
141
|
+
|
|
142
|
+
"vars": {
|
|
143
|
+
"JWT_SECRET": "${cfg.jwtSecret}",
|
|
144
|
+
"CORS_ORIGINS": "${cfg.corsOrigins}",
|
|
145
|
+
"PUBLIC_READ_API_KEY": "${cfg.publicReadKey}",
|
|
146
|
+
"PUBLIC_WRITE_API_KEY": "${cfg.publicWriteKey}",
|
|
147
|
+
"APP_URL": "${cfg.appUrl || 'http://localhost:5173'}"
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
"assets": {
|
|
151
|
+
"binding": "ASSETS",
|
|
152
|
+
"directory": "node_modules/@beechcms/api/assets/dashboard"
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
"d1_databases": [
|
|
156
|
+
{
|
|
157
|
+
"binding": "DB",
|
|
158
|
+
"database_name": "${cfg.d1Name}",
|
|
159
|
+
"database_id": "${cfg.d1Id}",
|
|
160
|
+
"migrations_dir": "node_modules/@beechcms/api/migrations"
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
|
|
164
|
+
"r2_buckets": [
|
|
165
|
+
{
|
|
166
|
+
"binding": "MEDIA_BUCKET",
|
|
167
|
+
"bucket_name": "${cfg.r2Bucket}"
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildDevVars(cloudflare) {
|
|
175
|
+
if (cloudflare) {
|
|
176
|
+
return [
|
|
177
|
+
`R2_ACCESS_KEY_ID=${cloudflare.r2AccessKey}`,
|
|
178
|
+
`R2_SECRET_ACCESS_KEY=${cloudflare.r2SecretKey}`,
|
|
179
|
+
`R2_ENDPOINT=${cloudflare.r2Endpoint}`,
|
|
180
|
+
`R2_BUCKET_NAME=${cloudflare.r2Bucket}`,
|
|
181
|
+
].join('\n') + '\n'
|
|
182
|
+
}
|
|
183
|
+
return [
|
|
184
|
+
'# R2 credentials — only needed if you want production-like S3 media uploads locally.',
|
|
185
|
+
'# For local development, media uploads work automatically via the Miniflare R2 binding.',
|
|
186
|
+
'# Fill these in only when testing production media behaviour:',
|
|
187
|
+
'# Guide: https://developers.cloudflare.com/r2/api/s3/tokens/',
|
|
188
|
+
'R2_ACCESS_KEY_ID=',
|
|
189
|
+
'R2_SECRET_ACCESS_KEY=',
|
|
190
|
+
'R2_ENDPOINT=https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com',
|
|
191
|
+
'R2_BUCKET_NAME=',
|
|
192
|
+
].join('\n') + '\n'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildTsConfig() {
|
|
196
|
+
return JSON.stringify({
|
|
197
|
+
compilerOptions: {
|
|
198
|
+
target: 'ES2022',
|
|
199
|
+
module: 'ES2022',
|
|
200
|
+
moduleResolution: 'bundler',
|
|
201
|
+
strict: true,
|
|
202
|
+
types: ['@cloudflare/workers-types'],
|
|
203
|
+
},
|
|
204
|
+
include: ['*.ts'],
|
|
205
|
+
}, null, 2) + '\n'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Cloudflare prompts ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async function askCloudflareConfig(name) {
|
|
211
|
+
p.note(
|
|
212
|
+
[
|
|
213
|
+
'You will need a free Cloudflare account with:',
|
|
214
|
+
'',
|
|
215
|
+
` D1 database → ${pc.cyan('npx wrangler d1 create ' + name + '-db')}`,
|
|
216
|
+
` R2 bucket → ${pc.cyan('npx wrangler r2 bucket create ' + name + '-media')}`,
|
|
217
|
+
'',
|
|
218
|
+
'Docs: https://developers.cloudflare.com/d1/',
|
|
219
|
+
' https://developers.cloudflare.com/r2/',
|
|
220
|
+
].join('\n'),
|
|
221
|
+
'Prerequisites'
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const accountId = await p.text({
|
|
225
|
+
message: 'Cloudflare Account ID',
|
|
226
|
+
hint: 'dash.cloudflare.com → right sidebar → "Account ID"',
|
|
227
|
+
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
228
|
+
})
|
|
229
|
+
if (p.isCancel(accountId)) return null
|
|
230
|
+
|
|
231
|
+
const d1Name = await p.text({
|
|
232
|
+
message: 'D1 Database name',
|
|
233
|
+
initialValue: `${name}-db`,
|
|
234
|
+
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
235
|
+
})
|
|
236
|
+
if (p.isCancel(d1Name)) return null
|
|
237
|
+
|
|
238
|
+
const d1Id = await p.text({
|
|
239
|
+
message: 'D1 Database ID',
|
|
240
|
+
placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
|
241
|
+
hint: `Run: npx wrangler d1 create ${d1Name} — copy the "database_id" from the output`,
|
|
242
|
+
validate: (v) => { if (!v.trim()) return 'Required — create the D1 database first and paste its ID here' },
|
|
243
|
+
})
|
|
244
|
+
if (p.isCancel(d1Id)) return null
|
|
245
|
+
|
|
246
|
+
const r2Bucket = await p.text({
|
|
247
|
+
message: 'R2 Bucket name',
|
|
248
|
+
initialValue: `${name}-media`,
|
|
249
|
+
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
250
|
+
})
|
|
251
|
+
if (p.isCancel(r2Bucket)) return null
|
|
252
|
+
|
|
253
|
+
p.note(
|
|
254
|
+
[
|
|
255
|
+
'Create an R2 API token:',
|
|
256
|
+
' Cloudflare Dashboard → R2 → "Manage R2 API Tokens"',
|
|
257
|
+
` → Create Token → Object Read & Write → bucket: ${r2Bucket}`,
|
|
258
|
+
].join('\n'),
|
|
259
|
+
'R2 credentials'
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const r2AccessKey = await p.text({
|
|
263
|
+
message: 'R2 Access Key ID',
|
|
264
|
+
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
265
|
+
})
|
|
266
|
+
if (p.isCancel(r2AccessKey)) return null
|
|
267
|
+
|
|
268
|
+
const r2SecretKey = await p.password({
|
|
269
|
+
message: 'R2 Secret Access Key',
|
|
270
|
+
validate: (v) => { if (!v.trim()) return 'Required' },
|
|
271
|
+
})
|
|
272
|
+
if (p.isCancel(r2SecretKey)) return null
|
|
273
|
+
|
|
274
|
+
const appUrl = await p.text({
|
|
275
|
+
message: 'Production dashboard URL (for CORS)',
|
|
276
|
+
placeholder: `https://cms.${name}.com`,
|
|
277
|
+
hint: 'Leave empty to configure later in wrangler.jsonc',
|
|
278
|
+
})
|
|
279
|
+
if (p.isCancel(appUrl)) return null
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
accountId: accountId.trim(),
|
|
283
|
+
d1Name: d1Name.trim(),
|
|
284
|
+
d1Id: d1Id.trim(),
|
|
285
|
+
r2Bucket: r2Bucket.trim(),
|
|
286
|
+
r2AccessKey: r2AccessKey.trim(),
|
|
287
|
+
r2SecretKey: r2SecretKey.trim(),
|
|
288
|
+
r2Endpoint: `https://${accountId.trim()}.r2.cloudflarestorage.com`,
|
|
289
|
+
appUrl: appUrl?.trim() ?? '',
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
async function main() {
|
|
296
|
+
const argv = process.argv.slice(2)
|
|
297
|
+
const silent = argv.includes('--yes') || argv.includes('-y') || !process.stdout.isTTY
|
|
298
|
+
|
|
299
|
+
console.log()
|
|
300
|
+
p.intro(pc.bgGreen(pc.black(' @beechcms/cms ')))
|
|
301
|
+
|
|
302
|
+
let name, selectedTemplates, cloudflare
|
|
303
|
+
|
|
304
|
+
if (silent) {
|
|
305
|
+
// Non-interactive: use first positional arg or default name, skip Cloudflare
|
|
306
|
+
const positional = argv.find((a) => !a.startsWith('-'))
|
|
307
|
+
name = positional ?? 'my-beech-project'
|
|
308
|
+
const withExamples = argv.includes('--with-examples') || argv.includes('--examples')
|
|
309
|
+
selectedTemplates = withExamples ? ['blog'] : []
|
|
310
|
+
cloudflare = null
|
|
311
|
+
const examplesNote = withExamples ? ' (with blog example content types)' : ''
|
|
312
|
+
console.log(pc.dim(` Running in non-interactive mode. Project name: ${name}${examplesNote}`))
|
|
313
|
+
} else {
|
|
314
|
+
// Project name
|
|
315
|
+
const projectName = await p.text({
|
|
316
|
+
message: 'Project name',
|
|
317
|
+
placeholder: 'my-website',
|
|
318
|
+
validate: (v) => {
|
|
319
|
+
if (!v.trim()) return 'Required'
|
|
320
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(v.trim())) return 'Lowercase letters, numbers and hyphens only'
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
if (p.isCancel(projectName)) { p.cancel('Cancelled'); process.exit(0) }
|
|
324
|
+
name = projectName.trim()
|
|
325
|
+
|
|
326
|
+
// Content types
|
|
327
|
+
const tmpl = await p.multiselect({
|
|
328
|
+
message: 'Which content types do you need?',
|
|
329
|
+
hint: 'Space to select, Enter to confirm. You can add more later in seeds.ts',
|
|
330
|
+
options: [
|
|
331
|
+
{ value: 'blog', label: 'Blog', hint: 'posts with rich text, cover image, tags and authors' },
|
|
332
|
+
{ value: 'gallery', label: 'Gallery', hint: 'media items with image, tags and featured flag' },
|
|
333
|
+
{ value: 'contact', label: 'Contact', hint: 'public form submissions with masked email and read status' },
|
|
334
|
+
{ value: 'commerce',label: 'Commerce',hint: 'e-commerce product catalog with prices, inventory and ratings' },
|
|
335
|
+
{ value: 'tasks', label: 'Tasks', hint: 'project management tasks with progress slider' },
|
|
336
|
+
],
|
|
337
|
+
required: false,
|
|
338
|
+
})
|
|
339
|
+
if (p.isCancel(tmpl)) { p.cancel('Cancelled'); process.exit(0) }
|
|
340
|
+
selectedTemplates = tmpl
|
|
341
|
+
|
|
342
|
+
// Cloudflare now or later?
|
|
343
|
+
const configureNow = await p.confirm({
|
|
344
|
+
message: 'Configure Cloudflare credentials now?',
|
|
345
|
+
hint: 'Choose "No" to scaffold the project and fill in the values later',
|
|
346
|
+
initialValue: true,
|
|
347
|
+
})
|
|
348
|
+
if (p.isCancel(configureNow)) { p.cancel('Cancelled'); process.exit(0) }
|
|
349
|
+
|
|
350
|
+
if (configureNow) {
|
|
351
|
+
cloudflare = await askCloudflareConfig(name)
|
|
352
|
+
if (!cloudflare) { p.cancel('Cancelled'); process.exit(0) }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const targetDir = resolve(process.cwd(), name)
|
|
357
|
+
if (existsSync(targetDir)) {
|
|
358
|
+
p.cancel(`Directory '${name}' already exists. Choose a different name or delete the folder.`)
|
|
359
|
+
process.exit(1)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const jwtSecret = generateSecret(32)
|
|
363
|
+
const publicReadKey = generateSecret(16)
|
|
364
|
+
const publicWriteKey = generateSecret(16)
|
|
365
|
+
|
|
366
|
+
const corsOrigins = cloudflare
|
|
367
|
+
? ['http://localhost:5173', 'http://localhost:5174', cloudflare.appUrl]
|
|
368
|
+
.filter(Boolean).join(',')
|
|
369
|
+
: 'http://localhost:5173,http://localhost:5174'
|
|
370
|
+
|
|
371
|
+
// Scaffold
|
|
372
|
+
const s = p.spinner()
|
|
373
|
+
s.start('Scaffolding project…')
|
|
374
|
+
|
|
375
|
+
mkdirSync(targetDir, { recursive: true })
|
|
376
|
+
|
|
377
|
+
writeFile(join(targetDir, 'seeds.ts'), buildSeedsTs(selectedTemplates))
|
|
378
|
+
writeFile(join(targetDir, 'worker.ts'), buildWorkerTs())
|
|
379
|
+
writeFile(join(targetDir, 'package.json'), buildPackageJson(name))
|
|
380
|
+
writeFile(join(targetDir, 'tsconfig.json'), buildTsConfig())
|
|
381
|
+
writeFile(join(targetDir, 'wrangler.jsonc'), buildWranglerJsonc({
|
|
382
|
+
name,
|
|
383
|
+
d1Name: cloudflare?.d1Name ?? `${name}-db`,
|
|
384
|
+
d1Id: cloudflare?.d1Id ?? 'FILL_IN_YOUR_D1_DATABASE_ID',
|
|
385
|
+
r2Bucket: cloudflare?.r2Bucket ?? `${name}-media`,
|
|
386
|
+
jwtSecret,
|
|
387
|
+
corsOrigins,
|
|
388
|
+
publicReadKey,
|
|
389
|
+
publicWriteKey,
|
|
390
|
+
appUrl: cloudflare?.appUrl ?? '',
|
|
391
|
+
}))
|
|
392
|
+
writeFile(join(targetDir, '.dev.vars'), buildDevVars(cloudflare))
|
|
393
|
+
writeFile(join(targetDir, '.gitignore'), '.wrangler\nnode_modules\n.dev.vars\ndist\n')
|
|
394
|
+
|
|
395
|
+
s.stop('Project scaffolded')
|
|
396
|
+
|
|
397
|
+
// Init git
|
|
398
|
+
s.start('Initialising git repository…')
|
|
399
|
+
try {
|
|
400
|
+
execSync(`git -C "${targetDir}" init -q`, { stdio: 'pipe' })
|
|
401
|
+
execSync(`git -C "${targetDir}" add -A`, { stdio: 'pipe' })
|
|
402
|
+
execSync(`git -C "${targetDir}" commit -q -m "feat: initialise BeechCMS project"`, { stdio: 'pipe' })
|
|
403
|
+
s.stop('Git initialised')
|
|
404
|
+
} catch {
|
|
405
|
+
s.stop('Git skipped (not available)')
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const pendingConfig = !cloudflare
|
|
409
|
+
const step = (n) => pc.bold(String(n + (pendingConfig ? 1 : 0)))
|
|
410
|
+
console.log()
|
|
411
|
+
p.note(
|
|
412
|
+
[
|
|
413
|
+
`${pc.bold('1. Enter the project')}`,
|
|
414
|
+
` ${pc.cyan('cd ' + name)}`,
|
|
415
|
+
'',
|
|
416
|
+
`${pc.bold('2. Install dependencies')}`,
|
|
417
|
+
` ${pc.cyan('npm install')}`,
|
|
418
|
+
'',
|
|
419
|
+
...(pendingConfig ? [
|
|
420
|
+
`${pc.bold('3. Complete Cloudflare configuration')} ${pc.yellow('← pending')}`,
|
|
421
|
+
` Edit ${pc.underline('wrangler.jsonc')} → fill in ${pc.yellow('database_id')} (D1) and ${pc.yellow('bucket_name')} (R2)`,
|
|
422
|
+
` Guide: https://developers.cloudflare.com/d1/`,
|
|
423
|
+
` ${pc.dim('Note: media uploads work locally without R2 credentials (.dev.vars optional)')}`,
|
|
424
|
+
'',
|
|
425
|
+
] : []),
|
|
426
|
+
`${step(3)}. Run local migrations`,
|
|
427
|
+
` ${pc.cyan('npm run db:migrate:local')}`,
|
|
428
|
+
'',
|
|
429
|
+
`${step(4)}. Start the dev server`,
|
|
430
|
+
` ${pc.cyan('npx wrangler dev')}`,
|
|
431
|
+
` Then open: ${pc.underline('http://localhost:8789/admin')}`,
|
|
432
|
+
'',
|
|
433
|
+
`${step(5)}. Deploy to production`,
|
|
434
|
+
` ${pc.cyan('npm run deploy')}`,
|
|
435
|
+
'',
|
|
436
|
+
`${pc.dim('Your content types are defined in seeds.ts')}`,
|
|
437
|
+
`${pc.dim('JWT secret and API keys have been auto-generated.')}`,
|
|
438
|
+
].join('\n'),
|
|
439
|
+
'Next steps'
|
|
440
|
+
)
|
|
441
|
+
p.outro(pc.green(`✔ BeechCMS project ready — ${name}/`))
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
main().catch((err) => {
|
|
445
|
+
console.error(err)
|
|
446
|
+
process.exit(1)
|
|
447
|
+
})
|