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