@beechcms/cli 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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/.turbo/turbo-lint.log +1 -0
  3. package/coverage/coverage-summary.json +3 -0
  4. package/dist/index.js +445 -249
  5. package/package.json +3 -2
  6. package/src/commands/deploy.ts +126 -126
  7. package/src/commands/generate-types.ts +65 -0
  8. package/src/commands/init.ts +599 -599
  9. package/src/commands/onboard.ts +32 -32
  10. package/src/commands/schema-diff.ts +78 -0
  11. package/src/commands/seed-create.ts +192 -192
  12. package/src/commands/seed-load.ts +206 -235
  13. package/src/commands/update.ts +54 -54
  14. package/src/commands/validate.ts +80 -80
  15. package/src/index.ts +24 -20
  16. package/src/lib/migration-writer.ts +106 -0
  17. package/src/lib/schema-diff.ts +175 -150
  18. package/src/lib/wrangler.ts +129 -129
  19. package/src/test/generate-types.test.ts +58 -0
  20. package/src/test/schema-diff.test.ts +232 -0
  21. package/src/test/seed-load.test.ts +158 -158
  22. package/src/test/validate.test.ts +261 -261
  23. package/tsconfig.json +16 -16
  24. package/tsconfig.tsbuildinfo +1 -1
  25. package/vitest.config.ts +33 -33
  26. package/coverage/base.css +0 -224
  27. package/coverage/block-navigation.js +0 -87
  28. package/coverage/favicon.png +0 -0
  29. package/coverage/index.html +0 -116
  30. package/coverage/lcov-report/base.css +0 -224
  31. package/coverage/lcov-report/block-navigation.js +0 -87
  32. package/coverage/lcov-report/favicon.png +0 -0
  33. package/coverage/lcov-report/index.html +0 -116
  34. package/coverage/lcov-report/prettify.css +0 -1
  35. package/coverage/lcov-report/prettify.js +0 -2
  36. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  37. package/coverage/lcov-report/sorter.js +0 -210
  38. package/coverage/lcov-report/validate.ts.html +0 -325
  39. package/coverage/lcov.info +0 -80
  40. package/coverage/prettify.css +0 -1
  41. package/coverage/prettify.js +0 -2
  42. package/coverage/sort-arrow-sprite.png +0 -0
  43. package/coverage/sorter.js +0 -210
  44. package/coverage/validate.ts.html +0 -325
  45. package/dist/commands/seed-load.d.ts +0 -8
  46. package/dist/commands/seed-load.d.ts.map +0 -1
  47. package/dist/commands/seed-load.js +0 -89
  48. package/dist/index.d.ts +0 -3
  49. package/dist/index.d.ts.map +0 -1
  50. package/dist/lib/schema-diff.d.ts +0 -15
  51. package/dist/lib/schema-diff.d.ts.map +0 -1
  52. package/dist/lib/schema-diff.js +0 -37
  53. package/dist/lib/wrangler.d.ts +0 -17
  54. package/dist/lib/wrangler.d.ts.map +0 -1
  55. package/dist/lib/wrangler.js +0 -65
