koguma 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/index.ts ADDED
@@ -0,0 +1,1150 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Koguma CLI — the bear's toolbox 🐻
4
+ *
5
+ * Commands:
6
+ * koguma init — Create D1/R2 resources and patch wrangler.toml
7
+ * koguma secret — Set the admin password
8
+ * koguma build — Build the admin dashboard bundle
9
+ * koguma seed — Apply schema + seed SQL to D1
10
+ * koguma migrate-media — Download images from Contentful → upload to R2
11
+ * koguma deploy — Build admin + run wrangler deploy
12
+ *
13
+ * All commands auto-detect the project root by looking for wrangler.toml.
14
+ */
15
+
16
+ import { execSync } from 'child_process';
17
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { resolve, dirname, basename } from 'path';
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────
21
+
22
+ const BOLD = '\x1b[1m';
23
+ const DIM = '\x1b[2m';
24
+ const GREEN = '\x1b[32m';
25
+ const YELLOW = '\x1b[33m';
26
+ const RED = '\x1b[31m';
27
+ const CYAN = '\x1b[36m';
28
+ const RESET = '\x1b[0m';
29
+
30
+ function log(msg: string) {
31
+ console.log(` ${msg}`);
32
+ }
33
+ function ok(msg: string) {
34
+ console.log(` ${GREEN}✓${RESET} ${msg}`);
35
+ }
36
+ function warn(msg: string) {
37
+ console.log(` ${YELLOW}⚠${RESET} ${msg}`);
38
+ }
39
+ function fail(msg: string) {
40
+ console.error(` ${RED}✗${RESET} ${msg}`);
41
+ }
42
+ function header(msg: string) {
43
+ console.log(`\n${BOLD}🐻 ${msg}${RESET}\n`);
44
+ }
45
+
46
+ function run(cmd: string, opts?: { cwd?: string; silent?: boolean }): string {
47
+ try {
48
+ return execSync(cmd, {
49
+ cwd: opts?.cwd,
50
+ encoding: 'utf-8',
51
+ stdio: opts?.silent ? 'pipe' : 'inherit'
52
+ }) as string;
53
+ } catch (e: unknown) {
54
+ const error = e as { stdout?: string; stderr?: string; status?: number };
55
+ if (opts?.silent) return error.stdout ?? '';
56
+ throw e;
57
+ }
58
+ }
59
+
60
+ function runCapture(cmd: string, cwd?: string): string {
61
+ return run(cmd, { cwd, silent: true }).trim();
62
+ }
63
+
64
+ /** Find the project root (directory containing wrangler.toml) */
65
+ function findProjectRoot(): string {
66
+ let dir = process.cwd();
67
+ for (let i = 0; i < 10; i++) {
68
+ if (existsSync(resolve(dir, 'wrangler.toml'))) return dir;
69
+ if (existsSync(resolve(dir, 'wrangler.jsonc'))) return dir;
70
+ const parent = dirname(dir);
71
+ if (parent === dir) break;
72
+ dir = parent;
73
+ }
74
+ fail('Could not find wrangler.toml — run this from your project directory.');
75
+ process.exit(1);
76
+ }
77
+
78
+ /** Find the Koguma package root (where this CLI lives) */
79
+ function findKogumaRoot(): string {
80
+ // This file is at cli/index.ts, so Koguma root is one level up
81
+ return resolve(dirname(new URL(import.meta.url).pathname), '..');
82
+ }
83
+
84
+ // ── Project templates ──────────────────────────────────────────────
85
+
86
+ interface TemplateField {
87
+ method: string; // e.g. 'field.text("Title").required()'
88
+ }
89
+
90
+ interface Template {
91
+ name: string;
92
+ description: string;
93
+ contentTypes: {
94
+ id: string;
95
+ name: string;
96
+ displayField: string;
97
+ singleton?: boolean;
98
+ fields: Record<string, string>; // fieldId → builder expression
99
+ }[];
100
+ }
101
+
102
+ const TEMPLATES: Template[] = [
103
+ {
104
+ name: 'Blog',
105
+ description: 'Blog with posts and site settings',
106
+ contentTypes: [
107
+ {
108
+ id: 'siteSettings',
109
+ name: 'Site Settings',
110
+ displayField: 'siteName',
111
+ singleton: true,
112
+ fields: {
113
+ siteName: 'field.text("Site Name").required()',
114
+ tagline: 'field.text("Tagline")',
115
+ footerText: 'field.text("Footer Text")'
116
+ }
117
+ },
118
+ {
119
+ id: 'post',
120
+ name: 'Posts',
121
+ displayField: 'title',
122
+ fields: {
123
+ title: 'field.text("Title").required()',
124
+ slug: 'field.text("Slug").required().max(120)',
125
+ excerpt: 'field.longText("Excerpt")',
126
+ body: 'field.richText("Body")',
127
+ coverImage: 'field.image("Cover Image")',
128
+ published: 'field.boolean("Published").default(false)',
129
+ date: 'field.date("Date")'
130
+ }
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ name: 'Portfolio',
136
+ description: 'Portfolio with projects and about page',
137
+ contentTypes: [
138
+ {
139
+ id: 'about',
140
+ name: 'About',
141
+ displayField: 'name',
142
+ singleton: true,
143
+ fields: {
144
+ name: 'field.text("Name").required()',
145
+ bio: 'field.richText("Bio")',
146
+ avatar: 'field.image("Avatar")',
147
+ email: 'field.text("Email")'
148
+ }
149
+ },
150
+ {
151
+ id: 'project',
152
+ name: 'Projects',
153
+ displayField: 'title',
154
+ fields: {
155
+ title: 'field.text("Title").required()',
156
+ description: 'field.longText("Description")',
157
+ heroImage: 'field.image("Hero Image")',
158
+ url: 'field.url("Live URL")',
159
+ year: 'field.number("Year")'
160
+ }
161
+ }
162
+ ]
163
+ },
164
+ {
165
+ name: 'Docs',
166
+ description: 'Documentation site with pages and categories',
167
+ contentTypes: [
168
+ {
169
+ id: 'page',
170
+ name: 'Pages',
171
+ displayField: 'title',
172
+ fields: {
173
+ title: 'field.text("Title").required()',
174
+ slug: 'field.text("Slug").required()',
175
+ body: 'field.richText("Body")',
176
+ sortOrder: 'field.number("Sort Order").default(0)'
177
+ }
178
+ }
179
+ ]
180
+ },
181
+ {
182
+ name: 'Blank',
183
+ description: 'Empty project — start from scratch',
184
+ contentTypes: [
185
+ {
186
+ id: 'page',
187
+ name: 'Pages',
188
+ displayField: 'title',
189
+ fields: {
190
+ title: 'field.text("Title").required()',
191
+ body: 'field.richText("Body")'
192
+ }
193
+ }
194
+ ]
195
+ }
196
+ ];
197
+
198
+ function generateSiteConfig(template: Template, siteName: string): string {
199
+ const lines: string[] = [
200
+ `import { defineConfig, contentType, field } from "koguma";`,
201
+ ``
202
+ ];
203
+
204
+ for (const ct of template.contentTypes) {
205
+ lines.push(`const ${ct.id} = contentType({`);
206
+ lines.push(` id: "${ct.id}",`);
207
+ lines.push(` name: "${ct.name}",`);
208
+ lines.push(` displayField: "${ct.displayField}",`);
209
+ if (ct.singleton) lines.push(` singleton: true,`);
210
+ lines.push(` fields: {`);
211
+ for (const [fid, expr] of Object.entries(ct.fields)) {
212
+ lines.push(` ${fid}: ${expr},`);
213
+ }
214
+ lines.push(` },`);
215
+ lines.push(`});`);
216
+ lines.push(``);
217
+ }
218
+
219
+ const ctIds = template.contentTypes.map(ct => ct.id).join(', ');
220
+ lines.push(`export default defineConfig({`);
221
+ lines.push(` siteName: "${siteName}",`);
222
+ lines.push(` contentTypes: [${ctIds}],`);
223
+ lines.push(`});`);
224
+ lines.push(``);
225
+
226
+ return lines.join('\n');
227
+ }
228
+
229
+ function generateWorkerTs(): string {
230
+ return `import { createWorker } from "koguma/worker";
231
+ import config from "./site.config";
232
+ export default createWorker(config);
233
+ `;
234
+ }
235
+
236
+ function generateWranglerToml(projectName: string): string {
237
+ return `name = "${projectName}"
238
+ main = "worker.ts"
239
+ compatibility_date = "2024-11-01"
240
+ compatibility_flags = ["nodejs_compat"]
241
+
242
+ # ── D1 Database ──
243
+ [[d1_databases]]
244
+ binding = "DB"
245
+ database_name = "${projectName}-db"
246
+ database_id = ""
247
+
248
+ # ── R2 Media Storage ──
249
+ [[r2_buckets]]
250
+ binding = "MEDIA"
251
+ bucket_name = "${projectName}-media"
252
+ `;
253
+ }
254
+
255
+ function generatePackageJson(projectName: string): string {
256
+ return (
257
+ JSON.stringify(
258
+ {
259
+ name: projectName,
260
+ private: true,
261
+ scripts: {
262
+ dev: 'wrangler dev',
263
+ deploy: 'koguma deploy'
264
+ },
265
+ dependencies: {
266
+ koguma: 'latest'
267
+ },
268
+ devDependencies: {
269
+ wrangler: 'latest'
270
+ }
271
+ },
272
+ null,
273
+ 2
274
+ ) + '\n'
275
+ );
276
+ }
277
+
278
+ function generateTsconfig(): string {
279
+ return (
280
+ JSON.stringify(
281
+ {
282
+ compilerOptions: {
283
+ target: 'ESNext',
284
+ module: 'ESNext',
285
+ moduleResolution: 'Bundler',
286
+ strict: true,
287
+ esModuleInterop: true,
288
+ skipLibCheck: true,
289
+ types: ['@cloudflare/workers-types']
290
+ },
291
+ include: ['*.ts']
292
+ },
293
+ null,
294
+ 2
295
+ ) + '\n'
296
+ );
297
+ }
298
+
299
+ async function scaffoldNewProject(): Promise<string> {
300
+ header('koguma init — new project');
301
+ const root = process.cwd();
302
+
303
+ // Project name
304
+ const dirName = root.split('/').pop() ?? 'my-koguma-site';
305
+ log(`Project name: ${CYAN}${dirName}${RESET}`);
306
+ const projectName = dirName;
307
+
308
+ // Template selection
309
+ log(`\n Pick a template:\n`);
310
+ for (let i = 0; i < TEMPLATES.length; i++) {
311
+ const t = TEMPLATES[i]!;
312
+ log(
313
+ ` ${BOLD}${i + 1}${RESET}. ${t.name} ${DIM}— ${t.description}${RESET}`
314
+ );
315
+ }
316
+ log('');
317
+
318
+ // Default to Blog (template 1), non-interactive for CI
319
+ const choice = process.env.KOGUMA_TEMPLATE
320
+ ? parseInt(process.env.KOGUMA_TEMPLATE, 10)
321
+ : 1;
322
+ const template = TEMPLATES[choice - 1] ?? TEMPLATES[0]!;
323
+
324
+ ok(`Using template: ${BOLD}${template.name}${RESET}`);
325
+
326
+ // Generate files
327
+ const files: [string, string][] = [
328
+ ['site.config.ts', generateSiteConfig(template, projectName)],
329
+ ['worker.ts', generateWorkerTs()],
330
+ ['wrangler.toml', generateWranglerToml(projectName)],
331
+ ['package.json', generatePackageJson(projectName)],
332
+ ['tsconfig.json', generateTsconfig()]
333
+ ];
334
+
335
+ for (const [name, content] of files) {
336
+ const path = resolve(root, name);
337
+ if (existsSync(path)) {
338
+ warn(`${name} already exists, skipping`);
339
+ } else {
340
+ writeFileSync(path, content);
341
+ ok(`Created ${name}`);
342
+ }
343
+ }
344
+
345
+ // Install dependencies
346
+ log(`\n Installing dependencies…\n`);
347
+ try {
348
+ run('bun install', { cwd: root });
349
+ ok('Dependencies installed');
350
+ } catch {
351
+ warn("Failed to install — run 'bun install' manually");
352
+ }
353
+
354
+ return root;
355
+ }
356
+
357
+ // ── Commands ────────────────────────────────────────────────────────
358
+
359
+ async function cmdInit() {
360
+ header('koguma init');
361
+
362
+ // Detect if this is a new project or existing
363
+ const cwd = process.cwd();
364
+ const hasWrangler =
365
+ existsSync(resolve(cwd, 'wrangler.toml')) ||
366
+ existsSync(resolve(cwd, 'wrangler.jsonc'));
367
+
368
+ let root: string;
369
+ if (!hasWrangler) {
370
+ // New project — scaffold first
371
+ root = await scaffoldNewProject();
372
+ } else {
373
+ root = findProjectRoot();
374
+ }
375
+
376
+ const wranglerPath = resolve(root, 'wrangler.toml');
377
+ let toml = readFileSync(wranglerPath, 'utf-8');
378
+
379
+ // Parse project name from wrangler.toml
380
+ const nameMatch = toml.match(/^name\s*=\s*"([^"]+)"/m);
381
+ const projectName = nameMatch?.[1] ?? 'my-project';
382
+
383
+ // ── D1 Database ──
384
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
385
+ const dbName = dbNameMatch?.[1] ?? `${projectName}-db`;
386
+ const dbIdMatch = toml.match(/database_id\s*=\s*"([^"]*)"/);
387
+ const existingId = dbIdMatch?.[1];
388
+
389
+ if (existingId) {
390
+ ok(
391
+ `D1 database already configured: ${DIM}${dbName} (${existingId})${RESET}`
392
+ );
393
+ } else {
394
+ log(`Creating D1 database: ${CYAN}${dbName}${RESET}`);
395
+ try {
396
+ const output = runCapture(`npx wrangler d1 create ${dbName}`, root);
397
+ const idMatch = output.match(/database_id\s*=\s*"([^"]+)"/);
398
+ if (idMatch?.[1]) {
399
+ toml = toml.replace(
400
+ /database_id\s*=\s*""/,
401
+ `database_id = "${idMatch[1]}"`
402
+ );
403
+ writeFileSync(wranglerPath, toml);
404
+ ok(
405
+ `Created D1 database and patched wrangler.toml: ${DIM}${idMatch[1]}${RESET}`
406
+ );
407
+ } else {
408
+ warn("D1 database created but couldn't parse ID. Check output above.");
409
+ }
410
+ } catch {
411
+ fail(
412
+ "Failed to create D1 database. Make sure you're logged into Cloudflare: npx wrangler login"
413
+ );
414
+ }
415
+ }
416
+
417
+ // ── R2 Bucket ──
418
+ const bucketMatch = toml.match(/bucket_name\s*=\s*"([^"]+)"/);
419
+ const bucketName = bucketMatch?.[1] ?? `${projectName}-media`;
420
+
421
+ log(`Checking R2 bucket: ${CYAN}${bucketName}${RESET}`);
422
+ try {
423
+ const buckets = runCapture('npx wrangler r2 bucket list', root);
424
+ if (buckets.includes(bucketName)) {
425
+ ok(`R2 bucket already exists: ${DIM}${bucketName}${RESET}`);
426
+ } else {
427
+ runCapture(`npx wrangler r2 bucket create ${bucketName}`, root);
428
+ ok(`Created R2 bucket: ${DIM}${bucketName}${RESET}`);
429
+ }
430
+ } catch {
431
+ fail(
432
+ "Failed to check/create R2 bucket. Make sure you're logged into Cloudflare."
433
+ );
434
+ }
435
+
436
+ // ── KOGUMA_SECRET ──
437
+ log(`\n ${YELLOW}Next, set your admin password:${RESET}`);
438
+ log(` ${DIM}$ koguma secret${RESET}\n`);
439
+
440
+ ok('Init complete!');
441
+ }
442
+
443
+ async function cmdBuild() {
444
+ header('koguma build');
445
+ const kogumaRoot = findKogumaRoot();
446
+ const adminDir = resolve(kogumaRoot, 'admin');
447
+
448
+ if (!existsSync(adminDir)) {
449
+ fail('Admin directory not found at: ' + adminDir);
450
+ process.exit(1);
451
+ }
452
+
453
+ // Install admin deps if needed
454
+ if (!existsSync(resolve(adminDir, 'node_modules'))) {
455
+ log('Installing admin dependencies...');
456
+ run('bun install', { cwd: adminDir });
457
+ }
458
+
459
+ // Build Vite app
460
+ log('Building admin dashboard...');
461
+ run('bun run build', { cwd: adminDir });
462
+
463
+ // Generate bundle
464
+ log('Generating admin bundle...');
465
+ const scriptPath = resolve(kogumaRoot, 'scripts/bundle-admin.ts');
466
+ run(`bun run ${scriptPath}`, { cwd: kogumaRoot });
467
+
468
+ ok('Admin bundle ready!');
469
+ }
470
+
471
+ async function cmdSeed() {
472
+ header('koguma seed');
473
+ const root = findProjectRoot();
474
+ const seedSql = resolve(root, 'db/seed.sql');
475
+
476
+ if (!existsSync(seedSql)) {
477
+ fail('db/seed.sql not found. Generate it first with your seed script.');
478
+ process.exit(1);
479
+ }
480
+
481
+ // Parse database name from wrangler.toml
482
+ const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
483
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
484
+ const dbName = dbNameMatch?.[1] ?? 'my-db';
485
+
486
+ const isRemote = process.argv.includes('--remote');
487
+ const target = isRemote ? '--remote' : '--local';
488
+
489
+ log(
490
+ `Seeding ${isRemote ? 'REMOTE' : 'local'} database: ${CYAN}${dbName}${RESET}`
491
+ );
492
+ run(`npx wrangler d1 execute ${dbName} ${target} --file=${seedSql}`, {
493
+ cwd: root
494
+ });
495
+
496
+ ok(`Database seeded (${isRemote ? 'remote' : 'local'})!`);
497
+ }
498
+
499
+ async function cmdDeploy() {
500
+ header('koguma deploy');
501
+
502
+ // Build admin first
503
+ await cmdBuild();
504
+
505
+ // Build frontend
506
+ const root = findProjectRoot();
507
+ log('\nBuilding frontend...');
508
+ run('bun run build', { cwd: root });
509
+
510
+ // Deploy
511
+ log('\nDeploying to Cloudflare...');
512
+ run('npx wrangler deploy', { cwd: root });
513
+
514
+ ok('Deployed! 🎉');
515
+ }
516
+
517
+ async function cmdSecret() {
518
+ header('koguma secret');
519
+ const root = findProjectRoot();
520
+ log('Setting KOGUMA_SECRET (your admin password)...');
521
+ run('npx wrangler secret put KOGUMA_SECRET', { cwd: root });
522
+ ok('Secret set!');
523
+ }
524
+
525
+ async function cmdMigrateMedia() {
526
+ header('koguma migrate-media');
527
+ const root = findProjectRoot();
528
+ const seedPath = resolve(root, 'db/seed.json');
529
+
530
+ if (!existsSync(seedPath)) {
531
+ fail('db/seed.json not found.');
532
+ process.exit(1);
533
+ }
534
+
535
+ // Read .dev.vars for local, or prompt for production URL
536
+ const isRemote = process.argv.includes('--remote');
537
+ const targetUrl = isRemote
538
+ ? process.argv[process.argv.indexOf('--remote') + 1]
539
+ : 'http://localhost:8787';
540
+
541
+ if (isRemote && !targetUrl) {
542
+ fail('Usage: koguma migrate-media --remote https://your-site.workers.dev');
543
+ process.exit(1);
544
+ }
545
+
546
+ log(`Target: ${CYAN}${targetUrl}${RESET}`);
547
+
548
+ // Read password from .dev.vars
549
+ const devVarsPath = resolve(root, '.dev.vars');
550
+ let password = '';
551
+ if (existsSync(devVarsPath)) {
552
+ const content = readFileSync(devVarsPath, 'utf-8');
553
+ const match = content.match(/KOGUMA_SECRET=(.+)/);
554
+ if (match?.[1]) password = match[1].trim();
555
+ }
556
+ if (!password) {
557
+ fail('KOGUMA_SECRET not found in .dev.vars');
558
+ process.exit(1);
559
+ }
560
+
561
+ // Login
562
+ log('Logging in...');
563
+ const loginRes = await fetch(`${targetUrl}/api/auth/login`, {
564
+ method: 'POST',
565
+ headers: { 'Content-Type': 'application/json' },
566
+ body: JSON.stringify({ password }),
567
+ redirect: 'manual'
568
+ });
569
+ const setCookie = loginRes.headers.get('set-cookie') ?? '';
570
+ const cookieMatch = setCookie.match(/koguma_session=[^;]+/);
571
+ if (!cookieMatch) {
572
+ fail('Login failed — check your KOGUMA_SECRET');
573
+ process.exit(1);
574
+ }
575
+ const cookie = cookieMatch[0];
576
+ ok('Authenticated');
577
+
578
+ // Load seed.json
579
+ const seed = JSON.parse(readFileSync(seedPath, 'utf-8')) as {
580
+ assets: Array<{
581
+ id: string;
582
+ title: string;
583
+ file: { url: string; contentType: string; fileName: string };
584
+ }>;
585
+ };
586
+
587
+ log(`Found ${seed.assets.length} assets\n`);
588
+
589
+ const results: Array<{ id: string; newUrl: string }> = [];
590
+
591
+ for (const asset of seed.assets) {
592
+ const url = asset.file.url.startsWith('//')
593
+ ? `https:${asset.file.url}`
594
+ : asset.file.url;
595
+ log(`⬇ ${asset.title}`);
596
+
597
+ try {
598
+ const dlRes = await fetch(url);
599
+ if (!dlRes.ok) {
600
+ warn(` Download failed: ${dlRes.status}`);
601
+ continue;
602
+ }
603
+
604
+ const blob = await dlRes.blob();
605
+ const fileName =
606
+ asset.file.fileName ||
607
+ `${asset.id}.${asset.file.contentType.split('/')[1]}`;
608
+
609
+ const formData = new FormData();
610
+ formData.append(
611
+ 'file',
612
+ new File([blob], fileName, { type: asset.file.contentType })
613
+ );
614
+ formData.append('title', asset.title);
615
+
616
+ const upRes = await fetch(`${targetUrl}/api/admin/media`, {
617
+ method: 'POST',
618
+ headers: { Cookie: cookie },
619
+ body: formData
620
+ });
621
+
622
+ if (!upRes.ok) {
623
+ warn(` Upload failed: ${await upRes.text()}`);
624
+ continue;
625
+ }
626
+
627
+ const { url: newUrl } = (await upRes.json()) as { url: string };
628
+ ok(` → ${newUrl}`);
629
+ results.push({ id: asset.id, newUrl });
630
+ } catch (e) {
631
+ warn(` Error: ${e}`);
632
+ }
633
+ }
634
+
635
+ log(`\n✅ Migrated: ${results.length}/${seed.assets.length}`);
636
+
637
+ // Generate and apply URL update SQL
638
+ if (results.length > 0) {
639
+ const sql = results
640
+ .map(r => `UPDATE _assets SET url = '${r.newUrl}' WHERE id = '${r.id}';`)
641
+ .join('\n');
642
+
643
+ const sqlPath = resolve(root, 'db/migrate-urls.sql');
644
+ writeFileSync(sqlPath, sql);
645
+
646
+ // Auto-apply to local or remote D1
647
+ const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
648
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
649
+ const dbName = dbNameMatch?.[1] ?? 'my-db';
650
+ const target = isRemote ? '--remote' : '--local';
651
+
652
+ log(`\nUpdating ${isRemote ? 'remote' : 'local'} database URLs...`);
653
+ run(`npx wrangler d1 execute ${dbName} ${target} --file=${sqlPath}`, {
654
+ cwd: root
655
+ });
656
+
657
+ ok('Media migration complete!');
658
+ }
659
+ }
660
+
661
+ // ── Typegen ─────────────────────────────────────────────────────────
662
+
663
+ function fieldTypeToTs(
664
+ fieldType: string,
665
+ meta: { required: boolean; refContentType?: string; options?: string[] }
666
+ ): string {
667
+ switch (fieldType) {
668
+ case 'text':
669
+ case 'longText':
670
+ case 'url':
671
+ case 'date':
672
+ return 'string';
673
+ case 'richText':
674
+ return 'Record<string, unknown>';
675
+ case 'image':
676
+ return '{ id: string; url: string; title?: string; width?: number; height?: number }';
677
+ case 'boolean':
678
+ return 'boolean';
679
+ case 'number':
680
+ return 'number';
681
+ case 'select':
682
+ return meta.options?.map(o => `'${o}'`).join(' | ') ?? 'string';
683
+ case 'reference':
684
+ return meta.refContentType
685
+ ? `${capitalize(meta.refContentType)}Entry`
686
+ : 'Record<string, unknown>';
687
+ case 'references':
688
+ return meta.refContentType
689
+ ? `${capitalize(meta.refContentType)}Entry[]`
690
+ : 'Record<string, unknown>[]';
691
+ default:
692
+ return 'unknown';
693
+ }
694
+ }
695
+
696
+ function capitalize(s: string): string {
697
+ return s.charAt(0).toUpperCase() + s.slice(1);
698
+ }
699
+
700
+ async function cmdTypegen() {
701
+ header('koguma typegen');
702
+ const root = findProjectRoot();
703
+
704
+ // Import site.config.ts
705
+ const configPath = resolve(root, 'site.config.ts');
706
+ if (!existsSync(configPath)) {
707
+ fail('site.config.ts not found in project root.');
708
+ process.exit(1);
709
+ }
710
+
711
+ log('Reading site.config.ts...');
712
+ const configModule = await import(configPath);
713
+ const config = configModule.default as {
714
+ contentTypes: {
715
+ id: string;
716
+ name: string;
717
+ fieldMeta: Record<
718
+ string,
719
+ {
720
+ fieldType: string;
721
+ required: boolean;
722
+ refContentType?: string;
723
+ options?: string[];
724
+ }
725
+ >;
726
+ }[];
727
+ };
728
+
729
+ if (!config.contentTypes?.length) {
730
+ fail('No content types found in site.config.ts');
731
+ process.exit(1);
732
+ }
733
+
734
+ // Generate .d.ts
735
+ const lines: string[] = [
736
+ '/**',
737
+ ' * Auto-generated by `koguma typegen`',
738
+ ' * Do not edit manually.',
739
+ ' */',
740
+ '',
741
+ '// ── System fields ── common to all entries',
742
+ 'interface KogumaSystemFields {',
743
+ ' id: string;',
744
+ ' created_at: string;',
745
+ ' updated_at: string;',
746
+ ' status: "draft" | "published";',
747
+ ' publishAt: string | null;',
748
+ '}',
749
+ ''
750
+ ];
751
+
752
+ // Generate interfaces for each content type
753
+ const typeNames: string[] = [];
754
+ for (const ct of config.contentTypes) {
755
+ const typeName = capitalize(ct.id) + 'Entry';
756
+ typeNames.push(typeName);
757
+
758
+ lines.push(`// ── ${ct.name} ──`);
759
+ lines.push(`export interface ${typeName} extends KogumaSystemFields {`);
760
+
761
+ for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
762
+ const tsType = fieldTypeToTs(meta.fieldType, meta);
763
+ const optional = meta.required ? '' : '?';
764
+ lines.push(` ${fieldId}${optional}: ${tsType};`);
765
+ }
766
+
767
+ lines.push('}');
768
+ lines.push('');
769
+ }
770
+
771
+ // Generate client interface
772
+ lines.push('// ── Koguma Client ──');
773
+ lines.push('export interface KogumaClient {');
774
+
775
+ for (const ct of config.contentTypes) {
776
+ const typeName = capitalize(ct.id) + 'Entry';
777
+ lines.push(` /** ${ct.name} */`);
778
+ lines.push(` get(type: '${ct.id}', id: string): Promise<${typeName}>;`);
779
+ lines.push(
780
+ ` list(type: '${ct.id}'): Promise<{ entries: ${typeName}[] }>;`
781
+ );
782
+ lines.push(
783
+ ` create(type: '${ct.id}', data: Partial<${typeName}>): Promise<${typeName}>;`
784
+ );
785
+ lines.push(
786
+ ` update(type: '${ct.id}', id: string, data: Partial<${typeName}>): Promise<${typeName}>;`
787
+ );
788
+ lines.push(` delete(type: '${ct.id}', id: string): Promise<void>;`);
789
+ }
790
+
791
+ lines.push('}');
792
+ lines.push('');
793
+
794
+ // Content type union
795
+ lines.push(
796
+ `export type ContentType = ${config.contentTypes.map(ct => `'${ct.id}'`).join(' | ')};`
797
+ );
798
+ lines.push('');
799
+
800
+ const output = lines.join('\n');
801
+ const outPath = resolve(root, 'koguma.d.ts');
802
+ writeFileSync(outPath, output);
803
+
804
+ ok(
805
+ `Generated ${CYAN}koguma.d.ts${RESET} with ${typeNames.length} type(s): ${typeNames.join(', ')}`
806
+ );
807
+ }
808
+
809
+ // ── Migrate ─────────────────────────────────────────────────────────
810
+
811
+ async function cmdMigrate() {
812
+ header('koguma migrate');
813
+ const root = findProjectRoot();
814
+
815
+ // Import site.config.ts
816
+ const configPath = resolve(root, 'site.config.ts');
817
+ if (!existsSync(configPath)) {
818
+ fail('site.config.ts not found.');
819
+ process.exit(1);
820
+ }
821
+
822
+ log('Reading site.config.ts...');
823
+ const configModule = await import(configPath);
824
+ const config = configModule.default as {
825
+ contentTypes: {
826
+ id: string;
827
+ name: string;
828
+ fieldMeta: Record<
829
+ string,
830
+ { fieldType: string; required: boolean; refContentType?: string }
831
+ >;
832
+ }[];
833
+ };
834
+
835
+ // Get DB name from wrangler.toml
836
+ const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
837
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
838
+ const dbName = dbNameMatch?.[1] ?? 'my-db';
839
+
840
+ const isRemote = process.argv.includes('--remote');
841
+ const target = isRemote ? '--remote' : '--local';
842
+
843
+ // Get existing columns for each content type
844
+ log(`Inspecting ${isRemote ? 'REMOTE' : 'local'} database...`);
845
+ const existingColumns: Record<string, { name: string; type: string }[]> = {};
846
+
847
+ for (const ct of config.contentTypes) {
848
+ try {
849
+ const output = runCapture(
850
+ `npx wrangler d1 execute ${dbName} ${target} --command "SELECT name, type FROM pragma_table_info('${ct.id}')" --json`,
851
+ root
852
+ );
853
+ const parsed = JSON.parse(output);
854
+ // Wrangler D1 returns results in an array
855
+ const results = parsed?.[0]?.results ?? [];
856
+ existingColumns[ct.id] = results;
857
+ } catch {
858
+ warn(`Table '${ct.id}' does not exist yet.`);
859
+ }
860
+ }
861
+
862
+ // Detect drift
863
+ const { detectDrift } = await import('../src/db/migrate.ts');
864
+ const result = detectDrift(config.contentTypes as any, existingColumns);
865
+
866
+ // Display results
867
+ if (result.drift.length === 0 && result.warnings.length === 0) {
868
+ ok('Schema is up to date — no changes needed!');
869
+ return;
870
+ }
871
+
872
+ if (result.warnings.length > 0) {
873
+ console.log('');
874
+ for (const w of result.warnings) {
875
+ warn(w);
876
+ }
877
+ }
878
+
879
+ if (result.sql.length > 0) {
880
+ console.log(`\n${BOLD}Migration SQL:${RESET}`);
881
+ for (const s of result.sql) {
882
+ console.log(` ${CYAN}${s}${RESET}`);
883
+ }
884
+
885
+ // Apply the SQL
886
+ const sqlFile = resolve(root, 'db/migration.sql');
887
+ writeFileSync(sqlFile, result.sql.join('\n'));
888
+ log(`\nWritten to ${CYAN}db/migration.sql${RESET}`);
889
+
890
+ log(`\nApplying migration to ${isRemote ? 'REMOTE' : 'local'} database...`);
891
+ run(`npx wrangler d1 execute ${dbName} ${target} --file=${sqlFile}`, {
892
+ cwd: root
893
+ });
894
+
895
+ ok('Migration applied!');
896
+ }
897
+ }
898
+
899
+ // ── Export ───────────────────────────────────────────────────────────
900
+
901
+ async function cmdExport() {
902
+ header('koguma export');
903
+ const root = findProjectRoot();
904
+
905
+ // Import config
906
+ const configPath = resolve(root, 'site.config.ts');
907
+ const configModule = await import(configPath);
908
+ const config = configModule.default as {
909
+ contentTypes: {
910
+ id: string;
911
+ fieldMeta: Record<string, { fieldType: string }>;
912
+ }[];
913
+ };
914
+
915
+ // Get DB name
916
+ const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
917
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
918
+ const dbName = dbNameMatch?.[1] ?? 'my-db';
919
+
920
+ const isRemote = process.argv.includes('--remote');
921
+ const target = isRemote ? '--remote' : '--local';
922
+
923
+ const exportData: Record<
924
+ string,
925
+ { entries: unknown[]; joinTables: Record<string, unknown[]> }
926
+ > = {};
927
+
928
+ for (const ct of config.contentTypes) {
929
+ log(`Exporting ${CYAN}${ct.id}${RESET}...`);
930
+
931
+ // Export main table
932
+ try {
933
+ const output = runCapture(
934
+ `npx wrangler d1 execute ${dbName} ${target} --command "SELECT * FROM ${ct.id}" --json`,
935
+ root
936
+ );
937
+ const parsed = JSON.parse(output);
938
+ const entries = parsed?.[0]?.results ?? [];
939
+
940
+ // Export join tables
941
+ const joinTables: Record<string, unknown[]> = {};
942
+ for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
943
+ if (meta.fieldType === 'references') {
944
+ const joinTable = `${ct.id}__${fieldId}`;
945
+ try {
946
+ const jtOutput = runCapture(
947
+ `npx wrangler d1 execute ${dbName} ${target} --command "SELECT * FROM ${joinTable}" --json`,
948
+ root
949
+ );
950
+ const jtParsed = JSON.parse(jtOutput);
951
+ joinTables[joinTable] = jtParsed?.[0]?.results ?? [];
952
+ } catch {
953
+ // Join table may not exist
954
+ }
955
+ }
956
+ }
957
+
958
+ exportData[ct.id] = { entries, joinTables };
959
+ } catch {
960
+ warn(`Could not export ${ct.id} — table may not exist.`);
961
+ }
962
+ }
963
+
964
+ const exportFile = resolve(
965
+ root,
966
+ `koguma-export-${new Date().toISOString().slice(0, 10)}.json`
967
+ );
968
+ const payload = {
969
+ version: 1,
970
+ exportedAt: new Date().toISOString(),
971
+ source: isRemote ? 'remote' : 'local',
972
+ contentTypes: exportData
973
+ };
974
+ writeFileSync(exportFile, JSON.stringify(payload, null, 2));
975
+
976
+ const entryCount = Object.values(exportData).reduce(
977
+ (sum, ct) => sum + ct.entries.length,
978
+ 0
979
+ );
980
+ ok(
981
+ `Exported ${entryCount} entries to ${CYAN}${basename(exportFile)}${RESET}`
982
+ );
983
+ }
984
+
985
+ // ── Import ──────────────────────────────────────────────────────────
986
+
987
+ async function cmdImport() {
988
+ header('koguma import');
989
+ const root = findProjectRoot();
990
+
991
+ const inputFile = process.argv[3];
992
+ if (!inputFile) {
993
+ fail('Usage: koguma import <file.json> [--remote]');
994
+ process.exit(1);
995
+ }
996
+
997
+ const filePath = resolve(root, inputFile);
998
+ if (!existsSync(filePath)) {
999
+ fail(`File not found: ${inputFile}`);
1000
+ process.exit(1);
1001
+ }
1002
+
1003
+ const payload = JSON.parse(readFileSync(filePath, 'utf-8'));
1004
+ if (payload.version !== 1) {
1005
+ fail(`Unsupported export version: ${payload.version}`);
1006
+ process.exit(1);
1007
+ }
1008
+
1009
+ // Get DB name
1010
+ const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
1011
+ const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
1012
+ const dbName = dbNameMatch?.[1] ?? 'my-db';
1013
+
1014
+ const isRemote = process.argv.includes('--remote');
1015
+ const target = isRemote ? '--remote' : '--local';
1016
+
1017
+ let totalEntries = 0;
1018
+
1019
+ for (const [typeId, data] of Object.entries(
1020
+ payload.contentTypes as Record<
1021
+ string,
1022
+ {
1023
+ entries: Record<string, unknown>[];
1024
+ joinTables: Record<string, Record<string, unknown>[]>;
1025
+ }
1026
+ >
1027
+ )) {
1028
+ log(
1029
+ `Importing ${CYAN}${typeId}${RESET} (${data.entries.length} entries)...`
1030
+ );
1031
+
1032
+ for (const entry of data.entries) {
1033
+ const cols = Object.keys(entry);
1034
+ const vals = Object.values(entry).map(v => {
1035
+ if (v === null) return 'NULL';
1036
+ if (typeof v === 'number') return String(v);
1037
+ return `'${String(v).replace(/'/g, "''")}'`;
1038
+ });
1039
+
1040
+ const sql = `INSERT OR REPLACE INTO ${typeId} (${cols.join(', ')}) VALUES (${vals.join(', ')})`;
1041
+ run(
1042
+ `npx wrangler d1 execute ${dbName} ${target} --command "${sql.replace(/"/g, '\\"')}"`,
1043
+ { cwd: root, silent: true }
1044
+ );
1045
+ totalEntries++;
1046
+ }
1047
+
1048
+ // Import join tables
1049
+ for (const [jtName, rows] of Object.entries(data.joinTables)) {
1050
+ for (const row of rows) {
1051
+ const cols = Object.keys(row);
1052
+ const vals = Object.values(row).map(v => {
1053
+ if (v === null) return 'NULL';
1054
+ if (typeof v === 'number') return String(v);
1055
+ return `'${String(v).replace(/'/g, "''")}'`;
1056
+ });
1057
+
1058
+ const sql = `INSERT OR REPLACE INTO ${jtName} (${cols.join(', ')}) VALUES (${vals.join(', ')})`;
1059
+ run(
1060
+ `npx wrangler d1 execute ${dbName} ${target} --command "${sql.replace(/"/g, '\\"')}"`,
1061
+ { cwd: root, silent: true }
1062
+ );
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ ok(
1068
+ `Imported ${totalEntries} entries from ${CYAN}${basename(inputFile)}${RESET}`
1069
+ );
1070
+ }
1071
+
1072
+ function cmdHelp() {
1073
+ console.log(`
1074
+ ${BOLD}🐻 Koguma CLI${RESET} ${DIM}v0.4.0${RESET}
1075
+
1076
+ ${BOLD}Usage:${RESET} koguma <command>
1077
+
1078
+ ${BOLD}Commands:${RESET}
1079
+ ${CYAN}init${RESET} Create D1 database and R2 bucket, patch wrangler.toml
1080
+ ${CYAN}secret${RESET} Set the admin password on Cloudflare
1081
+ ${CYAN}build${RESET} Build the admin dashboard bundle
1082
+ ${CYAN}seed${RESET} Seed the database from db/seed.sql
1083
+ ${CYAN}typegen${RESET} Generate koguma.d.ts typed interfaces
1084
+ ${CYAN}migrate${RESET} Detect schema drift and apply ALTER TABLE changes
1085
+ ${CYAN}export${RESET} Export all content to JSON
1086
+ ${CYAN}import${RESET} Import content from JSON file
1087
+ ${CYAN}migrate-media${RESET} Download images and upload to R2
1088
+ ${CYAN}deploy${RESET} Build admin + frontend, then deploy via wrangler
1089
+
1090
+ ${BOLD}Options:${RESET}
1091
+ ${DIM}koguma seed --remote${RESET} Seed the production database
1092
+ ${DIM}koguma migrate --remote${RESET} Migrate the production database
1093
+ ${DIM}koguma export --remote${RESET} Export from production
1094
+ ${DIM}koguma import data.json --remote${RESET} Import to production
1095
+ ${DIM}koguma migrate-media --remote https://...${RESET} Migrate to production R2
1096
+
1097
+ ${BOLD}First deploy:${RESET}
1098
+ ${DIM}$${RESET} koguma init ${DIM}# Create D1 + R2${RESET}
1099
+ ${DIM}$${RESET} koguma secret ${DIM}# Set admin password${RESET}
1100
+ ${DIM}$${RESET} koguma seed --remote ${DIM}# Seed production DB${RESET}
1101
+ ${DIM}$${RESET} koguma deploy ${DIM}# Build + deploy${RESET}
1102
+ `);
1103
+ }
1104
+
1105
+ // ── Dispatch ────────────────────────────────────────────────────────
1106
+
1107
+ const command = process.argv[2];
1108
+
1109
+ switch (command) {
1110
+ case 'init':
1111
+ await cmdInit();
1112
+ break;
1113
+ case 'secret':
1114
+ await cmdSecret();
1115
+ break;
1116
+ case 'build':
1117
+ await cmdBuild();
1118
+ break;
1119
+ case 'seed':
1120
+ await cmdSeed();
1121
+ break;
1122
+ case 'typegen':
1123
+ await cmdTypegen();
1124
+ break;
1125
+ case 'migrate':
1126
+ await cmdMigrate();
1127
+ break;
1128
+ case 'export':
1129
+ await cmdExport();
1130
+ break;
1131
+ case 'import':
1132
+ await cmdImport();
1133
+ break;
1134
+ case 'migrate-media':
1135
+ await cmdMigrateMedia();
1136
+ break;
1137
+ case 'deploy':
1138
+ await cmdDeploy();
1139
+ break;
1140
+ case 'help':
1141
+ case '--help':
1142
+ case '-h':
1143
+ case undefined:
1144
+ cmdHelp();
1145
+ break;
1146
+ default:
1147
+ fail(`Unknown command: ${command}`);
1148
+ cmdHelp();
1149
+ process.exit(1);
1150
+ }