koguma 0.6.6 → 2.0.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.
Files changed (44) hide show
  1. package/README.md +109 -139
  2. package/cli/auth.ts +101 -0
  3. package/cli/config.ts +149 -0
  4. package/cli/constants.ts +38 -0
  5. package/cli/content.ts +503 -0
  6. package/cli/dev-sync.ts +305 -0
  7. package/cli/exec.ts +61 -0
  8. package/cli/index.ts +779 -1545
  9. package/cli/log.ts +49 -0
  10. package/cli/preflight.ts +105 -0
  11. package/cli/scaffold.ts +680 -0
  12. package/cli/typegen.ts +190 -0
  13. package/cli/ui.ts +55 -0
  14. package/cli/wrangler.ts +367 -0
  15. package/package.json +7 -4
  16. package/src/admin/_bundle.ts +1 -1
  17. package/src/api/router.integration.test.ts +63 -80
  18. package/src/api/router.ts +85 -59
  19. package/src/config/define.ts +1 -1
  20. package/src/config/field.ts +10 -9
  21. package/src/config/index.ts +1 -13
  22. package/src/config/meta.ts +7 -7
  23. package/src/config/types.ts +1 -95
  24. package/src/db/init.ts +68 -0
  25. package/src/db/queries.ts +120 -211
  26. package/src/db/sql.ts +10 -25
  27. package/src/media/index.ts +105 -47
  28. package/src/react/Markdown.test.tsx +195 -0
  29. package/src/react/Markdown.tsx +40 -0
  30. package/src/react/index.ts +6 -22
  31. package/src/react/types.ts +3 -112
  32. package/src/db/migrate.ts +0 -182
  33. package/src/db/schema.ts +0 -122
  34. package/src/react/RichText.test.tsx +0 -535
  35. package/src/react/RichText.tsx +0 -350
  36. package/src/rich-text/index.ts +0 -4
  37. package/src/rich-text/koguma-to-lexical.ts +0 -340
  38. package/src/rich-text/lexical-compat.test.ts +0 -513
  39. package/src/rich-text/lexical-to-koguma.test.ts +0 -906
  40. package/src/rich-text/lexical-to-koguma.ts +0 -400
  41. package/src/rich-text/markdown-to-koguma.ts +0 -164
  42. package/src/rich-text/plain.test.ts +0 -208
  43. package/src/rich-text/plain.ts +0 -114
  44. package/src/rich-text/snapshots.test.ts +0 -284
package/cli/index.ts CHANGED
@@ -3,1708 +3,972 @@
3
3
  * Koguma CLI — the bear's toolbox 🐻
4
4
  *
5
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 seedApply schema + seed SQL to D1
10
- * koguma migrate-media Download images from Contentful → upload to R2
11
- * koguma deploy Build admin + run wrangler deploy
6
+ * koguma init — Create D1/R2 resources, scaffold project
7
+ * koguma dev Run local dev server
8
+ * koguma push — Build + deploy to production
9
+ * koguma pullDownload production content
10
+ * koguma gen-types Generate koguma.d.ts
11
+ * koguma tidy Sync content/ dirs with config + validate
12
12
  *
13
- * All commands auto-detect the project root by looking for wrangler.toml.
13
+ * All commands auto-detect the project root by looking for koguma.toml.
14
14
  */
15
15
 
16
- import { execSync } from 'child_process';
17
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
18
- import { resolve, dirname, basename } from 'path';
19
- import { generateSchema } from '../src/db/schema.ts';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
17
+ import { resolve, dirname, extname } from 'path';
18
+
19
+ import { ANSI, log, ok, warn, fail, header } from './log.ts';
20
+ import { run } from './exec.ts';
21
+ import {
22
+ CLI_VERSION,
23
+ CONFIG_FILE,
24
+ SITE_CONFIG_FILE,
25
+ CONTENT_DIR,
26
+ DB_DIR,
27
+ KOGUMA_DIR
28
+ } from './constants.ts';
29
+ import { readConfig, writeConfig, deriveNames } from './config.ts';
30
+ import { preflight } from './preflight.ts';
31
+ import {
32
+ scaffoldNewProject,
33
+ scaffoldContentDir,
34
+ syncContentDirsWithConfig,
35
+ TEMPLATES
36
+ } from './scaffold.ts';
37
+ import { runTypegen } from './typegen.ts';
38
+ import { authenticate, getRemoteUrl } from './auth.ts';
39
+ import {
40
+ checkWranglerAuth,
41
+ applySchema,
42
+ d1Query,
43
+ d1InsertRow,
44
+ d1InsertBatch,
45
+ applySchemaAsync,
46
+ d1InsertBatchAsync,
47
+ d1ExecuteBatchSqlAsync,
48
+ r2PutLocal,
49
+ r2PutLocalAsync,
50
+ wranglerDev,
51
+ wranglerDeploy,
52
+ createD1Database,
53
+ ensureR2Bucket,
54
+ type D1Target
55
+ } from './wrangler.ts';
20
56
  import {
21
- buildInsertSql,
22
- wrapForShell,
23
- buildAssetIndex,
24
- processSeedEntry,
25
- buildImportSql
26
- } from '../src/db/sql.ts';
57
+ prepareContentForSync,
58
+ writeContentDir,
59
+ validateContent,
60
+ type ContentTypeInfo
61
+ } from './content.ts';
62
+ import { startDevSync, DEV_SYNC_ENV_VAR, killStalePortHolder } from './dev-sync.ts';
63
+ import { buildInsertSql, wrapForShell } from '../src/db/sql.ts';
64
+ import { intro, outro, handleCancel, p, BRAND } from './ui.ts';
27
65
 
28
66
  // ── Helpers ─────────────────────────────────────────────────────────
29
67
 
30
- const BOLD = '\x1b[1m';
31
- const DIM = '\x1b[2m';
32
- const GREEN = '\x1b[32m';
33
- const YELLOW = '\x1b[33m';
34
- const RED = '\x1b[31m';
35
- const CYAN = '\x1b[36m';
36
- const RESET = '\x1b[0m';
37
-
38
- function log(msg: string) {
39
- console.log(` ${msg}`);
40
- }
41
- function ok(msg: string) {
42
- console.log(` ${GREEN}✓${RESET} ${msg}`);
43
- }
44
- function warn(msg: string) {
45
- console.log(` ${YELLOW}⚠${RESET} ${msg}`);
46
- }
47
- function fail(msg: string) {
48
- console.error(` ${RED}✗${RESET} ${msg}`);
49
- }
50
- function header(msg: string) {
51
- console.log(`\n${BOLD}🐻 ${msg}${RESET}\n`);
52
- }
53
-
54
- function run(cmd: string, opts?: { cwd?: string; silent?: boolean }): string {
55
- try {
56
- return execSync(cmd, {
57
- cwd: opts?.cwd,
58
- encoding: 'utf-8',
59
- stdio: opts?.silent ? 'pipe' : 'inherit'
60
- }) as string;
61
- } catch (e: unknown) {
62
- const error = e as { stdout?: string; stderr?: string; status?: number };
63
- if (opts?.silent) return error.stdout ?? '';
64
- throw e;
65
- }
66
- }
67
-
68
- function runCapture(cmd: string, cwd?: string): string {
69
- return run(cmd, { cwd, silent: true }).trim();
70
- }
71
-
72
- /** Find the project root (directory containing wrangler.toml) */
68
+ /** Find the project root (directory containing koguma.toml) */
73
69
  function findProjectRoot(): string {
74
70
  let dir = process.cwd();
75
71
  for (let i = 0; i < 10; i++) {
76
- if (existsSync(resolve(dir, 'wrangler.toml'))) return dir;
77
- if (existsSync(resolve(dir, 'wrangler.jsonc'))) return dir;
72
+ if (existsSync(resolve(dir, CONFIG_FILE))) return dir;
78
73
  const parent = dirname(dir);
79
74
  if (parent === dir) break;
80
75
  dir = parent;
81
76
  }
82
- fail('Could not find wrangler.toml — run this from your project directory.');
77
+ p.log.error(
78
+ `Could not find ${CONFIG_FILE} — run this from your project directory.\n` +
79
+ ` Run ${BRAND.ACCENT}koguma init${BRAND.RESET} to create a new project.`
80
+ );
83
81
  process.exit(1);
84
82
  }
85
83
 
86
84
  /** Find the Koguma package root (where this CLI lives) */
87
85
  function findKogumaRoot(): string {
88
- // This file is at cli/index.ts, so Koguma root is one level up
89
86
  return resolve(dirname(new URL(import.meta.url).pathname), '..');
90
87
  }
91
88
 