@@ -1,599 +1,599 @@
1
- // SPDX-License-Identifier: MIT
2
- // Copyright (c) 2024–2026 Flavio De Musso
3
-
4
- import pc from 'picocolors'
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
6
- import { createInterface } from 'node:readline/promises'
7
- import { resolve, basename } from 'node:path'
8
- import { spawnSync } from 'node:child_process'
9
- import { findWranglerConfig, resolveDbName, executeD1File, queryD1, type WranglerOptions } from '../lib/wrangler.js'
10
-
11
- // System tables created by the base schema migration (0000_v040_base.sql).
12
- // Used to detect whether the DB has been initialised.
13
- const SYSTEM_TABLES = [
14
- 'users',
15
- 'refresh_tokens',
16
- 'password_reset_tokens',
17
- 'public_idempotency_keys',
18
- 'analytics',
19
- 'system_stats',
20
- 'activity_logs',
21
- 'notifications',
22
- 'media_objects',
23
- 'content_event_log',
24
- 'automations',
25
- 'seeds',
26
- 'seed_meta',
27
- 'site_settings',
28
- ]
29
-
30
- // Embedded copy of 0000_v040_base.sql — all DDL uses CREATE TABLE IF NOT EXISTS,
31
- // so this is safe to re-run against an already-initialised database.
32
- const BASE_SCHEMA_SQL = `
33
- CREATE TABLE IF NOT EXISTS users (
34
- id TEXT NOT NULL PRIMARY KEY,
35
- email TEXT NOT NULL UNIQUE,
36
- password_hash TEXT NOT NULL,
37
- role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('admin', 'editor')),
38
- name TEXT,
39
- avatar_url TEXT,
40
- notification_prefs TEXT NOT NULL DEFAULT '{}',
41
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
42
- );
43
-
44
- CREATE TABLE IF NOT EXISTS refresh_tokens (
45
- id TEXT NOT NULL PRIMARY KEY,
46
- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
47
- token_hash TEXT NOT NULL,
48
- expires_at INTEGER NOT NULL,
49
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
50
- revoked_at INTEGER DEFAULT NULL
51
- );
52
-
53
- CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
54
- CREATE INDEX IF NOT EXISTS idx_refresh_hash ON refresh_tokens(token_hash);
55
- CREATE INDEX IF NOT EXISTS idx_refresh_expires ON refresh_tokens(expires_at);
56
-
57
- CREATE TABLE IF NOT EXISTS password_reset_tokens (
58
- id TEXT NOT NULL PRIMARY KEY,
59
- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
60
- token_hash TEXT NOT NULL,
61
- expires_at INTEGER NOT NULL,
62
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
63
- used_at INTEGER DEFAULT NULL
64
- );
65
-
66
- CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
67
- CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
68
-
69
- CREATE TABLE IF NOT EXISTS public_idempotency_keys (
70
- idempotency_key TEXT NOT NULL PRIMARY KEY,
71
- request_fingerprint TEXT NOT NULL,
72
- response_status INTEGER NOT NULL,
73
- response_body TEXT NOT NULL,
74
- created_at INTEGER NOT NULL,
75
- expires_at INTEGER NOT NULL
76
- );
77
-
78
- CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON public_idempotency_keys(expires_at);
79
-
80
- CREATE TABLE IF NOT EXISTS analytics (
81
- id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
82
- day_ts INTEGER NOT NULL,
83
- metric TEXT NOT NULL,
84
- seed TEXT NOT NULL DEFAULT '',
85
- value INTEGER NOT NULL DEFAULT 0,
86
- UNIQUE(day_ts, metric, seed)
87
- );
88
-
89
- CREATE INDEX IF NOT EXISTS idx_analytics_day ON analytics(day_ts);
90
- CREATE INDEX IF NOT EXISTS idx_analytics_seed ON analytics(seed, day_ts);
91
-
92
- CREATE TABLE IF NOT EXISTS system_stats (
93
- id TEXT NOT NULL PRIMARY KEY,
94
- value TEXT NOT NULL
95
- );
96
-
97
- INSERT OR IGNORE INTO system_stats (id, value) VALUES ('total_storage_bytes', '0');
98
-
99
- CREATE TABLE IF NOT EXISTS activity_logs (
100
- id TEXT NOT NULL PRIMARY KEY,
101
- user_id TEXT NOT NULL,
102
- user_email TEXT NOT NULL,
103
- user_name TEXT,
104
- action TEXT NOT NULL,
105
- entity_type TEXT NOT NULL,
106
- entity_id TEXT NOT NULL,
107
- entity_slug TEXT,
108
- details TEXT,
109
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
110
- );
111
-
112
- CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_logs(user_id);
113
- CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_logs(created_at);
114
-
115
- CREATE TABLE IF NOT EXISTS notifications (
116
- id TEXT NOT NULL PRIMARY KEY,
117
- title TEXT NOT NULL,
118
- message TEXT NOT NULL,
119
- type TEXT NOT NULL DEFAULT 'info' CHECK (type IN ('info', 'warning', 'error')),
120
- is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0, 1)),
121
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
122
- );
123
-
124
- CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
125
- CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(is_read);
126
-
127
- CREATE TABLE IF NOT EXISTS media_objects (
128
- key TEXT NOT NULL PRIMARY KEY,
129
- filename TEXT NOT NULL,
130
- mime_type TEXT NOT NULL,
131
- size_bytes INTEGER NOT NULL,
132
- uploaded_by TEXT NOT NULL DEFAULT '',
133
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
134
- );
135
-
136
- CREATE INDEX IF NOT EXISTS idx_media_user ON media_objects(uploaded_by);
137
- CREATE INDEX IF NOT EXISTS idx_media_created ON media_objects(created_at DESC);
138
-
139
- CREATE TABLE IF NOT EXISTS content_event_log (
140
- id TEXT NOT NULL PRIMARY KEY,
141
- schema_slug TEXT NOT NULL,
142
- entry_id TEXT NOT NULL,
143
- action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
144
- user_id TEXT,
145
- details TEXT,
146
- created_at INTEGER NOT NULL DEFAULT (unixepoch())
147
- );
148
-
149
- CREATE INDEX IF NOT EXISTS idx_event_log_schema_slug ON content_event_log(schema_slug);
150
- CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON content_event_log(created_at DESC);
151
- CREATE INDEX IF NOT EXISTS idx_event_log_entry_id ON content_event_log(entry_id);
152
-
153
- CREATE TABLE IF NOT EXISTS automations (
154
- id TEXT NOT NULL PRIMARY KEY,
155
- seed_slug TEXT NOT NULL,
156
- name TEXT NOT NULL,
157
- enabled INTEGER NOT NULL DEFAULT 1,
158
- trigger_event TEXT NOT NULL CHECK(trigger_event IN ('create','update','delete','cron')),
159
- trigger_cron TEXT,
160
- trigger_conditions TEXT,
161
- actions TEXT NOT NULL,
162
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
163
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
164
- );
165
-
166
- CREATE INDEX IF NOT EXISTS idx_automations_seed_slug ON automations(seed_slug);
167
- CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations(enabled);
168
-
169
- CREATE TABLE IF NOT EXISTS seeds (
170
- slug TEXT NOT NULL PRIMARY KEY,
171
- definition TEXT NOT NULL,
172
- status TEXT NOT NULL DEFAULT 'active'
173
- CHECK (status IN ('active', 'deleted')),
174
- source TEXT NOT NULL DEFAULT 'runtime'
175
- CHECK (source IN ('code', 'runtime')),
176
- created_at INTEGER NOT NULL DEFAULT (unixepoch()),
177
- updated_at INTEGER NOT NULL DEFAULT (unixepoch())
178
- );
179
-
180
- CREATE INDEX IF NOT EXISTS idx_seeds_status ON seeds(status);
181
-
182
- CREATE TABLE IF NOT EXISTS seed_meta (
183
- id TEXT NOT NULL PRIMARY KEY,
184
- value TEXT NOT NULL
185
- );
186
-
187
- INSERT OR IGNORE INTO seed_meta (id, value) VALUES ('registry_version', '1');
188
-
189
- CREATE TABLE IF NOT EXISTS site_settings (
190
- key TEXT NOT NULL PRIMARY KEY,
191
- value TEXT NOT NULL
192
- );
193
- `.trim()
194
-
195
- const PLACEHOLDER_DB_IDS = [
196
- 'INCOLLA_QUI_IL_TUO_ID_D1',
197
- 'FILL_IN_YOUR_D1_DATABASE_ID',
198
- 'YOUR_D1_DATABASE_ID',
199
- ]
200
-
201
- export interface InitOptions {
202
- initDb: boolean
203
- local: boolean
204
- db?: string
205
- nonInteractive?: boolean
206
- }
207
-
208
- function checkWranglerAuth(): boolean {
209
- const result = spawnSync('npx', ['wrangler', 'whoami', '--json'], {
210
- encoding: 'utf-8',
211
- cwd: process.cwd(),
212
- shell: true,
213
- })
214
- return result.status === 0
215
- }
216
-
217
- function checkWranglerPlaceholders(configPath: string): string[] {
218
- try {
219
- const raw = readFileSync(configPath, 'utf-8')
220
- const stripped = raw
221
- .replace(/\/\/[^\n]*/g, '')
222
- .replace(/\/\*[\s\S]*?\*\//g, '')
223
- const parsed = JSON.parse(stripped)
224
- const bindings: { database_id?: string; database_name?: string }[] = parsed?.d1_databases ?? []
225
- const issues: string[] = []
226
- for (const b of bindings) {
227
- const id = b.database_id ?? ''
228
- if (!id || PLACEHOLDER_DB_IDS.includes(id)) {
229
- issues.push(`d1_databases[0].database_id is "${id || '(empty)'}"`)
230
- }
231
- }
232
- return issues
233
- } catch {
234
- return []
235
- }
236
- }
237
-
238
- function readProjectName(configPath: string | null): string {
239
- if (!configPath) return basename(process.cwd())
240
- try {
241
- const raw = readFileSync(configPath, 'utf-8')
242
- const stripped = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
243
- const parsed = JSON.parse(stripped)
244
- return (parsed?.name as string | undefined) || basename(process.cwd())
245
- } catch {
246
- return basename(process.cwd())
247
- }
248
- }
249
-
250
- function readBucketName(configPath: string | null): string | null {
251
- if (!configPath) return null
252
- try {
253
- const raw = readFileSync(configPath, 'utf-8')
254
- const stripped = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
255
- const parsed = JSON.parse(stripped)
256
- const buckets: { bucket_name?: string }[] = parsed?.r2_buckets ?? []
257
- return buckets[0]?.bucket_name ?? null
258
- } catch {
259
- return null
260
- }
261
- }
262
-
263
- function createD1Database(dbName: string): string | null {
264
- const result = spawnSync('npx', ['wrangler', 'd1', 'create', dbName, '--json'], {
265
- encoding: 'utf-8',
266
- cwd: process.cwd(),
267
- shell: true,
268
- stdio: ['inherit', 'pipe', 'pipe'],
269
- })
270
- if (result.status !== 0) return null
271
- try {
272
- const parsed = JSON.parse(result.stdout)
273
- return (parsed?.uuid ?? parsed?.database_id ?? null) as string | null
274
- } catch {
275
- return null
276
- }
277
- }
278
-
279
- function createR2Bucket(bucketName: string): boolean {
280
- const result = spawnSync('npx', ['wrangler', 'r2', 'bucket', 'create', bucketName], {
281
- stdio: 'inherit',
282
- cwd: process.cwd(),
283
- shell: true,
284
- })
285
- return result.status === 0
286
- }
287
-
288
- function patchWranglerConfig(configPath: string, dbId: string): boolean {
289
- try {
290
- let raw = readFileSync(configPath, 'utf-8')
291
- for (const placeholder of PLACEHOLDER_DB_IDS) {
292
- raw = raw.split(placeholder).join(dbId)
293
- }
294
- raw = raw.replace(/"database_id"\s*:\s*""/g, `"database_id": "${dbId}"`)
295
- writeFileSync(configPath, raw, 'utf-8')
296
- return true
297
- } catch {
298
- return false
299
- }
300
- }
301
-
302
- function printManualDbInstructions(): void {
303
- console.log(pc.dim('\n Update your D1 database_id in wrangler.jsonc,'))
304
- console.log(pc.dim(' or create a new database with:'))
305
- console.log(pc.cyan('\n → Run: npx wrangler d1 create my-project-db'))
306
- console.log(pc.cyan(' → Then: npx beech init --db\n'))
307
- }
308
-
309
- function echoApiKeys(configPath: string | null | undefined): void {
310
- if (!configPath) return
311
- try {
312
- const raw = readFileSync(configPath, 'utf-8')
313
- const stripped = raw
314
- .replace(/\/\/[^\n]*/g, '')
315
- .replace(/\/\*[\s\S]*?\*\//g, '')
316
- const parsed = JSON.parse(stripped)
317
- const vars: Record<string, string> = parsed?.vars ?? {}
318
- const readKey = vars['PUBLIC_READ_API_KEY']
319
- const writeKey = vars['PUBLIC_WRITE_API_KEY']
320
- if (!readKey && !writeKey) return
321
- console.log(pc.dim(' API keys detected in wrangler.jsonc:\n'))
322
- if (readKey) {
323
- const masked = readKey.length > 8 ? readKey.slice(0, 4) + '****' + readKey.slice(-4) : '****'
324
- console.log(pc.dim(` PUBLIC_READ_API_KEY = ${masked}`))
325
- }
326
- if (writeKey) {
327
- const masked = writeKey.length > 8 ? writeKey.slice(0, 4) + '****' + writeKey.slice(-4) : '****'
328
- console.log(pc.dim(` PUBLIC_WRITE_API_KEY = ${masked}`))
329
- }
330
- console.log(pc.dim('\n Use these in your frontend as the X-API-Key header.\n'))
331
- } catch {
332
- // wrangler.jsonc may be missing or malformed — skip silently
333
- }
334
- }
335
-
336
- function checkFiles(cwd: string, checkDevVars: boolean): boolean {
337
- let ok = true
338
-
339
- const workerExists = existsSync(resolve(cwd, 'worker.ts')) || existsSync(resolve(cwd, 'worker.js'))
340
- if (!workerExists) {
341
- console.log(pc.red(' ✗ worker.ts — missing (required)'))
342
- ok = false
343
- } else {
344
- console.log(pc.green(' ✓ worker.ts'))
345
- }
346
-
347
- const configPath = findWranglerConfig()
348
- const configInCwd = configPath &&
349
- (configPath === resolve(cwd, 'wrangler.jsonc') ||
350
- configPath === resolve(cwd, 'wrangler.json') ||
351
- configPath === resolve(cwd, 'wrangler.toml'))
352
-
353
- if (!configInCwd) {
354
- console.log(pc.red(' ✗ wrangler.jsonc — missing (required)'))
355
- ok = false
356
- } else {
357
- console.log(pc.green(` ✓ ${basename(configPath!)}`))
358
- }
359
-
360
- const seedsExists =
361
- existsSync(resolve(cwd, 'seeds.ts')) ||
362
- existsSync(resolve(cwd, 'seeds.js')) ||
363
- existsSync(resolve(cwd, 'seed.ts')) ||
364
- existsSync(resolve(cwd, 'seed.js'))
365
-
366
- if (!seedsExists) {
367
- console.log(pc.yellow(' ⚠ seeds.ts — not found (optional: needed only for the one-time code → DB load; after `beech seed:load`, the DB is canonical)'))
368
- } else {
369
- console.log(pc.green(' ✓ seeds.ts'))
370
- }
371
-
372
- if (checkDevVars) {
373
- if (!existsSync(resolve(cwd, '.dev.vars'))) {
374
- console.log(pc.dim(' ○ .dev.vars — not found (optional: only needed for production R2 credentials)'))
375
- } else {
376
- console.log(pc.green(' ✓ .dev.vars'))
377
- }
378
- }
379
-
380
- return ok
381
- }
382
-
383
- function printNextSteps(local: boolean): void {
384
- const localFlag = local ? ' --local' : ''
385
- console.log(pc.dim(' Next steps:'))
386
- console.log(pc.cyan(` 1. npx beech seed:load${localFlag}`))
387
- console.log(pc.dim(' → create content tables and register seed definitions in D1'))
388
- console.log(pc.cyan(' 2. npx wrangler dev'))
389
- console.log(pc.dim(' → start API + dashboard'))
390
- console.log(pc.dim(' 3. Open http://localhost:8789/admin\n'))
391
- }
392
-
393
- function getExistingTables(options: WranglerOptions): string[] | null {
394
- try {
395
- const rows = queryD1<{ name: string }>(
396
- `SELECT name FROM sqlite_master WHERE type='table'`,
397
- options
398
- )
399
- return rows.map(r => r.name)
400
- } catch {
401
- return null
402
- }
403
- }
404
-
405
- export async function init(args: InitOptions): Promise<void> {
406
- const cwd = process.cwd()
407
-
408
- console.log(pc.cyan('\n beech init — project check\n'))
409
-
410
- const filesOk = checkFiles(cwd, args.local)
411
-
412
- if (!filesOk) {
413
- console.log(pc.red('\n ✗ Required files missing\n'))
414
- console.log(pc.dim(' Fix the errors above before initialising the database.'))
415
- console.log(pc.cyan('\n → See: https://beechcms.dev/docs/getting-started\n'))
416
- process.exit(1)
417
- }
418
-
419
- console.log(pc.green('\n All required files present.\n'))
420
-
421
- if (!args.initDb) {
422
- echoApiKeys(findWranglerConfig())
423
- const localFlag = args.local ? ' --local' : ''
424
- console.log(pc.dim(' Next steps:'))
425
- console.log(pc.dim(` 1. npx beech init --db${localFlag} # initialise D1 database`))
426
- console.log(pc.dim(` 2. npx beech seed:load${localFlag} # create content tables`))
427
- console.log(pc.dim(' 3. npx wrangler dev # start API + dashboard'))
428
- console.log(pc.dim(' 4. Open http://localhost:8789/admin\n'))
429
- return
430
- }
431
-
432
- // --- Database initialisation ---
433
- const configPath = findWranglerConfig()
434
-
435
- // Check for placeholder database_id before touching the DB
436
- if (configPath) {
437
- const placeholders = checkWranglerPlaceholders(configPath)
438
- if (placeholders.length > 0) {
439
- console.log(pc.yellow(' ⚠ wrangler.jsonc contains placeholder values:\n'))
440
- for (const issue of placeholders) {
441
- console.log(pc.yellow(` - ${issue}`))
442
- }
443
-
444
- if (process.stdin.isTTY && !args.nonInteractive) {
445
- // Interactive: offer auto-creation of D1 + R2
446
- const rl = createInterface({ input: process.stdin, output: process.stdout })
447
- let autoCreate = false
448
- try {
449
- const answer = (await rl.question(
450
- pc.cyan('\n → Create a new D1 database (and R2 bucket) on Cloudflare automatically? (Y/n): ')
451
- )).trim().toLowerCase()
452
- autoCreate = !answer || answer === 'y' || answer === 'yes'
453
- } finally {
454
- rl.close()
455
- }
456
-
457
- if (autoCreate) {
458
- // Auth required for resource creation
459
- const authed = checkWranglerAuth()
460
- if (!authed) {
461
- console.log(pc.red('\n ✗ Not logged in to Cloudflare\n'))
462
- console.log(pc.dim(' BeechCMS needs access to your Cloudflare account to create the database.'))
463
- console.log(pc.cyan('\n → Run: npx wrangler login'))
464
- console.log(pc.cyan(' → Then: npx beech init --db\n'))
465
- process.exit(1)
466
- }
467
-
468
- const projectName = readProjectName(configPath)
469
- const dbName = `${projectName}-db`
470
- const bucketName = readBucketName(configPath) || `${projectName}-media`
471
-
472
- console.log(pc.dim(`\n Creating D1 database "${dbName}"…`))
473
- const dbId = createD1Database(dbName)
474
- if (!dbId) {
475
- console.log(pc.red('\n ✗ Failed to create D1 database\n'))
476
- console.log(pc.dim(' Create it manually and retry:'))
477
- console.log(pc.cyan(`\n → Run: npx wrangler d1 create ${dbName}`))
478
- console.log(pc.cyan(' → Then: npx beech init --db\n'))
479
- process.exit(1)
480
- }
481
- console.log(pc.green(` ✓ D1 database created (id: ${dbId})`))
482
-
483
- console.log(pc.dim(`\n Creating R2 bucket "${bucketName}"…`))
484
- const r2Ok = createR2Bucket(bucketName)
485
- if (r2Ok) {
486
- console.log(pc.green(` ✓ R2 bucket "${bucketName}" created`))
487
- } else {
488
- console.log(pc.yellow(` ⚠ R2 bucket creation failed (may already exist — continuing)`))
489
- }
490
-
491
- console.log(pc.dim('\n Updating wrangler.jsonc…'))
492
- const patched = patchWranglerConfig(configPath, dbId)
493
- if (patched) {
494
- console.log(pc.green(' ✓ wrangler.jsonc updated\n'))
495
- } else {
496
- console.log(pc.yellow(` ⚠ Could not update wrangler.jsonc automatically\n`))
497
- console.log(pc.dim(` Set database_id = "${dbId}" in wrangler.jsonc manually, then retry:`))
498
- console.log(pc.cyan(' → Run: npx beech init --db\n'))
499
- process.exit(1)
500
- }
501
- // Fall through to continue DB initialization with the newly created database
502
- } else {
503
- printManualDbInstructions()
504
- process.exit(1)
505
- }
506
- } else if (args.nonInteractive && args.local) {
507
- // --yes + --local: local D1 needs no real database_id — just proceed
508
- console.log(pc.dim(' --yes: proceeding with local mode (no remote resources needed)\n'))
509
- // Fall through to DB initialization
510
- } else {
511
- if (args.nonInteractive) {
512
- // --yes + --remote + placeholder → cannot proceed non-interactively
513
- console.log(pc.red('\n ✗ Cannot proceed non-interactively: remote database has a placeholder database_id\n'))
514
- console.log(pc.dim(' Set a real database_id in wrangler.jsonc, then retry.'))
515
- process.exit(1)
516
- }
517
- printManualDbInstructions()
518
- process.exit(1)
519
- }
520
- }
521
- }
522
-
523
- // For remote operations, verify Cloudflare auth before attempting any wrangler calls
524
- if (!args.local) {
525
- const authed = checkWranglerAuth()
526
- if (!authed) {
527
- console.log(pc.red(' ✗ Not logged in to Cloudflare\n'))
528
- console.log(pc.dim(' BeechCMS needs access to your Cloudflare account to manage the D1 database.'))
529
- console.log(pc.cyan('\n → Run: npx wrangler login'))
530
- console.log(pc.cyan(' → Then: npx beech init --db\n'))
531
- process.exit(1)
532
- }
533
- }
534
-
535
- const db = args.db ?? resolveDbName(configPath)
536
- const options: WranglerOptions = { db, local: args.local, configPath }
537
-
538
- console.log(pc.cyan(` Checking database "${db}" (${args.local ? 'local' : 'remote'})…\n`))
539
-
540
- const existingTables = getExistingTables(options)
541
- const missingTables = SYSTEM_TABLES.filter(t => !existingTables?.includes(t))
542
-
543
- // Remote mode: verification only — do not auto-apply schema.
544
- // In production, system tables are created by `wrangler deploy` migrations.
545
- if (!args.local) {
546
- if (existingTables === null) {
547
- console.log(pc.red(' ✗ Remote database unreachable\n'))
548
- console.log(pc.dim(' Most likely causes:'))
549
- console.log(pc.dim(' - Wrong database_id in wrangler.jsonc'))
550
- console.log(pc.dim(' - Worker not yet deployed'))
551
- console.log(pc.cyan('\n → Fix: Update d1_databases.database_id in wrangler.jsonc'))
552
- console.log(pc.cyan(' → Then: npm run deploy\n'))
553
- process.exit(1)
554
- }
555
-
556
- if (missingTables.length > 0) {
557
- console.log(pc.yellow(` ⚠ Missing system tables: ${missingTables.join(', ')}\n`))
558
- console.log(pc.dim(' Most likely causes:'))
559
- console.log(pc.dim(' - Wrong database_id in wrangler.jsonc'))
560
- console.log(pc.dim(' - Migrations did not run during deploy'))
561
- console.log(pc.cyan('\n → Fix: Update d1_databases.database_id in wrangler.jsonc'))
562
- console.log(pc.cyan(' → Then: npm run deploy\n'))
563
- process.exit(1)
564
- }
565
-
566
- for (const table of SYSTEM_TABLES) {
567
- console.log(pc.green(` ✓ ${table}`))
568
- }
569
- console.log(pc.green('\n All system tables present. Remote database is initialized.\n'))
570
- return
571
- }
572
-
573
- // Local mode: apply schema if tables are missing.
574
- if (existingTables === null) {
575
- console.log(pc.yellow(' Database unreachable or not yet created — applying base schema…\n'))
576
- } else if (missingTables.length === 0) {
577
- console.log(pc.green(' ✓ All system tables present. Database already initialised.\n'))
578
- printNextSteps(args.local)
579
- return
580
- } else {
581
- console.log(pc.yellow(` Missing system tables: ${missingTables.join(', ')}`))
582
- console.log(pc.cyan('\n Applying base schema…\n'))
583
- }
584
-
585
- const ok = executeD1File(BASE_SCHEMA_SQL, options)
586
- if (!ok) {
587
- console.log(pc.red('\n ✗ Database initialisation failed\n'))
588
- console.log(pc.dim(' wrangler reported an error above.'))
589
- console.log(pc.cyan('\n → Run: npx beech init --db --local\n'))
590
- process.exit(1)
591
- }
592
-
593
- console.log(pc.green('\n ✓ worker.ts'))
594
- console.log(pc.green(` ✓ ${configPath ? basename(configPath) : 'wrangler.jsonc'}`))
595
- console.log(pc.green(' ✓ seeds.ts'))
596
- console.log(pc.green(' ✓ Local D1 system tables ready\n'))
597
- echoApiKeys(configPath)
598
- printNextSteps(args.local)
599
- }
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2024–2026 Flavio De Musso
3
+
4
+ import pc from 'picocolors'
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { createInterface } from 'node:readline/promises'
7
+ import { resolve, basename } from 'node:path'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { findWranglerConfig, resolveDbName, executeD1File, queryD1, type WranglerOptions } from '../lib/wrangler.js'
10
+
11
+ // System tables created by the base schema migration (0000_v040_base.sql).
12
+ // Used to detect whether the DB has been initialised.
13
+ const SYSTEM_TABLES = [
14
+ 'users',
15
+ 'refresh_tokens',
16
+ 'password_reset_tokens',
17
+ 'public_idempotency_keys',
18
+ 'analytics',
19
+ 'system_stats',
20
+ 'activity_logs',
21
+ 'notifications',
22
+ 'media_objects',
23
+ 'content_event_log',
24
+ 'automations',
25
+ 'seeds',
26
+ 'seed_meta',
27
+ 'site_settings',
28
+ ]
29
+
30
+ // Embedded copy of 0000_v040_base.sql — all DDL uses CREATE TABLE IF NOT EXISTS,
31
+ // so this is safe to re-run against an already-initialised database.
32
+ const BASE_SCHEMA_SQL = `
33
+ CREATE TABLE IF NOT EXISTS users (
34
+ id TEXT NOT NULL PRIMARY KEY,
35
+ email TEXT NOT NULL UNIQUE,
36
+ password_hash TEXT NOT NULL,
37
+ role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('admin', 'editor')),
38
+ name TEXT,
39
+ avatar_url TEXT,
40
+ notification_prefs TEXT NOT NULL DEFAULT '{}',
41
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
45
+ id TEXT NOT NULL PRIMARY KEY,
46
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
47
+ token_hash TEXT NOT NULL,
48
+ expires_at INTEGER NOT NULL,
49
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
50
+ revoked_at INTEGER DEFAULT NULL
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
54
+ CREATE INDEX IF NOT EXISTS idx_refresh_hash ON refresh_tokens(token_hash);
55
+ CREATE INDEX IF NOT EXISTS idx_refresh_expires ON refresh_tokens(expires_at);
56
+
57
+ CREATE TABLE IF NOT EXISTS password_reset_tokens (
58
+ id TEXT NOT NULL PRIMARY KEY,
59
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
60
+ token_hash TEXT NOT NULL,
61
+ expires_at INTEGER NOT NULL,
62
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
63
+ used_at INTEGER DEFAULT NULL
64
+ );
65
+
66
+ CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash);
67
+ CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id);
68
+
69
+ CREATE TABLE IF NOT EXISTS public_idempotency_keys (
70
+ idempotency_key TEXT NOT NULL PRIMARY KEY,
71
+ request_fingerprint TEXT NOT NULL,
72
+ response_status INTEGER NOT NULL,
73
+ response_body TEXT NOT NULL,
74
+ created_at INTEGER NOT NULL,
75
+ expires_at INTEGER NOT NULL
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_idempotency_expires ON public_idempotency_keys(expires_at);
79
+
80
+ CREATE TABLE IF NOT EXISTS analytics (
81
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
82
+ day_ts INTEGER NOT NULL,
83
+ metric TEXT NOT NULL,
84
+ seed TEXT NOT NULL DEFAULT '',
85
+ value INTEGER NOT NULL DEFAULT 0,
86
+ UNIQUE(day_ts, metric, seed)
87
+ );
88
+
89
+ CREATE INDEX IF NOT EXISTS idx_analytics_day ON analytics(day_ts);
90
+ CREATE INDEX IF NOT EXISTS idx_analytics_seed ON analytics(seed, day_ts);
91
+
92
+ CREATE TABLE IF NOT EXISTS system_stats (
93
+ id TEXT NOT NULL PRIMARY KEY,
94
+ value TEXT NOT NULL
95
+ );
96
+
97
+ INSERT OR IGNORE INTO system_stats (id, value) VALUES ('total_storage_bytes', '0');
98
+
99
+ CREATE TABLE IF NOT EXISTS activity_logs (
100
+ id TEXT NOT NULL PRIMARY KEY,
101
+ user_id TEXT NOT NULL,
102
+ user_email TEXT NOT NULL,
103
+ user_name TEXT,
104
+ action TEXT NOT NULL,
105
+ entity_type TEXT NOT NULL,
106
+ entity_id TEXT NOT NULL,
107
+ entity_slug TEXT,
108
+ details TEXT,
109
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_activity_user ON activity_logs(user_id);
113
+ CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_logs(created_at);
114
+
115
+ CREATE TABLE IF NOT EXISTS notifications (
116
+ id TEXT NOT NULL PRIMARY KEY,
117
+ title TEXT NOT NULL,
118
+ message TEXT NOT NULL,
119
+ type TEXT NOT NULL DEFAULT 'info' CHECK (type IN ('info', 'warning', 'error')),
120
+ is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0, 1)),
121
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
122
+ );
123
+
124
+ CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
125
+ CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(is_read);
126
+
127
+ CREATE TABLE IF NOT EXISTS media_objects (
128
+ key TEXT NOT NULL PRIMARY KEY,
129
+ filename TEXT NOT NULL,
130
+ mime_type TEXT NOT NULL,
131
+ size_bytes INTEGER NOT NULL,
132
+ uploaded_by TEXT NOT NULL DEFAULT '',
133
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
134
+ );
135
+
136
+ CREATE INDEX IF NOT EXISTS idx_media_user ON media_objects(uploaded_by);
137
+ CREATE INDEX IF NOT EXISTS idx_media_created ON media_objects(created_at DESC);
138
+
139
+ CREATE TABLE IF NOT EXISTS content_event_log (
140
+ id TEXT NOT NULL PRIMARY KEY,
141
+ schema_slug TEXT NOT NULL,
142
+ entry_id TEXT NOT NULL,
143
+ action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')),
144
+ user_id TEXT,
145
+ details TEXT,
146
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
147
+ );
148
+
149
+ CREATE INDEX IF NOT EXISTS idx_event_log_schema_slug ON content_event_log(schema_slug);
150
+ CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON content_event_log(created_at DESC);
151
+ CREATE INDEX IF NOT EXISTS idx_event_log_entry_id ON content_event_log(entry_id);
152
+
153
+ CREATE TABLE IF NOT EXISTS automations (
154
+ id TEXT NOT NULL PRIMARY KEY,
155
+ seed_slug TEXT NOT NULL,
156
+ name TEXT NOT NULL,
157
+ enabled INTEGER NOT NULL DEFAULT 1,
158
+ trigger_event TEXT NOT NULL CHECK(trigger_event IN ('create','update','delete','cron')),
159
+ trigger_cron TEXT,
160
+ trigger_conditions TEXT,
161
+ actions TEXT NOT NULL,
162
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
163
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
164
+ );
165
+
166
+ CREATE INDEX IF NOT EXISTS idx_automations_seed_slug ON automations(seed_slug);
167
+ CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations(enabled);
168
+
169
+ CREATE TABLE IF NOT EXISTS seeds (
170
+ slug TEXT NOT NULL PRIMARY KEY,
171
+ definition TEXT NOT NULL,
172
+ status TEXT NOT NULL DEFAULT 'active'
173
+ CHECK (status IN ('active', 'deleted')),
174
+ source TEXT NOT NULL DEFAULT 'runtime'
175
+ CHECK (source IN ('code', 'runtime')),
176
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
177
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
178
+ );
179
+
180
+ CREATE INDEX IF NOT EXISTS idx_seeds_status ON seeds(status);
181
+
182
+ CREATE TABLE IF NOT EXISTS seed_meta (
183
+ id TEXT NOT NULL PRIMARY KEY,
184
+ value TEXT NOT NULL
185
+ );
186
+
187
+ INSERT OR IGNORE INTO seed_meta (id, value) VALUES ('registry_version', '1');
188
+
189
+ CREATE TABLE IF NOT EXISTS site_settings (
190
+ key TEXT NOT NULL PRIMARY KEY,
191
+ value TEXT NOT NULL
192
+ );
193
+ `.trim()
194
+
195
+ const PLACEHOLDER_DB_IDS = [
196
+ 'INCOLLA_QUI_IL_TUO_ID_D1',
197
+ 'FILL_IN_YOUR_D1_DATABASE_ID',
198
+ 'YOUR_D1_DATABASE_ID',
199
+ ]
200
+
201
+ export interface InitOptions {
202
+ initDb: boolean
203
+ local: boolean
204
+ db?: string
205
+ nonInteractive?: boolean
206
+ }
207
+
208
+ function checkWranglerAuth(): boolean {
209
+ const result = spawnSync('npx', ['wrangler', 'whoami', '--json'], {
210
+ encoding: 'utf-8',
211
+ cwd: process.cwd(),
212
+ shell: true,
213
+ })
214
+ return result.status === 0
215
+ }
216
+
217
+ function checkWranglerPlaceholders(configPath: string): string[] {
218
+ try {
219
+ const raw = readFileSync(configPath, 'utf-8')
220
+ const stripped = raw
221
+ .replace(/\/\/[^\n]*/g, '')
222
+ .replace(/\/\*[\s\S]*?\*\//g, '')
223
+ const parsed = JSON.parse(stripped)
224
+ const bindings: { database_id?: string; database_name?: string }[] = parsed?.d1_databases ?? []
225
+ const issues: string[] = []
226
+ for (const b of bindings) {
227
+ const id = b.database_id ?? ''
228
+ if (!id || PLACEHOLDER_DB_IDS.includes(id)) {
229
+ issues.push(`d1_databases[0].database_id is "${id || '(empty)'}"`)
230
+ }
231
+ }
232
+ return issues
233
+ } catch {
234
+ return []
235
+ }
236
+ }
237
+
238
+ function readProjectName(configPath: string | null): string {
239
+ if (!configPath) return basename(process.cwd())
240
+ try {
241
+ const raw = readFileSync(configPath, 'utf-8')
242
+ const stripped = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
243
+ const parsed = JSON.parse(stripped)
244
+ return (parsed?.name as string | undefined) || basename(process.cwd())
245
+ } catch {
246
+ return basename(process.cwd())
247
+ }
248
+ }
249
+
250
+ function readBucketName(configPath: string | null): string | null {
251
+ if (!configPath) return null
252
+ try {
253
+ const raw = readFileSync(configPath, 'utf-8')
254
+ const stripped = raw.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
255
+ const parsed = JSON.parse(stripped)
256
+ const buckets: { bucket_name?: string }[] = parsed?.r2_buckets ?? []
257
+ return buckets[0]?.bucket_name ?? null
258
+ } catch {
259
+ return null
260
+ }
261
+ }
262
+
263
+ function createD1Database(dbName: string): string | null {
264
+ const result = spawnSync('npx', ['wrangler', 'd1', 'create', dbName, '--json'], {
265
+ encoding: 'utf-8',
266
+ cwd: process.cwd(),
267
+ shell: true,
268
+ stdio: ['inherit', 'pipe', 'pipe'],
269
+ })
270
+ if (result.status !== 0) return null
271
+ try {
272
+ const parsed = JSON.parse(result.stdout)
273
+ return (parsed?.uuid ?? parsed?.database_id ?? null) as string | null
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+
279
+ function createR2Bucket(bucketName: string): boolean {
280
+ const result = spawnSync('npx', ['wrangler', 'r2', 'bucket', 'create', bucketName], {
281
+ stdio: 'inherit',
282
+ cwd: process.cwd(),
283
+ shell: true,
284
+ })
285
+ return result.status === 0
286
+ }
287
+
288
+ function patchWranglerConfig(configPath: string, dbId: string): boolean {
289
+ try {
290
+ let raw = readFileSync(configPath, 'utf-8')
291
+ for (const placeholder of PLACEHOLDER_DB_IDS) {
292
+ raw = raw.split(placeholder).join(dbId)
293
+ }
294
+ raw = raw.replace(/"database_id"\s*:\s*""/g, `"database_id": "${dbId}"`)
295
+ writeFileSync(configPath, raw, 'utf-8')
296
+ return true
297
+ } catch {
298
+ return false
299
+ }
300
+ }
301
+
302
+ function printManualDbInstructions(): void {
303
+ console.log(pc.dim('\n Update your D1 database_id in wrangler.jsonc,'))
304
+ console.log(pc.dim(' or create a new database with:'))
305
+ console.log(pc.cyan('\n → Run: npx wrangler d1 create my-project-db'))
306
+ console.log(pc.cyan(' → Then: npx beech init --db\n'))
307
+ }
308
+
309
+ function echoApiKeys(configPath: string | null | undefined): void {
310
+ if (!configPath) return
311
+ try {
312
+ const raw = readFileSync(configPath, 'utf-8')
313
+ const stripped = raw
314
+ .replace(/\/\/[^\n]*/g, '')
315
+ .replace(/\/\*[\s\S]*?\*\//g, '')
316
+ const parsed = JSON.parse(stripped)
317
+ const vars: Record<string, string> = parsed?.vars ?? {}
318
+ const readKey = vars['PUBLIC_READ_API_KEY']
319
+ const writeKey = vars['PUBLIC_WRITE_API_KEY']
320
+ if (!readKey && !writeKey) return
321
+ console.log(pc.dim(' API keys detected in wrangler.jsonc:\n'))
322
+ if (readKey) {
323
+ const masked = readKey.length > 8 ? readKey.slice(0, 4) + '****' + readKey.slice(-4) : '****'
324
+ console.log(pc.dim(` PUBLIC_READ_API_KEY = ${masked}`))
325
+ }
326
+ if (writeKey) {
327
+ const masked = writeKey.length > 8 ? writeKey.slice(0, 4) + '****' + writeKey.slice(-4) : '****'
328
+ console.log(pc.dim(` PUBLIC_WRITE_API_KEY = ${masked}`))
329
+ }
330
+ console.log(pc.dim('\n Use these in your frontend as the X-API-Key header.\n'))
331
+ } catch {
332
+ // wrangler.jsonc may be missing or malformed — skip silently
333
+ }
334
+ }
335
+
336
+ function checkFiles(cwd: string, checkDevVars: boolean): boolean {
337
+ let ok = true
338
+
339
+ const workerExists = existsSync(resolve(cwd, 'worker.ts')) || existsSync(resolve(cwd, 'worker.js'))
340
+ if (!workerExists) {
341
+ console.log(pc.red(' ✗ worker.ts — missing (required)'))
342
+ ok = false
343
+ } else {
344
+ console.log(pc.green(' ✓ worker.ts'))
345
+ }
346
+
347
+ const configPath = findWranglerConfig()
348
+ const configInCwd = configPath &&
349
+ (configPath === resolve(cwd, 'wrangler.jsonc') ||
350
+ configPath === resolve(cwd, 'wrangler.json') ||
351
+ configPath === resolve(cwd, 'wrangler.toml'))
352
+
353
+ if (!configInCwd) {
354
+ console.log(pc.red(' ✗ wrangler.jsonc — missing (required)'))
355
+ ok = false
356
+ } else {
357
+ console.log(pc.green(` ✓ ${basename(configPath!)}`))
358
+ }
359
+
360
+ const seedsExists =
361
+ existsSync(resolve(cwd, 'seeds.ts')) ||
362
+ existsSync(resolve(cwd, 'seeds.js')) ||
363
+ existsSync(resolve(cwd, 'seed.ts')) ||
364
+ existsSync(resolve(cwd, 'seed.js'))
365
+
366
+ if (!seedsExists) {
367
+ console.log(pc.yellow(' ⚠ seeds.ts — not found (optional: needed only for the one-time code → DB load; after `beech seed:load`, the DB is canonical)'))
368
+ } else {
369
+ console.log(pc.green(' ✓ seeds.ts'))
370
+ }
371
+
372
+ if (checkDevVars) {
373
+ if (!existsSync(resolve(cwd, '.dev.vars'))) {
374
+ console.log(pc.dim(' ○ .dev.vars — not found (optional: only needed for production R2 credentials)'))
375
+ } else {
376
+ console.log(pc.green(' ✓ .dev.vars'))
377
+ }
378
+ }
379
+
380
+ return ok
381
+ }
382
+
383
+ function printNextSteps(local: boolean): void {
384
+ const localFlag = local ? ' --local' : ''
385
+ console.log(pc.dim(' Next steps:'))
386
+ console.log(pc.cyan(` 1. npx beech seed:load${localFlag}`))
387
+ console.log(pc.dim(' → create content tables and register seed definitions in D1'))
388
+ console.log(pc.cyan(' 2. npx wrangler dev'))
389
+ console.log(pc.dim(' → start API + dashboard'))
390
+ console.log(pc.dim(' 3. Open http://localhost:8789/admin\n'))
391
+ }
392
+
393
+ function getExistingTables(options: WranglerOptions): string[] | null {
394
+ try {
395
+ const rows = queryD1<{ name: string }>(
396
+ `SELECT name FROM sqlite_master WHERE type='table'`,
397
+ options
398
+ )
399
+ return rows.map(r => r.name)
400
+ } catch {
401
+ return null
402
+ }
403
+ }
404
+
405
+ export async function init(args: InitOptions): Promise<void> {
406
+ const cwd = process.cwd()
407
+
408
+ console.log(pc.cyan('\n beech init — project check\n'))
409
+
410
+ const filesOk = checkFiles(cwd, args.local)
411
+
412
+ if (!filesOk) {
413
+ console.log(pc.red('\n ✗ Required files missing\n'))
414
+ console.log(pc.dim(' Fix the errors above before initialising the database.'))
415
+ console.log(pc.cyan('\n → See: https://beechcms.dev/docs/getting-started\n'))
416
+ process.exit(1)
417
+ }
418
+
419
+ console.log(pc.green('\n All required files present.\n'))
420
+
421
+ if (!args.initDb) {
422
+ echoApiKeys(findWranglerConfig())
423
+ const localFlag = args.local ? ' --local' : ''
424
+ console.log(pc.dim(' Next steps:'))
425
+ console.log(pc.dim(` 1. npx beech init --db${localFlag} # initialise D1 database`))
426
+ console.log(pc.dim(` 2. npx beech seed:load${localFlag} # create content tables`))
427
+ console.log(pc.dim(' 3. npx wrangler dev # start API + dashboard'))
428
+ console.log(pc.dim(' 4. Open http://localhost:8789/admin\n'))
429
+ return
430
+ }
431
+
432
+ // --- Database initialisation ---
433
+ const configPath = findWranglerConfig()
434
+
435
+ // Check for placeholder database_id before touching the DB
436
+ if (configPath) {
437
+ const placeholders = checkWranglerPlaceholders(configPath)
438
+ if (placeholders.length > 0) {
439
+ console.log(pc.yellow(' ⚠ wrangler.jsonc contains placeholder values:\n'))
440
+ for (const issue of placeholders) {
441
+ console.log(pc.yellow(` - ${issue}`))
442
+ }
443
+
444
+ if (process.stdin.isTTY && !args.nonInteractive) {
445
+ // Interactive: offer auto-creation of D1 + R2
446
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
447
+ let autoCreate = false
448
+ try {
449
+ const answer = (await rl.question(
450
+ pc.cyan('\n → Create a new D1 database (and R2 bucket) on Cloudflare automatically? (Y/n): ')
451
+ )).trim().toLowerCase()
452
+ autoCreate = !answer || answer === 'y' || answer === 'yes'
453
+ } finally {
454
+ rl.close()
455
+ }
456
+
457
+ if (autoCreate) {
458
+ // Auth required for resource creation
459
+ const authed = checkWranglerAuth()
460
+ if (!authed) {
461
+ console.log(pc.red('\n ✗ Not logged in to Cloudflare\n'))
462
+ console.log(pc.dim(' BeechCMS needs access to your Cloudflare account to create the database.'))
463
+ console.log(pc.cyan('\n → Run: npx wrangler login'))
464
+ console.log(pc.cyan(' → Then: npx beech init --db\n'))
465
+ process.exit(1)
466
+ }
467
+
468
+ const projectName = readProjectName(configPath)
469
+ const dbName = `${projectName}-db`
470
+ const bucketName = readBucketName(configPath) || `${projectName}-media`
471
+
472
+ console.log(pc.dim(`\n Creating D1 database "${dbName}"…`))
473
+ const dbId = createD1Database(dbName)
474
+ if (!dbId) {
475
+ console.log(pc.red('\n ✗ Failed to create D1 database\n'))
476
+ console.log(pc.dim(' Create it manually and retry:'))
477
+ console.log(pc.cyan(`\n → Run: npx wrangler d1 create ${dbName}`))
478
+ console.log(pc.cyan(' → Then: npx beech init --db\n'))
479
+ process.exit(1)
480
+ }
481
+ console.log(pc.green(` ✓ D1 database created (id: ${dbId})`))
482
+
483
+ console.log(pc.dim(`\n Creating R2 bucket "${bucketName}"…`))
484
+ const r2Ok = createR2Bucket(bucketName)
485
+ if (r2Ok) {
486
+ console.log(pc.green(` ✓ R2 bucket "${bucketName}" created`))
487
+ } else {
488
+ console.log(pc.yellow(` ⚠ R2 bucket creation failed (may already exist — continuing)`))
489
+ }
490
+
491
+ console.log(pc.dim('\n Updating wrangler.jsonc…'))
492
+ const patched = patchWranglerConfig(configPath, dbId)
493
+ if (patched) {
494
+ console.log(pc.green(' ✓ wrangler.jsonc updated\n'))
495
+ } else {
496
+ console.log(pc.yellow(` ⚠ Could not update wrangler.jsonc automatically\n`))
497
+ console.log(pc.dim(` Set database_id = "${dbId}" in wrangler.jsonc manually, then retry:`))
498
+ console.log(pc.cyan(' → Run: npx beech init --db\n'))
499
+ process.exit(1)
500
+ }
501
+ // Fall through to continue DB initialization with the newly created database
502
+ } else {
503
+ printManualDbInstructions()
504
+ process.exit(1)
505
+ }
506
+ } else if (args.nonInteractive && args.local) {
507
+ // --yes + --local: local D1 needs no real database_id — just proceed
508
+ console.log(pc.dim(' --yes: proceeding with local mode (no remote resources needed)\n'))
509
+ // Fall through to DB initialization
510
+ } else {
511
+ if (args.nonInteractive) {
512
+ // --yes + --remote + placeholder → cannot proceed non-interactively
513
+ console.log(pc.red('\n ✗ Cannot proceed non-interactively: remote database has a placeholder database_id\n'))
514
+ console.log(pc.dim(' Set a real database_id in wrangler.jsonc, then retry.'))
515
+ process.exit(1)
516
+ }
517
+ printManualDbInstructions()
518
+ process.exit(1)
519
+ }
520
+ }
521
+ }
522
+
523
+ // For remote operations, verify Cloudflare auth before attempting any wrangler calls
524
+ if (!args.local) {
525
+ const authed = checkWranglerAuth()
526
+ if (!authed) {
527
+ console.log(pc.red(' ✗ Not logged in to Cloudflare\n'))
528
+ console.log(pc.dim(' BeechCMS needs access to your Cloudflare account to manage the D1 database.'))
529
+ console.log(pc.cyan('\n → Run: npx wrangler login'))
530
+ console.log(pc.cyan(' → Then: npx beech init --db\n'))
531
+ process.exit(1)
532
+ }
533
+ }
534
+
535
+ const db = args.db ?? resolveDbName(configPath)
536
+ const options: WranglerOptions = { db, local: args.local, configPath }
537
+
538
+ console.log(pc.cyan(` Checking database "${db}" (${args.local ? 'local' : 'remote'})…\n`))
539
+
540
+ const existingTables = getExistingTables(options)
541
+ const missingTables = SYSTEM_TABLES.filter(t => !existingTables?.includes(t))
542
+
543
+ // Remote mode: verification only — do not auto-apply schema.
544
+ // In production, system tables are created by `wrangler deploy` migrations.
545
+ if (!args.local) {
546
+ if (existingTables === null) {
547
+ console.log(pc.red(' ✗ Remote database unreachable\n'))
548
+ console.log(pc.dim(' Most likely causes:'))
549
+ console.log(pc.dim(' - Wrong database_id in wrangler.jsonc'))
550
+ console.log(pc.dim(' - Worker not yet deployed'))
551
+ console.log(pc.cyan('\n → Fix: Update d1_databases.database_id in wrangler.jsonc'))
552
+ console.log(pc.cyan(' → Then: npm run deploy\n'))
553
+ process.exit(1)
554
+ }
555
+
556
+ if (missingTables.length > 0) {
557
+ console.log(pc.yellow(` ⚠ Missing system tables: ${missingTables.join(', ')}\n`))
558
+ console.log(pc.dim(' Most likely causes:'))
559
+ console.log(pc.dim(' - Wrong database_id in wrangler.jsonc'))
560
+ console.log(pc.dim(' - Migrations did not run during deploy'))
561
+ console.log(pc.cyan('\n → Fix: Update d1_databases.database_id in wrangler.jsonc'))
562
+ console.log(pc.cyan(' → Then: npm run deploy\n'))
563
+ process.exit(1)
564
+ }
565
+
566
+ for (const table of SYSTEM_TABLES) {
567
+ console.log(pc.green(` ✓ ${table}`))
568
+ }
569
+ console.log(pc.green('\n All system tables present. Remote database is initialized.\n'))
570
+ return
571
+ }
572
+
573
+ // Local mode: apply schema if tables are missing.
574
+ if (existingTables === null) {
575
+ console.log(pc.yellow(' Database unreachable or not yet created — applying base schema…\n'))
576
+ } else if (missingTables.length === 0) {
577
+ console.log(pc.green(' ✓ All system tables present. Database already initialised.\n'))
578
+ printNextSteps(args.local)
579
+ return
580
+ } else {
581
+ console.log(pc.yellow(` Missing system tables: ${missingTables.join(', ')}`))
582
+ console.log(pc.cyan('\n Applying base schema…\n'))
583
+ }
584
+
585
+ const ok = executeD1File(BASE_SCHEMA_SQL, options)
586
+ if (!ok) {
587
+ console.log(pc.red('\n ✗ Database initialisation failed\n'))
588
+ console.log(pc.dim(' wrangler reported an error above.'))
589
+ console.log(pc.cyan('\n → Run: npx beech init --db --local\n'))
590
+ process.exit(1)
591
+ }
592
+
593
+ console.log(pc.green('\n ✓ worker.ts'))
594
+ console.log(pc.green(` ✓ ${configPath ? basename(configPath) : 'wrangler.jsonc'}`))
595
+ console.log(pc.green(' ✓ seeds.ts'))
596
+ console.log(pc.green(' ✓ Local D1 system tables ready\n'))
597
+ echoApiKeys(configPath)
598
+ printNextSteps(args.local)
599
+ }