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