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 +584 -54
- package/package.json +1 -1
- package/src/config/define.ts +3 -3
- package/src/db/migrate.ts +7 -1
- package/src/db/sql.ts +169 -0
- package/src/rich-text/index.ts +1 -0
- package/src/rich-text/koguma-to-lexical.ts +340 -0
- package/src/rich-text/markdown-to-koguma.ts +164 -0
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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 '
|
|
761
|
+
return 'KogumaDocument';
|
|
676
762
|
case 'image':
|
|
677
|
-
return '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
1669
|
+
${BOLD}🐻 Koguma CLI${RESET} ${DIM}v0.6.0${RESET}
|
|
1164
1670
|
|
|
1165
1671
|
${BOLD}Usage:${RESET} koguma <command>
|
|
1166
1672
|
|
|
1167
|
-
${BOLD}
|
|
1168
|
-
${CYAN}
|
|
1169
|
-
${CYAN}
|
|
1170
|
-
${CYAN}
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
|
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
|
-
|
|
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}
|
|
1181
|
-
${DIM}koguma
|
|
1182
|
-
${DIM}koguma migrate --remote${
|
|
1183
|
-
${DIM}koguma
|
|
1184
|
-
${DIM}koguma
|
|
1185
|
-
${DIM}koguma
|
|
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':
|