92
- // ── Project templates ──────────────────────────────────────────────
93
-
94
- interface TemplateField {
95
- method: string; // e.g. 'field.text("Title").required()'
89
+ /** Read koguma.toml and derive conventional names */
90
+ function getProjectNames(root: string) {
91
+ const config = readConfig(root);
92
+ return deriveNames(config.name);
96
93
  }
97
94
 
98
- interface Template {
99
- name: string;
100
- description: string;
101
- contentTypes: {
102
- id: string;
103
- name: string;
104
- displayField: string;
105
- singleton?: boolean;
106
- fields: Record<string, string>; // fieldId → builder expression
107
- }[];
108
- }
109
-
110
- const TEMPLATES: Template[] = [
111
- {
112
- name: 'Blog',
113
- description: 'Blog with posts and site settings',
114
- contentTypes: [
115
- {
116
- id: 'siteSettings',
117
- name: 'Site Settings',
118
- displayField: 'siteName',
119
- singleton: true,
120
- fields: {
121
- siteName: 'field.text("Site Name").required()',
122
- tagline: 'field.text("Tagline")',
123
- footerText: 'field.text("Footer Text")'
124
- }
125
- },
126
- {
127
- id: 'post',
128
- name: 'Posts',
129
- displayField: 'title',
130
- fields: {
131
- title: 'field.text("Title").required()',
132
- slug: 'field.text("Slug").required().max(120)',
133
- excerpt: 'field.longText("Excerpt")',
134
- body: 'field.richText("Body")',
135
- coverImage: 'field.image("Cover Image")',
136
- published: 'field.boolean("Published").default(false)',
137
- date: 'field.date("Date")'
138
- }
139
- }
140
- ]
141
- },
142
- {
143
- name: 'Portfolio',
144
- description: 'Portfolio with projects and about page',
145
- contentTypes: [
146
- {
147
- id: 'about',
148
- name: 'About',
149
- displayField: 'name',
150
- singleton: true,
151
- fields: {
152
- name: 'field.text("Name").required()',
153
- bio: 'field.richText("Bio")',
154
- avatar: 'field.image("Avatar")',
155
- email: 'field.text("Email")'
156
- }
157
- },
158
- {
159
- id: 'project',
160
- name: 'Projects',
161
- displayField: 'title',
162
- fields: {
163
- title: 'field.text("Title").required()',
164
- description: 'field.longText("Description")',
165
- heroImage: 'field.image("Hero Image")',
166
- url: 'field.url("Live URL")',
167
- year: 'field.number("Year")'
168
- }
169
- }
170
- ]
171
- },
172
- {
173
- name: 'Docs',
174
- description: 'Documentation site with pages and categories',
175
- contentTypes: [
176
- {
177
- id: 'page',
178
- name: 'Pages',
179
- displayField: 'title',
180
- fields: {
181
- title: 'field.text("Title").required()',
182
- slug: 'field.text("Slug").required()',
183
- body: 'field.richText("Body")',
184
- sortOrder: 'field.number("Sort Order").default(0)'
185
- }
186
- }
187
- ]
188
- },
189
- {
190
- name: 'Blank',
191
- description: 'Empty project — start from scratch',
192
- contentTypes: [
193
- {
194
- id: 'page',
195
- name: 'Pages',
196
- displayField: 'title',
197
- fields: {
198
- title: 'field.text("Title").required()',
199
- body: 'field.richText("Body")'
200
- }
201
- }
202
- ]
203
- }
204
- ];
205
-
206
- function generateSiteConfig(template: Template, siteName: string): string {
207
- const lines: string[] = [
208
- `import { defineConfig, contentType, field } from "koguma";`,
209
- ``
210
- ];
211
-
212
- for (const ct of template.contentTypes) {
213
- lines.push(`const ${ct.id} = contentType({`);
214
- lines.push(` id: "${ct.id}",`);
215
- lines.push(` name: "${ct.name}",`);
216
- lines.push(` displayField: "${ct.displayField}",`);
217
- if (ct.singleton) lines.push(` singleton: true,`);
218
- lines.push(` fields: {`);
219
- for (const [fid, expr] of Object.entries(ct.fields)) {
220
- lines.push(` ${fid}: ${expr},`);
221
- }
222
- lines.push(` },`);
223
- lines.push(`});`);
224
- lines.push(``);
225
- }
226
-
227
- const ctIds = template.contentTypes.map(ct => ct.id).join(', ');
228
- lines.push(`export default defineConfig({`);
229
- lines.push(` siteName: "${siteName}",`);
230
- lines.push(` contentTypes: [${ctIds}],`);
231
- lines.push(`});`);
232
- lines.push(``);
233
-
234
- return lines.join('\n');
235
- }
236
-
237
- function generateWorkerTs(): string {
238
- return `import { createWorker } from "koguma/worker";
239
- import config from "./site.config";
240
- export default createWorker(config);
241
- `;
242
- }
243
-
244
- function generateWranglerToml(projectName: string): string {
245
- return `name = "${projectName}"
246
- main = "worker.ts"
247
- compatibility_date = "2024-11-01"
248
- compatibility_flags = ["nodejs_compat"]
249
-
250
- # ── D1 Database ──
251
- [[d1_databases]]
252
- binding = "DB"
253
- database_name = "${projectName}-db"
254
- database_id = ""
255
-
256
- # ── R2 Media Storage ──
257
- [[r2_buckets]]
258
- binding = "MEDIA"
259
- bucket_name = "${projectName}-media"
260
- `;
261
- }
262
-
263
- function generatePackageJson(projectName: string): string {
264
- return (
265
- JSON.stringify(
266
- {
267
- name: projectName,
268
- private: true,
269
- scripts: {
270
- dev: 'wrangler dev',
271
- deploy: 'koguma deploy'
272
- },
273
- dependencies: {
274
- koguma: 'latest'
275
- },
276
- devDependencies: {
277
- wrangler: 'latest'
278
- }
279
- },
280
- null,
281
- 2
282
- ) + '\n'
283
- );
284
- }
285
-
286
- function generateTsconfig(): string {
287
- return (
288
- JSON.stringify(
289
- {
290
- compilerOptions: {
291
- target: 'ESNext',
292
- module: 'ESNext',
293
- moduleResolution: 'Bundler',
294
- strict: true,
295
- esModuleInterop: true,
296
- skipLibCheck: true,
297
- types: ['@cloudflare/workers-types']
298
- },
299
- include: ['*.ts']
300
- },
301
- null,
302
- 2
303
- ) + '\n'
304
- );
305
- }
306
-
307
- async function scaffoldNewProject(): Promise<string> {
308
- header('koguma init — new project');
309
- const root = process.cwd();
310
-
311
- // Project name
312
- const dirName = root.split('/').pop() ?? 'my-koguma-site';
313
- log(`Project name: ${CYAN}${dirName}${RESET}`);
314
- const projectName = dirName;
315
-
316
- // Template selection
317
- log(`\n Pick a template:\n`);
318
- for (let i = 0; i < TEMPLATES.length; i++) {
319
- const t = TEMPLATES[i]!;
320
- log(
321
- ` ${BOLD}${i + 1}${RESET}. ${t.name} ${DIM}— ${t.description}${RESET}`
322
- );
323
- }
324
- log('');
325
-
326
- // Default to Blog (template 1), non-interactive for CI
327
- const choice = process.env.KOGUMA_TEMPLATE
328
- ? parseInt(process.env.KOGUMA_TEMPLATE, 10)
329
- : 1;
330
- const template = TEMPLATES[choice - 1] ?? TEMPLATES[0]!;
331
-
332
- ok(`Using template: ${BOLD}${template.name}${RESET}`);
333
-
334
- // Generate files
335
- const files: [string, string][] = [
336
- ['site.config.ts', generateSiteConfig(template, projectName)],
337
- ['worker.ts', generateWorkerTs()],
338
- ['wrangler.toml', generateWranglerToml(projectName)],
339
- ['package.json', generatePackageJson(projectName)],
340
- ['tsconfig.json', generateTsconfig()]
341
- ];
342
-
343
- for (const [name, content] of files) {
344
- const path = resolve(root, name);
345
- if (existsSync(path)) {
346
- warn(`${name} already exists, skipping`);
347
- } else {
348
- writeFileSync(path, content);
349
- ok(`Created ${name}`);
350
- }
351
- }
352
-
353
- // Install dependencies
354
- log(`\n Installing dependencies…\n`);
355
- try {
356
- run('bun install', { cwd: root });
357
- ok('Dependencies installed');
358
- } catch {
359
- warn("Failed to install — run 'bun install' manually");
360
- }
361
-
362
- return root;
95
+ /** Load site.config.ts content types */
96
+ async function loadSiteConfig(
97
+ root: string
98
+ ): Promise<{ contentTypes: ContentTypeInfo[] }> {
99
+ const configPath = resolve(root, SITE_CONFIG_FILE);
100
+ const configModule = await import(configPath);
101
+ return configModule.default as { contentTypes: ContentTypeInfo[] };
363
102
  }
364
103
 
365
- // ── Commands ────────────────────────────────────────────────────────
366
-
367
- async function cmdInit() {
368
- header('koguma init');
104
+ /** Run content validation and print warnings */
105
+ async function runValidation(root: string): Promise<void> {
106
+ const contentDir = resolve(root, CONTENT_DIR);
107
+ if (!existsSync(contentDir)) return;
369
108
 
370
- // Detect if this is a new project or existing
371
- const cwd = process.cwd();
372
- const hasWrangler =
373
- existsSync(resolve(cwd, 'wrangler.toml')) ||
374
- existsSync(resolve(cwd, 'wrangler.jsonc'));
109
+ const configPath = resolve(root, SITE_CONFIG_FILE);
110
+ if (!existsSync(configPath)) return;
375
111
 
376
- let root: string;
377
- if (!hasWrangler) {
378
- // New project — scaffold first
379
- root = await scaffoldNewProject();
380
- } else {
381
- root = findProjectRoot();
382
- }
383
-
384
- const wranglerPath = resolve(root, 'wrangler.toml');
385
- let toml = readFileSync(wranglerPath, 'utf-8');
386
-
387
- // Parse project name from wrangler.toml
388
- const nameMatch = toml.match(/^name\s*=\s*"([^"]+)"/m);
389
- const projectName = nameMatch?.[1] ?? 'my-project';
390
-
391
- // ── D1 Database ──
392
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
393
- const dbName = dbNameMatch?.[1] ?? `${projectName}-db`;
394
- const dbIdMatch = toml.match(/database_id\s*=\s*"([^"]*)"/);
395
- const existingId = dbIdMatch?.[1];
396
-
397
- if (existingId) {
398
- ok(
399
- `D1 database already configured: ${DIM}${dbName} (${existingId})${RESET}`
400
- );
401
- } else {
402
- log(`Creating D1 database: ${CYAN}${dbName}${RESET}`);
403
- try {
404
- const output = runCapture(`bunx wrangler d1 create ${dbName}`, root);
405
- const idMatch = output.match(/database_id\s*=\s*"([^"]+)"/);
406
- if (idMatch?.[1]) {
407
- toml = toml.replace(
408
- /database_id\s*=\s*""/,
409
- `database_id = "${idMatch[1]}"`
410
- );
411
- writeFileSync(wranglerPath, toml);
412
- ok(
413
- `Created D1 database and patched wrangler.toml: ${DIM}${idMatch[1]}${RESET}`
414
- );
415
- } else {
416
- warn("D1 database created but couldn't parse ID. Check output above.");
417
- }
418
- } catch {
419
- fail(
420
- "Failed to create D1 database. Make sure you're logged into Cloudflare: bunx wrangler login"
421
- );
422
- }
423
- }
424
-
425
- // ── R2 Bucket ──
426
- const bucketMatch = toml.match(/bucket_name\s*=\s*"([^"]+)"/);
427
- const bucketName = bucketMatch?.[1] ?? `${projectName}-media`;
428
-
429
- log(`Checking R2 bucket: ${CYAN}${bucketName}${RESET}`);
430
112
  try {
431
- const buckets = runCapture('bunx wrangler r2 bucket list', root);
432
- if (buckets.includes(bucketName)) {
433
- ok(`R2 bucket already exists: ${DIM}${bucketName}${RESET}`);
434
- } else {
435
- runCapture(`bunx wrangler r2 bucket create ${bucketName}`, root);
436
- ok(`Created R2 bucket: ${DIM}${bucketName}${RESET}`);
113
+ const config = await loadSiteConfig(root);
114
+ const warnings = validateContent(contentDir, config.contentTypes);
115
+ for (const w of warnings) {
116
+ p.log.warn(`${w.file}: ${w.message}`);
437
117
  }
438
- } catch {
439
- fail(
440
- "Failed to check/create R2 bucket. Make sure you're logged into Cloudflare."
441
- );
118
+ } catch (e) {
119
+ p.log.warn(`Validation failed: ${e}`);
442
120
  }
443
-
444
- // ── KOGUMA_SECRET ──
445
- log(`\n ${YELLOW}Next, set your admin password:${RESET}`);
446
- log(` ${DIM}$ koguma secret${RESET}\n`);
447
-
448
- ok('Init complete!');
449
121
  }
450
122
 
