koguma 0.6.4 → 0.6.6

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 CHANGED
@@ -17,6 +17,13 @@ import { execSync } from 'child_process';
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
18
18
  import { resolve, dirname, basename } from 'path';
19
19
  import { generateSchema } from '../src/db/schema.ts';
20
+ import {
21
+ buildInsertSql,
22
+ wrapForShell,
23
+ buildAssetIndex,
24
+ processSeedEntry,
25
+ buildImportSql
26
+ } from '../src/db/sql.ts';
20
27
 
21
28
  // ── Helpers ─────────────────────────────────────────────────────────
22
29
 
@@ -472,13 +479,9 @@ async function cmdBuild() {
472
479
  async function cmdSeed() {
473
480
  header('koguma seed');
474
481
  const root = findProjectRoot();
482
+ const seedTs = resolve(root, 'db/seed.ts');
475
483
  const seedSql = resolve(root, 'db/seed.sql');
476
484
 
477
- if (!existsSync(seedSql)) {
478
- fail('db/seed.sql not found. Generate it first with your seed script.');
479
- process.exit(1);
480
- }
481
-
482
485
  // Parse database name from wrangler.toml
483
486
  const toml = readFileSync(resolve(root, 'wrangler.toml'), 'utf-8');
484
487
  const dbNameMatch = toml.match(/database_name\s*=\s*"([^"]+)"/);
@@ -487,14 +490,97 @@ async function cmdSeed() {
487
490
  const isRemote = process.argv.includes('--remote');
488
491
  const target = isRemote ? '--remote' : '--local';
489
492
 
490
- log(
491
- `Seeding ${isRemote ? 'REMOTE' : 'local'} database: ${CYAN}${dbName}${RESET}`
492
- );
493
- run(`bunx wrangler d1 execute ${dbName} ${target} --file=${seedSql}`, {
494
- cwd: root
495
- });
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]));
512
+
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
520
+ );
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');
527
+ }
528
+
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;
542
+ }
543
+
544
+ log(`Seeding ${CYAN}${typeId}${RESET} (${entries.length} entries)...`);
496
545
 
497
- ok(`Database seeded (${isRemote ? 'remote' : 'local'})!`);
546
+ for (const entry of entries) {
547
+ const { processed, resolutions } = processSeedEntry(
548
+ entry,
549
+ ct.fieldMeta,
550
+ assetIndex,
551
+ markdownToKoguma,
552
+ kogumaToLexical
553
+ );
554
+
555
+ for (const r of resolutions) {
556
+ ok(` ${r}`);
557
+ }
558
+
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++;
565
+ }
566
+ }
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);
583
+ }
498
584
  }
499
585
 
