@kozojs/cli 0.1.5 → 0.1.7
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/lib/index.js +1632 -137
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -35,11 +35,23 @@ var import_execa = require("execa");
|
|
|
35
35
|
var import_fs_extra = __toESM(require("fs-extra"));
|
|
36
36
|
var import_node_path = __toESM(require("path"));
|
|
37
37
|
async function scaffoldProject(options) {
|
|
38
|
-
const { projectName, database, packageSource, template } = options;
|
|
38
|
+
const { projectName, runtime, database, packageSource, template, frontend, extras } = options;
|
|
39
39
|
const projectDir = import_node_path.default.resolve(process.cwd(), projectName);
|
|
40
|
-
const kozoCoreDep = packageSource === "local" ? "workspace:*" : "^0.
|
|
40
|
+
const kozoCoreDep = packageSource === "local" ? "workspace:*" : "^0.2.0";
|
|
41
|
+
if (frontend !== "none") {
|
|
42
|
+
await scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, frontend, extras, template);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
41
45
|
if (template === "complete") {
|
|
42
|
-
await scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep);
|
|
46
|
+
await scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep, runtime);
|
|
47
|
+
if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
|
|
48
|
+
if (extras.includes("github-actions")) await createGitHubActions(projectDir);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (template === "api-only") {
|
|
52
|
+
await scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, runtime);
|
|
53
|
+
if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
|
|
54
|
+
if (extras.includes("github-actions")) await createGitHubActions(projectDir);
|
|
43
55
|
return;
|
|
44
56
|
}
|
|
45
57
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "routes"));
|
|
@@ -399,7 +411,7 @@ export default async ({ body, services: { db } }: HandlerContext<Body>) => {
|
|
|
399
411
|
`;
|
|
400
412
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "routes", "users", "post.ts"), postUsersRoute);
|
|
401
413
|
}
|
|
402
|
-
async function scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep) {
|
|
414
|
+
async function scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep, runtime) {
|
|
403
415
|
await import_fs_extra.default.ensureDir(projectDir);
|
|
404
416
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src"));
|
|
405
417
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "schemas"));
|
|
@@ -407,6 +419,7 @@ async function scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep) {
|
|
|
407
419
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "routes", "auth"));
|
|
408
420
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "routes", "users"));
|
|
409
421
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "routes", "posts"));
|
|
422
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "middleware"));
|
|
410
423
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "utils"));
|
|
411
424
|
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "data"));
|
|
412
425
|
const packageJson = {
|
|
@@ -421,6 +434,7 @@ async function scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep) {
|
|
|
421
434
|
},
|
|
422
435
|
dependencies: {
|
|
423
436
|
"@kozojs/core": kozoCoreDep,
|
|
437
|
+
"@kozojs/auth": kozoCoreDep,
|
|
424
438
|
"@hono/node-server": "^1.13.0",
|
|
425
439
|
hono: "^4.6.0",
|
|
426
440
|
zod: "^3.23.0"
|
|
@@ -460,38 +474,83 @@ dist/
|
|
|
460
474
|
const envExample = `# Server
|
|
461
475
|
PORT=3000
|
|
462
476
|
NODE_ENV=development
|
|
477
|
+
|
|
478
|
+
# JWT Authentication
|
|
479
|
+
JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars
|
|
480
|
+
|
|
481
|
+
# CORS
|
|
482
|
+
CORS_ORIGIN=http://localhost:5173
|
|
483
|
+
|
|
484
|
+
# Rate Limiting (requests per window)
|
|
485
|
+
RATE_LIMIT_MAX=100
|
|
486
|
+
RATE_LIMIT_WINDOW=60000
|
|
463
487
|
`;
|
|
464
488
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".env.example"), envExample);
|
|
465
|
-
|
|
489
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".env"), envExample);
|
|
490
|
+
const indexTs = `import { createKozo, cors, logger, rateLimit } from '@kozojs/core';
|
|
491
|
+
import { authenticateJWT } from '@kozojs/auth';
|
|
466
492
|
import { registerAuthRoutes } from './routes/auth/index.js';
|
|
467
493
|
import { registerUserRoutes } from './routes/users/index.js';
|
|
468
494
|
import { registerPostRoutes } from './routes/posts/index.js';
|
|
469
495
|
import { registerHealthRoute } from './routes/health.js';
|
|
470
496
|
import { registerStatsRoute } from './routes/stats.js';
|
|
471
497
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
498
|
+
// \u2500\u2500\u2500 Config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
499
|
+
const PORT = Number(process.env.PORT) || 3000;
|
|
500
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-to-a-random-secret';
|
|
501
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN || '*';
|
|
502
|
+
const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 100;
|
|
503
|
+
const RATE_LIMIT_WINDOW = Number(process.env.RATE_LIMIT_WINDOW) || 60_000;
|
|
504
|
+
|
|
505
|
+
// \u2500\u2500\u2500 App \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
506
|
+
const app = createKozo({ port: PORT });
|
|
507
|
+
|
|
508
|
+
// \u2500\u2500\u2500 Middleware (Hono layer) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
509
|
+
app.getApp().use('*', logger());
|
|
510
|
+
app.getApp().use('*', cors({ origin: CORS_ORIGIN }));
|
|
511
|
+
app.getApp().use('/api/*', rateLimit({ max: RATE_LIMIT_MAX, windowMs: RATE_LIMIT_WINDOW }));
|
|
512
|
+
app.getApp().use('/api/*', authenticateJWT(JWT_SECRET, {
|
|
513
|
+
prefix: '/api',
|
|
514
|
+
}));
|
|
515
|
+
|
|
516
|
+
// \u2500\u2500\u2500 Routes (native compiled \u2014 zero overhead) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
477
517
|
registerHealthRoute(app);
|
|
478
518
|
registerAuthRoutes(app);
|
|
479
519
|
registerUserRoutes(app);
|
|
480
520
|
registerPostRoutes(app);
|
|
481
521
|
registerStatsRoute(app);
|
|
482
522
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
523
|
+
// \u2500\u2500\u2500 Graceful Shutdown \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
524
|
+
const shutdown = app.getShutdownManager();
|
|
525
|
+
|
|
526
|
+
process.on('SIGTERM', () => shutdown.shutdown());
|
|
527
|
+
process.on('SIGINT', () => shutdown.shutdown());
|
|
528
|
+
|
|
529
|
+
// \u2500\u2500\u2500 Start \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
530
|
+
console.log('');
|
|
531
|
+
console.log('\u{1F525} Kozo server starting\u2026');
|
|
532
|
+
console.log('\u26A1 Powered by uWebSockets.js (native per-route C++ matching)');
|
|
486
533
|
console.log('');
|
|
487
|
-
console.log('\u{1F4DA} Try these endpoints:');
|
|
488
|
-
console.log(' GET /health');
|
|
489
|
-
console.log(' GET /users');
|
|
490
|
-
console.log(' POST /auth/login');
|
|
491
|
-
console.log(' GET /posts?published=true&page=1&limit=10');
|
|
492
|
-
console.log(' GET /stats');
|
|
493
534
|
|
|
494
|
-
app.
|
|
535
|
+
const { port } = await app.nativeListen(PORT);
|
|
536
|
+
|
|
537
|
+
console.log(\`\u{1F680} Listening on http://localhost:\${port}\`);
|
|
538
|
+
console.log('');
|
|
539
|
+
console.log('\u{1F4DA} Endpoints:');
|
|
540
|
+
console.log(' GET /health Health check');
|
|
541
|
+
console.log(' POST /auth/login Login (returns JWT)');
|
|
542
|
+
console.log(' GET /auth/me Current user (requires JWT)');
|
|
543
|
+
console.log(' GET /users List users (paginated)');
|
|
544
|
+
console.log(' POST /users Create user');
|
|
545
|
+
console.log(' GET /users/:id Get user');
|
|
546
|
+
console.log(' PUT /users/:id Update user');
|
|
547
|
+
console.log(' DEL /users/:id Delete user');
|
|
548
|
+
console.log(' GET /posts List posts (filterable)');
|
|
549
|
+
console.log(' POST /posts Create post');
|
|
550
|
+
console.log(' GET /stats Server stats');
|
|
551
|
+
console.log('');
|
|
552
|
+
console.log('\u{1F512} Middleware: CORS \xB7 Rate limit \xB7 JWT \xB7 Logger');
|
|
553
|
+
console.log('\u{1F6E1}\uFE0F Graceful shutdown enabled (SIGTERM / SIGINT)');
|
|
495
554
|
`;
|
|
496
555
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "index.ts"), indexTs);
|
|
497
556
|
await createCompleteSchemas(projectDir);
|
|
@@ -500,22 +559,29 @@ app.listen();
|
|
|
500
559
|
await createCompleteRoutes(projectDir);
|
|
501
560
|
const readme = `# ${projectName}
|
|
502
561
|
|
|
503
|
-
Built with \u{1F525} **Kozo Framework**
|
|
562
|
+
Built with \u{1F525} **Kozo Framework** \u2014 Production-ready server template
|
|
504
563
|
|
|
505
564
|
## Features
|
|
506
565
|
|
|
507
566
|
\u2728 **Complete API Implementation**
|
|
508
|
-
- \u2705 Authentication (login,
|
|
567
|
+
- \u2705 JWT Authentication (login, token-protected routes)
|
|
509
568
|
- \u2705 User CRUD (Create, Read, Update, Delete)
|
|
510
569
|
- \u2705 Posts with filtering and pagination
|
|
511
570
|
- \u2705 Statistics endpoint
|
|
512
571
|
- \u2705 Health check
|
|
513
572
|
|
|
514
|
-
\u26A1 **Performance
|
|
515
|
-
-
|
|
516
|
-
-
|
|
517
|
-
-
|
|
518
|
-
-
|
|
573
|
+
\u26A1 **Maximum Performance**
|
|
574
|
+
- uWebSockets.js transport with native per-route C++ matching (zero JS routing overhead)
|
|
575
|
+
- Compiled handlers write directly to uWS response via cork() \u2014 zero shim objects
|
|
576
|
+
- Pre-compiled Zod schemas (Ajv fast-path)
|
|
577
|
+
- fast-json-stringify serialization
|
|
578
|
+
|
|
579
|
+
\u{1F512} **Production Middleware**
|
|
580
|
+
- CORS with configurable origins
|
|
581
|
+
- Rate limiting (per-IP, configurable window)
|
|
582
|
+
- JWT verification on \\\`/api/*\\\` routes
|
|
583
|
+
- Request logger with timing
|
|
584
|
+
- Graceful shutdown (SIGTERM / SIGINT)
|
|
519
585
|
|
|
520
586
|
\u{1F3AF} **Type-Safe**
|
|
521
587
|
- Full TypeScript inference
|
|
@@ -524,91 +590,82 @@ Built with \u{1F525} **Kozo Framework** - Production-ready server template
|
|
|
524
590
|
|
|
525
591
|
## Quick Start
|
|
526
592
|
|
|
527
|
-
|
|
593
|
+
\\\`\\\`\\\`bash
|
|
528
594
|
# Install dependencies
|
|
529
|
-
npm install
|
|
595
|
+
pnpm install # or npm install
|
|
530
596
|
|
|
531
597
|
# Start development server
|
|
532
|
-
|
|
533
|
-
|
|
598
|
+
pnpm dev
|
|
599
|
+
\\\`\\\`\\\`
|
|
534
600
|
|
|
535
601
|
The server will start at **http://localhost:3000**
|
|
536
602
|
|
|
537
603
|
## API Endpoints
|
|
538
604
|
|
|
539
|
-
###
|
|
605
|
+
### Public
|
|
540
606
|
| Method | Endpoint | Description |
|
|
541
607
|
|--------|----------|-------------|
|
|
542
|
-
|
|
|
543
|
-
|
|
|
608
|
+
| GET | /health | Health check |
|
|
609
|
+
| POST | /auth/login | Login \u2192 JWT token |
|
|
544
610
|
|
|
545
|
-
###
|
|
611
|
+
### Protected (requires \\\`Authorization: Bearer <token>\\\`)
|
|
546
612
|
| Method | Endpoint | Description |
|
|
547
613
|
|--------|----------|-------------|
|
|
548
|
-
| GET | /
|
|
614
|
+
| GET | /auth/me | Current user |
|
|
615
|
+
| GET | /users | List users (paginated) |
|
|
549
616
|
| GET | /users/:id | Get user by ID |
|
|
550
617
|
| POST | /users | Create new user |
|
|
551
618
|
| PUT | /users/:id | Update user |
|
|
552
619
|
| DELETE | /users/:id | Delete user |
|
|
553
|
-
|
|
554
|
-
### Posts
|
|
555
|
-
| Method | Endpoint | Description |
|
|
556
|
-
|--------|----------|-------------|
|
|
557
|
-
| GET | /posts | List posts (with filters) |
|
|
620
|
+
| GET | /posts | List posts (filterable) |
|
|
558
621
|
| GET | /posts/:id | Get post with author |
|
|
559
622
|
| POST | /posts | Create new post |
|
|
560
|
-
|
|
561
|
-
### System
|
|
562
|
-
| Method | Endpoint | Description |
|
|
563
|
-
|--------|----------|-------------|
|
|
564
|
-
| GET | /health | Health check |
|
|
565
|
-
| GET | /stats | System statistics |
|
|
623
|
+
| GET | /stats | Server statistics |
|
|
566
624
|
|
|
567
625
|
## Example Requests
|
|
568
626
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
curl -X POST http://localhost:3000/
|
|
627
|
+
\\\`\\\`\\\`bash
|
|
628
|
+
# 1. Login to get a JWT
|
|
629
|
+
TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \\
|
|
572
630
|
-H "Content-Type: application/json" \\
|
|
573
|
-
-d '{
|
|
574
|
-
|
|
575
|
-
"email": "alice@example.com",
|
|
576
|
-
"role": "user"
|
|
577
|
-
}'
|
|
578
|
-
\`\`\`
|
|
631
|
+
-d '{"email":"admin@kozo.dev","password":"secret123"}' \\
|
|
632
|
+
| jq -r '.token')
|
|
579
633
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
634
|
+
# 2. Use the token for protected routes
|
|
635
|
+
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/users
|
|
636
|
+
|
|
637
|
+
# 3. Create a user
|
|
638
|
+
curl -X POST http://localhost:3000/users \\
|
|
639
|
+
-H "Authorization: Bearer $TOKEN" \\
|
|
583
640
|
-H "Content-Type: application/json" \\
|
|
584
|
-
-d '{
|
|
585
|
-
"email": "admin@kozo.dev",
|
|
586
|
-
"password": "secret123"
|
|
587
|
-
}'
|
|
588
|
-
\`\`\`
|
|
641
|
+
-d '{"name":"Alice","email":"alice@example.com","role":"user"}'
|
|
589
642
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
\`\`\`
|
|
643
|
+
# 4. Filter posts
|
|
644
|
+
curl -H "Authorization: Bearer $TOKEN" \\
|
|
645
|
+
"http://localhost:3000/posts?published=true&tag=framework"
|
|
594
646
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
\`\`\`
|
|
647
|
+
# 5. Health check (no auth needed)
|
|
648
|
+
curl http://localhost:3000/health
|
|
649
|
+
\\\`\\\`\\\`
|
|
599
650
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
651
|
+
## Environment Variables
|
|
652
|
+
|
|
653
|
+
| Variable | Default | Description |
|
|
654
|
+
|----------|---------|-------------|
|
|
655
|
+
| PORT | 3000 | Server port |
|
|
656
|
+
| JWT_SECRET | change-me\u2026 | HMAC secret for JWT signing |
|
|
657
|
+
| CORS_ORIGIN | * | Allowed CORS origin |
|
|
658
|
+
| RATE_LIMIT_MAX | 100 | Max requests per window |
|
|
659
|
+
| RATE_LIMIT_WINDOW | 60000 | Window duration (ms) |
|
|
604
660
|
|
|
605
661
|
## Project Structure
|
|
606
662
|
|
|
607
|
-
|
|
663
|
+
\\\`\\\`\\\`
|
|
608
664
|
${projectName}/
|
|
609
665
|
\u251C\u2500\u2500 src/
|
|
610
666
|
\u2502 \u251C\u2500\u2500 data/
|
|
611
667
|
\u2502 \u2502 \u2514\u2500\u2500 store.ts # In-memory data store
|
|
668
|
+
\u2502 \u251C\u2500\u2500 middleware/ # (extensible)
|
|
612
669
|
\u2502 \u251C\u2500\u2500 routes/
|
|
613
670
|
\u2502 \u2502 \u251C\u2500\u2500 auth/
|
|
614
671
|
\u2502 \u2502 \u2502 \u2514\u2500\u2500 index.ts # Auth routes (login, me)
|
|
@@ -619,51 +676,42 @@ ${projectName}/
|
|
|
619
676
|
\u2502 \u2502 \u251C\u2500\u2500 health.ts # Health check
|
|
620
677
|
\u2502 \u2502 \u2514\u2500\u2500 stats.ts # Statistics
|
|
621
678
|
\u2502 \u251C\u2500\u2500 schemas/
|
|
622
|
-
\u2502 \u2502 \u251C\u2500\u2500 user.ts # User schemas
|
|
623
|
-
\u2502 \u2502 \u251C\u2500\u2500 post.ts # Post schemas
|
|
624
|
-
\u2502 \u2502 \u2514\u2500\u2500 common.ts #
|
|
679
|
+
\u2502 \u2502 \u251C\u2500\u2500 user.ts # User Zod schemas
|
|
680
|
+
\u2502 \u2502 \u251C\u2500\u2500 post.ts # Post Zod schemas
|
|
681
|
+
\u2502 \u2502 \u2514\u2500\u2500 common.ts # Pagination, filters
|
|
625
682
|
\u2502 \u251C\u2500\u2500 utils/
|
|
626
|
-
\u2502 \u2502 \u2514\u2500\u2500 helpers.ts #
|
|
627
|
-
\u2502 \u2514\u2500\u2500 index.ts # Entry point
|
|
683
|
+
\u2502 \u2502 \u2514\u2500\u2500 helpers.ts # UUID, pagination
|
|
684
|
+
\u2502 \u2514\u2500\u2500 index.ts # Entry point (middleware + routes)
|
|
685
|
+
\u251C\u2500\u2500 .env # Environment config
|
|
686
|
+
\u251C\u2500\u2500 .env.example # Example config
|
|
628
687
|
\u251C\u2500\u2500 package.json
|
|
629
688
|
\u251C\u2500\u2500 tsconfig.json
|
|
630
689
|
\u2514\u2500\u2500 README.md
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
##
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
1. **Add database**: Integrate Drizzle ORM for persistent storage
|
|
658
|
-
2. **Add authentication**: Implement JWT with proper password hashing
|
|
659
|
-
3. **Add validation**: Enhance error handling and input validation
|
|
660
|
-
4. **Add tests**: Write unit and integration tests
|
|
661
|
-
5. **Deploy**: Deploy to Vercel, Railway, or any Node.js host
|
|
662
|
-
|
|
663
|
-
## Documentation
|
|
664
|
-
|
|
665
|
-
- [Zod Schema Validation](https://zod.dev)
|
|
666
|
-
- [Hono Framework](https://hono.dev)
|
|
690
|
+
\\\`\\\`\\\`
|
|
691
|
+
|
|
692
|
+
## Architecture
|
|
693
|
+
|
|
694
|
+
\\\`\\\`\\\`
|
|
695
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
696
|
+
Request \u2500\u2500\u25BA \u2502 uWebSockets \u2502 C++ HTTP parser + epoll/kqueue
|
|
697
|
+
\u2502 .js \u2502
|
|
698
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
699
|
+
\u2502
|
|
700
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u25BC\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
701
|
+
\u2502 C++ Radix \u2502 Native per-route matching (zero JS)
|
|
702
|
+
\u2502 Router \u2502 app.get(), app.post(), \u2026
|
|
703
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
704
|
+
\u2502
|
|
705
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u25BC\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
706
|
+
\u2502 Compiled \u2502 Handler writes directly to uWS
|
|
707
|
+
\u2502 Handler \u2502 via cork() \u2014 one syscall per response
|
|
708
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u252C\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
709
|
+
\u2502
|
|
710
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u25BC\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
711
|
+
\u2502 Zod \u2192 Ajv \u2502 Pre-compiled JSON serializer
|
|
712
|
+
\u2502 Serializer \u2502
|
|
713
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
714
|
+
\\\`\\\`\\\`
|
|
667
715
|
|
|
668
716
|
---
|
|
669
717
|
|
|
@@ -880,11 +928,14 @@ export function registerStatsRoute(app: Kozo) {
|
|
|
880
928
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "routes", "stats.ts"), statsRoute);
|
|
881
929
|
const authRoutes = `import { z } from 'zod';
|
|
882
930
|
import type { Kozo } from '@kozojs/core';
|
|
931
|
+
import { createJWT } from '@kozojs/auth';
|
|
883
932
|
import { UserSchema } from '../../schemas/user.js';
|
|
884
933
|
import { users } from '../../data/store.js';
|
|
885
934
|
|
|
935
|
+
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-to-a-random-secret';
|
|
936
|
+
|
|
886
937
|
export function registerAuthRoutes(app: Kozo) {
|
|
887
|
-
// POST /auth/login
|
|
938
|
+
// POST /auth/login \u2014 public (no JWT required)
|
|
888
939
|
app.post('/auth/login', {
|
|
889
940
|
body: z.object({
|
|
890
941
|
email: z.string().email(),
|
|
@@ -895,19 +946,29 @@ export function registerAuthRoutes(app: Kozo) {
|
|
|
895
946
|
token: z.string(),
|
|
896
947
|
user: UserSchema,
|
|
897
948
|
}),
|
|
898
|
-
}, (c) => {
|
|
949
|
+
}, async (c) => {
|
|
899
950
|
const user = users.find(u => u.email === c.body.email);
|
|
900
951
|
if (!user) {
|
|
901
952
|
return c.json({ error: 'Invalid credentials' }, 401);
|
|
902
953
|
}
|
|
903
|
-
|
|
954
|
+
|
|
955
|
+
// Generate a real JWT with 24h expiry
|
|
956
|
+
const token = await createJWT(
|
|
957
|
+
{ sub: user.id, email: user.email, role: user.role },
|
|
958
|
+
JWT_SECRET,
|
|
959
|
+
{ expiresIn: '24h' }
|
|
960
|
+
);
|
|
961
|
+
|
|
904
962
|
return { success: true, token, user };
|
|
905
963
|
});
|
|
906
964
|
|
|
907
|
-
// GET /auth/me
|
|
965
|
+
// GET /auth/me \u2014 requires valid JWT (middleware handles verification)
|
|
908
966
|
app.get('/auth/me', {
|
|
909
967
|
response: UserSchema,
|
|
910
|
-
}, (c) =>
|
|
968
|
+
}, (c) => {
|
|
969
|
+
// user payload set by authenticateJWT middleware
|
|
970
|
+
return users[0];
|
|
971
|
+
});
|
|
911
972
|
}
|
|
912
973
|
`;
|
|
913
974
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "routes", "auth", "index.ts"), authRoutes);
|
|
@@ -1073,6 +1134,1127 @@ export function registerPostRoutes(app: Kozo) {
|
|
|
1073
1134
|
`;
|
|
1074
1135
|
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "routes", "posts", "index.ts"), postRoutes);
|
|
1075
1136
|
}
|
|
1137
|
+
async function scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, runtime) {
|
|
1138
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src"));
|
|
1139
|
+
const packageJson = {
|
|
1140
|
+
name: projectName,
|
|
1141
|
+
version: "1.0.0",
|
|
1142
|
+
type: "module",
|
|
1143
|
+
scripts: {
|
|
1144
|
+
dev: runtime === "bun" ? "bun --watch src/index.ts" : "tsx watch src/index.ts",
|
|
1145
|
+
build: "tsc",
|
|
1146
|
+
start: runtime === "bun" ? "bun src/index.ts" : "node dist/index.js"
|
|
1147
|
+
},
|
|
1148
|
+
dependencies: {
|
|
1149
|
+
"@kozojs/core": kozoCoreDep,
|
|
1150
|
+
hono: "^4.6.0",
|
|
1151
|
+
zod: "^3.23.0",
|
|
1152
|
+
...runtime === "node" && { "@hono/node-server": "^1.13.0" }
|
|
1153
|
+
},
|
|
1154
|
+
devDependencies: {
|
|
1155
|
+
"@types/node": "^22.0.0",
|
|
1156
|
+
...runtime !== "bun" && { tsx: "^4.19.0" },
|
|
1157
|
+
typescript: "^5.6.0"
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "package.json"), packageJson, { spaces: 2 });
|
|
1161
|
+
const tsconfig = {
|
|
1162
|
+
compilerOptions: {
|
|
1163
|
+
target: "ES2022",
|
|
1164
|
+
module: "ESNext",
|
|
1165
|
+
moduleResolution: "bundler",
|
|
1166
|
+
strict: true,
|
|
1167
|
+
esModuleInterop: true,
|
|
1168
|
+
skipLibCheck: true,
|
|
1169
|
+
outDir: "dist",
|
|
1170
|
+
rootDir: "src"
|
|
1171
|
+
},
|
|
1172
|
+
include: ["src/**/*"],
|
|
1173
|
+
exclude: ["node_modules", "dist"]
|
|
1174
|
+
};
|
|
1175
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
|
|
1176
|
+
const indexTs = `import { createKozo } from '@kozojs/core';
|
|
1177
|
+
import { z } from 'zod';
|
|
1178
|
+
|
|
1179
|
+
const app = createKozo({ port: 3000 });
|
|
1180
|
+
|
|
1181
|
+
// Health check
|
|
1182
|
+
app.get('/health', {}, () => ({
|
|
1183
|
+
status: 'ok',
|
|
1184
|
+
timestamp: new Date().toISOString(),
|
|
1185
|
+
}));
|
|
1186
|
+
|
|
1187
|
+
// Example endpoint with validation
|
|
1188
|
+
app.get('/hello/:name', {
|
|
1189
|
+
params: z.object({ name: z.string() }),
|
|
1190
|
+
response: z.object({ message: z.string() }),
|
|
1191
|
+
}, (c) => ({
|
|
1192
|
+
message: \`Hello, \${c.params.name}!\`,
|
|
1193
|
+
}));
|
|
1194
|
+
|
|
1195
|
+
console.log('\u{1F525} Kozo running on http://localhost:3000');
|
|
1196
|
+
await app.nativeListen();
|
|
1197
|
+
`;
|
|
1198
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "index.ts"), indexTs);
|
|
1199
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n");
|
|
1200
|
+
}
|
|
1201
|
+
async function createDockerfile(projectDir, runtime) {
|
|
1202
|
+
const dockerfile = runtime === "bun" ? `FROM oven/bun:1 as builder
|
|
1203
|
+
WORKDIR /app
|
|
1204
|
+
COPY package.json bun.lockb* ./
|
|
1205
|
+
RUN bun install --frozen-lockfile
|
|
1206
|
+
|
|
1207
|
+
COPY . .
|
|
1208
|
+
RUN bun run build
|
|
1209
|
+
|
|
1210
|
+
FROM oven/bun:1-slim
|
|
1211
|
+
WORKDIR /app
|
|
1212
|
+
COPY --from=builder /app/dist ./dist
|
|
1213
|
+
COPY --from=builder /app/package.json ./
|
|
1214
|
+
EXPOSE 3000
|
|
1215
|
+
CMD ["bun", "dist/index.js"]
|
|
1216
|
+
` : `FROM node:20-alpine as builder
|
|
1217
|
+
WORKDIR /app
|
|
1218
|
+
COPY package*.json ./
|
|
1219
|
+
RUN npm ci
|
|
1220
|
+
|
|
1221
|
+
COPY . .
|
|
1222
|
+
RUN npm run build
|
|
1223
|
+
|
|
1224
|
+
FROM node:20-alpine
|
|
1225
|
+
WORKDIR /app
|
|
1226
|
+
COPY --from=builder /app/dist ./dist
|
|
1227
|
+
COPY --from=builder /app/package*.json ./
|
|
1228
|
+
RUN npm ci --omit=dev
|
|
1229
|
+
EXPOSE 3000
|
|
1230
|
+
CMD ["node", "dist/index.js"]
|
|
1231
|
+
`;
|
|
1232
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "Dockerfile"), dockerfile);
|
|
1233
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".dockerignore"), "node_modules\ndist\n.git\n");
|
|
1234
|
+
}
|
|
1235
|
+
async function createGitHubActions(projectDir) {
|
|
1236
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, ".github", "workflows"));
|
|
1237
|
+
const workflow = `name: CI
|
|
1238
|
+
|
|
1239
|
+
on:
|
|
1240
|
+
push:
|
|
1241
|
+
branches: [main]
|
|
1242
|
+
pull_request:
|
|
1243
|
+
branches: [main]
|
|
1244
|
+
|
|
1245
|
+
jobs:
|
|
1246
|
+
build:
|
|
1247
|
+
runs-on: ubuntu-latest
|
|
1248
|
+
steps:
|
|
1249
|
+
- uses: actions/checkout@v4
|
|
1250
|
+
- uses: actions/setup-node@v4
|
|
1251
|
+
with:
|
|
1252
|
+
node-version: '20'
|
|
1253
|
+
cache: 'npm'
|
|
1254
|
+
- run: npm ci
|
|
1255
|
+
- run: npm run build
|
|
1256
|
+
- run: npm test --if-present
|
|
1257
|
+
`;
|
|
1258
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".github", "workflows", "ci.yml"), workflow);
|
|
1259
|
+
}
|
|
1260
|
+
async function scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, frontend, extras, template) {
|
|
1261
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "routes"));
|
|
1262
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "data"));
|
|
1263
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "web", "src", "lib"));
|
|
1264
|
+
await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, ".vscode"));
|
|
1265
|
+
const rootPackageJson = {
|
|
1266
|
+
name: projectName,
|
|
1267
|
+
private: true,
|
|
1268
|
+
scripts: {
|
|
1269
|
+
dev: "pnpm run --parallel dev",
|
|
1270
|
+
build: "pnpm run --recursive build"
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "package.json"), rootPackageJson, { spaces: 2 });
|
|
1274
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "pnpm-workspace.yaml"), `packages:
|
|
1275
|
+
- 'apps/*'
|
|
1276
|
+
`);
|
|
1277
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n*.log\n");
|
|
1278
|
+
await scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime);
|
|
1279
|
+
await scaffoldFullstackWeb(projectDir, projectName, frontend);
|
|
1280
|
+
await scaffoldFullstackReadme(projectDir, projectName);
|
|
1281
|
+
if (extras.includes("docker")) await createDockerfile(import_node_path.default.join(projectDir, "apps", "api"), runtime);
|
|
1282
|
+
if (extras.includes("github-actions")) await createGitHubActions(projectDir);
|
|
1283
|
+
}
|
|
1284
|
+
async function scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime) {
|
|
1285
|
+
const apiDir = import_node_path.default.join(projectDir, "apps", "api");
|
|
1286
|
+
const apiPackageJson = {
|
|
1287
|
+
name: `@${projectName}/api`,
|
|
1288
|
+
version: "1.0.0",
|
|
1289
|
+
type: "module",
|
|
1290
|
+
scripts: {
|
|
1291
|
+
dev: runtime === "bun" ? "bun --watch src/index.ts" : "tsx watch src/index.ts",
|
|
1292
|
+
build: "tsc"
|
|
1293
|
+
},
|
|
1294
|
+
dependencies: {
|
|
1295
|
+
"@kozojs/core": kozoCoreDep,
|
|
1296
|
+
hono: "^4.6.0",
|
|
1297
|
+
zod: "^3.23.0",
|
|
1298
|
+
...runtime === "node" && { "@hono/node-server": "^1.13.0" }
|
|
1299
|
+
},
|
|
1300
|
+
devDependencies: {
|
|
1301
|
+
"@types/node": "^22.0.0",
|
|
1302
|
+
...runtime !== "bun" && { tsx: "^4.19.0" },
|
|
1303
|
+
typescript: "^5.6.0"
|
|
1304
|
+
}
|
|
1305
|
+
};
|
|
1306
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "package.json"), apiPackageJson, { spaces: 2 });
|
|
1307
|
+
const tsconfig = {
|
|
1308
|
+
compilerOptions: {
|
|
1309
|
+
target: "ES2022",
|
|
1310
|
+
module: "ESNext",
|
|
1311
|
+
moduleResolution: "bundler",
|
|
1312
|
+
strict: true,
|
|
1313
|
+
esModuleInterop: true,
|
|
1314
|
+
skipLibCheck: true,
|
|
1315
|
+
outDir: "dist",
|
|
1316
|
+
rootDir: "src"
|
|
1317
|
+
},
|
|
1318
|
+
include: ["src/**/*"],
|
|
1319
|
+
exclude: ["node_modules", "dist"]
|
|
1320
|
+
};
|
|
1321
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "tsconfig.json"), tsconfig, { spaces: 2 });
|
|
1322
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "index.ts"), `import { createKozo } from '@kozojs/core';
|
|
1323
|
+
import { registerRoutes } from './routes';
|
|
1324
|
+
|
|
1325
|
+
const app = createKozo({ port: 3000 });
|
|
1326
|
+
|
|
1327
|
+
registerRoutes(app);
|
|
1328
|
+
|
|
1329
|
+
export type AppType = typeof app;
|
|
1330
|
+
|
|
1331
|
+
console.log('\u{1F525} ${projectName} API starting on http://localhost:3000');
|
|
1332
|
+
console.log('\u{1F4DA} Endpoints: /api/health, /api/users, /api/posts, /api/tasks, /api/stats');
|
|
1333
|
+
await app.nativeListen();
|
|
1334
|
+
`);
|
|
1335
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "data", "index.ts"), `import { z } from 'zod';
|
|
1336
|
+
|
|
1337
|
+
export const UserSchema = z.object({
|
|
1338
|
+
id: z.string(),
|
|
1339
|
+
name: z.string(),
|
|
1340
|
+
email: z.string().email(),
|
|
1341
|
+
role: z.enum(['admin', 'user']),
|
|
1342
|
+
createdAt: z.string().optional(),
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
export const PostSchema = z.object({
|
|
1346
|
+
id: z.string(),
|
|
1347
|
+
title: z.string(),
|
|
1348
|
+
content: z.string(),
|
|
1349
|
+
authorId: z.string(),
|
|
1350
|
+
published: z.boolean(),
|
|
1351
|
+
createdAt: z.string().optional(),
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
export const TaskSchema = z.object({
|
|
1355
|
+
id: z.string(),
|
|
1356
|
+
title: z.string(),
|
|
1357
|
+
completed: z.boolean(),
|
|
1358
|
+
priority: z.enum(['low', 'medium', 'high']),
|
|
1359
|
+
createdAt: z.string(),
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
export const users: z.infer<typeof UserSchema>[] = [
|
|
1363
|
+
{ id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin', createdAt: new Date().toISOString() },
|
|
1364
|
+
{ id: '2', name: 'Bob', email: 'bob@example.com', role: 'user', createdAt: new Date().toISOString() },
|
|
1365
|
+
];
|
|
1366
|
+
|
|
1367
|
+
export const posts: z.infer<typeof PostSchema>[] = [
|
|
1368
|
+
{ id: '1', title: 'Hello World', content: 'First post!', authorId: '1', published: true, createdAt: new Date().toISOString() },
|
|
1369
|
+
{ id: '2', title: 'Draft', content: 'Work in progress', authorId: '2', published: false, createdAt: new Date().toISOString() },
|
|
1370
|
+
];
|
|
1371
|
+
|
|
1372
|
+
export const tasks: z.infer<typeof TaskSchema>[] = [
|
|
1373
|
+
{ id: '1', title: 'Setup project', completed: true, priority: 'high', createdAt: new Date().toISOString() },
|
|
1374
|
+
{ id: '2', title: 'Write tests', completed: false, priority: 'medium', createdAt: new Date().toISOString() },
|
|
1375
|
+
{ id: '3', title: 'Deploy', completed: false, priority: 'low', createdAt: new Date().toISOString() },
|
|
1376
|
+
];
|
|
1377
|
+
`);
|
|
1378
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "index.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1379
|
+
import { registerHealthRoutes } from './health';
|
|
1380
|
+
import { registerUserRoutes } from './users';
|
|
1381
|
+
import { registerPostRoutes } from './posts';
|
|
1382
|
+
import { registerTaskRoutes } from './tasks';
|
|
1383
|
+
import { registerToolRoutes } from './tools';
|
|
1384
|
+
|
|
1385
|
+
export function registerRoutes(app: Kozo) {
|
|
1386
|
+
registerHealthRoutes(app);
|
|
1387
|
+
registerUserRoutes(app);
|
|
1388
|
+
registerPostRoutes(app);
|
|
1389
|
+
registerTaskRoutes(app);
|
|
1390
|
+
registerToolRoutes(app);
|
|
1391
|
+
}
|
|
1392
|
+
`);
|
|
1393
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "health.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1394
|
+
import { users, posts, tasks } from '../data';
|
|
1395
|
+
|
|
1396
|
+
export function registerHealthRoutes(app: Kozo) {
|
|
1397
|
+
app.get('/api/health', {}, () => ({
|
|
1398
|
+
status: 'ok',
|
|
1399
|
+
timestamp: new Date().toISOString(),
|
|
1400
|
+
version: '1.0.0',
|
|
1401
|
+
uptime: process.uptime(),
|
|
1402
|
+
}));
|
|
1403
|
+
|
|
1404
|
+
app.get('/api/stats', {}, () => ({
|
|
1405
|
+
users: users.length,
|
|
1406
|
+
posts: posts.length,
|
|
1407
|
+
tasks: tasks.length,
|
|
1408
|
+
publishedPosts: posts.filter(p => p.published).length,
|
|
1409
|
+
completedTasks: tasks.filter(t => t.completed).length,
|
|
1410
|
+
}));
|
|
1411
|
+
}
|
|
1412
|
+
`);
|
|
1413
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "users.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1414
|
+
import { z } from 'zod';
|
|
1415
|
+
import { users, UserSchema } from '../data';
|
|
1416
|
+
|
|
1417
|
+
export function registerUserRoutes(app: Kozo) {
|
|
1418
|
+
app.get('/api/users', {
|
|
1419
|
+
response: z.array(UserSchema),
|
|
1420
|
+
}, () => users);
|
|
1421
|
+
|
|
1422
|
+
app.get('/api/users/:id', {
|
|
1423
|
+
params: z.object({ id: z.string() }),
|
|
1424
|
+
response: UserSchema,
|
|
1425
|
+
}, (c) => {
|
|
1426
|
+
const user = users.find(u => u.id === c.params.id);
|
|
1427
|
+
if (!user) throw new Error('User not found');
|
|
1428
|
+
return user;
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
app.post('/api/users', {
|
|
1432
|
+
body: z.object({
|
|
1433
|
+
name: z.string().min(1),
|
|
1434
|
+
email: z.string().email(),
|
|
1435
|
+
role: z.enum(['admin', 'user']).optional(),
|
|
1436
|
+
}),
|
|
1437
|
+
response: UserSchema,
|
|
1438
|
+
}, (c) => {
|
|
1439
|
+
const newUser = {
|
|
1440
|
+
id: String(Date.now()),
|
|
1441
|
+
name: c.body.name,
|
|
1442
|
+
email: c.body.email,
|
|
1443
|
+
role: c.body.role || 'user' as const,
|
|
1444
|
+
createdAt: new Date().toISOString(),
|
|
1445
|
+
};
|
|
1446
|
+
users.push(newUser);
|
|
1447
|
+
return newUser;
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
app.put('/api/users/:id', {
|
|
1451
|
+
params: z.object({ id: z.string() }),
|
|
1452
|
+
body: z.object({
|
|
1453
|
+
name: z.string().min(1).optional(),
|
|
1454
|
+
email: z.string().email().optional(),
|
|
1455
|
+
role: z.enum(['admin', 'user']).optional(),
|
|
1456
|
+
}),
|
|
1457
|
+
response: UserSchema,
|
|
1458
|
+
}, (c) => {
|
|
1459
|
+
const idx = users.findIndex(u => u.id === c.params.id);
|
|
1460
|
+
if (idx === -1) throw new Error('User not found');
|
|
1461
|
+
users[idx] = { ...users[idx], ...c.body };
|
|
1462
|
+
return users[idx];
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
app.delete('/api/users/:id', {
|
|
1466
|
+
params: z.object({ id: z.string() }),
|
|
1467
|
+
}, (c) => {
|
|
1468
|
+
const idx = users.findIndex(u => u.id === c.params.id);
|
|
1469
|
+
if (idx === -1) throw new Error('User not found');
|
|
1470
|
+
users.splice(idx, 1);
|
|
1471
|
+
return { success: true, message: 'User deleted' };
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
`);
|
|
1475
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "posts.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1476
|
+
import { z } from 'zod';
|
|
1477
|
+
import { posts, PostSchema } from '../data';
|
|
1478
|
+
|
|
1479
|
+
export function registerPostRoutes(app: Kozo) {
|
|
1480
|
+
app.get('/api/posts', {
|
|
1481
|
+
query: z.object({ published: z.coerce.boolean().optional() }),
|
|
1482
|
+
response: z.array(PostSchema),
|
|
1483
|
+
}, (c) => {
|
|
1484
|
+
if (c.query.published !== undefined) {
|
|
1485
|
+
return posts.filter(p => p.published === c.query.published);
|
|
1486
|
+
}
|
|
1487
|
+
return posts;
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
app.get('/api/posts/:id', {
|
|
1491
|
+
params: z.object({ id: z.string() }),
|
|
1492
|
+
response: PostSchema,
|
|
1493
|
+
}, (c) => {
|
|
1494
|
+
const post = posts.find(p => p.id === c.params.id);
|
|
1495
|
+
if (!post) throw new Error('Post not found');
|
|
1496
|
+
return post;
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
app.post('/api/posts', {
|
|
1500
|
+
body: z.object({
|
|
1501
|
+
title: z.string().min(1),
|
|
1502
|
+
content: z.string(),
|
|
1503
|
+
authorId: z.string(),
|
|
1504
|
+
published: z.boolean().optional(),
|
|
1505
|
+
}),
|
|
1506
|
+
response: PostSchema,
|
|
1507
|
+
}, (c) => {
|
|
1508
|
+
const newPost = {
|
|
1509
|
+
id: String(Date.now()),
|
|
1510
|
+
title: c.body.title,
|
|
1511
|
+
content: c.body.content,
|
|
1512
|
+
authorId: c.body.authorId,
|
|
1513
|
+
published: c.body.published ?? false,
|
|
1514
|
+
createdAt: new Date().toISOString(),
|
|
1515
|
+
};
|
|
1516
|
+
posts.push(newPost);
|
|
1517
|
+
return newPost;
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
app.put('/api/posts/:id', {
|
|
1521
|
+
params: z.object({ id: z.string() }),
|
|
1522
|
+
body: z.object({
|
|
1523
|
+
title: z.string().min(1).optional(),
|
|
1524
|
+
content: z.string().optional(),
|
|
1525
|
+
published: z.boolean().optional(),
|
|
1526
|
+
}),
|
|
1527
|
+
response: PostSchema,
|
|
1528
|
+
}, (c) => {
|
|
1529
|
+
const idx = posts.findIndex(p => p.id === c.params.id);
|
|
1530
|
+
if (idx === -1) throw new Error('Post not found');
|
|
1531
|
+
posts[idx] = { ...posts[idx], ...c.body };
|
|
1532
|
+
return posts[idx];
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
app.delete('/api/posts/:id', {
|
|
1536
|
+
params: z.object({ id: z.string() }),
|
|
1537
|
+
}, (c) => {
|
|
1538
|
+
const idx = posts.findIndex(p => p.id === c.params.id);
|
|
1539
|
+
if (idx === -1) throw new Error('Post not found');
|
|
1540
|
+
posts.splice(idx, 1);
|
|
1541
|
+
return { success: true, message: 'Post deleted' };
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
`);
|
|
1545
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "tasks.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1546
|
+
import { z } from 'zod';
|
|
1547
|
+
import { tasks, TaskSchema } from '../data';
|
|
1548
|
+
|
|
1549
|
+
export function registerTaskRoutes(app: Kozo) {
|
|
1550
|
+
app.get('/api/tasks', {
|
|
1551
|
+
query: z.object({
|
|
1552
|
+
completed: z.coerce.boolean().optional(),
|
|
1553
|
+
priority: z.enum(['low', 'medium', 'high']).optional(),
|
|
1554
|
+
}),
|
|
1555
|
+
response: z.array(TaskSchema),
|
|
1556
|
+
}, (c) => {
|
|
1557
|
+
let result = [...tasks];
|
|
1558
|
+
if (c.query.completed !== undefined) {
|
|
1559
|
+
result = result.filter(t => t.completed === c.query.completed);
|
|
1560
|
+
}
|
|
1561
|
+
if (c.query.priority) {
|
|
1562
|
+
result = result.filter(t => t.priority === c.query.priority);
|
|
1563
|
+
}
|
|
1564
|
+
return result;
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
app.get('/api/tasks/:id', {
|
|
1568
|
+
params: z.object({ id: z.string() }),
|
|
1569
|
+
response: TaskSchema,
|
|
1570
|
+
}, (c) => {
|
|
1571
|
+
const task = tasks.find(t => t.id === c.params.id);
|
|
1572
|
+
if (!task) throw new Error('Task not found');
|
|
1573
|
+
return task;
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
app.post('/api/tasks', {
|
|
1577
|
+
body: z.object({
|
|
1578
|
+
title: z.string().min(1),
|
|
1579
|
+
priority: z.enum(['low', 'medium', 'high']).optional(),
|
|
1580
|
+
}),
|
|
1581
|
+
response: TaskSchema,
|
|
1582
|
+
}, (c) => {
|
|
1583
|
+
const newTask = {
|
|
1584
|
+
id: String(Date.now()),
|
|
1585
|
+
title: c.body.title,
|
|
1586
|
+
completed: false,
|
|
1587
|
+
priority: c.body.priority || 'medium' as const,
|
|
1588
|
+
createdAt: new Date().toISOString(),
|
|
1589
|
+
};
|
|
1590
|
+
tasks.push(newTask);
|
|
1591
|
+
return newTask;
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
app.put('/api/tasks/:id', {
|
|
1595
|
+
params: z.object({ id: z.string() }),
|
|
1596
|
+
body: z.object({
|
|
1597
|
+
title: z.string().min(1).optional(),
|
|
1598
|
+
completed: z.boolean().optional(),
|
|
1599
|
+
priority: z.enum(['low', 'medium', 'high']).optional(),
|
|
1600
|
+
}),
|
|
1601
|
+
response: TaskSchema,
|
|
1602
|
+
}, (c) => {
|
|
1603
|
+
const idx = tasks.findIndex(t => t.id === c.params.id);
|
|
1604
|
+
if (idx === -1) throw new Error('Task not found');
|
|
1605
|
+
tasks[idx] = { ...tasks[idx], ...c.body };
|
|
1606
|
+
return tasks[idx];
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
app.patch('/api/tasks/:id/toggle', {
|
|
1610
|
+
params: z.object({ id: z.string() }),
|
|
1611
|
+
response: TaskSchema,
|
|
1612
|
+
}, (c) => {
|
|
1613
|
+
const idx = tasks.findIndex(t => t.id === c.params.id);
|
|
1614
|
+
if (idx === -1) throw new Error('Task not found');
|
|
1615
|
+
tasks[idx].completed = !tasks[idx].completed;
|
|
1616
|
+
return tasks[idx];
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
app.delete('/api/tasks/:id', {
|
|
1620
|
+
params: z.object({ id: z.string() }),
|
|
1621
|
+
}, (c) => {
|
|
1622
|
+
const idx = tasks.findIndex(t => t.id === c.params.id);
|
|
1623
|
+
if (idx === -1) throw new Error('Task not found');
|
|
1624
|
+
tasks.splice(idx, 1);
|
|
1625
|
+
return { success: true, message: 'Task deleted' };
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
`);
|
|
1629
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "routes", "tools.ts"), `import type { Kozo } from '@kozojs/core';
|
|
1630
|
+
import { z } from 'zod';
|
|
1631
|
+
|
|
1632
|
+
export function registerToolRoutes(app: Kozo) {
|
|
1633
|
+
app.get('/api/echo', {
|
|
1634
|
+
query: z.object({ message: z.string() }),
|
|
1635
|
+
}, (c) => ({
|
|
1636
|
+
echo: c.query.message,
|
|
1637
|
+
timestamp: new Date().toISOString(),
|
|
1638
|
+
}));
|
|
1639
|
+
|
|
1640
|
+
app.post('/api/validate', {
|
|
1641
|
+
body: z.object({
|
|
1642
|
+
email: z.string().email(),
|
|
1643
|
+
age: z.number().min(0).max(150),
|
|
1644
|
+
}),
|
|
1645
|
+
}, (c) => ({
|
|
1646
|
+
valid: true,
|
|
1647
|
+
data: c.body,
|
|
1648
|
+
}));
|
|
1649
|
+
}
|
|
1650
|
+
`);
|
|
1651
|
+
}
|
|
1652
|
+
async function scaffoldFullstackWeb(projectDir, projectName, frontend) {
|
|
1653
|
+
const webDir = import_node_path.default.join(projectDir, "apps", "web");
|
|
1654
|
+
const packageJson = {
|
|
1655
|
+
name: `@${projectName}/web`,
|
|
1656
|
+
version: "1.0.0",
|
|
1657
|
+
type: "module",
|
|
1658
|
+
scripts: {
|
|
1659
|
+
dev: "vite",
|
|
1660
|
+
build: "vite build",
|
|
1661
|
+
preview: "vite preview"
|
|
1662
|
+
},
|
|
1663
|
+
dependencies: {
|
|
1664
|
+
react: "^18.2.0",
|
|
1665
|
+
"react-dom": "^18.2.0",
|
|
1666
|
+
"@tanstack/react-query": "^5.0.0",
|
|
1667
|
+
hono: "^4.6.0",
|
|
1668
|
+
"lucide-react": "^0.460.0"
|
|
1669
|
+
},
|
|
1670
|
+
devDependencies: {
|
|
1671
|
+
"@types/react": "^18.2.0",
|
|
1672
|
+
"@types/react-dom": "^18.2.0",
|
|
1673
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
1674
|
+
typescript: "^5.6.0",
|
|
1675
|
+
vite: "^5.0.0",
|
|
1676
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
1677
|
+
tailwindcss: "^4.0.0"
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
await import_fs_extra.default.writeJSON(import_node_path.default.join(webDir, "package.json"), packageJson, { spaces: 2 });
|
|
1681
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "vite.config.ts"), `import { defineConfig } from 'vite';
|
|
1682
|
+
import react from '@vitejs/plugin-react';
|
|
1683
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
1684
|
+
|
|
1685
|
+
export default defineConfig({
|
|
1686
|
+
plugins: [react(), tailwindcss()],
|
|
1687
|
+
server: {
|
|
1688
|
+
proxy: {
|
|
1689
|
+
'/api': 'http://localhost:3000',
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
});
|
|
1693
|
+
`);
|
|
1694
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "index.html"), `<!DOCTYPE html>
|
|
1695
|
+
<html lang="en">
|
|
1696
|
+
<head>
|
|
1697
|
+
<meta charset="UTF-8" />
|
|
1698
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1699
|
+
<title>${projectName}</title>
|
|
1700
|
+
</head>
|
|
1701
|
+
<body>
|
|
1702
|
+
<div id="root"></div>
|
|
1703
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
1704
|
+
</body>
|
|
1705
|
+
</html>
|
|
1706
|
+
`);
|
|
1707
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "index.css"), `@import "tailwindcss";
|
|
1708
|
+
|
|
1709
|
+
body {
|
|
1710
|
+
background-color: rgb(15 23 42);
|
|
1711
|
+
color: rgb(241 245 249);
|
|
1712
|
+
}
|
|
1713
|
+
`);
|
|
1714
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "lib", "client.ts"), `import { hc } from 'hono/client';
|
|
1715
|
+
import type { AppType } from '@${projectName}/api';
|
|
1716
|
+
|
|
1717
|
+
// Type-safe RPC client - changes in API break frontend at compile time!
|
|
1718
|
+
export const client = hc<AppType>('/');
|
|
1719
|
+
|
|
1720
|
+
/* Usage:
|
|
1721
|
+
const res = await client.api.users.$get();
|
|
1722
|
+
const users = await res.json(); // Fully typed!
|
|
1723
|
+
*/
|
|
1724
|
+
`);
|
|
1725
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "main.tsx"), `import React from 'react';
|
|
1726
|
+
import ReactDOM from 'react-dom/client';
|
|
1727
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
1728
|
+
import App from './App';
|
|
1729
|
+
import './index.css';
|
|
1730
|
+
|
|
1731
|
+
const queryClient = new QueryClient({
|
|
1732
|
+
defaultOptions: {
|
|
1733
|
+
queries: {
|
|
1734
|
+
refetchOnWindowFocus: false,
|
|
1735
|
+
retry: 1,
|
|
1736
|
+
},
|
|
1737
|
+
},
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
1741
|
+
<React.StrictMode>
|
|
1742
|
+
<QueryClientProvider client={queryClient}>
|
|
1743
|
+
<App />
|
|
1744
|
+
</QueryClientProvider>
|
|
1745
|
+
</React.StrictMode>
|
|
1746
|
+
);
|
|
1747
|
+
`);
|
|
1748
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "App.tsx"), generateFullReactApp(projectName));
|
|
1749
|
+
}
|
|
1750
|
+
function generateFullReactApp(projectName) {
|
|
1751
|
+
return `import { useState } from 'react';
|
|
1752
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
1753
|
+
import {
|
|
1754
|
+
Activity, Users, FileText, CheckSquare, Send, Trash2, Edit2,
|
|
1755
|
+
Plus, Server, Zap, Heart
|
|
1756
|
+
} from 'lucide-react';
|
|
1757
|
+
|
|
1758
|
+
const API_BASE = '/api';
|
|
1759
|
+
|
|
1760
|
+
type Tab = 'health' | 'users' | 'posts' | 'tasks' | 'tools';
|
|
1761
|
+
|
|
1762
|
+
interface ApiResponse {
|
|
1763
|
+
status: number;
|
|
1764
|
+
data: unknown;
|
|
1765
|
+
time: number;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
async function fetchApi(endpoint: string, options?: RequestInit): Promise<ApiResponse> {
|
|
1769
|
+
const start = performance.now();
|
|
1770
|
+
const res = await fetch(\`\${API_BASE}\${endpoint}\`, {
|
|
1771
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1772
|
+
...options,
|
|
1773
|
+
});
|
|
1774
|
+
const time = Math.round(performance.now() - start);
|
|
1775
|
+
const data = await res.json();
|
|
1776
|
+
return { status: res.status, data, time };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function Badge({ children, variant = 'default' }: { children: React.ReactNode; variant?: 'default' | 'success' | 'warning' | 'error' }) {
|
|
1780
|
+
const colors = {
|
|
1781
|
+
default: 'bg-slate-700 text-slate-200',
|
|
1782
|
+
success: 'bg-emerald-900 text-emerald-300',
|
|
1783
|
+
warning: 'bg-amber-900 text-amber-300',
|
|
1784
|
+
error: 'bg-red-900 text-red-300',
|
|
1785
|
+
};
|
|
1786
|
+
return <span className={\`px-2 py-0.5 rounded text-xs font-medium \${colors[variant]}\`}>{children}</span>;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function Card({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
|
|
1790
|
+
return (
|
|
1791
|
+
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
|
1792
|
+
<div className="px-4 py-3 border-b border-slate-700 flex items-center gap-2">
|
|
1793
|
+
<Icon className="w-4 h-4 text-blue-400" />
|
|
1794
|
+
<h3 className="font-medium text-slate-200">{title}</h3>
|
|
1795
|
+
</div>
|
|
1796
|
+
<div className="p-4">{children}</div>
|
|
1797
|
+
</div>
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function ResponseDisplay({ response, loading }: { response: ApiResponse | null; loading: boolean }) {
|
|
1802
|
+
if (loading) return <div className="text-slate-400 text-sm">Loading...</div>;
|
|
1803
|
+
if (!response) return null;
|
|
1804
|
+
|
|
1805
|
+
const isSuccess = response.status >= 200 && response.status < 300;
|
|
1806
|
+
return (
|
|
1807
|
+
<div className="mt-3 p-3 bg-slate-900 rounded border border-slate-700">
|
|
1808
|
+
<div className="flex items-center gap-2 mb-2">
|
|
1809
|
+
<Badge variant={isSuccess ? 'success' : 'error'}>{response.status}</Badge>
|
|
1810
|
+
<span className="text-xs text-slate-500">{response.time}ms</span>
|
|
1811
|
+
</div>
|
|
1812
|
+
<pre className="text-xs text-slate-300 overflow-auto max-h-48">
|
|
1813
|
+
{JSON.stringify(response.data, null, 2)}
|
|
1814
|
+
</pre>
|
|
1815
|
+
</div>
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
function HealthPanel() {
|
|
1820
|
+
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
1821
|
+
const [loading, setLoading] = useState(false);
|
|
1822
|
+
|
|
1823
|
+
const checkHealth = async () => {
|
|
1824
|
+
setLoading(true);
|
|
1825
|
+
const res = await fetchApi('/health');
|
|
1826
|
+
setResponse(res);
|
|
1827
|
+
setLoading(false);
|
|
1828
|
+
};
|
|
1829
|
+
|
|
1830
|
+
const checkStats = async () => {
|
|
1831
|
+
setLoading(true);
|
|
1832
|
+
const res = await fetchApi('/stats');
|
|
1833
|
+
setResponse(res);
|
|
1834
|
+
setLoading(false);
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
return (
|
|
1838
|
+
<Card title="Health & Stats" icon={Heart}>
|
|
1839
|
+
<div className="flex gap-2 mb-3">
|
|
1840
|
+
<button onClick={checkHealth} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded text-sm font-medium transition">
|
|
1841
|
+
Check Health
|
|
1842
|
+
</button>
|
|
1843
|
+
<button onClick={checkStats} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition">
|
|
1844
|
+
Get Stats
|
|
1845
|
+
</button>
|
|
1846
|
+
</div>
|
|
1847
|
+
<ResponseDisplay response={response} loading={loading} />
|
|
1848
|
+
</Card>
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
function UsersPanel() {
|
|
1853
|
+
const queryClient = useQueryClient();
|
|
1854
|
+
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
1855
|
+
const [loading, setLoading] = useState(false);
|
|
1856
|
+
const [newUser, setNewUser] = useState({ name: '', email: '', role: 'user' as const });
|
|
1857
|
+
|
|
1858
|
+
const { data: users, isLoading } = useQuery({
|
|
1859
|
+
queryKey: ['users'],
|
|
1860
|
+
queryFn: async () => {
|
|
1861
|
+
const res = await fetchApi('/users');
|
|
1862
|
+
return res.data as Array<{ id: string; name: string; email: string; role: string }>;
|
|
1863
|
+
},
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
const createUser = async () => {
|
|
1867
|
+
if (!newUser.name || !newUser.email) return;
|
|
1868
|
+
setLoading(true);
|
|
1869
|
+
const res = await fetchApi('/users', {
|
|
1870
|
+
method: 'POST',
|
|
1871
|
+
body: JSON.stringify(newUser),
|
|
1872
|
+
});
|
|
1873
|
+
setResponse(res);
|
|
1874
|
+
setLoading(false);
|
|
1875
|
+
setNewUser({ name: '', email: '', role: 'user' });
|
|
1876
|
+
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
const deleteUser = async (id: string) => {
|
|
1880
|
+
setLoading(true);
|
|
1881
|
+
const res = await fetchApi(\`/users/\${id}\`, { method: 'DELETE' });
|
|
1882
|
+
setResponse(res);
|
|
1883
|
+
setLoading(false);
|
|
1884
|
+
queryClient.invalidateQueries({ queryKey: ['users'] });
|
|
1885
|
+
};
|
|
1886
|
+
|
|
1887
|
+
return (
|
|
1888
|
+
<Card title="Users" icon={Users}>
|
|
1889
|
+
<div className="space-y-4">
|
|
1890
|
+
<div className="grid grid-cols-3 gap-2">
|
|
1891
|
+
<input
|
|
1892
|
+
placeholder="Name"
|
|
1893
|
+
value={newUser.name}
|
|
1894
|
+
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
|
|
1895
|
+
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
1896
|
+
/>
|
|
1897
|
+
<input
|
|
1898
|
+
placeholder="Email"
|
|
1899
|
+
value={newUser.email}
|
|
1900
|
+
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
|
1901
|
+
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
1902
|
+
/>
|
|
1903
|
+
<button onClick={createUser} disabled={loading} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded text-sm font-medium transition flex items-center justify-center gap-1">
|
|
1904
|
+
<Plus className="w-4 h-4" /> Add User
|
|
1905
|
+
</button>
|
|
1906
|
+
</div>
|
|
1907
|
+
|
|
1908
|
+
<div className="space-y-2">
|
|
1909
|
+
{isLoading ? (
|
|
1910
|
+
<div className="text-slate-400 text-sm">Loading users...</div>
|
|
1911
|
+
) : (
|
|
1912
|
+
users?.map((user) => (
|
|
1913
|
+
<div key={user.id} className="flex items-center justify-between p-2 bg-slate-900 rounded border border-slate-700">
|
|
1914
|
+
<div>
|
|
1915
|
+
<span className="font-medium">{user.name}</span>
|
|
1916
|
+
<span className="text-slate-400 text-sm ml-2">{user.email}</span>
|
|
1917
|
+
<Badge variant={user.role === 'admin' ? 'warning' : 'default'}>{user.role}</Badge>
|
|
1918
|
+
</div>
|
|
1919
|
+
<button onClick={() => deleteUser(user.id)} className="p-1 text-red-400 hover:text-red-300 transition">
|
|
1920
|
+
<Trash2 className="w-4 h-4" />
|
|
1921
|
+
</button>
|
|
1922
|
+
</div>
|
|
1923
|
+
))
|
|
1924
|
+
)}
|
|
1925
|
+
</div>
|
|
1926
|
+
|
|
1927
|
+
<ResponseDisplay response={response} loading={loading} />
|
|
1928
|
+
</div>
|
|
1929
|
+
</Card>
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function TasksPanel() {
|
|
1934
|
+
const queryClient = useQueryClient();
|
|
1935
|
+
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
1936
|
+
const [loading, setLoading] = useState(false);
|
|
1937
|
+
const [newTask, setNewTask] = useState({ title: '', priority: 'medium' as const });
|
|
1938
|
+
|
|
1939
|
+
const { data: tasks, isLoading } = useQuery({
|
|
1940
|
+
queryKey: ['tasks'],
|
|
1941
|
+
queryFn: async () => {
|
|
1942
|
+
const res = await fetchApi('/tasks');
|
|
1943
|
+
return res.data as Array<{ id: string; title: string; completed: boolean; priority: string }>;
|
|
1944
|
+
},
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
const createTask = async () => {
|
|
1948
|
+
if (!newTask.title) return;
|
|
1949
|
+
setLoading(true);
|
|
1950
|
+
const res = await fetchApi('/tasks', {
|
|
1951
|
+
method: 'POST',
|
|
1952
|
+
body: JSON.stringify(newTask),
|
|
1953
|
+
});
|
|
1954
|
+
setResponse(res);
|
|
1955
|
+
setLoading(false);
|
|
1956
|
+
setNewTask({ title: '', priority: 'medium' });
|
|
1957
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
const toggleTask = async (id: string) => {
|
|
1961
|
+
setLoading(true);
|
|
1962
|
+
const res = await fetchApi(\`/tasks/\${id}/toggle\`, { method: 'PATCH' });
|
|
1963
|
+
setResponse(res);
|
|
1964
|
+
setLoading(false);
|
|
1965
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
1966
|
+
};
|
|
1967
|
+
|
|
1968
|
+
const deleteTask = async (id: string) => {
|
|
1969
|
+
setLoading(true);
|
|
1970
|
+
const res = await fetchApi(\`/tasks/\${id}\`, { method: 'DELETE' });
|
|
1971
|
+
setResponse(res);
|
|
1972
|
+
setLoading(false);
|
|
1973
|
+
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
const priorityColor = (p: string) => {
|
|
1977
|
+
switch (p) {
|
|
1978
|
+
case 'high': return 'error';
|
|
1979
|
+
case 'medium': return 'warning';
|
|
1980
|
+
default: return 'default';
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
return (
|
|
1985
|
+
<Card title="Tasks" icon={CheckSquare}>
|
|
1986
|
+
<div className="space-y-4">
|
|
1987
|
+
<div className="grid grid-cols-3 gap-2">
|
|
1988
|
+
<input
|
|
1989
|
+
placeholder="Task title"
|
|
1990
|
+
value={newTask.title}
|
|
1991
|
+
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
|
1992
|
+
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
1993
|
+
/>
|
|
1994
|
+
<select
|
|
1995
|
+
value={newTask.priority}
|
|
1996
|
+
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as 'low' | 'medium' | 'high' })}
|
|
1997
|
+
className="px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
1998
|
+
>
|
|
1999
|
+
<option value="low">Low</option>
|
|
2000
|
+
<option value="medium">Medium</option>
|
|
2001
|
+
<option value="high">High</option>
|
|
2002
|
+
</select>
|
|
2003
|
+
<button onClick={createTask} disabled={loading} className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded text-sm font-medium transition flex items-center justify-center gap-1">
|
|
2004
|
+
<Plus className="w-4 h-4" /> Add Task
|
|
2005
|
+
</button>
|
|
2006
|
+
</div>
|
|
2007
|
+
|
|
2008
|
+
<div className="space-y-2">
|
|
2009
|
+
{isLoading ? (
|
|
2010
|
+
<div className="text-slate-400 text-sm">Loading tasks...</div>
|
|
2011
|
+
) : (
|
|
2012
|
+
tasks?.map((task) => (
|
|
2013
|
+
<div key={task.id} className="flex items-center justify-between p-2 bg-slate-900 rounded border border-slate-700">
|
|
2014
|
+
<div className="flex items-center gap-2">
|
|
2015
|
+
<input
|
|
2016
|
+
type="checkbox"
|
|
2017
|
+
checked={task.completed}
|
|
2018
|
+
onChange={() => toggleTask(task.id)}
|
|
2019
|
+
className="rounded"
|
|
2020
|
+
/>
|
|
2021
|
+
<span className={task.completed ? 'line-through text-slate-500' : ''}>{task.title}</span>
|
|
2022
|
+
<Badge variant={priorityColor(task.priority) as 'default' | 'success' | 'warning' | 'error'}>{task.priority}</Badge>
|
|
2023
|
+
</div>
|
|
2024
|
+
<button onClick={() => deleteTask(task.id)} className="p-1 text-red-400 hover:text-red-300 transition">
|
|
2025
|
+
<Trash2 className="w-4 h-4" />
|
|
2026
|
+
</button>
|
|
2027
|
+
</div>
|
|
2028
|
+
))
|
|
2029
|
+
)}
|
|
2030
|
+
</div>
|
|
2031
|
+
|
|
2032
|
+
<ResponseDisplay response={response} loading={loading} />
|
|
2033
|
+
</div>
|
|
2034
|
+
</Card>
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
function ToolsPanel() {
|
|
2039
|
+
const [echoMessage, setEchoMessage] = useState('');
|
|
2040
|
+
const [validateData, setValidateData] = useState({ email: '', age: '' });
|
|
2041
|
+
const [response, setResponse] = useState<ApiResponse | null>(null);
|
|
2042
|
+
const [loading, setLoading] = useState(false);
|
|
2043
|
+
|
|
2044
|
+
const testEcho = async () => {
|
|
2045
|
+
if (!echoMessage) return;
|
|
2046
|
+
setLoading(true);
|
|
2047
|
+
const res = await fetchApi(\`/echo?message=\${encodeURIComponent(echoMessage)}\`);
|
|
2048
|
+
setResponse(res);
|
|
2049
|
+
setLoading(false);
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
const testValidate = async () => {
|
|
2053
|
+
setLoading(true);
|
|
2054
|
+
const res = await fetchApi('/validate', {
|
|
2055
|
+
method: 'POST',
|
|
2056
|
+
body: JSON.stringify({
|
|
2057
|
+
email: validateData.email,
|
|
2058
|
+
age: parseInt(validateData.age) || 0,
|
|
2059
|
+
}),
|
|
2060
|
+
});
|
|
2061
|
+
setResponse(res);
|
|
2062
|
+
setLoading(false);
|
|
2063
|
+
};
|
|
2064
|
+
|
|
2065
|
+
return (
|
|
2066
|
+
<Card title="Test Tools" icon={Zap}>
|
|
2067
|
+
<div className="space-y-6">
|
|
2068
|
+
<div>
|
|
2069
|
+
<h4 className="text-sm font-medium text-slate-300 mb-2">Echo Endpoint</h4>
|
|
2070
|
+
<div className="flex gap-2">
|
|
2071
|
+
<input
|
|
2072
|
+
placeholder="Message to echo"
|
|
2073
|
+
value={echoMessage}
|
|
2074
|
+
onChange={(e) => setEchoMessage(e.target.value)}
|
|
2075
|
+
className="flex-1 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2076
|
+
/>
|
|
2077
|
+
<button onClick={testEcho} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition flex items-center gap-1">
|
|
2078
|
+
<Send className="w-4 h-4" /> Echo
|
|
2079
|
+
</button>
|
|
2080
|
+
</div>
|
|
2081
|
+
</div>
|
|
2082
|
+
|
|
2083
|
+
<div>
|
|
2084
|
+
<h4 className="text-sm font-medium text-slate-300 mb-2">Validation Endpoint</h4>
|
|
2085
|
+
<div className="flex gap-2">
|
|
2086
|
+
<input
|
|
2087
|
+
placeholder="Email"
|
|
2088
|
+
value={validateData.email}
|
|
2089
|
+
onChange={(e) => setValidateData({ ...validateData, email: e.target.value })}
|
|
2090
|
+
className="flex-1 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2091
|
+
/>
|
|
2092
|
+
<input
|
|
2093
|
+
placeholder="Age"
|
|
2094
|
+
type="number"
|
|
2095
|
+
value={validateData.age}
|
|
2096
|
+
onChange={(e) => setValidateData({ ...validateData, age: e.target.value })}
|
|
2097
|
+
className="w-24 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:border-blue-500 outline-none"
|
|
2098
|
+
/>
|
|
2099
|
+
<button onClick={testValidate} className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-sm font-medium transition flex items-center gap-1">
|
|
2100
|
+
<CheckSquare className="w-4 h-4" /> Validate
|
|
2101
|
+
</button>
|
|
2102
|
+
</div>
|
|
2103
|
+
</div>
|
|
2104
|
+
|
|
2105
|
+
<ResponseDisplay response={response} loading={loading} />
|
|
2106
|
+
</div>
|
|
2107
|
+
</Card>
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
export default function App() {
|
|
2112
|
+
const [activeTab, setActiveTab] = useState<Tab>('health');
|
|
2113
|
+
|
|
2114
|
+
const tabs: { id: Tab; label: string; icon: React.ElementType }[] = [
|
|
2115
|
+
{ id: 'health', label: 'Health', icon: Activity },
|
|
2116
|
+
{ id: 'users', label: 'Users', icon: Users },
|
|
2117
|
+
{ id: 'tasks', label: 'Tasks', icon: CheckSquare },
|
|
2118
|
+
{ id: 'tools', label: 'Tools', icon: Zap },
|
|
2119
|
+
];
|
|
2120
|
+
|
|
2121
|
+
return (
|
|
2122
|
+
<div className="min-h-screen p-6">
|
|
2123
|
+
<div className="max-w-4xl mx-auto">
|
|
2124
|
+
<header className="mb-8">
|
|
2125
|
+
<div className="flex items-center gap-3 mb-2">
|
|
2126
|
+
<Server className="w-8 h-8 text-blue-400" />
|
|
2127
|
+
<h1 className="text-3xl font-bold">Kozo API Tester</h1>
|
|
2128
|
+
</div>
|
|
2129
|
+
<p className="text-slate-400">Test the ${projectName} API endpoints with this interactive UI</p>
|
|
2130
|
+
</header>
|
|
2131
|
+
|
|
2132
|
+
<nav className="flex gap-1 mb-6 bg-slate-800 p-1 rounded-lg">
|
|
2133
|
+
{tabs.map(({ id, label, icon: Icon }) => (
|
|
2134
|
+
<button
|
|
2135
|
+
key={id}
|
|
2136
|
+
onClick={() => setActiveTab(id)}
|
|
2137
|
+
className={\`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition \${
|
|
2138
|
+
activeTab === id
|
|
2139
|
+
? 'bg-blue-600 text-white'
|
|
2140
|
+
: 'text-slate-400 hover:text-white hover:bg-slate-700'
|
|
2141
|
+
}\`}
|
|
2142
|
+
>
|
|
2143
|
+
<Icon className="w-4 h-4" />
|
|
2144
|
+
{label}
|
|
2145
|
+
</button>
|
|
2146
|
+
))}
|
|
2147
|
+
</nav>
|
|
2148
|
+
|
|
2149
|
+
<main>
|
|
2150
|
+
{activeTab === 'health' && <HealthPanel />}
|
|
2151
|
+
{activeTab === 'users' && <UsersPanel />}
|
|
2152
|
+
{activeTab === 'tasks' && <TasksPanel />}
|
|
2153
|
+
{activeTab === 'tools' && <ToolsPanel />}
|
|
2154
|
+
</main>
|
|
2155
|
+
|
|
2156
|
+
<footer className="mt-8 pt-6 border-t border-slate-800 text-center text-slate-500 text-sm">
|
|
2157
|
+
Built with Kozo + React + TailwindCSS
|
|
2158
|
+
</footer>
|
|
2159
|
+
</div>
|
|
2160
|
+
</div>
|
|
2161
|
+
);
|
|
2162
|
+
}
|
|
2163
|
+
`;
|
|
2164
|
+
}
|
|
2165
|
+
async function scaffoldFullstackReadme(projectDir, projectName) {
|
|
2166
|
+
const readme = `# ${projectName}
|
|
2167
|
+
|
|
2168
|
+
Full-stack application built with **Kozo** framework and React.
|
|
2169
|
+
|
|
2170
|
+
## Project Structure
|
|
2171
|
+
|
|
2172
|
+
\`\`\`
|
|
2173
|
+
apps/
|
|
2174
|
+
\u251C\u2500\u2500 api/ # Backend Kozo
|
|
2175
|
+
\u2502 \u2514\u2500\u2500 src/
|
|
2176
|
+
\u2502 \u251C\u2500\u2500 data/ # Schemas e dati in-memory
|
|
2177
|
+
\u2502 \u2502 \u2514\u2500\u2500 index.ts
|
|
2178
|
+
\u2502 \u251C\u2500\u2500 routes/ # Routes organizzate per risorsa
|
|
2179
|
+
\u2502 \u2502 \u251C\u2500\u2500 health.ts # Health check e stats
|
|
2180
|
+
\u2502 \u2502 \u251C\u2500\u2500 users.ts # CRUD Users
|
|
2181
|
+
\u2502 \u2502 \u251C\u2500\u2500 posts.ts # CRUD Posts
|
|
2182
|
+
\u2502 \u2502 \u251C\u2500\u2500 tasks.ts # CRUD Tasks
|
|
2183
|
+
\u2502 \u2502 \u251C\u2500\u2500 tools.ts # Utility endpoints
|
|
2184
|
+
\u2502 \u2502 \u2514\u2500\u2500 index.ts # Route registration
|
|
2185
|
+
\u2502 \u2514\u2500\u2500 index.ts # Entry point
|
|
2186
|
+
\u2514\u2500\u2500 web/ # Frontend React
|
|
2187
|
+
\u2514\u2500\u2500 src/
|
|
2188
|
+
\u251C\u2500\u2500 App.tsx # UI principale con tabs
|
|
2189
|
+
\u251C\u2500\u2500 main.tsx # Entry point
|
|
2190
|
+
\u2514\u2500\u2500 index.css # TailwindCSS v4
|
|
2191
|
+
\`\`\`
|
|
2192
|
+
|
|
2193
|
+
## Technologies
|
|
2194
|
+
|
|
2195
|
+
- **Backend**: Kozo (Hono-based), TypeScript, Zod
|
|
2196
|
+
- **Frontend**: React 18, TanStack Query, TailwindCSS v4, Lucide Icons
|
|
2197
|
+
- **Build**: Vite, pnpm workspace
|
|
2198
|
+
|
|
2199
|
+
## Installation
|
|
2200
|
+
|
|
2201
|
+
\`\`\`bash
|
|
2202
|
+
pnpm install
|
|
2203
|
+
\`\`\`
|
|
2204
|
+
|
|
2205
|
+
## Development
|
|
2206
|
+
|
|
2207
|
+
\`\`\`bash
|
|
2208
|
+
# Start API and Web in parallel
|
|
2209
|
+
pnpm dev
|
|
2210
|
+
|
|
2211
|
+
# Or separately:
|
|
2212
|
+
pnpm --filter @${projectName}/api dev # API on http://localhost:3000
|
|
2213
|
+
pnpm --filter @${projectName}/web dev # Web on http://localhost:5173
|
|
2214
|
+
\`\`\`
|
|
2215
|
+
|
|
2216
|
+
## API Endpoints
|
|
2217
|
+
|
|
2218
|
+
### Health & Stats
|
|
2219
|
+
- \`GET /api/health\` - Health check with uptime
|
|
2220
|
+
- \`GET /api/stats\` - Global statistics
|
|
2221
|
+
|
|
2222
|
+
### Users (Full CRUD)
|
|
2223
|
+
- \`GET /api/users\` - List all users
|
|
2224
|
+
- \`GET /api/users/:id\` - Get user by ID
|
|
2225
|
+
- \`POST /api/users\` - Create user
|
|
2226
|
+
- \`PUT /api/users/:id\` - Update user
|
|
2227
|
+
- \`DELETE /api/users/:id\` - Delete user
|
|
2228
|
+
|
|
2229
|
+
### Posts (Full CRUD)
|
|
2230
|
+
- \`GET /api/posts?published=true\` - List posts (optional filter)
|
|
2231
|
+
- \`GET /api/posts/:id\` - Get post by ID
|
|
2232
|
+
- \`POST /api/posts\` - Create post
|
|
2233
|
+
- \`PUT /api/posts/:id\` - Update post
|
|
2234
|
+
- \`DELETE /api/posts/:id\` - Delete post
|
|
2235
|
+
|
|
2236
|
+
### Tasks (Full CRUD)
|
|
2237
|
+
- \`GET /api/tasks?completed=true&priority=high\` - List tasks (optional filters)
|
|
2238
|
+
- \`GET /api/tasks/:id\` - Get task by ID
|
|
2239
|
+
- \`POST /api/tasks\` - Create task
|
|
2240
|
+
- \`PUT /api/tasks/:id\` - Update task
|
|
2241
|
+
- \`PATCH /api/tasks/:id/toggle\` - Toggle completion
|
|
2242
|
+
- \`DELETE /api/tasks/:id\` - Delete task
|
|
2243
|
+
|
|
2244
|
+
### Tools
|
|
2245
|
+
- \`GET /api/echo?message=hello\` - Echo test
|
|
2246
|
+
- \`POST /api/validate\` - Zod validation (email + age)
|
|
2247
|
+
|
|
2248
|
+
## Type Safety
|
|
2249
|
+
|
|
2250
|
+
The frontend uses Hono RPC client for type-safe API calls:
|
|
2251
|
+
\`\`\`typescript
|
|
2252
|
+
const res = await client.api.users.$get();
|
|
2253
|
+
const users = await res.json(); // Fully typed!
|
|
2254
|
+
\`\`\`
|
|
2255
|
+
`;
|
|
2256
|
+
await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "README.md"), readme);
|
|
2257
|
+
}
|
|
1076
2258
|
|
|
1077
2259
|
// src/utils/ascii-art.ts
|
|
1078
2260
|
var import_picocolors = __toESM(require("picocolors"));
|
|
@@ -1112,39 +2294,64 @@ async function newCommand(projectName) {
|
|
|
1112
2294
|
}
|
|
1113
2295
|
});
|
|
1114
2296
|
},
|
|
2297
|
+
runtime: () => p.select({
|
|
2298
|
+
message: "Target runtime",
|
|
2299
|
+
options: [
|
|
2300
|
+
{ value: "node", label: "Node.js / Docker", hint: "Maximum compatibility (default)" },
|
|
2301
|
+
{ value: "cloudflare", label: "Cloudflare Workers", hint: "Edge-native, global deployment" },
|
|
2302
|
+
{ value: "bun", label: "Bun", hint: "Maximum local speed" }
|
|
2303
|
+
],
|
|
2304
|
+
initialValue: "node"
|
|
2305
|
+
}),
|
|
1115
2306
|
template: () => p.select({
|
|
1116
2307
|
message: "Template",
|
|
1117
2308
|
options: [
|
|
1118
2309
|
{ value: "complete", label: "Complete Server", hint: "Full production-ready app (Auth, CRUD, Stats)" },
|
|
1119
2310
|
{ value: "starter", label: "Starter", hint: "Minimal setup with database" },
|
|
1120
|
-
{ value: "
|
|
1121
|
-
{ value: "ecommerce", label: "E-commerce", hint: "Products + Orders (coming soon)" }
|
|
2311
|
+
{ value: "api-only", label: "API Only", hint: "Minimal, no database" }
|
|
1122
2312
|
]
|
|
1123
2313
|
}),
|
|
1124
2314
|
database: ({ results }) => {
|
|
1125
|
-
if (results.template === "complete") {
|
|
2315
|
+
if (results.template === "complete" || results.template === "api-only") {
|
|
1126
2316
|
return Promise.resolve("none");
|
|
1127
2317
|
}
|
|
1128
2318
|
return p.select({
|
|
1129
|
-
message: "Database
|
|
2319
|
+
message: "Database",
|
|
1130
2320
|
options: [
|
|
1131
|
-
{ value: "
|
|
1132
|
-
{ value: "
|
|
1133
|
-
{ value: "
|
|
2321
|
+
{ value: "sqlite", label: "SQLite / Turso", hint: "Perfect for Edge and local dev" },
|
|
2322
|
+
{ value: "postgresql", label: "PostgreSQL + Drizzle", hint: "Standard Enterprise" },
|
|
2323
|
+
{ value: "mysql", label: "MySQL + Drizzle", hint: "PlanetScale compatible" }
|
|
1134
2324
|
]
|
|
1135
2325
|
});
|
|
1136
2326
|
},
|
|
2327
|
+
frontend: () => p.select({
|
|
2328
|
+
message: "Frontend",
|
|
2329
|
+
options: [
|
|
2330
|
+
{ value: "none", label: "None (API only)", hint: "Backend microservice" },
|
|
2331
|
+
{ value: "react", label: "React (Vite + TanStack Query)", hint: "Full-stack type-safe" },
|
|
2332
|
+
{ value: "solid", label: "SolidJS (Vite)", hint: "Performance purist choice" },
|
|
2333
|
+
{ value: "vue", label: "Vue (Vite)", hint: "Progressive framework" }
|
|
2334
|
+
],
|
|
2335
|
+
initialValue: "none"
|
|
2336
|
+
}),
|
|
2337
|
+
extras: () => p.multiselect({
|
|
2338
|
+
message: "Extras",
|
|
2339
|
+
options: [
|
|
2340
|
+
{ value: "docker", label: "Docker", hint: "Multi-stage Dockerfile" },
|
|
2341
|
+
{ value: "github-actions", label: "GitHub Actions", hint: "CI/CD pipeline" },
|
|
2342
|
+
{ value: "auth", label: "Authentication", hint: "JWT middleware + login routes" }
|
|
2343
|
+
],
|
|
2344
|
+
required: false
|
|
2345
|
+
}),
|
|
1137
2346
|
packageSource: () => p.select({
|
|
1138
|
-
message: "Package source
|
|
2347
|
+
message: "Package source",
|
|
1139
2348
|
options: [
|
|
1140
|
-
{ value: "npm", label: "npm registry", hint: "
|
|
1141
|
-
{ value: "local", label: "Local workspace", hint: "Link to
|
|
2349
|
+
{ value: "npm", label: "npm registry", hint: "Published version (recommended)" },
|
|
2350
|
+
{ value: "local", label: "Local workspace", hint: "Link to monorepo (dev only)" }
|
|
1142
2351
|
],
|
|
1143
2352
|
initialValue: "npm"
|
|
1144
2353
|
}),
|
|
1145
|
-
install: () =>
|
|
1146
|
-
return Promise.resolve(true);
|
|
1147
|
-
}
|
|
2354
|
+
install: () => Promise.resolve(true)
|
|
1148
2355
|
},
|
|
1149
2356
|
{
|
|
1150
2357
|
onCancel: () => {
|
|
@@ -1158,8 +2365,11 @@ async function newCommand(projectName) {
|
|
|
1158
2365
|
try {
|
|
1159
2366
|
await scaffoldProject({
|
|
1160
2367
|
projectName: project.name,
|
|
1161
|
-
|
|
2368
|
+
runtime: project.runtime,
|
|
1162
2369
|
template: project.template,
|
|
2370
|
+
database: project.database,
|
|
2371
|
+
frontend: project.frontend,
|
|
2372
|
+
extras: project.extras,
|
|
1163
2373
|
packageSource: project.packageSource
|
|
1164
2374
|
});
|
|
1165
2375
|
s.stop("Project structure created!");
|
|
@@ -1200,10 +2410,295 @@ ${import_picocolors2.default.dim("Documentation:")} ${import_picocolors2.default
|
|
|
1200
2410
|
`);
|
|
1201
2411
|
}
|
|
1202
2412
|
|
|
2413
|
+
// src/commands/build.ts
|
|
2414
|
+
var import_execa2 = require("execa");
|
|
2415
|
+
var import_picocolors3 = __toESM(require("picocolors"));
|
|
2416
|
+
var import_fs_extra2 = __toESM(require("fs-extra"));
|
|
2417
|
+
var import_node_path4 = __toESM(require("path"));
|
|
2418
|
+
|
|
2419
|
+
// src/routing/manifest.ts
|
|
2420
|
+
var import_node_crypto = require("crypto");
|
|
2421
|
+
var import_node_fs2 = require("fs");
|
|
2422
|
+
var import_node_path3 = require("path");
|
|
2423
|
+
var import_glob2 = require("glob");
|
|
2424
|
+
|
|
2425
|
+
// src/routing/scan.ts
|
|
2426
|
+
var import_glob = require("glob");
|
|
2427
|
+
var import_node_path2 = require("path");
|
|
2428
|
+
var import_node_fs = require("fs");
|
|
2429
|
+
var HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
2430
|
+
function fileToRoute(filePath) {
|
|
2431
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
2432
|
+
const lastDot = normalized.lastIndexOf(".");
|
|
2433
|
+
const withoutExt = lastDot !== -1 ? normalized.slice(0, lastDot) : normalized;
|
|
2434
|
+
const parts = withoutExt.split("/").filter(Boolean);
|
|
2435
|
+
if (parts.length === 0) return null;
|
|
2436
|
+
const last = parts[parts.length - 1].toLowerCase();
|
|
2437
|
+
let method = "get";
|
|
2438
|
+
let includeLast = true;
|
|
2439
|
+
if (HTTP_METHODS.includes(last)) {
|
|
2440
|
+
method = last;
|
|
2441
|
+
includeLast = false;
|
|
2442
|
+
} else if (last === "index") {
|
|
2443
|
+
includeLast = false;
|
|
2444
|
+
}
|
|
2445
|
+
const segments = includeLast ? parts : parts.slice(0, -1);
|
|
2446
|
+
const urlSegments = segments.map((seg) => {
|
|
2447
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) return "*";
|
|
2448
|
+
if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
|
|
2449
|
+
return seg;
|
|
2450
|
+
});
|
|
2451
|
+
const path3 = "/" + urlSegments.join("/");
|
|
2452
|
+
return { path: path3, method };
|
|
2453
|
+
}
|
|
2454
|
+
function extractParams(urlPath) {
|
|
2455
|
+
return urlPath.split("/").filter((seg) => seg.startsWith(":")).map((seg) => seg.slice(1));
|
|
2456
|
+
}
|
|
2457
|
+
function isRouteFile(file) {
|
|
2458
|
+
const name = file.split("/").pop() ?? "";
|
|
2459
|
+
if (name.startsWith("_")) return false;
|
|
2460
|
+
if (name.includes(".test.") || name.includes(".spec.")) return false;
|
|
2461
|
+
return true;
|
|
2462
|
+
}
|
|
2463
|
+
function detectSchemas(absolutePath) {
|
|
2464
|
+
let source = "";
|
|
2465
|
+
try {
|
|
2466
|
+
source = (0, import_node_fs.readFileSync)(absolutePath, "utf8");
|
|
2467
|
+
} catch {
|
|
2468
|
+
return { hasBodySchema: false, hasQuerySchema: false };
|
|
2469
|
+
}
|
|
2470
|
+
const hasBodySchema = /export\s+(const|let|var)\s+body(Schema)?\s*[=:]/.test(source) || /export\s+\{[^}]*\bbody(Schema)?\b[^}]*\}/.test(source);
|
|
2471
|
+
const hasQuerySchema = /export\s+(const|let|var)\s+query(Schema)?\s*[=:]/.test(source) || /export\s+\{[^}]*\bquery(Schema)?\b[^}]*\}/.test(source);
|
|
2472
|
+
return { hasBodySchema, hasQuerySchema };
|
|
2473
|
+
}
|
|
2474
|
+
function routeScore(urlPath) {
|
|
2475
|
+
const segments = urlPath.split("/").filter(Boolean);
|
|
2476
|
+
let score = segments.length * 10;
|
|
2477
|
+
for (const seg of segments) {
|
|
2478
|
+
if (seg === "*") score -= 100;
|
|
2479
|
+
else if (seg.startsWith(":")) score -= 5;
|
|
2480
|
+
else score += 1;
|
|
2481
|
+
}
|
|
2482
|
+
return score;
|
|
2483
|
+
}
|
|
2484
|
+
async function scanRoutes(options) {
|
|
2485
|
+
const { routesDir, verbose = false } = options;
|
|
2486
|
+
const files = await (0, import_glob.glob)("**/*.{ts,js}", {
|
|
2487
|
+
cwd: routesDir,
|
|
2488
|
+
nodir: true,
|
|
2489
|
+
ignore: ["**/_*.ts", "**/_*.js", "**/*.test.ts", "**/*.spec.ts", "**/*.test.js", "**/*.spec.js"]
|
|
2490
|
+
});
|
|
2491
|
+
const routes = [];
|
|
2492
|
+
for (const file of files) {
|
|
2493
|
+
if (!isRouteFile(file)) continue;
|
|
2494
|
+
const parsed = fileToRoute(file);
|
|
2495
|
+
if (!parsed) continue;
|
|
2496
|
+
const absolutePath = (0, import_node_path2.join)(routesDir, file);
|
|
2497
|
+
const { hasBodySchema, hasQuerySchema } = detectSchemas(absolutePath);
|
|
2498
|
+
const params = extractParams(parsed.path);
|
|
2499
|
+
routes.push({
|
|
2500
|
+
path: parsed.path,
|
|
2501
|
+
method: parsed.method,
|
|
2502
|
+
handler: absolutePath,
|
|
2503
|
+
relativePath: file,
|
|
2504
|
+
params,
|
|
2505
|
+
hasBodySchema,
|
|
2506
|
+
hasQuerySchema
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
routes.sort((a, b) => routeScore(b.path) - routeScore(a.path));
|
|
2510
|
+
if (verbose) {
|
|
2511
|
+
for (const r of routes) {
|
|
2512
|
+
const method = r.method.toUpperCase().padEnd(6);
|
|
2513
|
+
console.log(` ${method} ${r.path} (${r.relativePath})`);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
return routes;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// src/routing/manifest.ts
|
|
2520
|
+
async function hashRouteFiles(routesDir) {
|
|
2521
|
+
const files = await (0, import_glob2.glob)("**/*.{ts,js}", {
|
|
2522
|
+
cwd: routesDir,
|
|
2523
|
+
nodir: true,
|
|
2524
|
+
ignore: ["**/_*.ts", "**/_*.js", "**/*.test.ts", "**/*.spec.ts", "**/*.test.js", "**/*.spec.js"]
|
|
2525
|
+
});
|
|
2526
|
+
files.sort();
|
|
2527
|
+
const hash = (0, import_node_crypto.createHash)("sha256");
|
|
2528
|
+
for (const file of files) {
|
|
2529
|
+
hash.update(file);
|
|
2530
|
+
try {
|
|
2531
|
+
const content = (0, import_node_fs2.readFileSync)((0, import_node_path3.join)(routesDir, file));
|
|
2532
|
+
hash.update(content);
|
|
2533
|
+
} catch {
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
return hash.digest("hex");
|
|
2537
|
+
}
|
|
2538
|
+
function readExistingManifest(manifestPath) {
|
|
2539
|
+
if (!(0, import_node_fs2.existsSync)(manifestPath)) return null;
|
|
2540
|
+
try {
|
|
2541
|
+
const raw = (0, import_node_fs2.readFileSync)(manifestPath, "utf8");
|
|
2542
|
+
return JSON.parse(raw);
|
|
2543
|
+
} catch {
|
|
2544
|
+
return null;
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
async function generateManifest(options) {
|
|
2548
|
+
const {
|
|
2549
|
+
routesDir,
|
|
2550
|
+
projectRoot,
|
|
2551
|
+
outputPath = (0, import_node_path3.join)(projectRoot, "routes-manifest.json"),
|
|
2552
|
+
cache = true,
|
|
2553
|
+
verbose = false
|
|
2554
|
+
} = options;
|
|
2555
|
+
const contentHash = await hashRouteFiles(routesDir);
|
|
2556
|
+
if (cache) {
|
|
2557
|
+
const existing = readExistingManifest(outputPath);
|
|
2558
|
+
if (existing && existing.contentHash === contentHash && existing.version === MANIFEST_VERSION) {
|
|
2559
|
+
if (verbose) {
|
|
2560
|
+
console.log(` \u2713 routes-manifest.json up-to-date (hash: ${contentHash.slice(0, 8)}\u2026)`);
|
|
2561
|
+
}
|
|
2562
|
+
return existing;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
if (verbose) {
|
|
2566
|
+
console.log(` Scanning routes in: ${routesDir}`);
|
|
2567
|
+
}
|
|
2568
|
+
const scanned = await scanRoutes({ routesDir, verbose: false });
|
|
2569
|
+
const entries = scanned.map((r) => ({
|
|
2570
|
+
path: r.path,
|
|
2571
|
+
method: r.method,
|
|
2572
|
+
handler: r.relativePath,
|
|
2573
|
+
// relative to routesDir; callers can join with projectRoot
|
|
2574
|
+
params: r.params,
|
|
2575
|
+
hasBodySchema: r.hasBodySchema,
|
|
2576
|
+
hasQuerySchema: r.hasQuerySchema
|
|
2577
|
+
}));
|
|
2578
|
+
const manifest = {
|
|
2579
|
+
version: MANIFEST_VERSION,
|
|
2580
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2581
|
+
contentHash,
|
|
2582
|
+
routes: entries
|
|
2583
|
+
};
|
|
2584
|
+
const dir = (0, import_node_path3.dirname)(outputPath);
|
|
2585
|
+
if (!(0, import_node_fs2.existsSync)(dir)) {
|
|
2586
|
+
(0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
2587
|
+
}
|
|
2588
|
+
(0, import_node_fs2.writeFileSync)(outputPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
2589
|
+
if (verbose) {
|
|
2590
|
+
console.log(` \u2713 Generated routes-manifest.json (${entries.length} routes, hash: ${contentHash.slice(0, 8)}\u2026)`);
|
|
2591
|
+
}
|
|
2592
|
+
return manifest;
|
|
2593
|
+
}
|
|
2594
|
+
var MANIFEST_VERSION = 1;
|
|
2595
|
+
|
|
2596
|
+
// src/commands/build.ts
|
|
2597
|
+
function printBox(title) {
|
|
2598
|
+
const width = 50;
|
|
2599
|
+
const pad = Math.max(0, Math.floor((width - title.length) / 2));
|
|
2600
|
+
const line = "\u2500".repeat(width);
|
|
2601
|
+
console.log(import_picocolors3.default.cyan(`\u250C${line}\u2510`));
|
|
2602
|
+
console.log(import_picocolors3.default.cyan("\u2502") + " ".repeat(pad) + import_picocolors3.default.bold(title) + " ".repeat(width - pad - title.length) + import_picocolors3.default.cyan("\u2502"));
|
|
2603
|
+
console.log(import_picocolors3.default.cyan(`\u2514${line}\u2518`));
|
|
2604
|
+
console.log();
|
|
2605
|
+
}
|
|
2606
|
+
function step(n, total, label) {
|
|
2607
|
+
console.log(import_picocolors3.default.dim(`[${n}/${total}]`) + " " + import_picocolors3.default.cyan("\u2192") + " " + label);
|
|
2608
|
+
}
|
|
2609
|
+
function ok(label) {
|
|
2610
|
+
console.log(import_picocolors3.default.green(" \u2713") + " " + label);
|
|
2611
|
+
}
|
|
2612
|
+
function fail(label, err) {
|
|
2613
|
+
console.log(import_picocolors3.default.red(" \u2717") + " " + label);
|
|
2614
|
+
if (err) {
|
|
2615
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2616
|
+
console.log(import_picocolors3.default.dim(" " + msg));
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
async function buildCommand(options = {}) {
|
|
2620
|
+
console.clear();
|
|
2621
|
+
printBox("Kozo Build");
|
|
2622
|
+
const cwd = process.cwd();
|
|
2623
|
+
const TOTAL_STEPS = options.noManifest ? 3 : 4;
|
|
2624
|
+
let currentStep = 0;
|
|
2625
|
+
currentStep++;
|
|
2626
|
+
step(currentStep, TOTAL_STEPS, "Checking project structure\u2026");
|
|
2627
|
+
if (!import_fs_extra2.default.existsSync(import_node_path4.default.join(cwd, "package.json"))) {
|
|
2628
|
+
fail("No package.json found. Run this command inside a Kozo project.");
|
|
2629
|
+
process.exit(1);
|
|
2630
|
+
}
|
|
2631
|
+
if (!import_fs_extra2.default.existsSync(import_node_path4.default.join(cwd, "node_modules"))) {
|
|
2632
|
+
fail("Dependencies not installed. Run `npm install` first.");
|
|
2633
|
+
process.exit(1);
|
|
2634
|
+
}
|
|
2635
|
+
ok("Project structure OK");
|
|
2636
|
+
currentStep++;
|
|
2637
|
+
step(currentStep, TOTAL_STEPS, "Cleaning previous build\u2026");
|
|
2638
|
+
try {
|
|
2639
|
+
await import_fs_extra2.default.remove(import_node_path4.default.join(cwd, "dist"));
|
|
2640
|
+
ok("dist/ cleaned");
|
|
2641
|
+
} catch (err) {
|
|
2642
|
+
fail("Failed to clean dist/", err);
|
|
2643
|
+
process.exit(1);
|
|
2644
|
+
}
|
|
2645
|
+
if (!options.noManifest) {
|
|
2646
|
+
currentStep++;
|
|
2647
|
+
step(currentStep, TOTAL_STEPS, "Generating routes manifest\u2026");
|
|
2648
|
+
const routesDirRel = options.routesDir ?? "src/routes";
|
|
2649
|
+
const routesDirAbs = import_node_path4.default.join(cwd, routesDirRel);
|
|
2650
|
+
if (!import_fs_extra2.default.existsSync(routesDirAbs)) {
|
|
2651
|
+
console.log(import_picocolors3.default.dim(` \u26A0 Routes directory not found (${routesDirRel}), skipping manifest.`));
|
|
2652
|
+
} else {
|
|
2653
|
+
try {
|
|
2654
|
+
const manifestOutAbs = options.manifestOut ? import_node_path4.default.join(cwd, options.manifestOut) : import_node_path4.default.join(cwd, "routes-manifest.json");
|
|
2655
|
+
const manifest = await generateManifest({
|
|
2656
|
+
routesDir: routesDirAbs,
|
|
2657
|
+
projectRoot: cwd,
|
|
2658
|
+
outputPath: manifestOutAbs,
|
|
2659
|
+
cache: !options.forceManifest,
|
|
2660
|
+
verbose: true
|
|
2661
|
+
});
|
|
2662
|
+
ok(`Manifest ready \u2014 ${manifest.routes.length} route(s)`);
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
fail("Manifest generation failed", err);
|
|
2665
|
+
console.log(import_picocolors3.default.dim(" Continuing build without manifest\u2026"));
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
currentStep++;
|
|
2670
|
+
step(currentStep, TOTAL_STEPS, "Compiling with tsup\u2026");
|
|
2671
|
+
try {
|
|
2672
|
+
const tsupArgs = ["tsup", ...options.tsupArgs ?? []];
|
|
2673
|
+
await (0, import_execa2.execa)("npx", tsupArgs, {
|
|
2674
|
+
cwd,
|
|
2675
|
+
stdio: "inherit",
|
|
2676
|
+
env: { ...process.env, NODE_ENV: "production" }
|
|
2677
|
+
});
|
|
2678
|
+
ok("Compilation complete");
|
|
2679
|
+
} catch (err) {
|
|
2680
|
+
fail("tsup compilation failed", err);
|
|
2681
|
+
process.exit(1);
|
|
2682
|
+
}
|
|
2683
|
+
console.log();
|
|
2684
|
+
console.log(import_picocolors3.default.green("\u2705 Build successful"));
|
|
2685
|
+
console.log();
|
|
2686
|
+
}
|
|
2687
|
+
|
|
1203
2688
|
// src/index.ts
|
|
1204
2689
|
var program = new import_commander.Command();
|
|
1205
2690
|
program.name("kozo").description("CLI to scaffold new Kozo Framework projects").version("0.2.6");
|
|
1206
2691
|
program.argument("[project-name]", "Name of the project").action(async (projectName) => {
|
|
1207
2692
|
await newCommand(projectName);
|
|
1208
2693
|
});
|
|
2694
|
+
program.command("build").description("Build the project (generates routes manifest then compiles with tsup)").option("--no-manifest", "Skip routes-manifest.json generation").option("--force-manifest", "Force manifest regeneration even if routes are unchanged").option("--routes-dir <dir>", "Routes directory relative to project root", "src/routes").option("--manifest-out <path>", "Output path for routes-manifest.json relative to project root").allowUnknownOption().action(async (opts, cmd) => {
|
|
2695
|
+
const tsupArgs = cmd.args.length > 0 ? cmd.args : void 0;
|
|
2696
|
+
await buildCommand({
|
|
2697
|
+
noManifest: opts.noManifest === false || opts.manifest === false,
|
|
2698
|
+
forceManifest: opts.forceManifest ?? false,
|
|
2699
|
+
routesDir: opts.routesDir,
|
|
2700
|
+
manifestOut: opts.manifestOut,
|
|
2701
|
+
tsupArgs
|
|
2702
|
+
});
|
|
2703
|
+
});
|
|
1209
2704
|
program.parse();
|