451
- async function cmdBuild() {
452
- header('koguma build');
453
- const kogumaRoot = findKogumaRoot();
454
- const adminDir = resolve(kogumaRoot, 'admin');
123
+ /** Sync content/ files → local D1 entries (used by dev + push) */
124
+ async function syncContentToLocalD1(
125
+ root: string,
126
+ dbName: string,
127
+ s?: ReturnType<typeof p.spinner>
128
+ ): Promise<number> {
129
+ const contentDir = resolve(root, CONTENT_DIR);
130
+ if (!existsSync(contentDir)) return 0;
455
131
 
456
- if (!existsSync(adminDir)) {
457
- fail('Admin directory not found at: ' + adminDir);
458
- process.exit(1);
459
- }
460
-
461
- // Install admin deps if needed
462
- if (!existsSync(resolve(adminDir, 'node_modules'))) {
463
- log('Installing admin dependencies...');
464
- run('bun install', { cwd: adminDir });
465
- }
132
+ const configPath = resolve(root, SITE_CONFIG_FILE);
133
+ if (!existsSync(configPath)) return 0;
466
134
 
467
- // Build Vite app
468
- log('Building admin dashboard...');
469
- run('bun run build', { cwd: adminDir });
135
+ try {
136
+ const config = await loadSiteConfig(root);
470
137
 
471
- // Generate bundle
472
- log('Generating admin bundle...');
473
- const scriptPath = resolve(kogumaRoot, 'scripts/bundle-admin.ts');
474
- run(`bun run ${scriptPath}`, { cwd: kogumaRoot });
138
+ // Run validation before sync
139
+ const warnings = validateContent(contentDir, config.contentTypes);
140
+ for (const w of warnings) {
141
+ warn(`${w.file}: ${w.message}`);
142
+ }
475
143
 
476
- ok('Admin bundle ready!');
477
- }
144
+ // Ensure schema exists
145
+ if (s) s.message('Applying schema...');
146
+ await applySchemaAsync(root, dbName, '--local');
478
147
 
479
- async function cmdSeed() {
480
- header('koguma seed');
481
- const root = findProjectRoot();
482
- const seedTs = resolve(root, 'db/seed.ts');
483
- const seedSql = resolve(root, 'db/seed.sql');
484
-
485
- // Parse database name from wrangler.toml
486
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
487
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
488
- const dbName = dbNameMatch?.[1] ?? 'my-db';
489
-
490
- const isRemote = process.argv.includes('--remote');
491
- const target = isRemote ? '--remote' : '--local';
492
-
493
- if (existsSync(seedTs)) {
494
- // ── seed.ts path — structured seeding with smart field resolution ──
495
- log(`Using ${CYAN}db/seed.ts${RESET} (structured seed)`);
496
- const seedModule = await import(seedTs);
497
- const seedData = seedModule.default as Record<
498
- string,
499
- Record<string, unknown>[]
500
- >;
501
-
502
- // Import config for field metadata
503
- const configPath = resolve(root, 'site.config.ts');
504
- const configModule = await import(configPath);
505
- const config = configModule.default as {
506
- contentTypes: {
507
- id: string;
508
- fieldMeta: Record<string, { fieldType: string; required: boolean }>;
509
- }[];
510
- };
511
- const ctMap = new Map(config.contentTypes.map(ct => [ct.id, ct]));
148
+ // Build asset ID map from content/media/ filenames
149
+ const mediaDir = resolve(contentDir, 'media');
150
+ const assetIdMap = new Map<string, string>();
151
+ const assetSql: string[] = [];
152
+ const mediaUploads: { filePath: string; key: string }[] = [];
512
153
 
513
- // Build asset title→id lookup
514
- log('Loading asset index...');
515
- let assetIndex = buildAssetIndex([]);
516
- try {
517
- const output = runCapture(
518
- `bunx wrangler d1 execute ${dbName} ${target} --command "SELECT id, title FROM _assets" --json`,
519
- root
154
+ if (existsSync(mediaDir)) {
155
+ const mediaFiles = readdirSync(mediaDir).filter(
156
+ f => !f.startsWith('.') && !f.startsWith('_')
520
157
  );
521
- const parsed = JSON.parse(output);
522
- const assets = parsed?.[0]?.results ?? [];
523
- assetIndex = buildAssetIndex(assets);
524
- ok(`Loaded ${assetIndex.titleMap.size} asset mappings`);
525
- } catch {
526
- warn('No _assets table found — image title resolution disabled');
158
+ const mimeTypes: Record<string, string> = {
159
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
160
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
161
+ };
162
+
163
+ for (const file of mediaFiles) {
164
+ const ext = extname(file).toLowerCase();
165
+ const id = `media-${file.replace(/\.\w+$/, '')}`;
166
+ const key = `${id}${ext}`;
167
+
168
+ assetIdMap.set(file, id);
169
+ mediaUploads.push({ filePath: resolve(mediaDir, file), key });
170
+ assetSql.push(buildInsertSql('assets', {
171
+ id,
172
+ data: JSON.stringify({
173
+ title: file,
174
+ url: `/api/media/${key}`,
175
+ content_type: mimeTypes[ext] ?? 'application/octet-stream',
176
+ width: null, height: null, file_size: null
177
+ })
178
+ }));
179
+ }
527
180
  }
528
181
 
529
- // Lazy-load markdown converter
530
- const { markdownToKoguma } =
531
- await import('../src/rich-text/markdown-to-koguma.ts');
532
- const { kogumaToLexical } =
533
- await import('../src/rich-text/koguma-to-lexical.ts');
534
-
535
- let totalEntries = 0;
536
-
537
- for (const [typeId, entries] of Object.entries(seedData)) {
538
- const ct = ctMap.get(typeId);
539
- if (!ct) {
540
- warn(`Content type '${typeId}' not found in site.config.ts skipping`);
541
- continue;
182
+ // Sync content/ files → D1 entries
183
+ const prepared = prepareContentForSync(contentDir, config.contentTypes);
184
+ const entrySql: string[] = [];
185
+ for (const { contentType, rowData } of prepared) {
186
+ const {
187
+ id, slug, status, publish_at, publishAt,
188
+ created_at: _ca, updated_at: _ua, content_type: _ct,
189
+ ...fields
190
+ } = rowData;
191
+
192
+ // Resolve media filenames → asset IDs
193
+ const ct = config.contentTypes.find(c => c.id === contentType);
194
+ if (ct && assetIdMap.size > 0) {
195
+ for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
196
+ if (meta.fieldType === 'image' && typeof fields[fieldId] === 'string') {
197
+ fields[fieldId] = assetIdMap.get(fields[fieldId] as string) ?? fields[fieldId];
198
+ } else if (meta.fieldType === 'images' && Array.isArray(fields[fieldId])) {
199
+ fields[fieldId] = (fields[fieldId] as string[]).map(
200
+ v => assetIdMap.get(v) ?? v
201
+ );
202
+ }
203
+ }
542
204
  }
543
205
 
544
- log(`Seeding ${CYAN}${typeId}${RESET} (${entries.length} entries)...`);
545
-
546
- for (const entry of entries) {
547
- const { processed, resolutions } = processSeedEntry(
548
- entry,
549
- ct.fieldMeta,
550
- assetIndex,
551
- markdownToKoguma,
552
- kogumaToLexical
553
- );
206
+ entrySql.push(buildInsertSql('entries', {
207
+ id: id as string,
208
+ content_type: contentType,
209
+ slug: (slug as string | undefined) ?? null,
210
+ data: JSON.stringify(fields),
211
+ status: (status as string | undefined) ?? 'draft',
212
+ ...(publish_at !== undefined ? { publish_at } : {}),
213
+ ...(publishAt !== undefined ? { publish_at: publishAt } : {})
214
+ }));
215
+ }
554
216
 
555
- for (const r of resolutions) {
556
- ok(` ${r}`);
557
- }
217
+ // Single batch: assets + entries written to one SQL file
218
+ const allStatements = [...assetSql, ...entrySql];
219
+ if (allStatements.length > 0) {
220
+ if (s) s.message(`Syncing ${entrySql.length} entries + ${assetSql.length} assets...`);
221
+ await d1ExecuteBatchSqlAsync(root, dbName, '--local', allStatements);
222
+ }
558
223
 
559
- const sql = buildInsertSql(typeId, processed);
560
- run(
561
- `bunx wrangler d1 execute ${dbName} ${target} --command "${wrapForShell(sql)}"`,
562
- { cwd: root, silent: true }
563
- );
564
- totalEntries++;
224
+ // Upload media files to local R2
225
+ if (mediaUploads.length > 0) {
226
+ if (s) s.message(`Uploading ${mediaUploads.length} media files...`);
227
+ const { bucketName } = deriveNames(readConfig(root).name);
228
+ for (const { filePath, key } of mediaUploads) {
229
+ await r2PutLocalAsync(root, bucketName, key, filePath);
565
230
  }
566
231
  }
567
-
568
- ok(`Seeded ${totalEntries} entries (${isRemote ? 'remote' : 'local'})!`);
569
- } else if (existsSync(seedSql)) {
570
- // ── seed.sql path — legacy SQL seeding ──
571
- log(
572
- `Seeding ${isRemote ? 'REMOTE' : 'local'} database: ${CYAN}${dbName}${RESET}`
573
- );
574
- run(`bunx wrangler d1 execute ${dbName} ${target} --file=${seedSql}`, {
575
- cwd: root
576
- });
577
- ok(`Database seeded (${isRemote ? 'remote' : 'local'})!`);
578
- } else {
579
- fail(
580
- 'No seed file found. Create db/seed.ts (structured) or db/seed.sql (raw SQL).'
581
- );
582
- process.exit(1);
232
+ return prepared.length;
233
+ } catch (e) {
234
+ if (s) s.stop('Content sync failed');
235
+ warn(`Content sync failed: ${e}`);
236
+ return 0;
583
237
  }
584
238
  }
585
239
 
586
- async function cmdDeploy() {
587
- header('koguma deploy');
240
+ // ── Commands ────────────────────────────────────────────────────────
588
241
 
589
- // Build admin first
590
- await cmdBuild();
242
+ /**
243
+ * koguma init — Full project setup.
244
+ *
245
+ * Interactive flow: project name → template → scaffold → Cloudflare setup.
246
+ *
247
+ * CI mode (KOGUMA_CI=1): Uses env vars instead of interactive prompts:
248
+ * KOGUMA_PROJECT_NAME — project name (default: directory name)
249
+ * KOGUMA_TEMPLATE — template name: Blog|Portfolio|Docs|Blank (default: Blog)
250
+ * Skips Cloudflare setup and dependency install.
251
+ */
252
+ async function cmdInit(): Promise<void> {
253
+ intro('init');
591
254
 
592
- // Build frontend
593
- const root = findProjectRoot();
594
- log('\nBuilding frontend...');
595
- run('bun run build', { cwd: root });
255
+ const cwd = process.cwd();
256
+ const hasConfig = existsSync(resolve(cwd, CONFIG_FILE));
257
+ const ciMode = process.env.KOGUMA_CI === '1';
596
258
 
597
- // Deploy
598
- log('\nDeploying to Cloudflare...');
599
- run('bunx wrangler deploy', { cwd: root });
259
+ let root: string;
600
260
 
601
- ok('Deployed! 🎉');
602
- }
261
+ if (!hasConfig) {
262
+ const dirName = cwd.split('/').pop() ?? 'my-koguma-site';
603
263
 
604
- async function cmdSecret() {
605
- header('koguma secret');
606
- const root = findProjectRoot();
607
- log('Setting KOGUMA_SECRET (your admin password)...');
608
- run('bunx wrangler secret put KOGUMA_SECRET', { cwd: root });
609
- ok('Secret set!');
610
- }
264
+ let projectName: string;
265
+ let template: (typeof TEMPLATES)[number];
611
266
 
612
- async function cmdMigrateMedia() {
613
- header('koguma migrate-media');
614
- const root = findProjectRoot();
615
- const seedPath = resolve(root, 'db/seed.json');
616
-
617
- if (!existsSync(seedPath)) {
618
- fail('db/seed.json not found.');
619
- process.exit(1);
620
- }
267
+ if (ciMode) {
268
+ // ── CI mode: use env vars ──
269
+ projectName = process.env.KOGUMA_PROJECT_NAME || dirName;
270
+ const templateName = process.env.KOGUMA_TEMPLATE || 'Blog';
271
+ template = TEMPLATES.find(t => t.name === templateName) ?? TEMPLATES[0]!;
272
+ p.log.info(`CI mode: ${projectName} (${template.name})`);
273
+ } else {
274
+ // ── Interactive mode ──
275
+ const nameResult = await p.text({
276
+ message: 'Project name',
277
+ placeholder: dirName,
278
+ defaultValue: dirName,
279
+ validate(value) {
280
+ if (!value || value.length === 0) return 'Project name is required';
281
+ if (!/^[a-z0-9-]+$/i.test(value))
282
+ return 'Use only letters, numbers, and hyphens';
283
+ }
284
+ });
285
+ if (handleCancel(nameResult)) return;
286
+ projectName = nameResult as string;
287
+
288
+ const templateChoice = await p.select({
289
+ message: 'Pick a template',
290
+ options: TEMPLATES.map(t => ({
291
+ value: t.name,
292
+ label: t.name,
293
+ hint: t.description
294
+ }))
295
+ });
296
+ if (handleCancel(templateChoice)) return;
297
+ template =
298
+ TEMPLATES.find(t => t.name === templateChoice) ?? TEMPLATES[0]!;
299
+ }
621
300
 
622
- // Read .dev.vars for local, or prompt for production URL
623
- const isRemote = process.argv.includes('--remote');
624
- const targetUrl = isRemote
625
- ? process.argv[process.argv.indexOf('--remote') + 1]
626
- : 'http://localhost:8787';
301
+ // Scaffold files
302
+ const s = p.spinner();
303
+ s.start('Scaffolding project files...');
627
304
 
628
- if (isRemote && !targetUrl) {
629
- fail('Usage: koguma migrate-media --remote https://your-site.workers.dev');
630
- process.exit(1);
631
- }
305
+ const {
306
+ root: scaffoldRoot,
307
+ createdFiles,
308
+ skippedFiles
309
+ } = scaffoldNewProject(projectName, template);
310
+ root = scaffoldRoot;
632
311
 
633
- log(`Target: ${CYAN}${targetUrl}${RESET}`);
312
+ s.stop('Project scaffolded');
634
313
 
635
- // Read password from .dev.vars
636
- const devVarsPath = resolve(root, '.dev.vars');
637
- let password = '';
638
- if (existsSync(devVarsPath)) {
639
- const content = readFileSync(devVarsPath, 'utf-8');
640
- const match = content.match(/KOGUMA_SECRET=(.+)/);
641
- if (match?.[1]) password = match[1].trim();
642
- }
643
- if (!password) {
644
- fail('KOGUMA_SECRET not found in .dev.vars');
645
- process.exit(1);
646
- }
314
+ for (const f of createdFiles) {
315
+ p.log.success(`Created ${f}`);
316
+ }
317
+ for (const f of skippedFiles) {
318
+ p.log.warn(`${f} already exists, skipped`);
319
+ }
647
320
 
648
- // Login
649
- log('Logging in...');
650
- const loginRes = await fetch(`${targetUrl}/api/auth/login`, {
651
- method: 'POST',
652
- headers: { 'Content-Type': 'application/json' },
653
- body: JSON.stringify({ password }),
654
- redirect: 'manual'
655
- });
656
- const setCookie = loginRes.headers.get('set-cookie') ?? '';
657
- const cookieMatch = setCookie.match(/koguma_session=[^;]+/);
658
- if (!cookieMatch) {
659
- fail('Login failed — check your KOGUMA_SECRET');
660
- process.exit(1);
321
+ // Install dependencies (skip in CI mode)
322
+ if (!ciMode) {
323
+ const si = p.spinner();
324
+ si.start('Installing dependencies...');
325
+ try {
326
+ run('bun install', { cwd: root });
327
+ si.stop('Dependencies installed');
328
+ } catch {
329
+ si.stop('Dependency install failed');
330
+ p.log.warn("Run 'bun install' manually");
331
+ }
332
+ }
333
+ } else {
334
+ root = findProjectRoot();
335
+ p.log.info('Existing project detected');
661
336
  }
662
- const cookie = cookieMatch[0];
663
- ok('Authenticated');
664
-
665
- // Load seed.json
666
- const seed = JSON.parse(readFileSync(seedPath, 'utf-8')) as {
667
- assets: Array<{
668
- id: string;
669
- title: string;
670
- file: { url: string; contentType: string; fileName: string };
671
- }>;
672
- };
673
-
674
- log(`Found ${seed.assets.length} assets\n`);
675
-
676
- const results: Array<{ id: string; newUrl: string }> = [];
677
-
678
- for (const asset of seed.assets) {
679
- const url = asset.file.url.startsWith('//')
680
- ? `https:${asset.file.url}`
681
- : asset.file.url;
682
- log(`⬇ ${asset.title}`);
683
337
 
338
+ // ── Scaffold content/ dirs from real config if available ──
339
+ const siteConfigPath = resolve(root, SITE_CONFIG_FILE);
340
+ if (existsSync(siteConfigPath)) {
684
341
  try {
685
- const dlRes = await fetch(url);
686
- if (!dlRes.ok) {
687
- warn(` Download failed: ${dlRes.status}`);
688
- continue;
689
- }
690
-
691
- const blob = await dlRes.blob();
692
- const fileName =
693
- asset.file.fileName ||
694
- `${asset.id}.${asset.file.contentType.split('/')[1]}`;
695
-
696
- const formData = new FormData();
697
- formData.append(
698
- 'file',
699
- new File([blob], fileName, { type: asset.file.contentType })
700
- );
701
- formData.append('title', asset.title);
702
-
703
- const upRes = await fetch(`${targetUrl}/api/admin/media`, {
704
- method: 'POST',
705
- headers: { Cookie: cookie },
706
- body: formData
707
- });
708
-
709
- if (!upRes.ok) {
710
- warn(` Upload failed: ${await upRes.text()}`);
711
- continue;
712
- }
713
-
714
- const { url: newUrl } = (await upRes.json()) as { url: string };
715
- ok(` → ${newUrl}`);
716
- results.push({ id: asset.id, newUrl });
342
+ const siteConfig = await loadSiteConfig(root);
343
+ scaffoldContentDir(root, siteConfig.contentTypes);
717
344
  } catch (e) {
718
- warn(` Error: ${e}`);
345
+ p.log.warn(`Could not scaffold content dirs: ${e}`);
719
346
  }
720
347
  }
721
348
 
722
- log(`\n✅ Migrated: ${results.length}/${seed.assets.length}`);
723
-
724
- // Generate and apply URL update SQL
725
- if (results.length > 0) {
726
- const sql = results
727
- .map(r => `UPDATE _assets SET url = '${r.newUrl}' WHERE id = '${r.id}';`)
728
- .join('\n');
729
-
730
- const sqlPath = resolve(root, 'db/migrate-urls.sql');
731
- writeFileSync(sqlPath, sql);
732
-
733
- // Auto-apply to local or remote D1
734
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
735
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
736
- const dbName = dbNameMatch?.[1] ?? 'my-db';
737
- const target = isRemote ? '--remote' : '--local';
738
-
739
- log(`\nUpdating ${isRemote ? 'remote' : 'local'} database URLs...`);
740
- run(`bunx wrangler d1 execute ${dbName} ${target} --file=${sqlPath}`, {
741
- cwd: root
742
- });
743
-
744
- ok('Media migration complete!');
745
- }
746
- }
747
-
748
- // ── Typegen ─────────────────────────────────────────────────────────
749
-
750
- function fieldTypeToTs(
751
- fieldType: string,
752
- meta: { required: boolean; refContentType?: string; options?: string[] }
753
- ): string {
754
- switch (fieldType) {
755
- case 'text':
756
- case 'longText':
757
- case 'url':
758
- case 'date':
759
- return 'string';
760
- case 'richText':
761
- return 'KogumaDocument';
762
- case 'image':
763
- return 'KogumaAsset';
764
- case 'boolean':
765
- return 'boolean';
766
- case 'number':
767
- return 'number';
768
- case 'select':
769
- return meta.options?.map(o => `'${o}'`).join(' | ') ?? 'string';
770
- case 'reference':
771
- return meta.refContentType
772
- ? `${capitalize(meta.refContentType)}Entry`
773
- : 'Record<string, unknown>';
774
- case 'references':
775
- return meta.refContentType
776
- ? `${capitalize(meta.refContentType)}Entry[]`
777
- : 'Record<string, unknown>[]';
778
- case 'youtube':
779
- case 'instagram':
780
- case 'email':
781
- case 'phone':
782
- case 'color':
783
- return 'string';
784
- case 'images':
785
- return 'string[]';
786
- default:
787
- return 'unknown';
349
+ // ── Cloudflare setup (skip in CI mode) ──
350
+ if (ciMode) {
351
+ outro('Project scaffolded! Run `koguma dev` to start. 🐻');
352
+ return;
788
353
  }
789
- }
790
354
 
791
- function capitalize(s: string): string {
792
- return s.charAt(0).toUpperCase() + s.slice(1);
793
- }
794
-
795
- async function cmdTypegen() {
796
- header('koguma typegen');
797
- const root = findProjectRoot();
355
+ const setupNow = await p.confirm({
356
+ message: 'Set up Cloudflare resources now? (D1, R2, secret)',
357
+ initialValue: true
358
+ });
359
+ if (handleCancel(setupNow)) return;
798
360
 
799
- // Import site.config.ts
800
- const configPath = resolve(root, 'site.config.ts');
801
- if (!existsSync(configPath)) {
802
- fail('site.config.ts not found in project root.');
803
- process.exit(1);
361
+ if (!setupNow) {
362
+ p.log.info('Skipped Cloudflare setup. Run `koguma init` again when ready.');
363
+ outro('Project ready! Run `koguma dev` to start. 🐻');
364
+ return;
804
365
  }
805
366
 
806
- log('Reading site.config.ts...');
807
- const configModule = await import(configPath);
808
- const config = configModule.default as {
809
- contentTypes: {
810
- id: string;
811
- name: string;
812
- fieldMeta: Record<
813
- string,
814
- {
815
- fieldType: string;
816
- required: boolean;
817
- refContentType?: string;
818
- options?: string[];
819
- }
820
- >;
821
- }[];
822
- };
367
+ const config = readConfig(root);
368
+ const projectName = config.name || 'my-project';
369
+ const { dbName, bucketName } = deriveNames(projectName);
823
370
 
824
- if (!config.contentTypes?.length) {
825
- fail('No content types found in site.config.ts');
826
- process.exit(1);
371
+ // Step 1: Wrangler login
372
+ const sAuth = p.spinner();
373
+ sAuth.start('Checking Cloudflare authentication...');
374
+ try {
375
+ checkWranglerAuth(root);
376
+ sAuth.stop('Logged in to Cloudflare');
377
+ } catch {
378
+ sAuth.stop('Not logged in');
379
+ p.log.step('Opening Cloudflare login...');
380
+ run('bunx wrangler login');
381
+ p.log.success('Logged in');
827
382
  }
828
383
 
829
- // Generate .d.ts
830
- const lines: string[] = [
831
- '/**',
832
- ' * Auto-generated by `koguma typegen`',
833
- ' * Do not edit manually.',
834
- ' */',
835
- '',
836
- 'import type { KogumaDocument, KogumaAsset } from "koguma/types";',
837
- '',
838
- '// ── System fields ── common to all entries',
839
- 'interface KogumaSystemFields {',
840
- ' id: string;',
841
- ' created_at: string;',
842
- ' updated_at: string;',
843
- ' status: "draft" | "published";',
844
- ' publishAt: string | null;',
845
- '}',
846
- ''
847
- ];
848
-
849
- // Generate interfaces for each content type
850
- const typeNames: string[] = [];
851
- for (const ct of config.contentTypes) {
852
- const typeName = capitalize(ct.id) + 'Entry';
853
- typeNames.push(typeName);
854
-
855
- lines.push(`// ── ${ct.name} ──`);
856
- lines.push(`export interface ${typeName} extends KogumaSystemFields {`);
857
-
858
- for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
859
- const tsType = fieldTypeToTs(meta.fieldType, meta);
860
- const optional = meta.required ? '' : '?';
861
- lines.push(` ${fieldId}${optional}: ${tsType};`);
384
+ // Step 2: D1 database
385
+ const sDb = p.spinner();
386
+ if (config.databaseId) {
387
+ p.log.success(`D1 database: ${dbName} (${config.databaseId})`);
388
+ } else {
389
+ sDb.start(`Creating D1 database: ${dbName}...`);
390
+ const dbId = createD1Database(root, dbName);
391
+ if (dbId) {
392
+ writeConfig(root, { name: projectName, databaseId: dbId });
393
+ sDb.stop(`Created D1 database: ${dbName}`);
394
+ } else {
395
+ sDb.stop('D1 creation failed');
396
+ p.log.error("Make sure you're logged in: bunx wrangler login");
862
397
  }
863
-
864
- lines.push('}');
865
- lines.push('');
866
398
  }
867
399
 
868
- // Generate client interface
869
- lines.push('// ── Koguma Client ──');
870
- lines.push('export interface KogumaClient {');
400
+ // Step 3: R2 bucket
401
+ const sR2 = p.spinner();
402
+ sR2.start(`Checking R2 bucket: ${bucketName}...`);
403
+ if (ensureR2Bucket(root, bucketName)) {
404
+ sR2.stop(`R2 bucket ready: ${bucketName}`);
405
+ } else {
406
+ sR2.stop('R2 creation failed');
407
+ p.log.error("Make sure you're logged in: bunx wrangler login");
408
+ }
871
409
 
872
- for (const ct of config.contentTypes) {
873
- const typeName = capitalize(ct.id) + 'Entry';
874
- lines.push(` /** ${ct.name} */`);
875
- lines.push(` get(type: '${ct.id}', id: string): Promise<${typeName}>;`);
876
- lines.push(
877
- ` list(type: '${ct.id}'): Promise<{ entries: ${typeName}[] }>;`
878
- );
879
- lines.push(
880
- ` create(type: '${ct.id}', data: Partial<${typeName}>): Promise<${typeName}>;`
881
- );
882
- lines.push(
883
- ` update(type: '${ct.id}', id: string, data: Partial<${typeName}>): Promise<${typeName}>;`
410
+ // Step 4: Admin secret
411
+ p.log.step('Setting KOGUMA_SECRET (your admin password)...');
412
+ try {
413
+ run('bunx wrangler secret put KOGUMA_SECRET', { cwd: root });
414
+ p.log.success('Secret set');
415
+ } catch {
416
+ p.log.warn(
417
+ 'Could not set secret. Set it later:\n bunx wrangler secret put KOGUMA_SECRET'
884
418
  );
885
- lines.push(` delete(type: '${ct.id}', id: string): Promise<void>;`);
886
419
  }
887
420
 
888
- lines.push('}');
889
- lines.push('');
890
-
891
- // Content type union
892
- lines.push(
893
- `export type ContentType = ${config.contentTypes.map(ct => `'${ct.id}'`).join(' | ')};`
894
- );
895
- lines.push('');
896
-
897
- const output = lines.join('\n');
898
- const outPath = resolve(root, 'koguma.d.ts');
899
- writeFileSync(outPath, output);
421
+ // Step 5: Apply schema
422
+ const sSchema = p.spinner();
423
+ const updatedConfig = readConfig(root);
424
+ if (updatedConfig.databaseId) {
425
+ const updatedDbName = deriveNames(updatedConfig.name).dbName;
426
+ sSchema.start('Applying database schema...');
427
+ applySchema(root, updatedDbName, '--remote');
428
+ applySchema(root, updatedDbName, '--local');
429
+ sSchema.stop('Schema applied to remote + local D1');
430
+ } else {
431
+ p.log.warn('Skipping schema no database_id configured');
432
+ }
900
433
 
901
- ok(
902
- `Generated ${CYAN}koguma.d.ts${RESET} with ${typeNames.length} type(s): ${typeNames.join(', ')}`
903
- );
434
+ outro('Init complete! Run `koguma dev` to start. 🐻');
904
435
  }
905
436
 
906
- // ── Migrate ─────────────────────────────────────────────────────────
907
-
908
- async function cmdMigrate() {
909
- header('koguma migrate');
437
+ /**
438
+ * koguma dev — Local development.
439
+ *
440
+ * Syncs content/ → local D1, generates types, starts bidirectional
441
+ * sync (file watcher + webhook server), then starts wrangler dev.
442
+ */
443
+ async function cmdDev(): Promise<void> {
444
+ intro('dev');
910
445
  const root = findProjectRoot();
911
446
 
912
- // Import site.config.ts
913
- const configPath = resolve(root, 'site.config.ts');
914
- if (!existsSync(configPath)) {
915
- fail('site.config.ts not found.');
916
- process.exit(1);
917
- }
918
-
919
- log('Reading site.config.ts...');
920
- const configModule = await import(configPath);
921
- const config = configModule.default as {
922
- contentTypes: {
923
- id: string;
924
- name: string;
925
- fieldMeta: Record<
926
- string,
927
- { fieldType: string; required: boolean; refContentType?: string }
928
- >;
929
- }[];
930
- };
931
-
932
- // Get DB name from wrangler.toml
933
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
934
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
935
- const dbName = dbNameMatch?.[1] ?? 'my-db';
936
-
937
- const isRemote = process.argv.includes('--remote');
938
- const target = isRemote ? '--remote' : '--local';
939
-
940
- // Get existing columns for each content type
941
- log(`Inspecting ${isRemote ? 'REMOTE' : 'local'} database...`);
942
- const existingColumns: Record<string, { name: string; type: string }[]> = {};
447
+ preflight(root, { needsSiteConfig: true });
943
448
 
944
- for (const ct of config.contentTypes) {
945
- try {
946
- const output = runCapture(
947
- `bunx wrangler d1 execute ${dbName} ${target} --command "SELECT name, type FROM pragma_table_info('${ct.id}')" --json`,
948
- root
949
- );
950
- const parsed = JSON.parse(output);
951
- // Wrangler D1 returns results in an array
952
- const results = parsed?.[0]?.results ?? [];
953
- existingColumns[ct.id] = results;
954
- } catch {
955
- warn(`Table '${ct.id}' does not exist yet — will create it.`);
956
- existingColumns[ct.id] = [];
957
- }
958
- }
449
+ const { dbName } = getProjectNames(root);
959
450
 
960
- // Detect drift
961
- const { detectDrift } = await import('../src/db/migrate.ts');
962
- const result = detectDrift(config.contentTypes as any, existingColumns);
963
-
964
- // Also ensure _assets table exists
451
+ // ── Auto-tidy content/ directories ──
452
+ const sTidy = p.spinner();
453
+ sTidy.start('Syncing content directories...');
965
454
  try {
966
- const output = runCapture(
967
- `bunx wrangler d1 execute ${dbName} ${target} --command "SELECT name FROM pragma_table_info('_assets')" --json`,
968
- root
969
- );
970
- const parsed = JSON.parse(output);
971
- const results = parsed?.[0]?.results ?? [];
972
- if (results.length === 0) {
973
- result.sql.unshift(
974
- `CREATE TABLE IF NOT EXISTS _assets (\n` +
975
- ` id TEXT PRIMARY KEY,\n` +
976
- ` title TEXT NOT NULL DEFAULT '',\n` +
977
- ` description TEXT DEFAULT '',\n` +
978
- ` url TEXT NOT NULL,\n` +
979
- ` content_type TEXT DEFAULT '',\n` +
980
- ` width INTEGER,\n` +
981
- ` height INTEGER,\n` +
982
- ` file_size INTEGER,\n` +
983
- ` created_at TEXT DEFAULT (datetime('now')),\n` +
984
- ` updated_at TEXT DEFAULT (datetime('now'))\n` +
985
- `);`
986
- );
987
- }
988
- } catch {
989
- result.sql.unshift(
990
- `CREATE TABLE IF NOT EXISTS _assets (\n` +
991
- ` id TEXT PRIMARY KEY,\n` +
992
- ` title TEXT NOT NULL DEFAULT '',\n` +
993
- ` description TEXT DEFAULT '',\n` +
994
- ` url TEXT NOT NULL,\n` +
995
- ` content_type TEXT DEFAULT '',\n` +
996
- ` width INTEGER,\n` +
997
- ` height INTEGER,\n` +
998
- ` file_size INTEGER,\n` +
999
- ` created_at TEXT DEFAULT (datetime('now')),\n` +
1000
- ` updated_at TEXT DEFAULT (datetime('now'))\n` +
1001
- `);`
1002
- );
455
+ const config = await loadSiteConfig(root);
456
+ syncContentDirsWithConfig(root, config.contentTypes);
457
+ sTidy.stop('Content directories synced');
458
+ } catch (e) {
459
+ sTidy.stop('Content tidy failed');
460
+ warn(`${e}`);
461
+ }
462
+
463
+ // ── Auto-sync content/ local D1 ──
464
+ const sSync = p.spinner();
465
+ sSync.start('Syncing content files...');
466
+ const syncCount = await syncContentToLocalD1(root, dbName);
467
+ sSync.stop(`Synced ${syncCount} content entries`);
468
+
469
+ // ── Auto-generate types ──
470
+ const sTypes = p.spinner();
471
+ sTypes.start('Generating types...');
472
+ try {
473
+ const typeNames = await runTypegen(root, { silent: true });
474
+ sTypes.stop(`Generated koguma.d.ts — ${typeNames.length} type(s)`);
475
+ } catch (e) {
476
+ sTypes.stop('Type generation failed');
477
+ warn(`${e}`);
1003
478
  }
1004
479
 
1005
- // Display results
1006
- if (result.sql.length === 0 && result.warnings.length === 0) {
1007
- ok('Schema is up to date — no changes needed!');
1008
- return;
1009
- }
480
+ // ── Start bidirectional dev sync ──
481
+ const sDevSync = p.spinner();
482
+ sDevSync.start('Starting dev sync...');
483
+ let devSync: ReturnType<typeof startDevSync> | null = null;
484
+ try {
485
+ const config = await loadSiteConfig(root);
486
+ devSync = startDevSync(root, dbName, config.contentTypes, { silent: true });
487
+ sDevSync.stop('Dev sync active (file watcher + sync server)');
488
+ } catch (e) {
489
+ sDevSync.stop('Dev sync failed');
490
+ warn(`${e}`);
491
+ }
492
+
493
+ // ── Cleanup on exit ──
494
+ const cleanup = () => {
495
+ devSync?.stop();
496
+ process.exit(0);
497
+ };
498
+ process.on('SIGINT', cleanup);
499
+ process.on('SIGTERM', cleanup);
1010
500
 
1011
- if (result.warnings.length > 0) {
1012
- console.log('');
1013
- for (const w of result.warnings) {
1014
- warn(w);
501
+ // ── Show admin password ──
502
+ const devVarsPath = resolve(root, KOGUMA_DIR, '.dev.vars');
503
+ if (existsSync(devVarsPath)) {
504
+ const dvContent = readFileSync(devVarsPath, 'utf-8');
505
+ const secretMatch = dvContent.match(/KOGUMA_SECRET=(.+)/);
506
+ if (secretMatch) {
507
+ p.log.info(
508
+ `Admin password: ${BRAND.ACCENT}${secretMatch[1]!.trim()}${BRAND.RESET} ${BRAND.DIM}(${KOGUMA_DIR}/.dev.vars)${BRAND.RESET}`
509
+ );
1015
510
  }
1016
511
  }
1017
512
 
1018
- if (result.sql.length > 0) {
1019
- console.log(`\n${BOLD}Migration SQL:${RESET}`);
1020
- for (const s of result.sql) {
1021
- console.log(` ${CYAN}${s}${RESET}`);
1022
- }
1023
-
1024
- // Apply the SQL
1025
- const sqlFile = resolve(root, 'db/migration.sql');
1026
- writeFileSync(sqlFile, result.sql.join('\n'));
1027
- log(`\nWritten to ${CYAN}db/migration.sql${RESET}`);
513
+ // ── Kill stale wrangler/workerd from previous runs ──
514
+ killStalePortHolder(8787);
1028
515
 
1029
- log(`\nApplying migration to ${isRemote ? 'REMOTE' : 'local'} database...`);
1030
- run(`bunx wrangler d1 execute ${dbName} ${target} --file=${sqlFile}`, {
1031
- cwd: root
1032
- });
516
+ console.log(
517
+ `\n ${ANSI.BRAND_TEAL}${ANSI.BOLD}🐻 Starting dev server...${ANSI.RESET}\n`
518
+ );
1033
519
 
1034
- ok('Migration applied!');
520
+ // ── Start dev server (takes over stdout) ──
521
+ const extra = process.argv.slice(3).join(' ');
522
+ try {
523
+ await wranglerDev(
524
+ root,
525
+ extra,
526
+ devSync ? { [DEV_SYNC_ENV_VAR]: devSync.syncUrl } : undefined
527
+ );
528
+ } catch (e) {
529
+ const msg = e instanceof Error ? e.message : String(e);
530
+ // Extract actionable wrangler error if present
531
+ const wranglerError = msg.match(/\[ERROR\]\s*(.+)/)?.[1] || msg;
532
+ p.log.error(`Wrangler failed: ${wranglerError}`);
533
+ p.outro('Dev server stopped due to an error.');
534
+ } finally {
535
+ // Always clean up devSync and exit immediately.
536
+ // Without this, the devSync HTTP server keeps Node alive for ~15-20s.
537
+ devSync?.stop();
538
+ process.exit(0);
1035
539
  }
1036
540
  }
1037
541
 
1038
- // ── Schema ──────────────────────────────────────────────────────────
1039
-
1040
- async function cmdSchema() {
1041
- header('koguma schema');
542
+ /**
543
+ * koguma push — Ship to production.
544
+ *
545
+ * Content sync → build admin → deploy → sync data + media to remote.
546
+ */
547
+ async function cmdPush(): Promise<void> {
548
+ intro('push');
1042
549
  const root = findProjectRoot();
550
+ const remoteUrl = getRemoteUrl();
1043
551
 
1044
- const configPath = resolve(root, 'site.config.ts');
1045
- if (!existsSync(configPath)) {
1046
- fail('site.config.ts not found in project root.');
1047
- process.exit(1);
1048
- }
552
+ preflight(root, {
553
+ needsSiteConfig: true,
554
+ needsAuth: true,
555
+ needsSecret: true
556
+ });
1049
557
 
1050
- log('Reading site.config.ts...');
1051
- const configModule = await import(configPath);
1052
- const config = configModule.default as {
1053
- contentTypes: {
1054
- id: string;
1055
- fieldMeta: Record<string, { fieldType: string; required: boolean }>;
1056
- }[];
1057
- };
558
+ const { dbName, bucketName } = getProjectNames(root);
1058
559
 
1059
- if (!config.contentTypes?.length) {
1060
- fail('No content types found in site.config.ts');
1061
- process.exit(1);
560
+ // ── Confirmation ──
561
+ const confirmed = await p.confirm({
562
+ message: `Push local content to ${BRAND.ACCENT}${remoteUrl}${BRAND.RESET}?`,
563
+ initialValue: true
564
+ });
565
+ if (handleCancel(confirmed)) return;
566
+ if (!confirmed) {
567
+ outro('Push cancelled.');
568
+ return;
1062
569
  }
1063
570
 
1064
- const schema = generateSchema(config.contentTypes as any);
571
+ // ── Step 1: Apply schema to remote ──
572
+ const s1 = p.spinner();
573
+ s1.start('Applying schema to remote...');
574
+ applySchema(root, dbName, '--remote');
575
+ s1.stop('Remote schema applied');
1065
576
 
1066
- // Print to stdout
1067
- console.log('');
1068
- console.log(schema.sql);
1069
- console.log('');
577
+ // ── Step 2: Sync content/ → local D1 ──
578
+ const s2 = p.spinner();
579
+ s2.start('Syncing content/ to local D1...');
580
+ await syncContentToLocalD1(root, dbName, s2);
581
+ s2.stop('Local content synced');
1070
582
 
1071
- // Also write to db/schema.sql
1072
- const dbDir = resolve(root, 'db');
1073
- if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
1074
- const outPath = resolve(dbDir, 'schema.sql');
1075
- writeFileSync(outPath, schema.sql + '\n');
1076
-
1077
- ok(
1078
- `Written to ${CYAN}db/schema.sql${RESET} (${schema.tables.length} tables, ${schema.indexes.length} indexes)`
1079
- );
1080
- log(
1081
- `\n ${DIM}Use this as the basis for your seed.sql to avoid schema drift.${RESET}`
1082
- );
1083
- }
1084
-
1085
- // ── Export ───────────────────────────────────────────────────────────
1086
-
1087
- async function cmdExport() {
1088
- header('koguma export');
1089
- const root = findProjectRoot();
583
+ // ── Step 3: Export local content ──
584
+ const s3 = p.spinner();
585
+ s3.start('Exporting local content...');
1090
586
 
1091
- // Import config
1092
- const configPath = resolve(root, 'site.config.ts');
1093
- const configModule = await import(configPath);
1094
- const config = configModule.default as {
1095
- contentTypes: {
1096
- id: string;
1097
- fieldMeta: Record<string, { fieldType: string }>;
1098
- }[];
1099
- };
1100
-
1101
- // Get DB name
1102
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
1103
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
1104
- const dbName = dbNameMatch?.[1] ?? 'my-db';
1105
-
1106
- const isRemote = process.argv.includes('--remote');
1107
- const target = isRemote ? '--remote' : '--local';
1108
-
1109
- const exportData: Record<
1110
- string,
1111
- { entries: unknown[]; joinTables: Record<string, unknown[]> }
1112
- > = {};
1113
-
1114
- for (const ct of config.contentTypes) {
1115
- log(`Exporting ${CYAN}${ct.id}${RESET}...`);
1116
-
1117
- // Export main table
1118
- try {
1119
- const output = runCapture(
1120
- `bunx wrangler d1 execute ${dbName} ${target} --command "SELECT * FROM ${ct.id}" --json`,
1121
- root
1122
- );
1123
- const parsed = JSON.parse(output);
1124
- const entries = parsed?.[0]?.results ?? [];
1125
-
1126
- // Export join tables
1127
- const joinTables: Record<string, unknown[]> = {};
1128
- for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
1129
- if (meta.fieldType === 'references') {
1130
- const joinTable = `${ct.id}__${fieldId}`;
1131
- try {
1132
- const jtOutput = runCapture(
1133
- `bunx wrangler d1 execute ${dbName} ${target} --command "SELECT * FROM ${joinTable}" --json`,
1134
- root
1135
- );
1136
- const jtParsed = JSON.parse(jtOutput);
1137
- joinTables[joinTable] = jtParsed?.[0]?.results ?? [];
1138
- } catch {
1139
- // Join table may not exist
1140
- }
1141
- }
1142
- }
1143
-
1144
- exportData[ct.id] = { entries, joinTables };
1145
- } catch {
1146
- warn(`Could not export ${ct.id} — table may not exist.`);
1147
- }
587
+ let localEntries: Record<string, unknown>[] = [];
588
+ try {
589
+ localEntries = d1Query(root, dbName, '--local', 'SELECT * FROM entries');
590
+ } catch {
591
+ p.log.warn('Could not export entries from local');
1148
592
  }
1149
593
 
1150
- const exportFile = resolve(
1151
- root,
1152
- `koguma-export-${new Date().toISOString().slice(0, 10)}.json`
1153
- );
1154
- const payload = {
1155
- version: 1,
1156
- exportedAt: new Date().toISOString(),
1157
- source: isRemote ? 'remote' : 'local',
1158
- contentTypes: exportData
1159
- };
1160
- writeFileSync(exportFile, JSON.stringify(payload, null, 2));
1161
-
1162
- const entryCount = Object.values(exportData).reduce(
1163
- (sum, ct) => sum + ct.entries.length,
1164
- 0
1165
- );
1166
- ok(
1167
- `Exported ${entryCount} entries to ${CYAN}${basename(exportFile)}${RESET}`
594
+ let localAssets: Record<string, unknown>[] = [];
595
+ try {
596
+ localAssets = d1Query(root, dbName, '--local', 'SELECT * FROM assets');
597
+ } catch {
598
+ p.log.warn('Could not export assets from local');
599
+ }
600
+ s3.stop(
601
+ `Exported ${localEntries.length} entries + ${localAssets.length} assets`
1168
602
  );
1169
- }
1170
603
 
1171
- // ── Import ──────────────────────────────────────────────────────────
1172
-
1173
- async function cmdImport() {
1174
- header('koguma import');
1175
- const root = findProjectRoot();
1176
-
1177
- const inputFile = process.argv[3];
1178
- if (!inputFile) {
1179
- fail('Usage: koguma import <file.json> [--remote]');
1180
- process.exit(1);
604
+ // ── Step 4: Import to remote ──
605
+ const total = localEntries.length + localAssets.length;
606
+ if (total > 0) {
607
+ const prog = p.progress({ max: total });
608
+ prog.start('Importing content to remote...');
609
+
610
+ let done = 0;
611
+ for (const asset of localAssets) {
612
+ d1InsertRow(root, dbName, '--remote', 'assets', asset);
613
+ done++;
614
+ prog.advance(done, `Importing... (${done}/${total})`);
615
+ }
616
+ for (const entry of localEntries) {
617
+ d1InsertRow(root, dbName, '--remote', 'entries', entry);
618
+ done++;
619
+ prog.advance(done, `Importing... (${done}/${total})`);
620
+ }
621
+ prog.stop(
622
+ `Synced ${localEntries.length} entries + ${localAssets.length} assets to remote`
623
+ );
1181
624
  }
1182
625
 
1183
- const filePath = resolve(root, inputFile);
1184
- if (!existsSync(filePath)) {
1185
- fail(`File not found: ${inputFile}`);
1186
- process.exit(1);
1187
- }
626
+ // ── Step 5: Upload media to remote ──
627
+ if (localAssets.length > 0) {
628
+ const cookie = await authenticate(remoteUrl, root);
629
+ const sProg = p.progress({ max: localAssets.length });
630
+ sProg.start('Uploading media to remote...');
1188
631
 
1189
- const payload = JSON.parse(readFileSync(filePath, 'utf-8'));
1190
- if (payload.version !== 1) {
1191
- fail(`Unsupported export version: ${payload.version}`);
1192
- process.exit(1);
1193
- }
632
+ let uploaded = 0;
633
+ for (const asset of localAssets) {
634
+ const assetData =
635
+ typeof asset.data === 'string'
636
+ ? JSON.parse(asset.data)
637
+ : (asset.data as Record<string, string>);
638
+ const assetUrl = assetData.url ?? '';
639
+ const assetTitle = assetData.title ?? '';
640
+ const contentType = assetData.content_type ?? 'application/octet-stream';
641
+ const key = assetUrl.replace('/api/media/', '');
1194
642
 
1195
- // Get DB name
1196
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
1197
- const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
1198
- const dbName = dbNameMatch?.[1] ?? 'my-db';
643
+ try {
644
+ const dlRes = await fetch(`http://localhost:8787${assetUrl}`);
645
+ if (!dlRes.ok) {
646
+ uploaded++;
647
+ sProg.advance(uploaded);
648
+ continue;
649
+ }
1199
650
 
1200
- const isRemote = process.argv.includes('--remote');
1201
- const target = isRemote ? '--remote' : '--local';
651
+ const blob = await dlRes.blob();
652
+ const formData = new FormData();
653
+ formData.append('file', new File([blob], key, { type: contentType }));
654
+ formData.append('title', assetTitle || key);
1202
655
 
1203
- let totalEntries = 0;
656
+ const upRes = await fetch(`${remoteUrl}/api/admin/media`, {
657
+ method: 'POST',
658
+ headers: { Cookie: cookie },
659
+ body: formData
660
+ });
1204
661
 
1205
- for (const [typeId, data] of Object.entries(
1206
- payload.contentTypes as Record<
1207
- string,
1208
- {
1209
- entries: Record<string, unknown>[];
1210
- joinTables: Record<string, Record<string, unknown>[]>;
662
+ if (!upRes.ok) {
663
+ warn(`Upload failed for ${assetTitle}: ${await upRes.text()}`);
664
+ }
665
+ } catch (e) {
666
+ warn(`Error uploading ${assetTitle}: ${e}`);
1211
667
  }
1212
- >
1213
- )) {
1214
- log(
1215
- `Importing ${CYAN}${typeId}${RESET} (${data.entries.length} entries)...`
1216
- );
1217
-
1218
- for (const entry of data.entries) {
1219
- const sql = buildInsertSql(typeId, entry);
1220
- run(
1221
- `bunx wrangler d1 execute ${dbName} ${target} --command "${wrapForShell(sql)}"`,
1222
- { cwd: root, silent: true }
668
+ uploaded++;
669
+ sProg.advance(
670
+ uploaded,
671
+ `Uploading media... (${uploaded}/${localAssets.length})`
1223
672
  );
1224
- totalEntries++;
1225
- }
1226
-
1227
- // Import join tables
1228
- for (const [jtName, rows] of Object.entries(data.joinTables)) {
1229
- for (const row of rows) {
1230
- const sql = buildInsertSql(jtName, row);
1231
- run(
1232
- `bunx wrangler d1 execute ${dbName} ${target} --command "${wrapForShell(sql)}"`,
1233
- { cwd: root, silent: true }
1234
- );
1235
- }
1236
673
  }
674
+ sProg.stop(`Uploaded ${uploaded} media assets`);
1237
675
  }
1238
676
 
1239
- ok(
1240
- `Imported ${totalEntries} entries from ${CYAN}${basename(inputFile)}${RESET}`
1241
- );
1242
- }
1243
-
1244
- // ── Shared auth helper ──────────────────────────────────────────────
677
+ // ── Step 6: Build + Deploy ──
678
+ const sBuild = p.spinner();
679
+ sBuild.start('Building admin dashboard...');
1245
680
 
1246
- async function authenticate(targetUrl: string, root: string): Promise<string> {
1247
- const devVarsPath = resolve(root, '.dev.vars');
1248
- let password = '';
1249
- if (existsSync(devVarsPath)) {
1250
- const content = readFileSync(devVarsPath, 'utf-8');
1251
- const match = content.match(/KOGUMA_SECRET=(.+)/);
1252
- if (match?.[1]) password = match[1].trim();
1253
- }
1254
- if (!password) {
1255
- fail('KOGUMA_SECRET not found in .dev.vars');
1256
- process.exit(1);
1257
- }
1258
-
1259
- const loginRes = await fetch(`${targetUrl}/api/auth/login`, {
1260
- method: 'POST',
1261
- headers: { 'Content-Type': 'application/json' },
1262
- body: JSON.stringify({ password }),
1263
- redirect: 'manual'
1264
- });
1265
- const setCookie = loginRes.headers.get('set-cookie') ?? '';
1266
- const cookieMatch = setCookie.match(/koguma_session=[^;]+/);
1267
- if (!cookieMatch) {
1268
- fail('Login failed — check your KOGUMA_SECRET');
1269
- process.exit(1);
681
+ const kogumaRoot = findKogumaRoot();
682
+ const adminDir = resolve(kogumaRoot, 'admin');
683
+ if (existsSync(adminDir)) {
684
+ if (!existsSync(resolve(adminDir, 'node_modules'))) {
685
+ run('bun install', { cwd: adminDir });
686
+ }
687
+ run('bun run build', { cwd: adminDir });
688
+ const scriptPath = resolve(kogumaRoot, 'scripts/bundle-admin.ts');
689
+ run(`bun run ${scriptPath}`, { cwd: kogumaRoot });
1270
690
  }
1271
- return cookieMatch[0];
1272
- }
691
+ sBuild.stop('Admin bundle ready');
1273
692
 
1274
- function getRemoteUrl(): string {
1275
- const idx = process.argv.indexOf('--remote');
1276
- const url = idx >= 0 ? process.argv[idx + 1] : undefined;
1277
- if (!url || url.startsWith('-')) {
1278
- fail('Usage: koguma <command> --remote https://your-site.workers.dev');
1279
- process.exit(1);
1280
- }
1281
- return url.replace(/\/$/, '');
1282
- }
693
+ const sDeploy = p.spinner();
694
+ sDeploy.start('Building frontend & deploying...');
695
+ run('bun run build', { cwd: root });
696
+ wranglerDeploy(root);
697
+ sDeploy.stop('Deployed to Cloudflare');
698
+
699
+ // ── Remind about production secret ──
700
+ p.log.warn(
701
+ `Make sure KOGUMA_SECRET is set on your Cloudflare Worker:\n` +
702
+ ` ${BRAND.DIM}bunx wrangler secret put KOGUMA_SECRET${BRAND.RESET}\n` +
703
+ ` ${BRAND.DIM}Without it, the admin login won't work in production.${BRAND.RESET}`
704
+ );
1283
705
 
1284
- function getDbName(root: string): string {
1285
- const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
1286
- const match = toml.match(/database_name\s*=\s*"([^"]+)"/);
1287
- return match?.[1] ?? 'my-db';
706
+ outro('Push complete! Remote now mirrors local. 🎉');
1288
707
  }
1289
708
 
1290
- // ── Pull ────────────────────────────────────────────────────────────
1291
-
1292
- async function cmdPull() {
1293
- header('koguma pull');
709
+ /**
710
+ * koguma pull — Download remote content.
711
+ *
712
+ * Exports remote D1 → imports to local → writes content/ files.
713
+ */
714
+ async function cmdPull(): Promise<void> {
715
+ intro('pull');
1294
716
  const root = findProjectRoot();
1295
717
  const remoteUrl = getRemoteUrl();
1296
- const dbName = getDbName(root);
1297
718
 
1298
- // 1. Migrate local schema
1299
- log('Step 1: Migrating local schema...');
1300
- // Re-use migrate logic inline (avoid process.argv mutation)
1301
- const configPath = resolve(root, 'site.config.ts');
1302
- const configModule = await import(configPath);
1303
- const config = configModule.default as {
1304
- contentTypes: {
1305
- id: string;
1306
- name: string;
1307
- fieldMeta: Record<
1308
- string,
1309
- { fieldType: string; required: boolean; refContentType?: string }
1310
- >;
1311
- }[];
1312
- };
719
+ preflight(root, { needsAuth: true, needsSecret: true });
1313
720
 
1314
- // 2. Export remote content
1315
- log('\nStep 2: Exporting remote content...');
1316
- const exportData: Record<
1317
- string,
1318
- { entries: unknown[]; joinTables: Record<string, unknown[]> }
1319
- > = {};
721
+ const { dbName, bucketName } = getProjectNames(root);
1320
722
 
1321
- for (const ct of config.contentTypes) {
1322
- try {
1323
- const output = runCapture(
1324
- `bunx wrangler d1 execute ${dbName} --remote --command "SELECT * FROM ${ct.id}" --json`,
1325
- root
1326
- );
1327
- const parsed = JSON.parse(output);
1328
- const entries = parsed?.[0]?.results ?? [];
723
+ // ── Step 1: Apply schema to local ──
724
+ const s1 = p.spinner();
725
+ s1.start('Applying schema to local...');
726
+ applySchema(root, dbName, '--local');
727
+ s1.stop('Local schema applied');
1329
728
 
1330
- const joinTables: Record<string, unknown[]> = {};
1331
- for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
1332
- if (meta.fieldType === 'references') {
1333
- const joinTable = `${ct.id}__${fieldId}`;
1334
- try {
1335
- const jtOutput = runCapture(
1336
- `bunx wrangler d1 execute ${dbName} --remote --command "SELECT * FROM ${joinTable}" --json`,
1337
- root
1338
- );
1339
- const jtParsed = JSON.parse(jtOutput);
1340
- joinTables[joinTable] = jtParsed?.[0]?.results ?? [];
1341
- } catch {
1342
- // Join table may not exist
1343
- }
1344
- }
1345
- }
729
+ // ── Step 2: Export remote content ──
730
+ const s2 = p.spinner();
731
+ s2.start('Exporting remote content...');
1346
732
 
1347
- exportData[ct.id] = { entries, joinTables };
1348
- ok(`${ct.id}: ${entries.length} entries`);
1349
- } catch {
1350
- warn(`Could not export ${ct.id} from remote`);
1351
- }
733
+ let remoteEntries: Record<string, unknown>[] = [];
734
+ try {
735
+ remoteEntries = d1Query(root, dbName, '--remote', 'SELECT * FROM entries');
736
+ } catch {
737
+ p.log.warn('Could not export entries from remote');
1352
738
  }
1353
739
 
1354
- // Also export _assets
1355
740
  let remoteAssets: Record<string, unknown>[] = [];
1356
741
  try {
1357
- const output = runCapture(
1358
- `bunx wrangler d1 execute ${dbName} --remote --command "SELECT * FROM _assets" --json`,
1359
- root
1360
- );
1361
- const parsed = JSON.parse(output);
1362
- remoteAssets = parsed?.[0]?.results ?? [];
1363
- ok(`_assets: ${remoteAssets.length} assets`);
742
+ remoteAssets = d1Query(root, dbName, '--remote', 'SELECT * FROM assets');
1364
743
  } catch {
1365
- warn('Could not export _assets from remote');
1366
- }
1367
-
1368
- // 3. Import content into local
1369
- log('\nStep 3: Importing content to local...');
1370
-
1371
- // Import _assets first
1372
- for (const asset of remoteAssets) {
1373
- const sql = buildInsertSql('_assets', asset);
1374
- run(
1375
- `bunx wrangler d1 execute ${dbName} --local --command "${wrapForShell(sql)}"`,
1376
- { cwd: root, silent: true }
1377
- );
744
+ p.log.warn('Could not export assets from remote');
1378
745
  }
746
+ s2.stop(
747
+ `Found ${remoteEntries.length} entries + ${remoteAssets.length} assets`
748
+ );
1379
749
 
1380
- // Import content types
1381
- for (const [typeId, data] of Object.entries(exportData)) {
1382
- for (const entry of data.entries as Record<string, unknown>[]) {
1383
- const sql = buildInsertSql(typeId, entry);
1384
- run(
1385
- `bunx wrangler d1 execute ${dbName} --local --command "${wrapForShell(sql)}"`,
1386
- { cwd: root, silent: true }
1387
- );
750
+ // ── Step 3: Import to local ──
751
+ const total = remoteEntries.length + remoteAssets.length;
752
+ if (total > 0) {
753
+ const prog = p.progress({ max: total });
754
+ prog.start('Importing to local D1...');
755
+
756
+ let done = 0;
757
+ for (const asset of remoteAssets) {
758
+ d1InsertRow(root, dbName, '--local', 'assets', asset);
759
+ done++;
760
+ prog.advance(done, `Importing... (${done}/${total})`);
1388
761
  }
1389
- for (const [jtName, rows] of Object.entries(data.joinTables)) {
1390
- for (const row of rows as Record<string, unknown>[]) {
1391
- const sql = buildInsertSql(jtName, row);
1392
- run(
1393
- `bunx wrangler d1 execute ${dbName} --local --command "${wrapForShell(sql)}"`,
1394
- { cwd: root, silent: true }
1395
- );
1396
- }
762
+ for (const entry of remoteEntries) {
763
+ d1InsertRow(root, dbName, '--local', 'entries', entry);
764
+ done++;
765
+ prog.advance(done, `Importing... (${done}/${total})`);
1397
766
  }
767
+ prog.stop(
768
+ `Imported ${remoteEntries.length} entries + ${remoteAssets.length} assets to local`
769
+ );
1398
770
  }
1399
771
 
1400
- // 4. Download R2 media
1401
- log('\nStep 4: Downloading remote media...');
772
+ // ── Step 4: Download R2 media ──
1402
773
  const cookie = await authenticate(remoteUrl, root);
1403
774
 
1404
775
  const mediaRes = await fetch(`${remoteUrl}/api/admin/media`, {
1405
776
  headers: { Cookie: cookie }
1406
777
  });
778
+
1407
779
  if (!mediaRes.ok) {
1408
- warn('Could not list remote media');
780
+ p.log.warn('Could not list remote media');
1409
781
  } else {
1410
782
  const { assets } = (await mediaRes.json()) as {
1411
783
  assets: { id: string; url: string; title: string }[];
1412
784
  };
1413
- log(`Found ${assets.length} remote assets`);
1414
785
 
1415
- const bucketMatch = readFileSync(
1416
- resolve(root, 'wrangler.toml'),
1417
- 'utf-8'
1418
- ).match(/bucket_name\s*=\s*"([^"]+)"/);
1419
- const bucketName = bucketMatch?.[1] ?? 'media';
786
+ if (assets.length > 0) {
787
+ const prog = p.progress({ max: assets.length });
788
+ prog.start('Downloading remote media...');
1420
789
 
1421
- for (const asset of assets) {
1422
- const key = asset.url.replace('/api/media/', '');
1423
- log(`⬇ ${asset.title}`);
1424
- try {
1425
- const dlRes = await fetch(`${remoteUrl}${asset.url}`);
1426
- if (!dlRes.ok) {
1427
- warn(` Download failed: ${dlRes.status}`);
1428
- continue;
1429
- }
1430
- const buf = Buffer.from(await dlRes.arrayBuffer());
1431
- const tmpPath = resolve(root, `db/.media-tmp-${key}`);
1432
- writeFileSync(tmpPath, buf);
1433
- run(
1434
- `bunx wrangler r2 object put ${bucketName}/${key} --file=${tmpPath} --local`,
1435
- { cwd: root, silent: true }
1436
- );
1437
- // Clean up temp file
790
+ let downloaded = 0;
791
+ for (const asset of assets) {
792
+ const key = asset.url.replace('/api/media/', '');
1438
793
  try {
1439
- const { unlinkSync } = await import('fs');
1440
- unlinkSync(tmpPath);
1441
- } catch {
1442
- /* ignore */
794
+ const dlRes = await fetch(`${remoteUrl}${asset.url}`);
795
+ if (!dlRes.ok) {
796
+ downloaded++;
797
+ prog.advance(downloaded);
798
+ continue;
799
+ }
800
+ const buf = Buffer.from(await dlRes.arrayBuffer());
801
+ const tmpPath = resolve(root, DB_DIR, `.media-tmp-${key}`);
802
+ writeFileSync(tmpPath, buf);
803
+ r2PutLocal(root, bucketName, key, tmpPath);
804
+ try {
805
+ const { unlinkSync } = await import('fs');
806
+ unlinkSync(tmpPath);
807
+ } catch {
808
+ /* ignore cleanup failure */
809
+ }
810
+ } catch (e) {
811
+ warn(`Error downloading ${asset.title}: ${e}`);
1443
812
  }
1444
- ok(` → local R2`);
1445
- } catch (e) {
1446
- warn(` Error: ${e}`);
813
+ downloaded++;
814
+ prog.advance(
815
+ downloaded,
816
+ `Downloading... (${downloaded}/${assets.length})`
817
+ );
1447
818
  }
819
+ prog.stop(`Downloaded ${assets.length} media assets`);
1448
820
  }
1449
821
  }
1450
822
 
1451
- ok('Pull complete! Local now mirrors remote.');
1452
- }
1453
-
1454
- // ── Push ────────────────────────────────────────────────────────────
1455
-
1456
- async function cmdPush() {
1457
- header('koguma push');
1458
- const root = findProjectRoot();
1459
- const remoteUrl = getRemoteUrl();
1460
- const dbName = getDbName(root);
1461
-
1462
- // 1. Migrate remote schema
1463
- log('Step 1: Migrating remote schema...');
1464
- const configPath = resolve(root, 'site.config.ts');
1465
- const configModule = await import(configPath);
1466
- const config = configModule.default as {
1467
- contentTypes: {
1468
- id: string;
1469
- name: string;
1470
- fieldMeta: Record<
1471
- string,
1472
- { fieldType: string; required: boolean; refContentType?: string }
1473
- >;
1474
- }[];
1475
- };
1476
-
1477
- // Run migrate --remote
1478
- const existingColumns: Record<string, { name: string; type: string }[]> = {};
1479
- for (const ct of config.contentTypes) {
1480
- try {
1481
- const output = runCapture(
1482
- `bunx wrangler d1 execute ${dbName} --remote --command "SELECT name, type FROM pragma_table_info('${ct.id}')" --json`,
1483
- root
823
+ // ── Step 5: Write content/ files ──
824
+ const s5 = p.spinner();
825
+ s5.start('Writing content/ files...');
826
+ try {
827
+ const configPath = resolve(root, SITE_CONFIG_FILE);
828
+ if (existsSync(configPath)) {
829
+ const config = await loadSiteConfig(root);
830
+ const contentDir = resolve(root, CONTENT_DIR);
831
+ const count = writeContentDir(
832
+ contentDir,
833
+ remoteEntries,
834
+ config.contentTypes
1484
835
  );
1485
- const parsed = JSON.parse(output);
1486
- existingColumns[ct.id] = parsed?.[0]?.results ?? [];
1487
- } catch {
1488
- warn(`Table '${ct.id}' does not exist on remote — will create it.`);
1489
- existingColumns[ct.id] = [];
836
+ s5.stop(`Wrote ${count} content files to content/`);
837
+ } else {
838
+ s5.stop(`${SITE_CONFIG_FILE} not found — skipped content/ generation`);
1490
839
  }
840
+ } catch (e) {
841
+ s5.stop('Content file generation failed');
842
+ p.log.warn(`${e}`);
1491
843
  }
1492
844
 
1493
- const { detectDrift } = await import('../src/db/migrate.ts');
1494
- const driftResult = detectDrift(config.contentTypes as any, existingColumns);
845
+ outro('Pull complete! Local now mirrors remote. 🐻');
846
+ }
1495
847
 
1496
- if (driftResult.sql.length > 0) {
1497
- const sqlFile = resolve(root, 'db/migration.sql');
1498
- writeFileSync(sqlFile, driftResult.sql.join('\n'));
1499
- run(`bunx wrangler d1 execute ${dbName} --remote --file=${sqlFile}`, {
1500
- cwd: root
1501
- });
1502
- ok('Remote schema migrated');
1503
- } else {
1504
- ok('Remote schema is up to date');
1505
- }
848
+ /**
849
+ * koguma gen-types — One-shot type generation.
850
+ *
851
+ * Generates koguma.d.ts from site.config.ts without starting a dev server.
852
+ */
853
+ async function cmdGenTypes(): Promise<void> {
854
+ intro('gen-types');
855
+ const root = findProjectRoot();
1506
856
 
1507
- // 2. Export local content
1508
- log('\nStep 2: Exporting local content...');
1509
- const exportData: Record<
1510
- string,
1511
- { entries: unknown[]; joinTables: Record<string, unknown[]> }
1512
- > = {};
857
+ preflight(root, { needsSiteConfig: true });
1513
858
 
1514
- for (const ct of config.contentTypes) {
1515
- try {
1516
- const output = runCapture(
1517
- `bunx wrangler d1 execute ${dbName} --local --command "SELECT * FROM ${ct.id}" --json`,
1518
- root
1519
- );
1520
- const parsed = JSON.parse(output);
1521
- const entries = parsed?.[0]?.results ?? [];
859
+ const s = p.spinner();
860
+ s.start('Generating types from site.config.ts...');
861
+ await runTypegen(root);
862
+ s.stop('koguma.d.ts generated');
1522
863
 
1523
- const joinTables: Record<string, unknown[]> = {};
1524
- for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
1525
- if (meta.fieldType === 'references') {
1526
- const joinTable = `${ct.id}__${fieldId}`;
1527
- try {
1528
- const jtOutput = runCapture(
1529
- `bunx wrangler d1 execute ${dbName} --local --command "SELECT * FROM ${joinTable}" --json`,
1530
- root
1531
- );
1532
- const jtParsed = JSON.parse(jtOutput);
1533
- joinTables[joinTable] = jtParsed?.[0]?.results ?? [];
1534
- } catch {
1535
- /* */
1536
- }
1537
- }
1538
- }
864
+ outro('Types are up to date. 🐻');
865
+ }
1539
866
 
1540
- exportData[ct.id] = { entries, joinTables };
1541
- ok(`${ct.id}: ${entries.length} entries`);
1542
- } catch {
1543
- warn(`Could not export ${ct.id} from local`);
1544
- }
1545
- }
867
+ /**
868
+ * koguma tidy — Sync content/ structure with config.
869
+ *
870
+ * Creates missing dirs, updates _example files, orphans removed types.
871
+ * With --dry flag, shows what would happen without making changes.
872
+ */
873
+ async function cmdTidy(): Promise<void> {
874
+ const dryRun = process.argv.includes('--dry');
875
+ intro(dryRun ? 'tidy --dry' : 'tidy');
876
+ const root = findProjectRoot();
1546
877
 
1547
- // Also export _assets
1548
- let localAssets: Record<string, unknown>[] = [];
1549
- try {
1550
- const output = runCapture(
1551
- `bunx wrangler d1 execute ${dbName} --local --command "SELECT * FROM _assets" --json`,
1552
- root
1553
- );
1554
- const parsed = JSON.parse(output);
1555
- localAssets = parsed?.[0]?.results ?? [];
1556
- ok(`_assets: ${localAssets.length} assets`);
1557
- } catch {
1558
- warn('Could not export _assets from local');
1559
- }
878
+ preflight(root, { needsSiteConfig: true });
1560
879
 
1561
- // 3. Import content to remote
1562
- log('\nStep 3: Importing content to remote...');
880
+ const config = await loadSiteConfig(root);
1563
881
 
1564
- // Import _assets first
1565
- for (const asset of localAssets) {
1566
- const sql = buildInsertSql('_assets', asset);
1567
- run(
1568
- `bunx wrangler d1 execute ${dbName} --remote --command "${wrapForShell(sql)}"`,
1569
- { cwd: root, silent: true }
1570
- );
882
+ if (dryRun) {
883
+ p.log.info('Dry run no changes will be made.');
1571
884
  }
1572
885
 
1573
- // Import content types
1574
- for (const [typeId, data] of Object.entries(exportData)) {
1575
- for (const entry of data.entries as Record<string, unknown>[]) {
1576
- const sql = buildInsertSql(typeId, entry);
1577
- run(
1578
- `bunx wrangler d1 execute ${dbName} --remote --command "${wrapForShell(sql)}"`,
1579
- { cwd: root, silent: true }
1580
- );
1581
- }
1582
- for (const [jtName, rows] of Object.entries(data.joinTables)) {
1583
- for (const row of rows as Record<string, unknown>[]) {
1584
- const sql = buildInsertSql(jtName, row);
1585
- run(
1586
- `bunx wrangler d1 execute ${dbName} --remote --command "${wrapForShell(sql)}"`,
1587
- { cwd: root, silent: true }
1588
- );
886
+ // Run config-drift sync
887
+ const s = p.spinner();
888
+ s.start('Checking content/ directories...');
889
+ const actions = syncContentDirsWithConfig(root, config.contentTypes, dryRun);
890
+ s.stop('Directory check complete');
891
+
892
+ if (dryRun) {
893
+ if (actions.length === 0) {
894
+ p.log.success('Content directories are in sync with config.');
895
+ } else {
896
+ p.log.step('Planned actions:');
897
+ for (const action of actions) {
898
+ const detail = action.detail ? ` (${action.detail})` : '';
899
+ switch (action.type) {
900
+ case 'create_dir':
901
+ case 'create_example':
902
+ p.log.success(`+ ${action.path}`);
903
+ break;
904
+ case 'update_example':
905
+ p.log.info(`~ ${action.path}`);
906
+ break;
907
+ case 'delete_dir':
908
+ p.log.error(`- ${action.path}${detail}`);
909
+ break;
910
+ case 'orphan_dir':
911
+ p.log.warn(`→ ${action.path}${detail}`);
912
+ break;
913
+ case 'restore_dir':
914
+ p.log.success(`← ${action.path}${detail}`);
915
+ break;
916
+ }
1589
917
  }
1590
918
  }
919
+ } else if (actions.length === 0) {
920
+ p.log.success('Content directories already in sync.');
1591
921
  }
1592
922
 
1593
- // 4. Upload local media to remote
1594
- log('\nStep 4: Uploading local media to remote...');
1595
- const cookie = await authenticate(remoteUrl, root);
1596
-
1597
- for (const asset of localAssets as {
1598
- id: string;
1599
- url: string;
1600
- title: string;
1601
- content_type: string;
1602
- }[]) {
1603
- const key = (asset.url as string).replace('/api/media/', '');
1604
- log(`⬆ ${asset.title}`);
1605
- try {
1606
- // Download from local wrangler dev
1607
- const dlRes = await fetch(`http://localhost:8787${asset.url}`);
1608
- if (!dlRes.ok) {
1609
- warn(` Local download failed: ${dlRes.status}`);
1610
- continue;
1611
- }
1612
-
1613
- const blob = await dlRes.blob();
1614
- const fileName = key;
1615
- const formData = new FormData();
1616
- formData.append(
1617
- 'file',
1618
- new File([blob], fileName, {
1619
- type: asset.content_type ?? 'application/octet-stream'
1620
- })
1621
- );
1622
- formData.append('title', asset.title ?? fileName);
1623
-
1624
- const upRes = await fetch(`${remoteUrl}/api/admin/media`, {
1625
- method: 'POST',
1626
- headers: { Cookie: cookie },
1627
- body: formData
1628
- });
1629
-
1630
- if (!upRes.ok) {
1631
- warn(` Upload failed: ${await upRes.text()}`);
1632
- continue;
1633
- }
1634
- ok(` → remote R2`);
1635
- } catch (e) {
1636
- warn(` Error: ${e}`);
923
+ // Run validation
924
+ const contentDir = resolve(root, CONTENT_DIR);
925
+ const warnings = validateContent(contentDir, config.contentTypes);
926
+ if (warnings.length === 0) {
927
+ p.log.success('No validation warnings.');
928
+ } else {
929
+ p.log.step('Validation warnings:');
930
+ for (const w of warnings) {
931
+ p.log.warn(`${w.file}: ${w.message}`);
1637
932
  }
1638
933
  }
1639
934
 
1640
- // 5. Deploy
1641
- log('\nStep 5: Deploying...');
1642
- await cmdDeploy();
1643
-
1644
- ok('Push complete! Remote now mirrors local.');
1645
- }
1646
-
1647
- // ── Wrangler wrappers ───────────────────────────────────────────────
1648
-
1649
- function cmdDev() {
1650
- const root = findProjectRoot();
1651
- const extra = process.argv.slice(3).join(' ');
1652
- run(`bunx wrangler dev ${extra}`.trim(), { cwd: root });
1653
- }
1654
-
1655
- function cmdLogin() {
1656
- run('bunx wrangler login');
1657
- }
935
+ // Exit code for CI
936
+ if (dryRun && (actions.length > 0 || warnings.length > 0)) {
937
+ process.exit(1);
938
+ }
1658
939
 
1659
- function cmdTail() {
1660
- const root = findProjectRoot();
1661
- const extra = process.argv.slice(3).join(' ');
1662
- run(`bunx wrangler tail ${extra}`.trim(), { cwd: root });
940
+ outro(dryRun ? 'Dry run complete.' : 'Tidy complete. 🐻');
1663
941
  }
1664
942
 
1665
943
  // ── Help ────────────────────────────────────────────────────────────
1666
944
 
1667
- function cmdHelp() {
1668
- console.log(`
1669
- ${BOLD}🐻 Koguma CLI${RESET} ${DIM}v0.6.0${RESET}
1670
-
1671
- ${BOLD}Usage:${RESET} koguma <command>
1672
-
1673
- ${BOLD}Development:${RESET}
1674
- ${CYAN}dev${RESET} Start local dev server (wrangler dev)
1675
- ${CYAN}login${RESET} Authenticate with Cloudflare
1676
- ${CYAN}tail${RESET} Stream live logs from production
1677
-
1678
- ${BOLD}Schema & Types:${RESET}
1679
- ${CYAN}schema${RESET} Generate DDL from site.config.ts → db/schema.sql
1680
- ${CYAN}typegen${RESET} Generate koguma.d.ts typed interfaces
1681
- ${CYAN}migrate${RESET} Detect schema drift, apply CREATE/ALTER TABLE
1682
-
1683
- ${BOLD}Content:${RESET}
1684
- ${CYAN}seed${RESET} Seed database from db/seed.ts or db/seed.sql
1685
- ${CYAN}export${RESET} Export all content to JSON
1686
- ${CYAN}import${RESET} Import content from JSON file
1687
-
1688
- ${BOLD}Media:${RESET}
1689
- ${CYAN}migrate-media${RESET} Download external images and upload to R2
1690
-
1691
- ${BOLD}Sync:${RESET}
1692
- ${CYAN}pull${RESET} Download remote content + media → local
1693
- ${CYAN}push${RESET} Upload local content + media → remote + deploy
1694
-
1695
- ${BOLD}Deploy:${RESET}
1696
- ${CYAN}init${RESET} Create D1 database and R2 bucket, patch wrangler.toml
1697
- ${CYAN}secret${RESET} Set the admin password on Cloudflare
1698
- ${CYAN}build${RESET} Build the admin dashboard bundle
1699
- ${CYAN}deploy${RESET} Build admin + frontend, then deploy via wrangler
1700
-
1701
- ${BOLD}Examples:${RESET}
1702
- ${DIM}$${RESET} koguma dev ${DIM}# Local dev server${RESET}
1703
- ${DIM}$${RESET} koguma migrate --remote ${DIM}# Migrate production DB${RESET}
1704
- ${DIM}$${RESET} koguma pull --remote https://my-site.dev ${DIM}# Sync remote → local${RESET}
1705
- ${DIM}$${RESET} koguma push --remote https://my-site.dev ${DIM}# Sync local → remote${RESET}
1706
- ${DIM}$${RESET} koguma seed --remote ${DIM}# Seed production DB${RESET}
1707
- `);
945
+ function cmdHelp(): void {
946
+ intro();
947
+
948
+ p.note(
949
+ [
950
+ `${BRAND.ACCENT}init${BRAND.RESET} Set up a new project ${BRAND.DIM}(scaffold, login, D1, R2, secret)${BRAND.RESET}`,
951
+ `${BRAND.ACCENT}dev${BRAND.RESET} Start local dev server ${BRAND.DIM}with auto-sync + typegen${BRAND.RESET}`,
952
+ `${BRAND.ACCENT}push${BRAND.RESET} Build, deploy, and sync content to remote`,
953
+ `${BRAND.ACCENT}pull${BRAND.RESET} Download remote content + media to local`,
954
+ `${BRAND.ACCENT}gen-types${BRAND.RESET} Generate ${BRAND.DIM}koguma.d.ts${BRAND.RESET} typed interfaces`,
955
+ `${BRAND.ACCENT}tidy${BRAND.RESET} Sync content/ dirs with config + validate`,
956
+ `${BRAND.ACCENT}tidy --dry${BRAND.RESET} Preview tidy changes without writing`
957
+ ].join('\n'),
958
+ 'Commands'
959
+ );
960
+
961
+ p.note(
962
+ [
963
+ `${BRAND.DIM}$${BRAND.RESET} koguma init`,
964
+ `${BRAND.DIM}$${BRAND.RESET} koguma dev`,
965
+ `${BRAND.DIM}$${BRAND.RESET} koguma push --remote https://my-site.dev`,
966
+ `${BRAND.DIM}$${BRAND.RESET} koguma pull --remote https://my-site.dev`,
967
+ `${BRAND.DIM}$${BRAND.RESET} koguma gen-types`,
968
+ `${BRAND.DIM}$${BRAND.RESET} koguma tidy --dry`
969
+ ].join('\n'),
970
+ 'Examples'
971
+ );
1708
972
  }
1709
973
 
1710
974
  // ── Dispatch ────────────────────────────────────────────────────────
@@ -1715,50 +979,20 @@ switch (command) {
1715
979
  case 'init':
1716
980
  await cmdInit();
1717
981
  break;
1718
- case 'secret':
1719
- await cmdSecret();
1720
- break;
1721
- case 'build':
1722
- await cmdBuild();
1723
- break;
1724
- case 'seed':
1725
- await cmdSeed();
1726
- break;
1727
- case 'typegen':
1728
- await cmdTypegen();
1729
- break;
1730
- case 'schema':
1731
- await cmdSchema();
1732
- break;
1733
- case 'migrate':
1734
- await cmdMigrate();
1735
- break;
1736
- case 'export':
1737
- await cmdExport();
1738
- break;
1739
- case 'import':
1740
- await cmdImport();
1741
- break;
1742
- case 'migrate-media':
1743
- await cmdMigrateMedia();
1744
- break;
1745
- case 'deploy':
1746
- await cmdDeploy();
1747
- break;
1748
- case 'pull':
1749
- await cmdPull();
982
+ case 'dev':
983
+ await cmdDev();
1750
984
  break;
1751
985
  case 'push':
1752
986
  await cmdPush();
1753
987
  break;
1754
- case 'dev':
1755
- cmdDev();
988
+ case 'pull':
989
+ await cmdPull();
1756
990
  break;
1757
- case 'login':
1758
- cmdLogin();
991
+ case 'gen-types':
992
+ await cmdGenTypes();
1759
993
  break;
1760
- case 'tail':
1761
- cmdTail();
994
+ case 'tidy':
995
+ await cmdTidy();
1762
996
  break;
1763
997
  case 'help':
1764
998
  case '--help':
@@ -1767,7 +1001,7 @@ switch (command) {
1767
1001
  cmdHelp();
1768
1002
  break;
1769
1003
  default:
1770
- fail(`Unknown command: ${command}`);
1004
+ p.log.error(`Unknown command: ${command}`);
1771
1005
  cmdHelp();
1772
1006
  process.exit(1);
1773
1007
  }