@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.
Files changed (2) hide show
  1. package/lib/index.js +1632 -137
  2. 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.1.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
- const indexTs = `import { createKozo } from '@kozojs/core';
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
- const app = createKozo({
473
- port: Number(process.env.PORT) || 3000,
474
- });
475
-
476
- // Register all routes
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
- console.log('\u{1F525} Kozo server running on http://localhost:3000');
484
- console.log('\u{1F4CA} Features: Auth, Users CRUD, Posts, Stats');
485
- console.log('\u26A1 Optimized with pre-compiled handlers and Zod schemas');
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.listen();
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** - Production-ready server template
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, me)
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 Optimized**
515
- - Pre-compiled Zod schemas (no runtime overhead)
516
- - Fast-path routes for health checks
517
- - Optimized handler closures
518
- - Zero runtime decisions
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
- \`\`\`bash
593
+ \\\`\\\`\\\`bash
528
594
  # Install dependencies
529
- npm install
595
+ pnpm install # or npm install
530
596
 
531
597
  # Start development server
532
- npm run dev
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
- ### Authentication
605
+ ### Public
540
606
  | Method | Endpoint | Description |
541
607
  |--------|----------|-------------|
542
- | POST | /auth/login | Login with email/password |
543
- | GET | /auth/me | Get current user |
608
+ | GET | /health | Health check |
609
+ | POST | /auth/login | Login \u2192 JWT token |
544
610
 
545
- ### Users
611
+ ### Protected (requires \\\`Authorization: Bearer <token>\\\`)
546
612
  | Method | Endpoint | Description |
547
613
  |--------|----------|-------------|
548
- | GET | /users | List all users (paginated) |
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
- ### Create User
570
- \`\`\`bash
571
- curl -X POST http://localhost:3000/users \\
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
- "name": "Alice Smith",
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
- ### Login
581
- \`\`\`bash
582
- curl -X POST http://localhost:3000/auth/login \\
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
- ### List Users (Paginated)
591
- \`\`\`bash
592
- curl "http://localhost:3000/users?page=1&limit=10"
593
- \`\`\`
643
+ # 4. Filter posts
644
+ curl -H "Authorization: Bearer $TOKEN" \\
645
+ "http://localhost:3000/posts?published=true&tag=framework"
594
646
 
595
- ### Filter Posts
596
- \`\`\`bash
597
- curl "http://localhost:3000/posts?published=true&tag=framework"
598
- \`\`\`
647
+ # 5. Health check (no auth needed)
648
+ curl http://localhost:3000/health
649
+ \\\`\\\`\\\`
599
650
 
600
- ### Get Statistics
601
- \`\`\`bash
602
- curl http://localhost:3000/stats
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 # Common schemas
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 # Helper functions
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
- ## Development
634
-
635
- \`\`\`bash
636
- npm run dev # Start with hot reload
637
- npm run build # Build for production
638
- npm start # Run production build
639
- npm run type-check # TypeScript validation
640
- \`\`\`
641
-
642
- ## Performance Notes
643
-
644
- This template uses Kozo's optimized patterns:
645
- - **Pre-compiled schemas**: Zod schemas compiled to Ajv validators at boot
646
- - **Handler closures**: Routes capture dependencies at startup
647
- - **Fast paths**: Simple routes skip unnecessary middleware
648
- - **Minimal allocations**: Context objects only include what's needed
649
-
650
- These optimizations make Kozo competitive with Fastify while providing:
651
- - Better type safety with Zod
652
- - Simpler API than NestJS
653
- - More features than plain Hono
654
-
655
- ## Next Steps
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
- const token = \`mock_jwt_\${user.id}_\${Date.now()}\`;
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) => users[0]);
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: "saas", label: "SaaS", hint: "Auth + Stripe + Email (coming soon)" },
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 provider",
2319
+ message: "Database",
1130
2320
  options: [
1131
- { value: "postgresql", label: "PostgreSQL", hint: "Neon, Supabase, Railway" },
1132
- { value: "mysql", label: "MySQL", hint: "PlanetScale" },
1133
- { value: "sqlite", label: "SQLite", hint: "Turso, local dev" }
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 for @kozojs/core",
2347
+ message: "Package source",
1139
2348
  options: [
1140
- { value: "npm", label: "npm registry", hint: "Use published version (recommended)" },
1141
- { value: "local", label: "Local workspace", hint: "Link to local monorepo (for development)" }
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
- database: project.database,
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();