@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/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">&larr; 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
+ }