500
586
  async function cmdDeploy() {
@@ -672,9 +758,9 @@ function fieldTypeToTs(
672
758
  case 'date':
673
759
  return 'string';
674
760
  case 'richText':
675
- return 'Record<string, unknown>';
761
+ return 'KogumaDocument';
676
762
  case 'image':
677
- return '{ id: string; url: string; title?: string; width?: number; height?: number }';
763
+ return 'KogumaAsset';
678
764
  case 'boolean':
679
765
  return 'boolean';
680
766
  case 'number':
@@ -689,6 +775,14 @@ function fieldTypeToTs(
689
775
  return meta.refContentType
690
776
  ? `${capitalize(meta.refContentType)}Entry[]`
691
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[]';
692
786
  default:
693
787
  return 'unknown';
694
788
  }
@@ -739,6 +833,8 @@ async function cmdTypegen() {
739
833
  ' * Do not edit manually.',
740
834
  ' */',
741
835
  '',
836
+ 'import type { KogumaDocument, KogumaAsset } from "koguma/types";',
837
+ '',
742
838
  '// ── System fields ── common to all entries',
743
839
  'interface KogumaSystemFields {',
744
840
  ' id: string;',
@@ -856,7 +952,8 @@ async function cmdMigrate() {
856
952
  const results = parsed?.[0]?.results ?? [];
857
953
  existingColumns[ct.id] = results;
858
954
  } catch {
859
- warn(`Table '${ct.id}' does not exist yet.`);
955
+ warn(`Table '${ct.id}' does not exist yet — will create it.`);
956
+ existingColumns[ct.id] = [];
860
957
  }
861
958
  }
862
959
 
@@ -1119,16 +1216,9 @@ async function cmdImport() {
1119
1216
  );
1120
1217
 
1121
1218
  for (const entry of data.entries) {
1122
- const cols = Object.keys(entry);
1123
- const vals = Object.values(entry).map(v => {
1124
- if (v === null) return 'NULL';
1125
- if (typeof v === 'number') return String(v);
1126
- return `'${String(v).replace(/'/g, "''")}'`;
1127
- });
1128
-
1129
- const sql = `INSERT OR REPLACE INTO ${typeId} (${cols.join(', ')}) VALUES (${vals.join(', ')})`;
1219
+ const sql = buildInsertSql(typeId, entry);
1130
1220
  run(
1131
- `bunx wrangler d1 execute ${dbName} ${target} --command "${sql.replace(/"/g, '\\"')}"`,
1221
+ `bunx wrangler d1 execute ${dbName} ${target} --command "${wrapForShell(sql)}"`,
1132
1222
  { cwd: root, silent: true }
1133
1223
  );
1134
1224
  totalEntries++;
@@ -1137,16 +1227,9 @@ async function cmdImport() {
1137
1227
  // Import join tables
1138
1228
  for (const [jtName, rows] of Object.entries(data.joinTables)) {
1139
1229
  for (const row of rows) {
1140
- const cols = Object.keys(row);
1141
- const vals = Object.values(row).map(v => {
1142
- if (v === null) return 'NULL';
1143
- if (typeof v === 'number') return String(v);
1144
- return `'${String(v).replace(/'/g, "''")}'`;
1145
- });
1146
-
1147
- const sql = `INSERT OR REPLACE INTO ${jtName} (${cols.join(', ')}) VALUES (${vals.join(', ')})`;
1230
+ const sql = buildInsertSql(jtName, row);
1148
1231
  run(
1149
- `bunx wrangler d1 execute ${dbName} ${target} --command "${sql.replace(/"/g, '\\"')}"`,
1232
+ `bunx wrangler d1 execute ${dbName} ${target} --command "${wrapForShell(sql)}"`,
1150
1233
  { cwd: root, silent: true }
1151
1234
  );
1152
1235
  }
@@ -1158,37 +1241,469 @@ async function cmdImport() {
1158
1241
  );
1159
1242
  }
1160
1243
 
1244
+ // ── Shared auth helper ──────────────────────────────────────────────
1245
+
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);
1270
+ }
1271
+ return cookieMatch[0];
1272
+ }
1273
+
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
+ }
1283
+
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';
1288
+ }
1289
+
1290
+ // ── Pull ────────────────────────────────────────────────────────────
1291
+
1292
+ async function cmdPull() {
1293
+ header('koguma pull');
1294
+ const root = findProjectRoot();
1295
+ const remoteUrl = getRemoteUrl();
1296
+ const dbName = getDbName(root);
1297
+
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
+ };
1313
+
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
+ > = {};
1320
+
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 ?? [];
1329
+
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
+ }
1346
+
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
+ }
1352
+ }
1353
+
1354
+ // Also export _assets
1355
+ let remoteAssets: Record<string, unknown>[] = [];
1356
+ 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`);
1364
+ } 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
+ );
1378
+ }
1379
+
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
+ );
1388
+ }
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
+ }
1397
+ }
1398
+ }
1399
+
1400
+ // 4. Download R2 media
1401
+ log('\nStep 4: Downloading remote media...');
1402
+ const cookie = await authenticate(remoteUrl, root);
1403
+
1404
+ const mediaRes = await fetch(`${remoteUrl}/api/admin/media`, {
1405
+ headers: { Cookie: cookie }
1406
+ });
1407
+ if (!mediaRes.ok) {
1408
+ warn('Could not list remote media');
1409
+ } else {
1410
+ const { assets } = (await mediaRes.json()) as {
1411
+ assets: { id: string; url: string; title: string }[];
1412
+ };
1413
+ log(`Found ${assets.length} remote assets`);
1414
+
1415
+ const bucketMatch = readFileSync(
1416
+ resolve(root, 'wrangler.toml'),
1417
+ 'utf-8'
1418
+ ).match(/bucket_name\s*=\s*"([^"]+)"/);
1419
+ const bucketName = bucketMatch?.[1] ?? 'media';
1420
+
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
1438
+ try {
1439
+ const { unlinkSync } = await import('fs');
1440
+ unlinkSync(tmpPath);
1441
+ } catch {
1442
+ /* ignore */
1443
+ }
1444
+ ok(` → local R2`);
1445
+ } catch (e) {
1446
+ warn(` Error: ${e}`);
1447
+ }
1448
+ }
1449
+ }
1450
+
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
1484
+ );
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] = [];
1490
+ }
1491
+ }
1492
+
1493
+ const { detectDrift } = await import('../src/db/migrate.ts');
1494
+ const driftResult = detectDrift(config.contentTypes as any, existingColumns);
1495
+
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
+ }
1506
+
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
+ > = {};
1513
+
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 ?? [];
1522
+
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
+ }
1539
+
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
+ }
1546
+
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
+ }
1560
+
1561
+ // 3. Import content to remote
1562
+ log('\nStep 3: Importing content to remote...');
1563
+
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
+ );
1571
+ }
1572
+
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
+ );
1589
+ }
1590
+ }
1591
+ }
1592
+
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}`);
1637
+ }
1638
+ }
1639
+
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
+ }
1658
+
1659
+ function cmdTail() {
1660
+ const root = findProjectRoot();
1661
+ const extra = process.argv.slice(3).join(' ');
1662
+ run(`bunx wrangler tail ${extra}`.trim(), { cwd: root });
1663
+ }
1664
+
1665
+ // ── Help ────────────────────────────────────────────────────────────
1666
+
1161
1667
  function cmdHelp() {
1162
1668
  console.log(`
1163
- ${BOLD}🐻 Koguma CLI${RESET} ${DIM}v0.5.0${RESET}
1669
+ ${BOLD}🐻 Koguma CLI${RESET} ${DIM}v0.6.0${RESET}
1164
1670
 
1165
1671
  ${BOLD}Usage:${RESET} koguma <command>
1166
1672
 
1167
- ${BOLD}Commands:${RESET}
1168
- ${CYAN}init${RESET} Create D1 database and R2 bucket, patch wrangler.toml
1169
- ${CYAN}secret${RESET} Set the admin password on Cloudflare
1170
- ${CYAN}build${RESET} Build the admin dashboard bundle
1171
- ${CYAN}seed${RESET} Seed the database from db/seed.sql
1172
- ${CYAN}schema${RESET} Generate correct DDL from site.config.ts → db/schema.sql
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
1173
1680
  ${CYAN}typegen${RESET} Generate koguma.d.ts typed interfaces
1174
- ${CYAN}migrate${RESET} Detect schema drift and apply ALTER TABLE changes
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
1175
1685
  ${CYAN}export${RESET} Export all content to JSON
1176
1686
  ${CYAN}import${RESET} Import content from JSON file
1177
- ${CYAN}migrate-media${RESET} Download images and upload to R2
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
1178
1699
  ${CYAN}deploy${RESET} Build admin + frontend, then deploy via wrangler
1179
1700
 
1180
- ${BOLD}Options:${RESET}
1181
- ${DIM}koguma seed --remote${RESET} Seed the production database
1182
- ${DIM}koguma migrate --remote${RESET} Migrate the production database
1183
- ${DIM}koguma export --remote${RESET} Export from production
1184
- ${DIM}koguma import data.json --remote${RESET} Import to production
1185
- ${DIM}koguma migrate-media --remote https://...${RESET} Migrate to production R2
1186
-
1187
- ${BOLD}First deploy:${RESET}
1188
- ${DIM}$${RESET} koguma init ${DIM}# Create D1 + R2${RESET}
1189
- ${DIM}$${RESET} koguma secret ${DIM}# Set admin password${RESET}
1190
- ${DIM}$${RESET} koguma seed --remote ${DIM}# Seed production DB${RESET}
1191
- ${DIM}$${RESET} koguma deploy ${DIM}# Build + deploy${RESET}
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}
1192
1707
  `);
1193
1708
  }
1194
1709
 
@@ -1230,6 +1745,21 @@ switch (command) {
1230
1745
  case 'deploy':
1231
1746
  await cmdDeploy();
1232
1747
  break;
1748
+ case 'pull':
1749
+ await cmdPull();
1750
+ break;
1751
+ case 'push':
1752
+ await cmdPush();
1753
+ break;
1754
+ case 'dev':
1755
+ cmdDev();
1756
+ break;
1757
+ case 'login':
1758
+ cmdLogin();
1759
+ break;
1760
+ case 'tail':
1761
+ cmdTail();
1762
+ break;
1233
1763
  case 'help':
1234
1764
  case '--help':
1235
1765
  case '-h':