@kuratchi/js 0.0.1
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 +29 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +78 -0
- package/dist/compiler/index.d.ts +34 -0
- package/dist/compiler/index.js +2200 -0
- package/dist/compiler/parser.d.ts +40 -0
- package/dist/compiler/parser.js +534 -0
- package/dist/compiler/template.d.ts +30 -0
- package/dist/compiler/template.js +625 -0
- package/dist/create.d.ts +7 -0
- package/dist/create.js +876 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +15 -0
- package/dist/runtime/app.d.ts +12 -0
- package/dist/runtime/app.js +118 -0
- package/dist/runtime/config.d.ts +5 -0
- package/dist/runtime/config.js +6 -0
- package/dist/runtime/containers.d.ts +61 -0
- package/dist/runtime/containers.js +127 -0
- package/dist/runtime/context.d.ts +54 -0
- package/dist/runtime/context.js +134 -0
- package/dist/runtime/do.d.ts +81 -0
- package/dist/runtime/do.js +123 -0
- package/dist/runtime/index.d.ts +8 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/router.d.ts +29 -0
- package/dist/runtime/router.js +73 -0
- package/dist/runtime/types.d.ts +207 -0
- package/dist/runtime/types.js +4 -0
- package/package.json +50 -0
package/dist/create.js
ADDED
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kuratchi create <project-name>` — scaffold a new KuratchiJS project
|
|
3
|
+
*
|
|
4
|
+
* Interactive prompts for feature selection, then generates
|
|
5
|
+
* a ready-to-run project with the selected stack.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import * as readline from 'node:readline';
|
|
10
|
+
import * as crypto from 'node:crypto';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
const FRAMEWORK_PACKAGE_NAME = getFrameworkPackageName();
|
|
13
|
+
function getFrameworkPackageName() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8');
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
return parsed.name || 'KuratchiJS';
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 'KuratchiJS';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ── Prompt Helpers ──────────────────────────────────────────
|
|
24
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
+
function ask(question, defaultVal = '') {
|
|
26
|
+
const suffix = defaultVal ? ` (${defaultVal})` : '';
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
29
|
+
resolve(answer.trim() || defaultVal);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function confirm(question, defaultYes = true) {
|
|
34
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
rl.question(` ${question} (${hint}): `, (answer) => {
|
|
37
|
+
const a = answer.trim().toLowerCase();
|
|
38
|
+
if (!a)
|
|
39
|
+
return resolve(defaultYes);
|
|
40
|
+
resolve(a === 'y' || a === 'yes');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function create(projectName, flags = []) {
|
|
45
|
+
const autoYes = flags.includes('--yes') || flags.includes('-y');
|
|
46
|
+
console.log('\nâš¡ Create a new KuratchiJS project\n');
|
|
47
|
+
// Project name
|
|
48
|
+
const name = projectName || (autoYes ? 'my-kuratchi-app' : await ask('Project name', 'my-kuratchi-app'));
|
|
49
|
+
// Validate name
|
|
50
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
51
|
+
console.error(' ✗ Project name must be lowercase alphanumeric with hyphens');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
const targetDir = path.resolve(process.cwd(), name);
|
|
55
|
+
if (fs.existsSync(targetDir)) {
|
|
56
|
+
console.error(` ✗ Directory "${name}" already exists`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
// Feature selection
|
|
60
|
+
const ui = autoYes ? true : await confirm('Include @kuratchi/ui theme?');
|
|
61
|
+
const orm = autoYes ? true : await confirm('Include @kuratchi/orm with D1?');
|
|
62
|
+
const auth = autoYes ? true : (orm ? await confirm('Include @kuratchi/auth (credentials login)?') : false);
|
|
63
|
+
if (auth && !orm) {
|
|
64
|
+
console.log(' ℹ Auth requires ORM — enabling ORM automatically');
|
|
65
|
+
}
|
|
66
|
+
console.log();
|
|
67
|
+
console.log(` Project: ${name}`);
|
|
68
|
+
console.log(` UI: ${ui ? '✓' : '—'}`);
|
|
69
|
+
console.log(` ORM: ${orm ? '✓' : '—'}`);
|
|
70
|
+
console.log(` Auth: ${auth ? '✓' : '—'}`);
|
|
71
|
+
console.log();
|
|
72
|
+
if (!autoYes) {
|
|
73
|
+
const ok = await confirm('Create project?');
|
|
74
|
+
if (!ok) {
|
|
75
|
+
console.log(' Cancelled.');
|
|
76
|
+
rl.close();
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
rl.close();
|
|
81
|
+
// Detect monorepo — if we're inside a workspace with packages/kuratchi-js, use workspace:*
|
|
82
|
+
const monorepoRoot = detectMonorepo(targetDir);
|
|
83
|
+
const isMonorepo = !!monorepoRoot;
|
|
84
|
+
// Scaffold files
|
|
85
|
+
const opts = { name, ui, orm, auth, monorepo: isMonorepo, monorepoRoot, projectDir: targetDir };
|
|
86
|
+
scaffold(targetDir, opts);
|
|
87
|
+
// ── Post-scaffold setup ─────────────────────────────────────
|
|
88
|
+
console.log();
|
|
89
|
+
// 1. Install dependencies
|
|
90
|
+
step('Installing dependencies...');
|
|
91
|
+
run('bun install', isMonorepo ? monorepoRoot : targetDir);
|
|
92
|
+
// 2. Create D1 database (local only for now)
|
|
93
|
+
if (orm) {
|
|
94
|
+
step('Creating D1 database...');
|
|
95
|
+
try {
|
|
96
|
+
const output = run(`npx wrangler d1 create ${name}-db`, targetDir);
|
|
97
|
+
// Parse database_id from wrangler output
|
|
98
|
+
const idMatch = output.match(/database_id\s*=\s*"([^"]+)"/);
|
|
99
|
+
if (idMatch) {
|
|
100
|
+
const dbId = idMatch[1];
|
|
101
|
+
patchWranglerDbId(targetDir, dbId);
|
|
102
|
+
step(`D1 database created: ${dbId}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// D1 create may fail if not logged in — that's fine for local dev
|
|
107
|
+
step('D1 create skipped (not logged in to Cloudflare — local dev still works)');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 3. Generate worker types
|
|
111
|
+
step('Generating types...');
|
|
112
|
+
try {
|
|
113
|
+
run('npx wrangler types', targetDir);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// May fail without D1 — not critical
|
|
117
|
+
}
|
|
118
|
+
// 4. Build routes
|
|
119
|
+
step('Building routes...');
|
|
120
|
+
if (isMonorepo && monorepoRoot) {
|
|
121
|
+
const cliPath = path.join(monorepoRoot, 'packages', 'KuratchiJS', 'src', 'cli.ts');
|
|
122
|
+
if (fs.existsSync(cliPath)) {
|
|
123
|
+
run(`bun run --bun ${cliPath} build`, targetDir);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
run('npx kuratchi build', targetDir);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
run('npx kuratchi build', targetDir);
|
|
131
|
+
}
|
|
132
|
+
console.log();
|
|
133
|
+
console.log(` ✓ Project ready at ./${name}`);
|
|
134
|
+
console.log();
|
|
135
|
+
console.log(' Get started:');
|
|
136
|
+
console.log(` cd ${name}`);
|
|
137
|
+
console.log(' bun run dev');
|
|
138
|
+
console.log();
|
|
139
|
+
}
|
|
140
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
141
|
+
function step(msg) {
|
|
142
|
+
console.log(` â–¸ ${msg}`);
|
|
143
|
+
}
|
|
144
|
+
function run(cmd, cwd) {
|
|
145
|
+
try {
|
|
146
|
+
return execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8' });
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Return stderr/stdout even on failure for parsing
|
|
150
|
+
if (err.stdout)
|
|
151
|
+
return err.stdout;
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function detectMonorepo(targetDir) {
|
|
156
|
+
// Walk up from target to find a workspace root with packages/kuratchi-js
|
|
157
|
+
let dir = path.dirname(targetDir);
|
|
158
|
+
for (let i = 0; i < 5; i++) {
|
|
159
|
+
if (fs.existsSync(path.join(dir, 'packages', 'KuratchiJS', 'package.json'))) {
|
|
160
|
+
return dir;
|
|
161
|
+
}
|
|
162
|
+
const parent = path.dirname(dir);
|
|
163
|
+
if (parent === dir)
|
|
164
|
+
break;
|
|
165
|
+
dir = parent;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
function patchWranglerDbId(dir, dbId) {
|
|
170
|
+
const wranglerPath = path.join(dir, 'wrangler.jsonc');
|
|
171
|
+
let content = fs.readFileSync(wranglerPath, 'utf-8');
|
|
172
|
+
content = content.replace('"local-dev-only"', `"${dbId}"`);
|
|
173
|
+
fs.writeFileSync(wranglerPath, content, 'utf-8');
|
|
174
|
+
}
|
|
175
|
+
// ── Scaffold ────────────────────────────────────────────────
|
|
176
|
+
function scaffold(dir, opts) {
|
|
177
|
+
const { name, ui, orm, auth } = opts;
|
|
178
|
+
// Create directory structure
|
|
179
|
+
const dirs = [
|
|
180
|
+
'',
|
|
181
|
+
'src',
|
|
182
|
+
'src/routes',
|
|
183
|
+
];
|
|
184
|
+
if (orm) {
|
|
185
|
+
dirs.push('src/database', 'src/schemas');
|
|
186
|
+
}
|
|
187
|
+
if (auth) {
|
|
188
|
+
dirs.push('src/routes/auth', 'src/routes/auth/login', 'src/routes/auth/signup', 'src/routes/admin');
|
|
189
|
+
}
|
|
190
|
+
for (const d of dirs) {
|
|
191
|
+
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
192
|
+
}
|
|
193
|
+
// Generate files
|
|
194
|
+
write(dir, 'package.json', genPackageJson(opts));
|
|
195
|
+
write(dir, 'wrangler.jsonc', genWrangler(opts));
|
|
196
|
+
write(dir, 'kuratchi.config.ts', genConfig(opts));
|
|
197
|
+
write(dir, 'tsconfig.json', genTsConfig());
|
|
198
|
+
write(dir, '.gitignore', genGitIgnore());
|
|
199
|
+
write(dir, 'src/index.ts', genWorkerEntry(opts));
|
|
200
|
+
write(dir, 'src/routes/layout.html', genLayout(opts));
|
|
201
|
+
write(dir, 'src/routes/page.html', genLandingPage(opts));
|
|
202
|
+
if (orm) {
|
|
203
|
+
write(dir, 'src/schemas/app.ts', genSchema(opts));
|
|
204
|
+
write(dir, 'src/database/items.ts', genItemsCrud());
|
|
205
|
+
write(dir, 'src/routes/items/page.html', genItemsPage());
|
|
206
|
+
}
|
|
207
|
+
if (auth) {
|
|
208
|
+
write(dir, '.dev.vars', genDevVars());
|
|
209
|
+
write(dir, 'src/database/auth.ts', genAuthFunctions());
|
|
210
|
+
write(dir, 'src/database/admin.ts', genAdminLoader());
|
|
211
|
+
write(dir, 'src/routes/auth/login/page.html', genLoginPage());
|
|
212
|
+
write(dir, 'src/routes/auth/signup/page.html', genSignupPage());
|
|
213
|
+
write(dir, 'src/routes/admin/page.html', genAdminPage());
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function write(dir, filePath, content) {
|
|
217
|
+
const full = path.join(dir, filePath);
|
|
218
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
219
|
+
fs.writeFileSync(full, content, 'utf-8');
|
|
220
|
+
console.log(` + ${filePath}`);
|
|
221
|
+
}
|
|
222
|
+
// ── Template Generators ─────────────────────────────────────
|
|
223
|
+
function genPackageJson(opts) {
|
|
224
|
+
const ver = opts.monorepo ? 'workspace:*' : 'latest';
|
|
225
|
+
const deps = {
|
|
226
|
+
[FRAMEWORK_PACKAGE_NAME]: ver,
|
|
227
|
+
};
|
|
228
|
+
if (opts.ui)
|
|
229
|
+
deps['@kuratchi/ui'] = ver;
|
|
230
|
+
if (opts.orm)
|
|
231
|
+
deps['@kuratchi/orm'] = ver;
|
|
232
|
+
if (opts.auth)
|
|
233
|
+
deps['@kuratchi/auth'] = ver;
|
|
234
|
+
// In monorepo, scripts call the local CLI via bun with correct relative path
|
|
235
|
+
let devScript = 'kuratchi dev';
|
|
236
|
+
let buildScript = 'kuratchi build';
|
|
237
|
+
if (opts.monorepo && opts.monorepoRoot) {
|
|
238
|
+
const cliAbs = path.join(opts.monorepoRoot, 'packages', 'KuratchiJS', 'src', 'cli.ts');
|
|
239
|
+
const relCli = path.relative(opts.projectDir, cliAbs).replace(/\\/g, '/');
|
|
240
|
+
devScript = `bun run --bun ${relCli} dev`;
|
|
241
|
+
buildScript = `bun run --bun ${relCli} build`;
|
|
242
|
+
}
|
|
243
|
+
return JSON.stringify({
|
|
244
|
+
name: opts.monorepo ? `@kuratchi/${opts.name}` : opts.name,
|
|
245
|
+
version: '0.0.1',
|
|
246
|
+
private: true,
|
|
247
|
+
type: 'module',
|
|
248
|
+
scripts: {
|
|
249
|
+
dev: devScript,
|
|
250
|
+
build: buildScript,
|
|
251
|
+
},
|
|
252
|
+
dependencies: deps,
|
|
253
|
+
devDependencies: {
|
|
254
|
+
'@cloudflare/workers-types': '^4.20250214.0',
|
|
255
|
+
'wrangler': '^4.14.0',
|
|
256
|
+
},
|
|
257
|
+
}, null, 2) + '\n';
|
|
258
|
+
}
|
|
259
|
+
function genWrangler(opts) {
|
|
260
|
+
const config = {
|
|
261
|
+
name: opts.name,
|
|
262
|
+
main: 'src/index.ts',
|
|
263
|
+
compatibility_date: new Date().toISOString().split('T')[0],
|
|
264
|
+
compatibility_flags: ['nodejs_compat'],
|
|
265
|
+
};
|
|
266
|
+
if (opts.orm) {
|
|
267
|
+
config.d1_databases = [
|
|
268
|
+
{
|
|
269
|
+
binding: 'DB',
|
|
270
|
+
database_name: `${opts.name}-db`,
|
|
271
|
+
database_id: 'local-dev-only',
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
return JSON.stringify(config, null, 2) + '\n';
|
|
276
|
+
}
|
|
277
|
+
function genConfig(opts) {
|
|
278
|
+
const lines = [];
|
|
279
|
+
lines.push(`import { defineConfig } from '${FRAMEWORK_PACKAGE_NAME}';`);
|
|
280
|
+
if (opts.ui) {
|
|
281
|
+
lines.push(`import { kuratchiUiConfig } from '@kuratchi/ui/adapter';`);
|
|
282
|
+
}
|
|
283
|
+
if (opts.orm) {
|
|
284
|
+
lines.push(`import { kuratchiOrmConfig } from '@kuratchi/orm/adapter';`);
|
|
285
|
+
}
|
|
286
|
+
if (opts.auth) {
|
|
287
|
+
lines.push(`import { kuratchiAuthConfig } from '@kuratchi/auth/adapter';`);
|
|
288
|
+
}
|
|
289
|
+
if (opts.orm) {
|
|
290
|
+
lines.push(`import { appSchema } from './src/schemas/app';`);
|
|
291
|
+
}
|
|
292
|
+
lines.push('');
|
|
293
|
+
lines.push('export default defineConfig({');
|
|
294
|
+
// UI
|
|
295
|
+
if (opts.ui) {
|
|
296
|
+
lines.push(' ui: kuratchiUiConfig({');
|
|
297
|
+
lines.push(" theme: 'default',");
|
|
298
|
+
lines.push(' }),');
|
|
299
|
+
}
|
|
300
|
+
// ORM
|
|
301
|
+
if (opts.orm) {
|
|
302
|
+
lines.push(' orm: kuratchiOrmConfig({');
|
|
303
|
+
lines.push(' databases: {');
|
|
304
|
+
lines.push(' DB: { schema: appSchema },');
|
|
305
|
+
lines.push(' }');
|
|
306
|
+
lines.push(' }),');
|
|
307
|
+
}
|
|
308
|
+
// Auth
|
|
309
|
+
if (opts.auth) {
|
|
310
|
+
lines.push(' auth: kuratchiAuthConfig({');
|
|
311
|
+
lines.push(" cookieName: 'kuratchi_session',");
|
|
312
|
+
lines.push(' sessionEnabled: true,');
|
|
313
|
+
lines.push(' }),');
|
|
314
|
+
}
|
|
315
|
+
lines.push('});');
|
|
316
|
+
lines.push('');
|
|
317
|
+
return lines.join('\n');
|
|
318
|
+
}
|
|
319
|
+
function genTsConfig() {
|
|
320
|
+
return JSON.stringify({
|
|
321
|
+
compilerOptions: {
|
|
322
|
+
target: 'ESNext',
|
|
323
|
+
module: 'ESNext',
|
|
324
|
+
moduleResolution: 'bundler',
|
|
325
|
+
strict: true,
|
|
326
|
+
esModuleInterop: true,
|
|
327
|
+
skipLibCheck: true,
|
|
328
|
+
forceConsistentCasingInFileNames: true,
|
|
329
|
+
types: ['./worker-configuration.d.ts'],
|
|
330
|
+
},
|
|
331
|
+
include: ['src/**/*.ts', 'kuratchi.config.ts'],
|
|
332
|
+
exclude: ['node_modules'],
|
|
333
|
+
}, null, 2) + '\n';
|
|
334
|
+
}
|
|
335
|
+
function genGitIgnore() {
|
|
336
|
+
return `node_modules/
|
|
337
|
+
.wrangler/
|
|
338
|
+
.dev.vars
|
|
339
|
+
.kuratchi/
|
|
340
|
+
worker-configuration.d.ts
|
|
341
|
+
dist/
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
function genWorkerEntry(opts) {
|
|
345
|
+
return `export { default } from "../.kuratchi/routes.js";\n`;
|
|
346
|
+
}
|
|
347
|
+
function genLayout(opts) {
|
|
348
|
+
const navLinks = [' <a href="/">Home</a>'];
|
|
349
|
+
if (opts.orm)
|
|
350
|
+
navLinks.push(' <a href="/items">Items</a>');
|
|
351
|
+
if (opts.auth)
|
|
352
|
+
navLinks.push(' <a href="/admin">Admin</a>');
|
|
353
|
+
return `<!DOCTYPE html>
|
|
354
|
+
<html lang="en" class="dark">
|
|
355
|
+
<head>
|
|
356
|
+
<meta charset="utf-8" />
|
|
357
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
358
|
+
<title>${opts.name} — KuratchiJS</title>
|
|
359
|
+
</head>
|
|
360
|
+
<body>
|
|
361
|
+
<header>
|
|
362
|
+
<a href="/">âš¡ ${opts.name}</a>
|
|
363
|
+
<nav>
|
|
364
|
+
${navLinks.join('\n')}
|
|
365
|
+
</nav>
|
|
366
|
+
</header>
|
|
367
|
+
<main>
|
|
368
|
+
<slot></slot>
|
|
369
|
+
</main>
|
|
370
|
+
</body>
|
|
371
|
+
</html>
|
|
372
|
+
`;
|
|
373
|
+
}
|
|
374
|
+
function genLandingPage(opts) {
|
|
375
|
+
const imports = [];
|
|
376
|
+
const cards = [];
|
|
377
|
+
if (opts.ui) {
|
|
378
|
+
imports.push(" import Badge from '@kuratchi/ui/badge.html';");
|
|
379
|
+
imports.push(" import Card from '@kuratchi/ui/card.html';");
|
|
380
|
+
imports.push(" import DataList from '@kuratchi/ui/data-list.html';");
|
|
381
|
+
imports.push(" import DataItem from '@kuratchi/ui/data-item.html';");
|
|
382
|
+
}
|
|
383
|
+
let body = '';
|
|
384
|
+
body += '<header>\n';
|
|
385
|
+
body += ' <div>\n';
|
|
386
|
+
body += ` <h1>${opts.name}</h1>\n`;
|
|
387
|
+
body += ' <p>Built with KuratchiJS — a Cloudflare Workers-native framework</p>\n';
|
|
388
|
+
body += ' </div>\n';
|
|
389
|
+
body += '</header>\n\n';
|
|
390
|
+
if (opts.orm) {
|
|
391
|
+
body += '<div>\n';
|
|
392
|
+
body += ' <a href="/items">\n';
|
|
393
|
+
if (opts.ui)
|
|
394
|
+
body += ' <Badge variant="success">D1 Database</Badge>\n';
|
|
395
|
+
body += ' <h2>Items</h2>\n';
|
|
396
|
+
body += ' <p>Full CRUD backed by Cloudflare D1. Schema auto-migrated on first request.</p>\n';
|
|
397
|
+
body += ' </a>\n';
|
|
398
|
+
if (opts.auth) {
|
|
399
|
+
body += ' <a href="/admin">\n';
|
|
400
|
+
if (opts.ui)
|
|
401
|
+
body += ' <Badge variant="warning">Protected</Badge>\n';
|
|
402
|
+
body += ' <h2>Admin</h2>\n';
|
|
403
|
+
body += ' <p>Protected dashboard — sign in with credentials to access.</p>\n';
|
|
404
|
+
body += ' </a>\n';
|
|
405
|
+
}
|
|
406
|
+
body += '</div>\n';
|
|
407
|
+
}
|
|
408
|
+
if (opts.ui) {
|
|
409
|
+
body += '\n<Card title="Stack">\n';
|
|
410
|
+
body += ' <DataList>\n';
|
|
411
|
+
body += ` <DataItem label="Framework" value="${FRAMEWORK_PACKAGE_NAME}" />\n`;
|
|
412
|
+
if (opts.orm)
|
|
413
|
+
body += ' <DataItem label="ORM" value="@kuratchi/orm" />\n';
|
|
414
|
+
if (opts.auth)
|
|
415
|
+
body += ' <DataItem label="Auth" value="@kuratchi/auth" />\n';
|
|
416
|
+
body += ' <DataItem label="UI" value="@kuratchi/ui" />\n';
|
|
417
|
+
body += ' </DataList>\n';
|
|
418
|
+
body += '</Card>\n';
|
|
419
|
+
}
|
|
420
|
+
if (imports.length > 0) {
|
|
421
|
+
return `<script>\n${imports.join('\n')}\n</script>\n\n${body}`;
|
|
422
|
+
}
|
|
423
|
+
return body;
|
|
424
|
+
}
|
|
425
|
+
// ── ORM Templates ───────────────────────────────────────────
|
|
426
|
+
function genSchema(opts) {
|
|
427
|
+
const tables = [];
|
|
428
|
+
tables.push(` items: {
|
|
429
|
+
id: 'integer primary key',
|
|
430
|
+
title: 'text not null',
|
|
431
|
+
done: 'integer not null default 0',
|
|
432
|
+
created_at: 'text not null default now',
|
|
433
|
+
},`);
|
|
434
|
+
if (opts.auth) {
|
|
435
|
+
tables.push(` users: {
|
|
436
|
+
id: 'integer primary key',
|
|
437
|
+
email: 'text not null unique',
|
|
438
|
+
name: 'text',
|
|
439
|
+
password_hash: 'text not null',
|
|
440
|
+
created_at: 'text not null default now',
|
|
441
|
+
updated_at: 'text not null default now',
|
|
442
|
+
},
|
|
443
|
+
session: {
|
|
444
|
+
id: 'integer primary key',
|
|
445
|
+
sessionToken: 'text not null unique',
|
|
446
|
+
userId: 'integer not null',
|
|
447
|
+
expires: 'integer not null',
|
|
448
|
+
created_at: 'text not null default now',
|
|
449
|
+
updated_at: 'text not null default now',
|
|
450
|
+
deleted_at: 'text',
|
|
451
|
+
},`);
|
|
452
|
+
}
|
|
453
|
+
const version = opts.auth ? 1 : 1;
|
|
454
|
+
let types = `
|
|
455
|
+
export interface Item {
|
|
456
|
+
id: number;
|
|
457
|
+
title: string;
|
|
458
|
+
done: number;
|
|
459
|
+
created_at: string;
|
|
460
|
+
}`;
|
|
461
|
+
if (opts.auth) {
|
|
462
|
+
types += `
|
|
463
|
+
|
|
464
|
+
export interface User {
|
|
465
|
+
id: number;
|
|
466
|
+
email: string;
|
|
467
|
+
name: string | null;
|
|
468
|
+
password_hash: string;
|
|
469
|
+
created_at: string;
|
|
470
|
+
updated_at: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface Session {
|
|
474
|
+
id: number;
|
|
475
|
+
sessionToken: string;
|
|
476
|
+
userId: number;
|
|
477
|
+
expires: number;
|
|
478
|
+
created_at: string;
|
|
479
|
+
updated_at: string;
|
|
480
|
+
deleted_at: string | null;
|
|
481
|
+
}`;
|
|
482
|
+
}
|
|
483
|
+
return `import type { SchemaDsl } from '@kuratchi/orm';
|
|
484
|
+
|
|
485
|
+
export const appSchema: SchemaDsl = {
|
|
486
|
+
name: '${opts.name}',
|
|
487
|
+
version: ${version},
|
|
488
|
+
tables: {
|
|
489
|
+
${tables.join('\n')}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
${types}
|
|
493
|
+
`;
|
|
494
|
+
}
|
|
495
|
+
function genItemsCrud() {
|
|
496
|
+
return `import { env } from 'cloudflare:workers';
|
|
497
|
+
import { kuratchiORM } from '@kuratchi/orm';
|
|
498
|
+
import { getLocals } from '${FRAMEWORK_PACKAGE_NAME}';
|
|
499
|
+
import type { Item } from './schemas/app';
|
|
500
|
+
|
|
501
|
+
const db = kuratchiORM(() => (env as any).DB);
|
|
502
|
+
|
|
503
|
+
export async function getItems() {
|
|
504
|
+
const result = await db.items.orderBy({ created_at: 'desc' }).many();
|
|
505
|
+
return (result.data ?? []) as Item[];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function addItem(formData: FormData): Promise<void> {
|
|
509
|
+
const title = (formData.get('title') as string)?.trim();
|
|
510
|
+
if (!title) throw new Error('Title is required');
|
|
511
|
+
await db.items.insert({ title });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function deleteItem(id: number): Promise<void> {
|
|
515
|
+
await db.items.delete({ id });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export async function toggleItem(id: number): Promise<void> {
|
|
519
|
+
const result = await db.items.where({ id }).first();
|
|
520
|
+
const item = result.data as Item | null;
|
|
521
|
+
if (item) {
|
|
522
|
+
await db.items.where({ id }).update({ done: item.done ? 0 : 1 });
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
function genItemsPage() {
|
|
528
|
+
return `<script>
|
|
529
|
+
import { getItems, addItem, deleteItem, toggleItem } from '$database/items';
|
|
530
|
+
import EmptyState from '@kuratchi/ui/empty-state.html';
|
|
531
|
+
|
|
532
|
+
const items = await getItems();
|
|
533
|
+
</script>
|
|
534
|
+
|
|
535
|
+
<header>
|
|
536
|
+
<div>
|
|
537
|
+
<h1>Items</h1>
|
|
538
|
+
<p>Full CRUD backed by Cloudflare D1</p>
|
|
539
|
+
</div>
|
|
540
|
+
</header>
|
|
541
|
+
|
|
542
|
+
<form action={addItem} method="POST">
|
|
543
|
+
<input type="text" name="title" placeholder="What needs to be done?" required />
|
|
544
|
+
<button type="submit">Add</button>
|
|
545
|
+
</form>
|
|
546
|
+
|
|
547
|
+
if (items.length === 0) {
|
|
548
|
+
<EmptyState message="No items yet — add one above" />
|
|
549
|
+
} else {
|
|
550
|
+
<section>
|
|
551
|
+
for (const item of items) {
|
|
552
|
+
<article>
|
|
553
|
+
<span style={item.done ? 'text-decoration: line-through; opacity: 0.5' : ''}>{item.title}</span>
|
|
554
|
+
<div>
|
|
555
|
+
<button data-action="toggleItem" data-args={JSON.stringify([item.id])}>
|
|
556
|
+
{item.done ? '↩' : '✓'}
|
|
557
|
+
</button>
|
|
558
|
+
<button data-action="deleteItem" data-args={JSON.stringify([item.id])}>✕</button>
|
|
559
|
+
</div>
|
|
560
|
+
</article>
|
|
561
|
+
}
|
|
562
|
+
</section>
|
|
563
|
+
}
|
|
564
|
+
`;
|
|
565
|
+
}
|
|
566
|
+
// ── Auth Templates ──────────────────────────────────────────
|
|
567
|
+
function genDevVars() {
|
|
568
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
569
|
+
return `AUTH_SECRET=${secret}\n`;
|
|
570
|
+
}
|
|
571
|
+
function genAuthFunctions() {
|
|
572
|
+
return `import { env } from 'cloudflare:workers';
|
|
573
|
+
import { kuratchiORM } from '@kuratchi/orm';
|
|
574
|
+
import {
|
|
575
|
+
hashPassword,
|
|
576
|
+
comparePassword,
|
|
577
|
+
generateSessionToken,
|
|
578
|
+
hashToken,
|
|
579
|
+
buildSessionCookie,
|
|
580
|
+
parseSessionCookie,
|
|
581
|
+
} from '@kuratchi/auth';
|
|
582
|
+
import { getAuth } from '@kuratchi/auth';
|
|
583
|
+
import { getLocals } from '${FRAMEWORK_PACKAGE_NAME}';
|
|
584
|
+
import type { User } from '../schemas/app';
|
|
585
|
+
|
|
586
|
+
const db = kuratchiORM(() => (env as any).DB);
|
|
587
|
+
|
|
588
|
+
// ── Sign Up ─────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
export async function signUp(formData: FormData): Promise<void> {
|
|
591
|
+
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
|
592
|
+
const password = formData.get('password') as string;
|
|
593
|
+
const name = (formData.get('name') as string)?.trim() || null;
|
|
594
|
+
|
|
595
|
+
if (!email || !password) {
|
|
596
|
+
throw new Error('Email and password are required');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (password.length < 8) {
|
|
600
|
+
throw new Error('Password must be at least 8 characters');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Check if user already exists
|
|
604
|
+
const existing = await db.users.where({ email }).first();
|
|
605
|
+
if (existing.data && existing.data.id) {
|
|
606
|
+
throw new Error('An account with this email already exists');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Hash password with AUTH_SECRET as pepper
|
|
610
|
+
const secret = (env as any).AUTH_SECRET || '';
|
|
611
|
+
const hashedPassword = await hashPassword(password, undefined, secret);
|
|
612
|
+
|
|
613
|
+
// Create user
|
|
614
|
+
const insertResult = await db.users.insert({
|
|
615
|
+
email,
|
|
616
|
+
name,
|
|
617
|
+
password_hash: hashedPassword,
|
|
618
|
+
});
|
|
619
|
+
if (!insertResult.success) {
|
|
620
|
+
throw new Error('Failed to create account');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Redirect to login after successful signup
|
|
624
|
+
getLocals().__redirectTo = '/auth/login';
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ── Sign In ─────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
export async function signIn(formData: FormData): Promise<void> {
|
|
630
|
+
const email = (formData.get('email') as string)?.trim().toLowerCase();
|
|
631
|
+
const password = formData.get('password') as string;
|
|
632
|
+
|
|
633
|
+
if (!email || !password) {
|
|
634
|
+
throw new Error('Email and password are required');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Look up user
|
|
638
|
+
const result = await db.users.where({ email }).first();
|
|
639
|
+
const user = (result.data ?? null) as User | null;
|
|
640
|
+
|
|
641
|
+
if (!user || !user.password_hash) {
|
|
642
|
+
throw new Error('Invalid email or password');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Verify password
|
|
646
|
+
const secret = (env as any).AUTH_SECRET || '';
|
|
647
|
+
const isValid = await comparePassword(password, user.password_hash, secret);
|
|
648
|
+
|
|
649
|
+
if (!isValid) {
|
|
650
|
+
throw new Error('Invalid email or password');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Create session token
|
|
654
|
+
const sessionToken = generateSessionToken();
|
|
655
|
+
const sessionTokenHash = await hashToken(sessionToken);
|
|
656
|
+
|
|
657
|
+
const now = new Date();
|
|
658
|
+
const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
659
|
+
|
|
660
|
+
// Store session in DB
|
|
661
|
+
await db.session.insert({
|
|
662
|
+
sessionToken: sessionTokenHash,
|
|
663
|
+
userId: user.id,
|
|
664
|
+
expires: expires.getTime(),
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Build encrypted session cookie
|
|
668
|
+
const sessionCookie = await buildSessionCookie(
|
|
669
|
+
secret,
|
|
670
|
+
'default',
|
|
671
|
+
sessionTokenHash
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
// Set cookie on response
|
|
675
|
+
const auth = getAuth();
|
|
676
|
+
const setCookieHeader = auth.buildSetCookie('kuratchi_session', sessionCookie, {
|
|
677
|
+
expires,
|
|
678
|
+
httpOnly: true,
|
|
679
|
+
secure: true,
|
|
680
|
+
sameSite: 'lax',
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const locals = auth.getLocals();
|
|
684
|
+
if (!locals.__setCookieHeaders) locals.__setCookieHeaders = [];
|
|
685
|
+
locals.__setCookieHeaders.push(setCookieHeader);
|
|
686
|
+
|
|
687
|
+
// Redirect to admin after successful login
|
|
688
|
+
locals.__redirectTo = '/admin';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── Sign Out ────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
export async function signOut(formData: FormData): Promise<void> {
|
|
694
|
+
const auth = getAuth();
|
|
695
|
+
const sessionCookie = auth.getSessionCookie();
|
|
696
|
+
|
|
697
|
+
if (sessionCookie) {
|
|
698
|
+
const secret = (env as any).AUTH_SECRET || '';
|
|
699
|
+
const parsed = await parseSessionCookie(secret, sessionCookie);
|
|
700
|
+
if (parsed) {
|
|
701
|
+
await db.session.delete({ sessionToken: parsed.tokenHash });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Clear cookie
|
|
706
|
+
const clearHeader = auth.buildClearCookie('kuratchi_session');
|
|
707
|
+
const locals = auth.getLocals();
|
|
708
|
+
if (!locals.__setCookieHeaders) locals.__setCookieHeaders = [];
|
|
709
|
+
locals.__setCookieHeaders.push(clearHeader);
|
|
710
|
+
|
|
711
|
+
// Redirect to login after sign out
|
|
712
|
+
locals.__redirectTo = '/auth/login';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// ── Get Current User ────────────────────────────────────────
|
|
716
|
+
|
|
717
|
+
export async function getCurrentUser() {
|
|
718
|
+
const auth = getAuth();
|
|
719
|
+
const sessionCookie = auth.getSessionCookie();
|
|
720
|
+
|
|
721
|
+
if (!sessionCookie) return null;
|
|
722
|
+
|
|
723
|
+
const secret = (env as any).AUTH_SECRET || '';
|
|
724
|
+
const parsed = await parseSessionCookie(secret, sessionCookie);
|
|
725
|
+
if (!parsed) return null;
|
|
726
|
+
|
|
727
|
+
// Look up session in DB
|
|
728
|
+
const sessionResult = await db.session
|
|
729
|
+
.where({ sessionToken: parsed.tokenHash })
|
|
730
|
+
.first();
|
|
731
|
+
const session = (sessionResult.data ?? null) as any;
|
|
732
|
+
|
|
733
|
+
if (!session) return null;
|
|
734
|
+
|
|
735
|
+
// Check expiry
|
|
736
|
+
if (session.expires < Date.now()) {
|
|
737
|
+
await db.session.delete({ sessionToken: parsed.tokenHash });
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Look up user
|
|
742
|
+
const userResult = await db.users.where({ id: session.userId }).first();
|
|
743
|
+
const user = (userResult.data ?? null) as User | null;
|
|
744
|
+
|
|
745
|
+
if (!user) return null;
|
|
746
|
+
|
|
747
|
+
// Return safe user (no password_hash)
|
|
748
|
+
const { password_hash, ...safeUser } = user;
|
|
749
|
+
return safeUser;
|
|
750
|
+
}
|
|
751
|
+
`;
|
|
752
|
+
}
|
|
753
|
+
function genAdminLoader() {
|
|
754
|
+
return `import { getCurrentUser } from './auth';
|
|
755
|
+
|
|
756
|
+
export { signOut } from './auth';
|
|
757
|
+
|
|
758
|
+
export async function getAdminData() {
|
|
759
|
+
const user = await getCurrentUser();
|
|
760
|
+
return {
|
|
761
|
+
isAuthenticated: !!user,
|
|
762
|
+
user,
|
|
763
|
+
timestamp: new Date().toISOString(),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
`;
|
|
767
|
+
}
|
|
768
|
+
function genLoginPage() {
|
|
769
|
+
return `<script>
|
|
770
|
+
import { signIn } from '$database/auth';
|
|
771
|
+
import AuthCard from '@kuratchi/ui/auth-card.html';
|
|
772
|
+
</script>
|
|
773
|
+
|
|
774
|
+
<AuthCard
|
|
775
|
+
title="Sign In"
|
|
776
|
+
subtitle="Welcome back — sign in to your account"
|
|
777
|
+
footerText="Don't have an account?"
|
|
778
|
+
footerLink="Sign up"
|
|
779
|
+
footerHref="/auth/signup"
|
|
780
|
+
error={__error}
|
|
781
|
+
>
|
|
782
|
+
<form action={signIn} method="POST" class="kui-auth-form">
|
|
783
|
+
<div class="kui-field">
|
|
784
|
+
<label for="email">Email</label>
|
|
785
|
+
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" />
|
|
786
|
+
</div>
|
|
787
|
+
<div class="kui-field">
|
|
788
|
+
<label for="password">Password</label>
|
|
789
|
+
<input type="password" id="password" name="password" placeholder="••••••••" required autocomplete="current-password" minlength="8" />
|
|
790
|
+
</div>
|
|
791
|
+
<button type="submit" class="kui-button kui-button--primary kui-button--block kui-auth-submit">Sign In</button>
|
|
792
|
+
</form>
|
|
793
|
+
</AuthCard>
|
|
794
|
+
`;
|
|
795
|
+
}
|
|
796
|
+
function genSignupPage() {
|
|
797
|
+
return `<script>
|
|
798
|
+
import { signUp } from '$database/auth';
|
|
799
|
+
import AuthCard from '@kuratchi/ui/auth-card.html';
|
|
800
|
+
</script>
|
|
801
|
+
|
|
802
|
+
<AuthCard
|
|
803
|
+
title="Create Account"
|
|
804
|
+
subtitle="Sign up to get started"
|
|
805
|
+
footerText="Already have an account?"
|
|
806
|
+
footerLink="Sign in"
|
|
807
|
+
footerHref="/auth/login"
|
|
808
|
+
error={__error}
|
|
809
|
+
>
|
|
810
|
+
<form action={signUp} method="POST" class="kui-auth-form">
|
|
811
|
+
<div class="kui-field">
|
|
812
|
+
<label for="name">Name</label>
|
|
813
|
+
<input type="text" id="name" name="name" placeholder="Your name" autocomplete="name" />
|
|
814
|
+
</div>
|
|
815
|
+
<div class="kui-field">
|
|
816
|
+
<label for="email">Email</label>
|
|
817
|
+
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" />
|
|
818
|
+
</div>
|
|
819
|
+
<div class="kui-field">
|
|
820
|
+
<label for="password">Password</label>
|
|
821
|
+
<input type="password" id="password" name="password" placeholder="••••••••" required autocomplete="new-password" minlength="8" />
|
|
822
|
+
</div>
|
|
823
|
+
<button type="submit" class="kui-button kui-button--primary kui-button--block kui-auth-submit">Create Account</button>
|
|
824
|
+
</form>
|
|
825
|
+
</AuthCard>
|
|
826
|
+
`;
|
|
827
|
+
}
|
|
828
|
+
function genAdminPage() {
|
|
829
|
+
return `<script>
|
|
830
|
+
import { getAdminData, signOut } from '$database/admin';
|
|
831
|
+
import Badge from '@kuratchi/ui/badge.html';
|
|
832
|
+
import Card from '@kuratchi/ui/card.html';
|
|
833
|
+
import DataList from '@kuratchi/ui/data-list.html';
|
|
834
|
+
import DataItem from '@kuratchi/ui/data-item.html';
|
|
835
|
+
|
|
836
|
+
const admin = await getAdminData();
|
|
837
|
+
</script>
|
|
838
|
+
|
|
839
|
+
if (!admin.isAuthenticated) {
|
|
840
|
+
<head>
|
|
841
|
+
<meta http-equiv="refresh" content="0;url=/auth/login" />
|
|
842
|
+
</head>
|
|
843
|
+
<p>Redirecting to login...</p>
|
|
844
|
+
} else {
|
|
845
|
+
<header>
|
|
846
|
+
<div>
|
|
847
|
+
<h1>Admin Dashboard</h1>
|
|
848
|
+
<p>Welcome back, {admin.user.name || admin.user.email}</p>
|
|
849
|
+
</div>
|
|
850
|
+
<Badge variant="success">Authenticated</Badge>
|
|
851
|
+
</header>
|
|
852
|
+
|
|
853
|
+
<Card title="User Info">
|
|
854
|
+
<DataList>
|
|
855
|
+
<DataItem label="Email" value={admin.user.email} />
|
|
856
|
+
<DataItem label="Name" value={admin.user.name || '—'} />
|
|
857
|
+
<DataItem label="User ID" value={String(admin.user.id)} />
|
|
858
|
+
<DataItem label="Created" value={admin.user.created_at} />
|
|
859
|
+
</DataList>
|
|
860
|
+
</Card>
|
|
861
|
+
|
|
862
|
+
<Card title="Session">
|
|
863
|
+
<DataList>
|
|
864
|
+
<DataItem label="Timestamp" value={admin.timestamp} />
|
|
865
|
+
</DataList>
|
|
866
|
+
</Card>
|
|
867
|
+
|
|
868
|
+
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
|
|
869
|
+
<a href="/" class="kui-button kui-button--outline">← Back to Home</a>
|
|
870
|
+
<form action={signOut} method="POST" style="margin: 0;">
|
|
871
|
+
<button type="submit" class="kui-button kui-button--danger">Sign Out</button>
|
|
872
|
+
</form>
|
|
873
|
+
</div>
|
|
874
|
+
}
|
|
875
|
+
`;
|
|
876
|
+
}
|