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/typegen.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/typegen.ts — Generate koguma.d.ts from site.config.ts.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic: reads config → produces type declarations string.
|
|
5
|
+
* The file I/O is handled by the caller.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { ok, fail, log, ANSI } from './log.ts';
|
|
11
|
+
import { SITE_CONFIG_FILE, TYPEGEN_OUTPUT } from './constants.ts';
|
|
12
|
+
|
|
13
|
+
// ── TypeScript type mapping ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface FieldMeta {
|
|
16
|
+
fieldType: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
refContentType?: string;
|
|
19
|
+
options?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SIMPLE_STRING_TYPES = new Set([
|
|
23
|
+
'text',
|
|
24
|
+
'longText',
|
|
25
|
+
'url',
|
|
26
|
+
'date',
|
|
27
|
+
'markdown',
|
|
28
|
+
'youtube',
|
|
29
|
+
'instagram',
|
|
30
|
+
'email',
|
|
31
|
+
'phone',
|
|
32
|
+
'color'
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map a Koguma field type to its TypeScript representation.
|
|
37
|
+
*/
|
|
38
|
+
export function fieldTypeToTs(fieldType: string, meta: FieldMeta): string {
|
|
39
|
+
if (SIMPLE_STRING_TYPES.has(fieldType)) return 'string';
|
|
40
|
+
|
|
41
|
+
switch (fieldType) {
|
|
42
|
+
case 'image':
|
|
43
|
+
return 'KogumaAsset';
|
|
44
|
+
case 'images':
|
|
45
|
+
return 'string[]';
|
|
46
|
+
case 'boolean':
|
|
47
|
+
return 'boolean';
|
|
48
|
+
case 'number':
|
|
49
|
+
return 'number';
|
|
50
|
+
case 'select':
|
|
51
|
+
return meta.options?.map(o => `'${o}'`).join(' | ') ?? 'string';
|
|
52
|
+
case 'reference':
|
|
53
|
+
return meta.refContentType
|
|
54
|
+
? `${capitalize(meta.refContentType)}Entry`
|
|
55
|
+
: 'Record<string, unknown>';
|
|
56
|
+
case 'references':
|
|
57
|
+
return meta.refContentType
|
|
58
|
+
? `${capitalize(meta.refContentType)}Entry[]`
|
|
59
|
+
: 'Record<string, unknown>[]';
|
|
60
|
+
default:
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function capitalize(s: string): string {
|
|
66
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Type declaration generation ────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
interface ContentTypeConfig {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
fieldMeta: Record<string, FieldMeta>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate the full koguma.d.ts content string from content type configs.
|
|
79
|
+
*/
|
|
80
|
+
export function generateTypeDeclarations(
|
|
81
|
+
contentTypes: ContentTypeConfig[]
|
|
82
|
+
): string {
|
|
83
|
+
const lines: string[] = [
|
|
84
|
+
'/**',
|
|
85
|
+
' * Auto-generated by `koguma gen-types`',
|
|
86
|
+
' * Do not edit manually.',
|
|
87
|
+
' */',
|
|
88
|
+
'',
|
|
89
|
+
'import type { KogumaAsset } from "koguma/types";',
|
|
90
|
+
'',
|
|
91
|
+
'// ── System fields ── common to all entries',
|
|
92
|
+
'interface KogumaSystemFields {',
|
|
93
|
+
' id: string;',
|
|
94
|
+
' created_at: string;',
|
|
95
|
+
' updated_at: string;',
|
|
96
|
+
' status: "draft" | "published";',
|
|
97
|
+
' publishAt: string | null;',
|
|
98
|
+
'}',
|
|
99
|
+
''
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const typeNames: string[] = [];
|
|
103
|
+
|
|
104
|
+
for (const ct of contentTypes) {
|
|
105
|
+
const typeName = capitalize(ct.id) + 'Entry';
|
|
106
|
+
typeNames.push(typeName);
|
|
107
|
+
|
|
108
|
+
lines.push(`// ── ${ct.name} ──`);
|
|
109
|
+
lines.push(`export interface ${typeName} extends KogumaSystemFields {`);
|
|
110
|
+
|
|
111
|
+
for (const [fieldId, meta] of Object.entries(ct.fieldMeta)) {
|
|
112
|
+
const tsType = fieldTypeToTs(meta.fieldType, meta);
|
|
113
|
+
const optional = meta.required ? '' : '?';
|
|
114
|
+
lines.push(` ${fieldId}${optional}: ${tsType};`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lines.push('}');
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Client interface
|
|
122
|
+
lines.push('// ── Koguma Client ──');
|
|
123
|
+
lines.push('export interface KogumaClient {');
|
|
124
|
+
|
|
125
|
+
for (const ct of contentTypes) {
|
|
126
|
+
const typeName = capitalize(ct.id) + 'Entry';
|
|
127
|
+
lines.push(` /** ${ct.name} */`);
|
|
128
|
+
lines.push(` get(type: '${ct.id}', id: string): Promise<${typeName}>;`);
|
|
129
|
+
lines.push(
|
|
130
|
+
` list(type: '${ct.id}'): Promise<{ entries: ${typeName}[] }>;`
|
|
131
|
+
);
|
|
132
|
+
lines.push(
|
|
133
|
+
` create(type: '${ct.id}', data: Partial<${typeName}>): Promise<${typeName}>;`
|
|
134
|
+
);
|
|
135
|
+
lines.push(
|
|
136
|
+
` update(type: '${ct.id}', id: string, data: Partial<${typeName}>): Promise<${typeName}>;`
|
|
137
|
+
);
|
|
138
|
+
lines.push(` delete(type: '${ct.id}', id: string): Promise<void>;`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push('}');
|
|
142
|
+
lines.push('');
|
|
143
|
+
|
|
144
|
+
// Content type union
|
|
145
|
+
lines.push(
|
|
146
|
+
`export type ContentType = ${contentTypes.map(ct => `'${ct.id}'`).join(' | ')};`
|
|
147
|
+
);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── CLI command ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Load site.config.ts and write koguma.d.ts.
|
|
157
|
+
*/
|
|
158
|
+
export async function runTypegen(
|
|
159
|
+
root: string,
|
|
160
|
+
opts?: { silent?: boolean }
|
|
161
|
+
): Promise<string[]> {
|
|
162
|
+
const configPath = resolve(root, SITE_CONFIG_FILE);
|
|
163
|
+
if (!existsSync(configPath)) {
|
|
164
|
+
fail(`${SITE_CONFIG_FILE} not found in project root.`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!opts?.silent) log(`Reading ${SITE_CONFIG_FILE}...`);
|
|
169
|
+
const configModule = await import(configPath);
|
|
170
|
+
const config = configModule.default as {
|
|
171
|
+
contentTypes: ContentTypeConfig[];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (!config.contentTypes?.length) {
|
|
175
|
+
fail(`No content types found in ${SITE_CONFIG_FILE}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const output = generateTypeDeclarations(config.contentTypes);
|
|
180
|
+
const outPath = resolve(root, TYPEGEN_OUTPUT);
|
|
181
|
+
writeFileSync(outPath, output);
|
|
182
|
+
|
|
183
|
+
const typeNames = config.contentTypes.map(ct => capitalize(ct.id) + 'Entry');
|
|
184
|
+
if (!opts?.silent) {
|
|
185
|
+
ok(
|
|
186
|
+
`Generated ${ANSI.CYAN}${TYPEGEN_OUTPUT}${ANSI.RESET} with ${typeNames.length} type(s): ${typeNames.join(', ')}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return typeNames;
|
|
190
|
+
}
|
package/cli/ui.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/ui.ts — Brand colors and Clack wrappers for Koguma CLI.
|
|
3
|
+
*
|
|
4
|
+
* Provides Koguma-branded intro/outro and color constants derived
|
|
5
|
+
* from the admin dashboard theme (theme-override.css).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as p from '@clack/prompts';
|
|
9
|
+
import { CLI_VERSION } from './constants.ts';
|
|
10
|
+
|
|
11
|
+
// ── Brand colors (24-bit ANSI) ─────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// Teal: #2dd4bf → RGB(45,212,191) primary/headings
|
|
14
|
+
// Red: #ff4d4d → RGB(255,77,77) accent/errors
|
|
15
|
+
// Text: #0f172a → RGB(15,23,42) dark slate
|
|
16
|
+
|
|
17
|
+
export const BRAND = {
|
|
18
|
+
/** Teal primary (RGB 45,212,191) — #2dd4bf */
|
|
19
|
+
PRIMARY: '\x1b[38;2;45;212;191m',
|
|
20
|
+
/** Red accent (RGB 255,77,77) — #ff4d4d */
|
|
21
|
+
ACCENT: '\x1b[38;2;255;77;77m',
|
|
22
|
+
/** Dim slate (RGB 113,124,149) */
|
|
23
|
+
DIM: '\x1b[38;2;113;124;149m',
|
|
24
|
+
/** Bold */
|
|
25
|
+
BOLD: '\x1b[1m',
|
|
26
|
+
/** Reset */
|
|
27
|
+
RESET: '\x1b[0m'
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
// ── Branded Clack wrappers ─────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Show a branded intro banner. */
|
|
33
|
+
export function intro(command?: string): void {
|
|
34
|
+
const label = command
|
|
35
|
+
? `${BRAND.PRIMARY}${BRAND.BOLD}🐻 koguma ${command}${BRAND.RESET} ${BRAND.DIM}${CLI_VERSION}${BRAND.RESET}`
|
|
36
|
+
: `${BRAND.PRIMARY}${BRAND.BOLD}🐻 koguma${BRAND.RESET} ${BRAND.DIM}${CLI_VERSION}${BRAND.RESET}`;
|
|
37
|
+
p.intro(label);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Show a branded outro message. */
|
|
41
|
+
export function outro(message: string): void {
|
|
42
|
+
p.outro(`${BRAND.PRIMARY}${message}${BRAND.RESET}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Handle Clack cancellation — show message and exit. */
|
|
46
|
+
export function handleCancel(value: unknown): value is symbol {
|
|
47
|
+
if (p.isCancel(value)) {
|
|
48
|
+
p.cancel('Operation cancelled.');
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Re-export Clack for convenience
|
|
55
|
+
export { p };
|
package/cli/wrangler.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/wrangler.ts — Wrangler command wrappers.
|
|
3
|
+
*
|
|
4
|
+
* Provides typed helpers for D1 and R2 operations, centralizing
|
|
5
|
+
* the --config flag, --local/--remote targeting, and JSON parsing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { INIT_SQL } from '../src/db/init.ts';
|
|
11
|
+
import { buildInsertSql, wrapForShell } from '../src/db/sql.ts';
|
|
12
|
+
import { ensureWranglerConfig } from './config.ts';
|
|
13
|
+
import { run, runCapture, runAsync } from './exec.ts';
|
|
14
|
+
import { ok, warn, fail, log } from './log.ts';
|
|
15
|
+
import { DB_DIR, MIGRATION_FILE } from './constants.ts';
|
|
16
|
+
|
|
17
|
+
// ── Preflight checks ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Verify that `wrangler` can authenticate with Cloudflare.
|
|
21
|
+
* Gives a clear, actionable error message if not logged in.
|
|
22
|
+
*/
|
|
23
|
+
export function checkWranglerAuth(root: string): void {
|
|
24
|
+
try {
|
|
25
|
+
runCapture('bunx wrangler whoami', root);
|
|
26
|
+
} catch {
|
|
27
|
+
fail(
|
|
28
|
+
"Not logged in to Cloudflare. Run 'bunx wrangler login' first, " +
|
|
29
|
+
"or run 'koguma init' which handles login for you."
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Config-aware wrangler execution ────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the --config flag string. Writes .wrangler.toml if needed.
|
|
39
|
+
*/
|
|
40
|
+
function configFlag(root: string): string {
|
|
41
|
+
const configPath = ensureWranglerConfig(root);
|
|
42
|
+
return `--config ${configPath}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── D1 operations ──────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export type D1Target = '--local' | '--remote';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Execute a SQL command against D1.
|
|
51
|
+
*/
|
|
52
|
+
export function d1Execute(
|
|
53
|
+
root: string,
|
|
54
|
+
dbName: string,
|
|
55
|
+
target: D1Target,
|
|
56
|
+
command: string
|
|
57
|
+
): void {
|
|
58
|
+
run(
|
|
59
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --command "${wrapForShell(command)}"`,
|
|
60
|
+
{ cwd: root, silent: true }
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Execute a SQL file against D1.
|
|
66
|
+
*/
|
|
67
|
+
export function d1ExecuteFile(
|
|
68
|
+
root: string,
|
|
69
|
+
dbName: string,
|
|
70
|
+
target: D1Target,
|
|
71
|
+
filePath: string
|
|
72
|
+
): void {
|
|
73
|
+
run(
|
|
74
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --file=${filePath}`,
|
|
75
|
+
{ cwd: root, silent: true }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Execute a SQL query against D1 and return parsed JSON results.
|
|
81
|
+
*/
|
|
82
|
+
export function d1Query(
|
|
83
|
+
root: string,
|
|
84
|
+
dbName: string,
|
|
85
|
+
target: D1Target,
|
|
86
|
+
query: string
|
|
87
|
+
): Record<string, unknown>[] {
|
|
88
|
+
const output = runCapture(
|
|
89
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --command "${query}" --json`,
|
|
90
|
+
root
|
|
91
|
+
);
|
|
92
|
+
const parsed = JSON.parse(output);
|
|
93
|
+
return parsed?.[0]?.results ?? [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply the INIT_SQL schema to D1 (idempotent — uses IF NOT EXISTS).
|
|
98
|
+
*/
|
|
99
|
+
export function applySchema(
|
|
100
|
+
root: string,
|
|
101
|
+
dbName: string,
|
|
102
|
+
target: D1Target
|
|
103
|
+
): void {
|
|
104
|
+
const dbDir = resolve(root, DB_DIR);
|
|
105
|
+
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
const sqlFile = resolve(dbDir, MIGRATION_FILE);
|
|
108
|
+
writeFileSync(sqlFile, INIT_SQL);
|
|
109
|
+
d1ExecuteFile(root, dbName, target, sqlFile);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Insert a row into a D1 table via INSERT OR REPLACE.
|
|
114
|
+
*/
|
|
115
|
+
export function d1InsertRow(
|
|
116
|
+
root: string,
|
|
117
|
+
dbName: string,
|
|
118
|
+
target: D1Target,
|
|
119
|
+
table: string,
|
|
120
|
+
row: Record<string, unknown>
|
|
121
|
+
): void {
|
|
122
|
+
const sql = buildInsertSql(table, row);
|
|
123
|
+
d1Execute(root, dbName, target, sql);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Batch-insert multiple rows via a single --file execution.
|
|
128
|
+
* Dramatically faster than calling d1InsertRow per entry.
|
|
129
|
+
*/
|
|
130
|
+
export function d1InsertBatch(
|
|
131
|
+
root: string,
|
|
132
|
+
dbName: string,
|
|
133
|
+
target: D1Target,
|
|
134
|
+
table: string,
|
|
135
|
+
rows: Record<string, unknown>[]
|
|
136
|
+
): void {
|
|
137
|
+
if (rows.length === 0) return;
|
|
138
|
+
|
|
139
|
+
const dbDir = resolve(root, DB_DIR);
|
|
140
|
+
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
141
|
+
|
|
142
|
+
const sqlFile = resolve(dbDir, 'batch-sync.sql');
|
|
143
|
+
const statements = rows.map(row => buildInsertSql(table, row) + ';');
|
|
144
|
+
writeFileSync(sqlFile, statements.join('\n'));
|
|
145
|
+
d1ExecuteFile(root, dbName, target, sqlFile);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Async versions (non-blocking, spinner-friendly) ─────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Apply schema asynchronously (non-blocking for spinner animation).
|
|
152
|
+
*/
|
|
153
|
+
export async function applySchemaAsync(
|
|
154
|
+
root: string,
|
|
155
|
+
dbName: string,
|
|
156
|
+
target: D1Target
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const dbDir = resolve(root, DB_DIR);
|
|
159
|
+
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
160
|
+
|
|
161
|
+
const sqlFile = resolve(dbDir, MIGRATION_FILE);
|
|
162
|
+
writeFileSync(sqlFile, INIT_SQL);
|
|
163
|
+
await runAsync(
|
|
164
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --file=${sqlFile}`,
|
|
165
|
+
{ cwd: root, silent: true }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Batch-insert rows asynchronously (non-blocking for spinner animation).
|
|
171
|
+
*/
|
|
172
|
+
export async function d1InsertBatchAsync(
|
|
173
|
+
root: string,
|
|
174
|
+
dbName: string,
|
|
175
|
+
target: D1Target,
|
|
176
|
+
table: string,
|
|
177
|
+
rows: Record<string, unknown>[]
|
|
178
|
+
): Promise<void> {
|
|
179
|
+
if (rows.length === 0) return;
|
|
180
|
+
|
|
181
|
+
const dbDir = resolve(root, DB_DIR);
|
|
182
|
+
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
const sqlFile = resolve(dbDir, 'batch-sync.sql');
|
|
185
|
+
const statements = rows.map(row => buildInsertSql(table, row) + ';');
|
|
186
|
+
writeFileSync(sqlFile, statements.join('\n'));
|
|
187
|
+
await runAsync(
|
|
188
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --file=${sqlFile}`,
|
|
189
|
+
{ cwd: root, silent: true }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Execute pre-built SQL statements as a batch file (async, non-blocking).
|
|
195
|
+
* Unlike d1InsertBatchAsync, this takes raw SQL strings — useful when
|
|
196
|
+
* inserting into multiple tables in a single batch.
|
|
197
|
+
*/
|
|
198
|
+
export async function d1ExecuteBatchSqlAsync(
|
|
199
|
+
root: string,
|
|
200
|
+
dbName: string,
|
|
201
|
+
target: D1Target,
|
|
202
|
+
statements: string[]
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
if (statements.length === 0) return;
|
|
205
|
+
|
|
206
|
+
const dbDir = resolve(root, DB_DIR);
|
|
207
|
+
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
|
|
208
|
+
|
|
209
|
+
const sqlFile = resolve(dbDir, 'batch-sync.sql');
|
|
210
|
+
writeFileSync(sqlFile, statements.map(s => s.endsWith(';') ? s : s + ';').join('\n'));
|
|
211
|
+
await runAsync(
|
|
212
|
+
`bunx wrangler d1 execute ${dbName} ${target} ${configFlag(root)} --file=${sqlFile}`,
|
|
213
|
+
{ cwd: root, silent: true }
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Put an object into a local R2 bucket.
|
|
219
|
+
*/
|
|
220
|
+
export function r2PutLocal(
|
|
221
|
+
root: string,
|
|
222
|
+
bucketName: string,
|
|
223
|
+
key: string,
|
|
224
|
+
filePath: string
|
|
225
|
+
): void {
|
|
226
|
+
run(
|
|
227
|
+
`bunx wrangler r2 object put ${bucketName}/${key} ${configFlag(root)} --file=${filePath} --local`,
|
|
228
|
+
{ cwd: root, silent: true }
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Put an object into a local R2 bucket (async, non-blocking).
|
|
234
|
+
*/
|
|
235
|
+
export async function r2PutLocalAsync(
|
|
236
|
+
root: string,
|
|
237
|
+
bucketName: string,
|
|
238
|
+
key: string,
|
|
239
|
+
filePath: string
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
await runAsync(
|
|
242
|
+
`bunx wrangler r2 object put ${bucketName}/${key} ${configFlag(root)} --file=${filePath} --local`,
|
|
243
|
+
{ cwd: root, silent: true }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Wrangler dev / deploy ──────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Start wrangler dev server with piped output.
|
|
251
|
+
* Filters wrangler's noisy banner and shows koguma-branded messages.
|
|
252
|
+
* Returns a Promise that resolves when wrangler exits.
|
|
253
|
+
*/
|
|
254
|
+
export function wranglerDev(
|
|
255
|
+
root: string,
|
|
256
|
+
extraArgs: string,
|
|
257
|
+
env?: Record<string, string>
|
|
258
|
+
): Promise<number> {
|
|
259
|
+
return new Promise(resolve => {
|
|
260
|
+
const envVars = { ...process.env, ...(env ?? {}) };
|
|
261
|
+
const args = ['wrangler', 'dev', ...configFlag(root).split(' ')];
|
|
262
|
+
if (extraArgs.trim()) args.push(...extraArgs.trim().split(' '));
|
|
263
|
+
|
|
264
|
+
const { spawn } = require('child_process');
|
|
265
|
+
const child = spawn('bunx', args, {
|
|
266
|
+
cwd: root,
|
|
267
|
+
env: envVars,
|
|
268
|
+
stdio: ['inherit', 'pipe', 'pipe'] // stdin passthrough, capture stdout/stderr
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Lines to suppress from wrangler output
|
|
272
|
+
const suppressPatterns = [
|
|
273
|
+
/⛅️\s*wrangler/, // wrangler banner
|
|
274
|
+
/─{3,}/, // separator lines
|
|
275
|
+
/Your Worker has access/, // bindings header
|
|
276
|
+
/Binding\s+Resource/, // bindings table header
|
|
277
|
+
/env\./, // binding rows (e.g. "env.DB", "env.MEDIA")
|
|
278
|
+
/Using secrets defined in/, // .dev.vars notice
|
|
279
|
+
/╭.*╮/, // keyboard shortcuts box top
|
|
280
|
+
/│.*\[b\].*\[c\].*\[x\]/, // keyboard shortcuts content
|
|
281
|
+
/╰.*╯/, // keyboard shortcuts box bottom
|
|
282
|
+
/\[wrangler:/, // [wrangler:inf] prefixed messages (handled separately below)
|
|
283
|
+
/^\s*$/ // blank lines
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
const shouldSuppress = (line: string): boolean =>
|
|
287
|
+
suppressPatterns.some(p => p.test(line));
|
|
288
|
+
|
|
289
|
+
const handleOutput = (data: Buffer, isErr: boolean) => {
|
|
290
|
+
const text = data.toString();
|
|
291
|
+
for (const line of text.split('\n')) {
|
|
292
|
+
const trimmed = line.trim();
|
|
293
|
+
if (!trimmed) continue;
|
|
294
|
+
|
|
295
|
+
// Rebrand the "Ready" message before suppress check
|
|
296
|
+
if (trimmed.includes('Ready on http')) {
|
|
297
|
+
const urlMatch = trimmed.match(/(https?:\/\/[^\s]+)/);
|
|
298
|
+
const url = urlMatch?.[1] ?? 'http://localhost:8787';
|
|
299
|
+
ok(`Server ready → ${url}`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (shouldSuppress(line)) continue;
|
|
304
|
+
|
|
305
|
+
if (trimmed.includes('Starting local server')) continue;
|
|
306
|
+
if (trimmed.includes('Shutting down')) continue;
|
|
307
|
+
|
|
308
|
+
// Forward everything else (request logs, errors, warnings)
|
|
309
|
+
if (isErr) {
|
|
310
|
+
warn(trimmed);
|
|
311
|
+
} else {
|
|
312
|
+
log(trimmed);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
child.stdout.on('data', (data: Buffer) => handleOutput(data, false));
|
|
318
|
+
child.stderr.on('data', (data: Buffer) => handleOutput(data, true));
|
|
319
|
+
|
|
320
|
+
// Forward SIGINT to wrangler
|
|
321
|
+
const forwardSignal = () => child.kill('SIGINT');
|
|
322
|
+
process.on('SIGINT', forwardSignal);
|
|
323
|
+
process.on('SIGTERM', forwardSignal);
|
|
324
|
+
|
|
325
|
+
child.on('close', (code: number | null) => {
|
|
326
|
+
process.removeListener('SIGINT', forwardSignal);
|
|
327
|
+
process.removeListener('SIGTERM', forwardSignal);
|
|
328
|
+
resolve(code ?? 0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Deploy via wrangler.
|
|
335
|
+
*/
|
|
336
|
+
export function wranglerDeploy(root: string): void {
|
|
337
|
+
run(`bunx wrangler deploy ${configFlag(root)}`, { cwd: root });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── D1 / R2 resource creation ──────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Create a D1 database and return the database_id (or null on failure).
|
|
344
|
+
*/
|
|
345
|
+
export function createD1Database(root: string, dbName: string): string | null {
|
|
346
|
+
try {
|
|
347
|
+
const output = runCapture(`bunx wrangler d1 create ${dbName}`, root);
|
|
348
|
+
const idMatch = output.match(/database_id\s*=\s*"([^"]+)"/);
|
|
349
|
+
return idMatch?.[1] ?? null;
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Ensure an R2 bucket exists. Returns true if it exists or was created.
|
|
357
|
+
*/
|
|
358
|
+
export function ensureR2Bucket(root: string, bucketName: string): boolean {
|
|
359
|
+
try {
|
|
360
|
+
const buckets = runCapture('bunx wrangler r2 bucket list', root);
|
|
361
|
+
if (buckets.includes(bucketName)) return true;
|
|
362
|
+
runCapture(`bunx wrangler r2 bucket create ${bucketName}`, root);
|
|
363
|
+
return true;
|
|
364
|
+
} catch {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koguma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "🐻 A little CMS with big heart — schema-driven, runs on Cloudflare's free tier",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"packageManager": "bun@1.3.10",
|
|
24
24
|
"scripts": {
|
|
25
|
-
"dev": "concurrently --kill-others \"bun run --cwd admin dev\" \"
|
|
25
|
+
"dev": "concurrently --kill-others \"bun run --cwd admin dev\" \"bun run cli/index.ts dev\"",
|
|
26
26
|
"test": "bunx turbo run test:run",
|
|
27
27
|
"test:run": "bun test",
|
|
28
28
|
"build:admin": "bunx turbo run build:admin:run",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"./worker": "./src/worker.ts",
|
|
35
35
|
"./client": "./src/client/index.ts",
|
|
36
36
|
"./react": "./src/react/index.ts",
|
|
37
|
-
"./types": "./src/config/types.ts"
|
|
38
|
-
"./db": "./src/db/schema.ts"
|
|
37
|
+
"./types": "./src/config/types.ts"
|
|
39
38
|
},
|
|
40
39
|
"bin": {
|
|
41
40
|
"koguma": "./cli/index.ts"
|
|
@@ -47,6 +46,10 @@
|
|
|
47
46
|
"LICENSE"
|
|
48
47
|
],
|
|
49
48
|
"dependencies": {
|
|
49
|
+
"@clack/prompts": "^1.1.0",
|
|
50
|
+
"gray-matter": "^4.0.3",
|
|
51
|
+
"react-markdown": "^10.1.0",
|
|
52
|
+
"remark-gfm": "^4.0.1",
|
|
50
53
|
"zod": "^4.3.6"
|
|
51
54
|
},
|
|
52
55
|
"peerDependencies": {
|