@kozojs/cli 0.1.29 → 0.1.31

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 +2366 -1635
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -31,231 +31,13 @@ var p = __toESM(require("@clack/prompts"));
31
31
  var import_picocolors2 = __toESM(require("picocolors"));
32
32
  var import_execa = require("execa");
33
33
 
34
- // src/utils/scaffold.ts
34
+ // src/utils/scaffold/index.ts
35
+ var import_fs_extra5 = __toESM(require("fs-extra"));
36
+ var import_node_path5 = __toESM(require("path"));
37
+
38
+ // src/utils/scaffold/template-complete.ts
35
39
  var import_fs_extra = __toESM(require("fs-extra"));
36
40
  var import_node_path = __toESM(require("path"));
37
- async function scaffoldProject(options) {
38
- const { projectName, runtime, database, dbPort, auth, packageSource, template, frontend, extras } = options;
39
- const projectDir = import_node_path.default.resolve(process.cwd(), projectName);
40
- const kozoCoreDep = packageSource === "local" ? "workspace:*" : "^0.3.6";
41
- if (frontend !== "none") {
42
- await scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth, frontend, extras, template);
43
- return;
44
- }
45
- if (template === "complete") {
46
- await scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth);
47
- if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
48
- if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
49
- if (extras.includes("github-actions")) await createGitHubActions(projectDir);
50
- return;
51
- }
52
- if (template === "api-only") {
53
- await scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, runtime);
54
- if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
55
- if (extras.includes("github-actions")) await createGitHubActions(projectDir);
56
- return;
57
- }
58
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "routes"));
59
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "db"));
60
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src", "services"));
61
- const packageJson = {
62
- name: projectName,
63
- version: "0.1.0",
64
- type: "module",
65
- scripts: {
66
- dev: "tsx watch src/index.ts",
67
- build: "tsc",
68
- start: "node dist/index.js",
69
- "db:generate": "drizzle-kit generate",
70
- "db:push": "drizzle-kit push",
71
- "db:studio": "drizzle-kit studio"
72
- },
73
- dependencies: {
74
- "@kozojs/core": kozoCoreDep,
75
- "uWebSockets.js": "github:uNetworking/uWebSockets.js#6609a88ffa9a16ac5158046761356ce03250a0df",
76
- hono: "^4.12.5",
77
- zod: "^4.0.0",
78
- "drizzle-orm": "^0.36.0",
79
- ...database === "postgresql" && { postgres: "^3.4.8" },
80
- ...database === "mysql" && { mysql2: "^3.11.0" },
81
- ...database === "sqlite" && { "better-sqlite3": "^11.0.0" }
82
- },
83
- devDependencies: {
84
- "@types/node": "^22.0.0",
85
- tsx: "^4.21.0",
86
- typescript: "^5.6.0",
87
- "drizzle-kit": "^0.28.0",
88
- ...database === "sqlite" && { "@types/better-sqlite3": "^7.6.0" }
89
- }
90
- };
91
- await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "package.json"), packageJson, { spaces: 2 });
92
- const tsconfig = {
93
- compilerOptions: {
94
- target: "ES2022",
95
- module: "ESNext",
96
- moduleResolution: "bundler",
97
- strict: true,
98
- esModuleInterop: true,
99
- skipLibCheck: true,
100
- outDir: "dist",
101
- rootDir: "src",
102
- declaration: true
103
- },
104
- include: ["src/**/*"],
105
- exclude: ["node_modules", "dist"]
106
- };
107
- await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
108
- const drizzleConfig = `import { defineConfig } from 'drizzle-kit';
109
-
110
- export default defineConfig({
111
- schema: './src/db/schema.ts',
112
- out: './drizzle',
113
- dialect: '${database === "postgresql" ? "postgresql" : database === "mysql" ? "mysql" : "sqlite"}',
114
- dbCredentials: {
115
- ${database === "sqlite" ? "url: './data.db'" : "url: process.env.DATABASE_URL!"}
116
- }
117
- });
118
- `;
119
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "drizzle.config.ts"), drizzleConfig);
120
- const envExample = `# Database
121
- ${database === "sqlite" ? "# SQLite uses local file, no URL needed" : "DATABASE_URL="}
122
-
123
- # Server
124
- PORT=3000
125
- `;
126
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".env.example"), envExample);
127
- const gitignore = `node_modules/
128
- dist/
129
- .env
130
- *.db
131
- .turbo/
132
- `;
133
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), gitignore);
134
- const indexTs = `import { createKozo } from '@kozojs/core';
135
- import { services } from './services/index.js';
136
-
137
- const app = createKozo({
138
- services,
139
- port: Number(process.env.PORT) || 3000,
140
- openapi: {
141
- info: {
142
- title: '${projectName} API',
143
- version: '1.0.0',
144
- description: 'API documentation for ${projectName}'
145
- },
146
- servers: [
147
- {
148
- url: 'http://localhost:3000',
149
- description: 'Development server'
150
- }
151
- ]
152
- }
153
- });
154
-
155
- await app.nativeListen();
156
- `;
157
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "index.ts"), indexTs);
158
- const servicesTs = `import { db } from '../db/index.js';
159
-
160
- export const services = {
161
- db
162
- };
163
-
164
- // Type augmentation for autocomplete
165
- declare module '@kozojs/core' {
166
- interface Services {
167
- db: typeof db;
168
- }
169
- }
170
- `;
171
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "services", "index.ts"), servicesTs);
172
- const schemaTs = getDatabaseSchema(database);
173
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "db", "schema.ts"), schemaTs);
174
- const dbIndexTs = getDatabaseIndex(database);
175
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "db", "index.ts"), dbIndexTs);
176
- if (database === "sqlite") {
177
- const seedTs = getSQLiteSeed();
178
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "db", "seed.ts"), seedTs);
179
- }
180
- await createExampleRoutes(projectDir);
181
- const readme = `# ${projectName}
182
-
183
- Built with \u{1F525} **Kozo Framework**
184
-
185
- ## Getting Started
186
-
187
- \`\`\`bash
188
- # Install dependencies
189
- pnpm install
190
-
191
- # Start development server
192
- pnpm dev
193
- \`\`\`
194
-
195
- The server will start at http://localhost:3000
196
-
197
- ## Try the API
198
-
199
- \`\`\`bash
200
- # Get all users
201
- curl http://localhost:3000/users
202
-
203
- # Create a new user
204
- curl -X POST http://localhost:3000/users \\
205
- -H "Content-Type: application/json" \\
206
- -d '{"name":"Alice","email":"alice@example.com"}'
207
-
208
- # Health check
209
- curl http://localhost:3000
210
-
211
- # Open Swagger UI
212
- open http://localhost:3000/swagger
213
- \`\`\`
214
-
215
- ## API Documentation
216
-
217
- Once the server is running, visit:
218
- - **Swagger UI**: http://localhost:3000/swagger
219
- - **OpenAPI JSON**: http://localhost:3000/doc
220
-
221
- ## Project Structure
222
-
223
- \`\`\`
224
- src/
225
- \u251C\u2500\u2500 db/
226
- \u2502 \u251C\u2500\u2500 schema.ts # Drizzle schema
227
- \u2502 \u251C\u2500\u2500 seed.ts # Database initialization${database === "sqlite" ? " (SQLite)" : ""}
228
- \u2502 \u2514\u2500\u2500 index.ts # Database client
229
- \u251C\u2500\u2500 routes/
230
- \u2502 \u251C\u2500\u2500 index.ts # GET /
231
- \u2502 \u2514\u2500\u2500 users/
232
- \u2502 \u251C\u2500\u2500 get.ts # GET /users
233
- \u2502 \u2514\u2500\u2500 post.ts # POST /users
234
- \u251C\u2500\u2500 services/
235
- \u2502 \u2514\u2500\u2500 index.ts # Service definitions
236
- \u2514\u2500\u2500 index.ts # Entry point
237
- \`\`\`
238
-
239
- ## Database Commands
240
-
241
- \`\`\`bash
242
- pnpm db:generate # Generate migrations
243
- pnpm db:push # Push schema to database
244
- pnpm db:studio # Open Drizzle Studio
245
- \`\`\`
246
-
247
- ${database === "sqlite" ? "## SQLite Notes\n\nThe database is automatically initialized with example data on first run.\nDatabase file: `./data.db`\n" : ""}
248
- ## Documentation
249
-
250
- - [Kozo Docs](https://kozo-docs.vercel.app)
251
- - [Drizzle ORM](https://orm.drizzle.team)
252
- - [Hono](https://hono.dev)
253
- `;
254
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "README.md"), readme);
255
- if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
256
- if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
257
- if (extras.includes("github-actions")) await createGitHubActions(projectDir);
258
- }
259
41
  function getDatabaseSchema(database) {
260
42
  if (database === "postgresql") {
261
43
  return `import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core';
@@ -552,7 +334,17 @@ const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX) || 100;
552
334
  const RATE_LIMIT_WINDOW = Number(process.env.RATE_LIMIT_WINDOW) || 60_000;
553
335
 
554
336
  // \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
555
- const app = createKozo({ port: PORT });
337
+ const app = createKozo({
338
+ port: PORT,
339
+ openapi: {
340
+ info: {
341
+ title: '${projectName} API',
342
+ version: '1.0.0',
343
+ description: 'API documentation for ${projectName}',
344
+ },
345
+ servers: [{ url: \`http://localhost:\${PORT}\`, description: 'Development server' }],
346
+ },
347
+ });
556
348
 
557
349
  // \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
558
350
  app.getApp().use('*', logger());
@@ -1178,8 +970,12 @@ export function registerPostRoutes(app: Kozo) {
1178
970
  `;
1179
971
  await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "routes", "posts", "index.ts"), postRoutes);
1180
972
  }
973
+
974
+ // src/utils/scaffold/template-api-only.ts
975
+ var import_fs_extra2 = __toESM(require("fs-extra"));
976
+ var import_node_path2 = __toESM(require("path"));
1181
977
  async function scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, runtime) {
1182
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "src"));
978
+ await import_fs_extra2.default.ensureDir(import_node_path2.default.join(projectDir, "src"));
1183
979
  const packageJson = {
1184
980
  name: projectName,
1185
981
  version: "1.0.0",
@@ -1202,7 +998,7 @@ async function scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, run
1202
998
  typescript: "^5.6.0"
1203
999
  }
1204
1000
  };
1205
- await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "package.json"), packageJson, { spaces: 2 });
1001
+ await import_fs_extra2.default.writeJSON(import_node_path2.default.join(projectDir, "package.json"), packageJson, { spaces: 2 });
1206
1002
  const tsconfig = {
1207
1003
  compilerOptions: {
1208
1004
  target: "ES2022",
@@ -1217,7 +1013,7 @@ async function scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, run
1217
1013
  include: ["src/**/*"],
1218
1014
  exclude: ["node_modules", "dist"]
1219
1015
  };
1220
- await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
1016
+ await import_fs_extra2.default.writeJSON(import_node_path2.default.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
1221
1017
  const indexTs = `import { createKozo } from '@kozojs/core';
1222
1018
  import { z } from 'zod';
1223
1019
 
@@ -1240,8 +1036,8 @@ app.get('/hello/:name', {
1240
1036
  console.log('\u{1F525} Kozo running on http://localhost:3000');
1241
1037
  await app.nativeListen();
1242
1038
  `;
1243
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "src", "index.ts"), indexTs);
1244
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n");
1039
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(projectDir, "src", "index.ts"), indexTs);
1040
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n");
1245
1041
  }
1246
1042
  async function createDockerCompose(dir, projectName, database, dbPort, includeApiService = false, runtime = "node") {
1247
1043
  if (database === "none" || database === "sqlite") return;
@@ -1322,7 +1118,7 @@ async function createDockerCompose(dir, projectName, database, dbPort, includeAp
1322
1118
  const volumes = database === "postgresql" ? "\nvolumes:\n postgres_data:\n" : database === "mysql" ? "\nvolumes:\n mysql_data:\n" : "";
1323
1119
  const compose = `services:
1324
1120
  ${services}${volumes}`;
1325
- await import_fs_extra.default.writeFile(import_node_path.default.join(dir, "docker-compose.yml"), compose);
1121
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(dir, "docker-compose.yml"), compose);
1326
1122
  }
1327
1123
  async function createDockerfile(projectDir, runtime) {
1328
1124
  const dockerfile = runtime === "bun" ? `FROM oven/bun:1 AS builder
@@ -1353,11 +1149,11 @@ RUN npm ci --omit=dev
1353
1149
  EXPOSE 3000
1354
1150
  CMD ["node", "dist/index.js"]
1355
1151
  `;
1356
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "Dockerfile"), dockerfile);
1357
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".dockerignore"), "node_modules\ndist\n.git\n.env\n");
1152
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(projectDir, "Dockerfile"), dockerfile);
1153
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(projectDir, ".dockerignore"), "node_modules\ndist\n.git\n.env\n");
1358
1154
  }
1359
1155
  async function createGitHubActions(projectDir) {
1360
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, ".github", "workflows"));
1156
+ await import_fs_extra2.default.ensureDir(import_node_path2.default.join(projectDir, ".github", "workflows"));
1361
1157
  const workflow = `name: CI
1362
1158
 
1363
1159
  on:
@@ -1379,561 +1175,1991 @@ jobs:
1379
1175
  - run: npm run build
1380
1176
  - run: npm test --if-present
1381
1177
  `;
1382
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".github", "workflows", "ci.yml"), workflow);
1383
- }
1384
- async function scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth, frontend, extras, template) {
1385
- const hasDb = database !== "none";
1386
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "routes"));
1387
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "data"));
1388
- if (hasDb) await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "api", "src", "db"));
1389
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, "apps", "web", "src", "lib"));
1390
- await import_fs_extra.default.ensureDir(import_node_path.default.join(projectDir, ".vscode"));
1391
- const rootPackageJson = {
1392
- name: projectName,
1393
- private: true,
1394
- scripts: {
1395
- dev: "pnpm run --parallel dev",
1396
- build: "pnpm run --recursive build"
1397
- }
1398
- };
1399
- await import_fs_extra.default.writeJSON(import_node_path.default.join(projectDir, "package.json"), rootPackageJson, { spaces: 2 });
1400
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "pnpm-workspace.yaml"), `packages:
1401
- - 'apps/*'
1402
- `);
1403
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n*.log\n");
1404
- await scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth);
1405
- await scaffoldFullstackWeb(projectDir, projectName, frontend, auth);
1406
- await scaffoldFullstackReadme(projectDir, projectName);
1407
- if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
1408
- if (extras.includes("docker")) await createDockerfile(import_node_path.default.join(projectDir, "apps", "api"), runtime);
1409
- if (extras.includes("github-actions")) await createGitHubActions(projectDir);
1178
+ await import_fs_extra2.default.writeFile(import_node_path2.default.join(projectDir, ".github", "workflows", "ci.yml"), workflow);
1410
1179
  }
1411
- async function scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime, database = "none", dbPort, auth = true) {
1412
- const apiDir = import_node_path.default.join(projectDir, "apps", "api");
1413
- const hasDb = database !== "none";
1414
- if (hasDb) {
1415
- await import_fs_extra.default.ensureDir(import_node_path.default.join(apiDir, "src", "db"));
1416
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "db", "schema.ts"), getDatabaseSchema(database));
1417
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "db", "index.ts"), getDatabaseIndex(database));
1418
- if (database === "sqlite") {
1419
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "src", "db", "seed.ts"), getSQLiteSeed());
1420
- }
1421
- const dialect = database === "postgresql" ? "postgresql" : database === "mysql" ? "mysql" : "sqlite";
1422
- const pgPort = dbPort ?? 5436;
1423
- const dbUrl = database === "postgresql" ? `postgresql://postgres:postgres@localhost:${pgPort}/${projectName}` : database === "mysql" ? `mysql://root:root@localhost:3306/${projectName}` : void 0;
1424
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, "drizzle.config.ts"), `import { defineConfig } from 'drizzle-kit';
1425
- import 'dotenv/config';
1426
1180
 
1427
- export default defineConfig({
1428
- schema: './src/db/schema.ts',
1429
- out: './drizzle',
1430
- dialect: '${dialect}',
1431
- dbCredentials: {
1432
- ${database === "sqlite" ? "url: './data.db'" : "url: process.env.DATABASE_URL!"}
1433
- },
1434
- });
1435
- `);
1436
- const envContent = `PORT=3000
1437
- NODE_ENV=development
1438
- ${dbUrl ? `DATABASE_URL=${dbUrl}
1439
- ` : ""}${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
1440
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, ".env"), envContent);
1441
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, ".env.example"), envContent);
1442
- } else {
1443
- const envContent = `PORT=3000
1444
- NODE_ENV=development
1445
- ${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
1446
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, ".env"), envContent);
1447
- await import_fs_extra.default.writeFile(import_node_path.default.join(apiDir, ".env.example"), envContent);
1448
- }
1449
- const apiPackageJson = {
1450
- name: `@${projectName}/api`,
1451
- version: "1.0.0",
1452
- type: "module",
1453
- scripts: {
1454
- dev: runtime === "bun" ? "bun --watch src/index.ts" : "tsx watch src/index.ts",
1455
- build: "tsc",
1456
- ...hasDb && {
1457
- "db:generate": "drizzle-kit generate",
1458
- "db:push": "drizzle-kit push",
1459
- "db:studio": "drizzle-kit studio"
1460
- }
1461
- },
1462
- dependencies: {
1463
- "@kozojs/core": kozoCoreDep,
1464
- ...auth && { "@kozojs/auth": kozoCoreDep === "workspace:*" ? "workspace:*" : "^0.1.0" },
1465
- hono: "^4.12.5",
1466
- zod: "^4.0.0",
1467
- dotenv: "^16.4.0",
1468
- ...runtime === "node" && { "@hono/node-server": "^1.19.10" },
1469
- ...runtime === "node" && { "uWebSockets.js": "github:uNetworking/uWebSockets.js#6609a88ffa9a16ac5158046761356ce03250a0df" },
1470
- ...hasDb && { "drizzle-orm": "^0.36.0" },
1471
- ...database === "postgresql" && { postgres: "^3.4.8" },
1472
- ...database === "mysql" && { mysql2: "^3.11.0" },
1473
- ...database === "sqlite" && { "better-sqlite3": "^11.0.0" }
1474
- },
1475
- devDependencies: {
1476
- "@types/node": "^22.0.0",
1477
- ...runtime !== "bun" && { tsx: "^4.21.0" },
1478
- typescript: "^5.6.0",
1479
- ...hasDb && { "drizzle-kit": "^0.28.0" },
1480
- ...database === "sqlite" && { "@types/better-sqlite3": "^7.6.0" }
1481
- }
1482
- };
1483
- await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "package.json"), apiPackageJson, { spaces: 2 });
1484
- const tsconfig = {
1485
- compilerOptions: {
1486
- target: "ES2022",
1487
- module: "ESNext",
1488
- moduleResolution: "bundler",
1489
- strict: true,
1490
- esModuleInterop: true,
1491
- skipLibCheck: true,
1492
- outDir: "dist",
1493
- rootDir: "src"
1494
- },
1495
- include: ["src/**/*"],
1496
- exclude: ["node_modules", "dist"]
1497
- };
1498
- await import_fs_extra.default.writeJSON(import_node_path.default.join(apiDir, "tsconfig.json"), tsconfig, { spaces: 2 });
1499
- const authImport = auth ? `import { authenticateJWT } from '@kozojs/auth';
1500
- ` : "";
1501
- const authMiddleware = auth ? `
1502
- // JWT protects all /api/* routes except public ones
1503
- const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
1504
- const _jwt = authenticateJWT(JWT_SECRET, { prefix: '' });
1505
- const publicPaths = ['/api/auth/', '/api/health', '/api/stats'];
1506
- app.getApp().use('/api/*', (c, next) => {
1507
- if (publicPaths.some(p => c.req.path.startsWith(p))) return next();
1508
- return _jwt(c, next);
1509
- });
1510
- ` : "";
1511
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "index.ts"), `import 'dotenv/config';
1512
- import { createKozo } from '@kozojs/core';
1513
- ${authImport}import { fileURLToPath } from 'node:url';
1514
- import { dirname, join } from 'node:path';
1181
+ // src/utils/scaffold/fullstack-api.ts
1182
+ var import_fs_extra4 = __toESM(require("fs-extra"));
1183
+ var import_node_path4 = __toESM(require("path"));
1515
1184
 
1516
- const __dirname = dirname(fileURLToPath(import.meta.url));
1517
- const PORT = Number(process.env.PORT) || 3000;
1518
- const app = createKozo({ port: PORT });
1519
- ${authMiddleware}await app.loadRoutes(join(__dirname, 'routes'));
1185
+ // src/utils/scaffold/fullstack-web.ts
1186
+ var import_fs_extra3 = __toESM(require("fs-extra"));
1187
+ var import_node_path3 = __toESM(require("path"));
1520
1188
 
1521
- export type AppType = typeof app;
1189
+ // src/utils/scaffold/generators/pages.ts
1190
+ function generateAppTsx(projectName, auth) {
1191
+ return `import { useState, useEffect, useCallback, useMemo } from 'react';
1192
+ import { useQueryClient } from '@tanstack/react-query';
1193
+ import { LayoutDashboard, Users, FileText, CheckSquare, Server, Sun, Moon${auth ? ", LogOut" : ""} } from 'lucide-react';
1194
+ import { Toaster } from 'sonner';
1195
+ ${auth ? "import { getToken, clearToken } from '@/lib/api';" : ""}
1196
+ import { useUIStore } from '@/store/ui';
1197
+ import { useThemeStore } from '@/store/theme';
1198
+ import PreloadSpinner from '@/components/PreloadSpinner';
1199
+ ${auth ? "import Login from './pages/Login';" : ""}
1200
+ import Dashboard from './pages/Dashboard';
1201
+ import UsersPage from './pages/Users';
1202
+ import PostsPage from './pages/Posts';
1203
+ import TasksPage from './pages/Tasks';
1522
1204
 
1523
- console.log(\`\u{1F525} ${projectName} API on http://localhost:\${PORT}\`);
1524
- ${runtime === "node" ? "await app.nativeListen();" : "await app.listen();"}
1525
- `);
1526
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "schemas", "index.ts"), `import { z } from 'zod';
1205
+ // \u2500\u2500 Routing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1206
+ type AppPage = 'dashboard' | 'users' | 'posts' | 'tasks';
1207
+ const APP_PAGES: AppPage[] = ['dashboard', 'users', 'posts', 'tasks'];
1208
+ const NAV_SPINNER_MS = 300;
1527
1209
 
1528
- export const UserSchema = z.object({
1529
- id: z.string(),
1530
- name: z.string(),
1531
- email: z.string().email(),
1532
- role: z.enum(['admin', 'user']),
1533
- createdAt: z.string().optional(),
1534
- });
1210
+ function parseRoute(pathname: string): AppPage {
1211
+ const clean = pathname.replace(/\\/+$/, '') || '/';
1212
+ if (clean.startsWith('/app/')) {
1213
+ const seg = clean.replace('/app/', '') as AppPage;
1214
+ return APP_PAGES.includes(seg) ? seg : 'dashboard';
1215
+ }
1216
+ return 'dashboard';
1217
+ }
1535
1218
 
1536
- export const CreateUserBody = z.object({
1537
- name: z.string().min(1),
1538
- email: z.string().email(),
1539
- role: z.enum(['admin', 'user']).optional(),
1540
- });
1219
+ function buildUrl(page: AppPage): string {
1220
+ return page === 'dashboard' ? '/app' : \`/app/\${page}\`;
1221
+ }
1541
1222
 
1542
- export const UpdateUserBody = z.object({
1543
- name: z.string().optional(),
1544
- email: z.string().email().optional(),
1545
- role: z.enum(['admin', 'user']).optional(),
1546
- });
1223
+ const PAGE_TITLES: Record<AppPage, string> = {
1224
+ dashboard: 'Dashboard',
1225
+ users: 'Users',
1226
+ posts: 'Posts',
1227
+ tasks: 'Tasks',
1228
+ };
1547
1229
 
1548
- export const PostSchema = z.object({
1549
- id: z.string(),
1550
- title: z.string(),
1551
- content: z.string(),
1552
- authorId: z.string(),
1553
- published: z.boolean(),
1554
- createdAt: z.string().optional(),
1555
- });
1230
+ export default function App({ initialPath = '/' }: { initialPath?: string }) {
1231
+ const initial = useMemo(() => {
1232
+ const pathname = typeof window !== 'undefined' ? window.location.pathname : initialPath;
1233
+ return parseRoute(pathname);
1234
+ }, [initialPath]);
1556
1235
 
1557
- export const CreatePostBody = z.object({
1558
- title: z.string().min(1),
1559
- content: z.string().optional(),
1560
- authorId: z.string().optional(),
1561
- published: z.boolean().optional(),
1562
- });
1236
+ const [page, setPage] = useState<AppPage>(initial);
1237
+ ${auth ? ` const [token, setToken] = useState<string | null>(() => getToken());` : ""}
1238
+ const queryClient = useQueryClient();
1239
+ const sidebarOpen = useUIStore((s) => s.sidebarOpen);
1240
+ const toggleSidebar = useUIStore((s) => s.toggleSidebar);
1241
+ const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
1242
+ const setGlobalLoading = useUIStore((s) => s.setGlobalLoading);
1243
+ const theme = useThemeStore((s) => s.theme);
1244
+ const toggleTheme = useThemeStore((s) => s.toggleTheme);
1245
+
1246
+ // All hooks must be declared before any early return
1247
+ const navigate = useCallback((p: AppPage) => {
1248
+ if (p === page) return;
1249
+ if (typeof window !== 'undefined') window.history.pushState(null, '', buildUrl(p));
1250
+ setGlobalLoading(true);
1251
+ setTimeout(() => { setPage(p); setGlobalLoading(false); }, NAV_SPINNER_MS);
1252
+ }, [page, setGlobalLoading]);
1253
+
1254
+ // Sync browser back/forward
1255
+ useEffect(() => {
1256
+ const onPop = () => setPage(parseRoute(window.location.pathname));
1257
+ window.addEventListener('popstate', onPop);
1258
+ return () => window.removeEventListener('popstate', onPop);
1259
+ }, []);
1260
+
1261
+ // Close sidebar on wider screens
1262
+ useEffect(() => {
1263
+ const mq = window.matchMedia('(min-width: 768px)');
1264
+ if (mq.matches) setSidebarOpen(false);
1265
+ const onChange = (e: MediaQueryListEvent) => { if (e.matches) setSidebarOpen(false); };
1266
+ mq.addEventListener('change', onChange);
1267
+ return () => mq.removeEventListener('change', onChange);
1268
+ }, [setSidebarOpen]);
1269
+
1270
+ // Update <title>
1271
+ useEffect(() => { document.title = \`\${PAGE_TITLES[page]} | ${projectName}\`; }, [page]);
1272
+ ${auth ? `
1273
+ const handleLogin = (t: string) => setToken(t);
1274
+ const handleLogout = () => {
1275
+ clearToken();
1276
+ setToken(null);
1277
+ queryClient.clear();
1278
+ };
1563
1279
 
1564
- export const UpdatePostBody = z.object({
1565
- title: z.string().optional(),
1566
- content: z.string().optional(),
1567
- published: z.boolean().optional(),
1568
- });
1280
+ if (!token) return <Login onLogin={handleLogin} />;
1281
+ ` : ""}
1569
1282
 
1570
- export const TaskSchema = z.object({
1571
- id: z.string(),
1572
- title: z.string(),
1573
- completed: z.boolean(),
1574
- priority: z.enum(['low', 'medium', 'high']),
1575
- createdAt: z.string(),
1576
- });
1283
+ const nav = [
1284
+ { id: 'dashboard' as AppPage, label: 'Dashboard', icon: LayoutDashboard },
1285
+ { id: 'users' as AppPage, label: 'Users', icon: Users },
1286
+ { id: 'posts' as AppPage, label: 'Posts', icon: FileText },
1287
+ { id: 'tasks' as AppPage, label: 'Tasks', icon: CheckSquare },
1288
+ ];
1577
1289
 
1578
- export const CreateTaskBody = z.object({
1579
- title: z.string().min(1),
1580
- priority: z.enum(['low', 'medium', 'high']).optional(),
1581
- });
1290
+ const sidebar = (
1291
+ <aside
1292
+ className={\`flex flex-col h-full transition-transform md:translate-x-0 \${
1293
+ sidebarOpen ? 'translate-x-0' : '-translate-x-full'
1294
+ }\`}
1295
+ style={{ background: 'var(--sidebar-bg)', borderRight: '1px solid var(--border)', width: '224px' }}
1296
+ >
1297
+ <div className="flex items-center gap-2 p-5 pb-4">
1298
+ <Server className="w-5 h-5" style={{ color: 'var(--accent)' }} />
1299
+ <span className="font-bold text-sm tracking-wide" style={{ color: 'var(--fg)' }}>${projectName}</span>
1300
+ </div>
1301
+ <nav className="flex-1 px-3 space-y-0.5">
1302
+ {nav.map(({ id, label, icon: Icon }) => (
1303
+ <button
1304
+ key={id}
1305
+ onClick={() => { navigate(id); setSidebarOpen(false); }}
1306
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors"
1307
+ style={page === id
1308
+ ? { background: 'var(--sidebar-active-bg)', color: 'var(--sidebar-active-fg)' }
1309
+ : { color: 'var(--sidebar-fg)' }
1310
+ }
1311
+ >
1312
+ <Icon className="w-4 h-4 flex-shrink-0" />
1313
+ {label}
1314
+ </button>
1315
+ ))}
1316
+ </nav>
1317
+ <div className="p-3 border-t space-y-1" style={{ borderColor: 'var(--border)' }}>
1318
+ <button
1319
+ onClick={toggleTheme}
1320
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors"
1321
+ style={{ color: 'var(--sidebar-fg)' }}
1322
+ >
1323
+ {theme === 'dark'
1324
+ ? <Sun className="w-4 h-4" />
1325
+ : <Moon className="w-4 h-4" />
1326
+ }
1327
+ {theme === 'dark' ? 'Light mode' : 'Dark mode'}
1328
+ </button>
1329
+ ${auth ? ` <button
1330
+ onClick={handleLogout}
1331
+ className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors hover:text-red-400"
1332
+ style={{ color: 'var(--sidebar-fg)' }}
1333
+ >
1334
+ <LogOut className="w-4 h-4" />
1335
+ Sign out
1336
+ </button>
1337
+ ` : ""} </div>
1338
+ </aside>
1339
+ );
1582
1340
 
1583
- export const UpdateTaskBody = z.object({
1584
- title: z.string().optional(),
1585
- completed: z.boolean().optional(),
1586
- priority: z.enum(['low', 'medium', 'high']).optional(),
1587
- });
1588
- `);
1589
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "data", "index.ts"), `export const users = [
1590
- { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' as const, createdAt: new Date().toISOString() },
1591
- { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' as const, createdAt: new Date().toISOString() },
1592
- ];
1341
+ return (
1342
+ <div className="flex min-h-screen overflow-hidden" style={{ background: 'var(--bg)' }}>
1343
+ <PreloadSpinner />
1344
+ <Toaster position="bottom-right" richColors />
1345
+
1346
+ {/* Mobile overlay */}
1347
+ {sidebarOpen && (
1348
+ <div
1349
+ className="fixed inset-0 z-40 md:hidden"
1350
+ style={{ background: 'rgba(0,0,0,0.6)' }}
1351
+ onClick={() => setSidebarOpen(false)}
1352
+ />
1353
+ )}
1593
1354
 
1594
- export const posts = [
1595
- { id: '1', title: 'Hello World', content: 'First post!', authorId: '1', published: true, createdAt: new Date().toISOString() },
1596
- { id: '2', title: 'Draft', content: 'Work in progress', authorId: '2', published: false, createdAt: new Date().toISOString() },
1597
- ];
1355
+ {/* Sidebar \u2014 fixed on mobile, static on desktop */}
1356
+ <div className="hidden md:flex flex-col" style={{ width: '224px', flexShrink: 0 }}>
1357
+ {sidebar}
1358
+ </div>
1359
+ <div
1360
+ className={\`fixed inset-y-0 left-0 z-50 flex flex-col md:hidden transition-transform \${
1361
+ sidebarOpen ? 'translate-x-0' : '-translate-x-full'
1362
+ }\`}
1363
+ >
1364
+ {sidebar}
1365
+ </div>
1598
1366
 
1599
- export const tasks = [
1600
- { id: '1', title: 'Setup project', completed: true, priority: 'high' as const, createdAt: new Date().toISOString() },
1601
- { id: '2', title: 'Write tests', completed: false, priority: 'medium' as const, createdAt: new Date().toISOString() },
1602
- { id: '3', title: 'Deploy', completed: false, priority: 'low' as const, createdAt: new Date().toISOString() },
1603
- ];
1604
- `);
1605
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "health", "get.ts"), `import { z } from 'zod';
1367
+ {/* Main */}
1368
+ <div className="flex flex-col flex-1 min-w-0">
1369
+ {/* Mobile topbar */}
1370
+ <header className="flex md:hidden items-center gap-3 px-4 h-14 border-b"
1371
+ style={{ borderColor: 'var(--border)', background: 'var(--sidebar-bg)' }}>
1372
+ <button onClick={toggleSidebar} className="p-1.5 rounded-md"
1373
+ style={{ color: 'var(--fg-muted)' }}>
1374
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
1375
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
1376
+ </svg>
1377
+ </button>
1378
+ <span className="font-semibold text-sm" style={{ color: 'var(--fg)' }}>
1379
+ {PAGE_TITLES[page]}
1380
+ </span>
1381
+ </header>
1382
+ <main className="flex-1 overflow-auto p-6 sm:p-8">
1383
+ {page === 'dashboard' && <Dashboard />}
1384
+ {page === 'users' && <UsersPage />}
1385
+ {page === 'posts' && <PostsPage />}
1386
+ {page === 'tasks' && <TasksPage />}
1387
+ </main>
1388
+ </div>
1389
+ </div>
1390
+ );
1391
+ }
1392
+ `;
1393
+ }
1394
+ function generateLoginPage() {
1395
+ return `import { useState, FormEvent } from 'react';
1396
+ import { Server, Loader2 } from 'lucide-react';
1397
+ import { apiFetch, setToken } from '@/lib/api';
1606
1398
 
1607
- export const schema = {
1608
- response: z.object({
1609
- status: z.string(),
1610
- timestamp: z.string(),
1611
- version: z.string(),
1612
- uptime: z.number(),
1613
- }),
1614
- };
1399
+ interface Props {
1400
+ onLogin: (token: string) => void;
1401
+ }
1615
1402
 
1616
- export default async () => ({
1617
- status: 'ok',
1618
- timestamp: new Date().toISOString(),
1619
- version: '1.0.0',
1620
- uptime: process.uptime(),
1621
- });
1622
- `);
1623
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "stats", "get.ts"), `import { z } from 'zod';
1624
- import { users, posts, tasks } from '../../../data/index.js';
1403
+ export default function Login({ onLogin }: Props) {
1404
+ const [email, setEmail] = useState('admin@demo.com');
1405
+ const [password, setPassword] = useState('admin123');
1406
+ const [error, setError] = useState('');
1407
+ const [loading, setLoading] = useState(false);
1625
1408
 
1626
- export const schema = {
1627
- response: z.object({
1628
- users: z.number(),
1629
- posts: z.number(),
1630
- tasks: z.number(),
1631
- publishedPosts: z.number(),
1632
- completedTasks: z.number(),
1633
- }),
1634
- };
1409
+ const submit = async (e: FormEvent) => {
1410
+ e.preventDefault();
1411
+ setError('');
1412
+ setLoading(true);
1413
+ try {
1414
+ const res = await apiFetch<{ token: string }>('/api/auth/login', {
1415
+ method: 'POST',
1416
+ body: JSON.stringify({ email, password }),
1417
+ });
1418
+ if (res.status === 200 && res.data.token) {
1419
+ setToken(res.data.token);
1420
+ onLogin(res.data.token);
1421
+ } else {
1422
+ setError('Invalid credentials');
1423
+ }
1424
+ } catch {
1425
+ setError('Connection failed \u2014 is the API running?');
1426
+ } finally {
1427
+ setLoading(false);
1428
+ }
1429
+ };
1635
1430
 
1636
- export default async () => ({
1637
- users: users.length,
1638
- posts: posts.length,
1639
- tasks: tasks.length,
1640
- publishedPosts: posts.filter(p => p.published).length,
1641
- completedTasks: tasks.filter(t => t.completed).length,
1642
- });
1643
- `);
1644
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "echo", "get.ts"), `import { z } from 'zod';
1431
+ return (
1432
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg)' }}>
1433
+ <div className="w-full max-w-sm">
1434
+ <div className="flex flex-col items-center mb-8">
1435
+ <div className="w-14 h-14 rounded-2xl flex items-center justify-center mb-4"
1436
+ style={{ background: 'var(--accent)', color: 'var(--accent-fg)' }}>
1437
+ <Server className="w-7 h-7" />
1438
+ </div>
1439
+ <h1 className="text-2xl font-bold" style={{ color: 'var(--fg)' }}>Sign in</h1>
1440
+ <p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>to your kozo dashboard</p>
1441
+ </div>
1645
1442
 
1646
- export const schema = {
1647
- query: z.object({ message: z.string() }),
1648
- response: z.object({
1649
- echo: z.string(),
1650
- timestamp: z.string(),
1651
- }),
1652
- };
1443
+ <form onSubmit={submit} className="card space-y-4">
1444
+ <div>
1445
+ <label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--fg-muted)' }}>Email</label>
1446
+ <input
1447
+ type="email"
1448
+ value={email}
1449
+ onChange={e => setEmail(e.target.value)}
1450
+ className="w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1451
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1452
+ required
1453
+ />
1454
+ </div>
1455
+ <div>
1456
+ <label className="block text-xs font-medium mb-1.5" style={{ color: 'var(--fg-muted)' }}>Password</label>
1457
+ <input
1458
+ type="password"
1459
+ value={password}
1460
+ onChange={e => setPassword(e.target.value)}
1461
+ className="w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1462
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1463
+ required
1464
+ />
1465
+ </div>
1466
+ {error && <p className="text-xs" style={{ color: 'var(--destructive)' }}>{error}</p>}
1467
+ <button
1468
+ type="submit"
1469
+ disabled={loading}
1470
+ className="w-full py-2.5 rounded-lg text-sm font-semibold transition flex items-center justify-center gap-2 disabled:opacity-50"
1471
+ style={{ background: 'var(--accent)', color: 'var(--accent-fg)' }}
1472
+ >
1473
+ {loading && <Loader2 className="w-4 h-4 animate-spin" />}
1474
+ Sign in
1475
+ </button>
1476
+ </form>
1477
+ <p className="text-center text-xs mt-4" style={{ color: 'var(--fg-subtle)' }}>
1478
+ Demo: admin@demo.com / admin123
1479
+ </p>
1480
+ </div>
1481
+ </div>
1482
+ );
1483
+ }
1484
+ `;
1485
+ }
1486
+ function generateDashboardPage() {
1487
+ return `import { useQuery } from '@tanstack/react-query';
1488
+ import { healthQuery, statsQuery } from '@/lib/queries';
1489
+ import { Users, FileText, CheckSquare, Zap } from 'lucide-react';
1490
+ import { Skeleton } from '@/components/Skeleton';
1653
1491
 
1654
- export default async ({ query }: { query: { message: string } }) => ({
1655
- echo: query.message,
1656
- timestamp: new Date().toISOString(),
1657
- });
1658
- `);
1659
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "validate", "post.ts"), `import { z } from 'zod';
1492
+ function StatCard({ label, value, sub, icon: Icon, accent }: {
1493
+ label: string; value: string | number; sub?: string;
1494
+ icon: React.ElementType; accent: string;
1495
+ }) {
1496
+ return (
1497
+ <div className="card">
1498
+ <div className="flex items-start justify-between">
1499
+ <div className="flex-1 min-w-0">
1500
+ <p className="text-xs font-medium uppercase tracking-wider mb-1" style={{ color: 'var(--fg-muted)' }}>{label}</p>
1501
+ <p className="text-3xl font-bold" style={{ color: 'var(--fg)' }}>{value}</p>
1502
+ {sub && <p className="text-xs mt-1" style={{ color: 'var(--fg-subtle)' }}>{sub}</p>}
1503
+ </div>
1504
+ <div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
1505
+ style={{ background: accent + '22', color: accent }}>
1506
+ <Icon className="w-5 h-5" />
1507
+ </div>
1508
+ </div>
1509
+ </div>
1510
+ );
1511
+ }
1660
1512
 
1661
- export const schema = {
1662
- body: z.object({
1663
- email: z.string().email(),
1664
- age: z.number().min(0).max(150),
1665
- }),
1666
- response: z.object({
1667
- valid: z.boolean(),
1668
- data: z.object({ email: z.string(), age: z.number() }),
1669
- }),
1670
- };
1513
+ function DashboardSkeleton() {
1514
+ return (
1515
+ <div>
1516
+ <div className="mb-6">
1517
+ <Skeleton className="h-8 w-40 mb-2" />
1518
+ <Skeleton className="h-4 w-56" />
1519
+ </div>
1520
+ <div className="grid grid-cols-2 gap-3 sm:gap-4 mb-8">
1521
+ {Array.from({ length: 4 }).map((_, i) => (
1522
+ <div key={i} className="card">
1523
+ <div className="flex items-start justify-between">
1524
+ <div><Skeleton className="h-3 w-16 mb-3" /><Skeleton className="h-9 w-20 mb-2" /></div>
1525
+ <Skeleton className="w-10 h-10 rounded-xl" />
1526
+ </div>
1527
+ </div>
1528
+ ))}
1529
+ </div>
1530
+ </div>
1531
+ );
1532
+ }
1671
1533
 
1672
- export default async ({ body }: { body: { email: string; age: number } }) => ({
1673
- valid: true,
1674
- data: body,
1675
- });
1676
- `);
1677
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "users", "get.ts"), `import { z } from 'zod';
1678
- import { users } from '../../../data/index.js';
1679
- import { UserSchema } from '../../../schemas/index.js';
1534
+ export default function Dashboard() {
1535
+ const { data: health } = useQuery(healthQuery);
1536
+ const { data: stats, isLoading } = useQuery(statsQuery);
1680
1537
 
1681
- export const schema = {
1682
- response: z.array(UserSchema),
1683
- };
1538
+ const uptime = health?.uptime;
1539
+ const uptimeStr = uptime !== undefined
1540
+ ? uptime > 3600 ? \`\${Math.floor(uptime / 3600)}h \${Math.floor((uptime % 3600) / 60)}m\`
1541
+ : uptime > 60 ? \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`
1542
+ : \`\${Math.floor(uptime)}s\`
1543
+ : '\u2014';
1684
1544
 
1685
- export default async () => users;
1686
- `);
1687
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "users", "post.ts"), `import { users } from '../../../data/index.js';
1688
- import { UserSchema, CreateUserBody } from '../../../schemas/index.js';
1545
+ if (isLoading) return <DashboardSkeleton />;
1689
1546
 
1690
- export const schema = {
1691
- body: CreateUserBody,
1692
- response: UserSchema,
1693
- };
1547
+ return (
1548
+ <div>
1549
+ <div className="mb-6">
1550
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight" style={{ color: 'var(--fg)' }}>Dashboard</h1>
1551
+ <p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>Server overview and statistics</p>
1552
+ </div>
1694
1553
 
1695
- export default async ({ body }: { body: { name: string; email: string; role?: 'admin' | 'user' } }) => {
1696
- const newUser = {
1697
- id: String(Date.now()),
1698
- name: body.name,
1699
- email: body.email,
1700
- role: body.role ?? ('user' as const),
1701
- createdAt: new Date().toISOString(),
1702
- };
1703
- users.push(newUser);
1704
- return newUser;
1705
- };
1706
- `);
1707
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "users", "[id]", "get.ts"), `import { z } from 'zod';
1708
- import { KozoError } from '@kozojs/core';
1709
- import { users } from '../../../../data/index.js';
1710
- import { UserSchema } from '../../../../schemas/index.js';
1554
+ <div className="grid grid-cols-2 gap-3 sm:gap-4 mb-8">
1555
+ <StatCard label="Users" value={stats?.users ?? '\u2014'} icon={Users} accent="var(--accent-2)" />
1556
+ <StatCard label="Posts" value={stats?.posts ?? '\u2014'}
1557
+ sub={stats ? \`\${stats.publishedPosts} published\` : undefined}
1558
+ icon={FileText} accent="#c084fc" />
1559
+ <StatCard label="Tasks" value={stats?.tasks ?? '\u2014'}
1560
+ sub={stats ? \`\${stats.completedTasks} completed\` : undefined}
1561
+ icon={CheckSquare} accent="#34d399" />
1562
+ <StatCard label="Uptime" value={uptimeStr}
1563
+ sub={health?.version ? \`v\${health.version}\` : undefined}
1564
+ icon={Zap} accent="#fbbf24" />
1565
+ </div>
1711
1566
 
1712
- export const schema = {
1713
- params: z.object({ id: z.string() }),
1714
- response: UserSchema,
1715
- };
1567
+ <div className="card">
1568
+ <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--fg)' }}>API Status</h3>
1569
+ <div className="flex items-center gap-3">
1570
+ <code className="flex-1 px-3 py-2 rounded-lg text-xs" style={{ background: 'var(--bg-subtle)', color: 'var(--fg-muted)' }}>
1571
+ GET /api/health
1572
+ </code>
1573
+ <span className="px-2 py-0.5 rounded text-xs font-semibold"
1574
+ style={health?.status === 'ok'
1575
+ ? { background: 'rgba(52,211,153,0.15)', color: '#34d399' }
1576
+ : { background: 'var(--bg-subtle)', color: 'var(--fg-muted)' }
1577
+ }>
1578
+ {health?.status ?? 'pending'}
1579
+ </span>
1580
+ </div>
1581
+ </div>
1582
+ </div>
1583
+ );
1584
+ }
1585
+ `;
1586
+ }
1587
+ function generateUsersPage() {
1588
+ return `import { useState } from 'react';
1589
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
1590
+ import { usersQuery, type User } from '@/lib/queries';
1591
+ import { apiFetch } from '@/lib/api';
1592
+ import { useUIStore } from '@/store/ui';
1593
+ import { Plus, Trash2, Loader2 } from 'lucide-react';
1594
+ import { Skeleton } from '@/components/Skeleton';
1716
1595
 
1717
- export default async ({ params }: { params: { id: string } }) => {
1718
- const user = users.find(u => u.id === params.id);
1719
- if (!user) throw new KozoError('User not found', 404, 'NOT_FOUND');
1720
- return user;
1721
- };
1722
- `);
1723
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "users", "[id]", "put.ts"), `import { z } from 'zod';
1724
- import { KozoError } from '@kozojs/core';
1725
- import { users } from '../../../../data/index.js';
1726
- import { UserSchema, UpdateUserBody } from '../../../../schemas/index.js';
1596
+ function UsersSkeleton() {
1597
+ return (
1598
+ <div>
1599
+ <div className="mb-6"><Skeleton className="h-8 w-32 mb-2" /><Skeleton className="h-4 w-24" /></div>
1600
+ <div className="card mb-4"><Skeleton className="h-5 w-28 mb-3" /><div className="flex gap-3"><Skeleton className="h-10 flex-1" /><Skeleton className="h-10 flex-1" /><Skeleton className="h-10 w-20" /></div></div>
1601
+ <div className="card">
1602
+ {Array.from({ length: 3 }).map((_, i) => (
1603
+ <div key={i} className="flex items-center gap-4 py-3 border-b last:border-0" style={{ borderColor: 'var(--border)' }}>
1604
+ <Skeleton className="h-4 flex-1" /><Skeleton className="h-4 flex-1" /><Skeleton className="h-5 w-14 rounded-full" />
1605
+ </div>
1606
+ ))}
1607
+ </div>
1608
+ </div>
1609
+ );
1610
+ }
1727
1611
 
1728
- export const schema = {
1729
- params: z.object({ id: z.string() }),
1730
- body: UpdateUserBody,
1731
- response: UserSchema,
1732
- };
1612
+ export default function UsersPage() {
1613
+ const queryClient = useQueryClient();
1614
+ const notify = useUIStore((s) => s.notify);
1615
+ const [form, setForm] = useState({ name: '', email: '' });
1616
+ const [loading, setLoading] = useState(false);
1617
+ const [error, setError] = useState('');
1733
1618
 
1734
- export default async ({
1735
- params,
1736
- body,
1737
- }: {
1738
- params: { id: string };
1739
- body: { name?: string; email?: string; role?: 'admin' | 'user' };
1740
- }) => {
1741
- const idx = users.findIndex(u => u.id === params.id);
1742
- if (idx === -1) throw new KozoError('User not found', 404, 'NOT_FOUND');
1743
- users[idx] = { ...users[idx], ...body };
1744
- return users[idx];
1745
- };
1746
- `);
1747
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "users", "[id]", "delete.ts"), `import { z } from 'zod';
1748
- import { KozoError } from '@kozojs/core';
1749
- import { users } from '../../../../data/index.js';
1619
+ const { data: users = [], isLoading } = useQuery(usersQuery);
1750
1620
 
1751
- export const schema = {
1752
- params: z.object({ id: z.string() }),
1753
- response: z.object({ success: z.boolean(), message: z.string() }),
1754
- };
1621
+ const createUser = async () => {
1622
+ if (!form.name || !form.email) { setError('Name and email required'); return; }
1623
+ setLoading(true); setError('');
1624
+ try {
1625
+ const res = await apiFetch('/api/users', { method: 'POST', body: JSON.stringify(form) });
1626
+ if (res.status >= 400) throw new Error('Failed');
1627
+ setForm({ name: '', email: '' });
1628
+ queryClient.invalidateQueries({ queryKey: ['users'] });
1629
+ notify('success', 'User created');
1630
+ } catch { setError('Failed to create user'); notify('error', 'Failed to create user'); }
1631
+ finally { setLoading(false); }
1632
+ };
1755
1633
 
1756
- export default async ({ params }: { params: { id: string } }) => {
1757
- const idx = users.findIndex(u => u.id === params.id);
1758
- if (idx === -1) throw new KozoError('User not found', 404, 'NOT_FOUND');
1759
- users.splice(idx, 1);
1760
- return { success: true, message: 'User deleted' };
1761
- };
1762
- `);
1763
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "posts", "get.ts"), `import { z } from 'zod';
1764
- import { posts } from '../../../data/index.js';
1765
- import { PostSchema } from '../../../schemas/index.js';
1634
+ const deleteUser = async (id: string | number) => {
1635
+ await apiFetch(\`/api/users/\${id}\`, { method: 'DELETE' });
1636
+ queryClient.invalidateQueries({ queryKey: ['users'] });
1637
+ notify('success', 'User deleted');
1638
+ };
1766
1639
 
1767
- export const schema = {
1768
- query: z.object({ published: z.coerce.boolean().optional() }),
1769
- response: z.array(PostSchema),
1770
- };
1640
+ if (isLoading) return <UsersSkeleton />;
1771
1641
 
1772
- export default async ({ query }: { query: { published?: boolean } }) => {
1773
- if (query.published !== undefined) {
1774
- return posts.filter(p => p.published === query.published);
1775
- }
1776
- return posts;
1777
- };
1778
- `);
1779
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "posts", "post.ts"), `import { posts, users } from '../../../data/index.js';
1780
- import { PostSchema, CreatePostBody } from '../../../schemas/index.js';
1642
+ return (
1643
+ <div>
1644
+ <div className="mb-6">
1645
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight" style={{ color: 'var(--fg)' }}>Users</h1>
1646
+ <p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>{users.length} total</p>
1647
+ </div>
1781
1648
 
1782
- export const schema = {
1783
- body: CreatePostBody,
1784
- response: PostSchema,
1785
- };
1649
+ <div className="card mb-4">
1650
+ <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--fg)' }}>Add User</h3>
1651
+ <div className="flex gap-3">
1652
+ <input
1653
+ placeholder="Full name"
1654
+ value={form.name}
1655
+ onChange={e => setForm({ ...form, name: e.target.value })}
1656
+ className="flex-1 px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1657
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1658
+ />
1659
+ <input
1660
+ placeholder="email@example.com"
1661
+ type="email"
1662
+ value={form.email}
1663
+ onChange={e => setForm({ ...form, email: e.target.value })}
1664
+ className="flex-1 px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1665
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1666
+ />
1667
+ <button onClick={() => void createUser()} disabled={loading}
1668
+ className="px-4 py-2 rounded-lg text-sm font-semibold transition flex items-center gap-2 disabled:opacity-50"
1669
+ style={{ background: 'var(--accent)', color: 'var(--accent-fg)' }}>
1670
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
1671
+ Add
1672
+ </button>
1673
+ </div>
1674
+ {error && <p className="text-xs mt-2" style={{ color: 'var(--destructive)' }}>{error}</p>}
1675
+ </div>
1786
1676
 
1787
- export default async ({
1788
- body,
1789
- }: {
1790
- body: { title: string; content?: string; authorId?: string; published?: boolean };
1791
- }) => {
1792
- const authorId = body.authorId ?? users[0]?.id ?? 'unknown';
1793
- const newPost = {
1794
- id: String(Date.now()),
1795
- title: body.title,
1796
- content: body.content ?? '',
1797
- authorId,
1798
- published: body.published ?? false,
1799
- createdAt: new Date().toISOString(),
1677
+ <div className="card overflow-hidden">
1678
+ {users.length === 0 ? (
1679
+ <div className="py-8 text-center text-sm" style={{ color: 'var(--fg-muted)' }}>No users yet. Add one above.</div>
1680
+ ) : (
1681
+ <table className="w-full text-sm">
1682
+ <thead>
1683
+ <tr style={{ borderBottom: '1px solid var(--border)' }}>
1684
+ <th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--fg-muted)' }}>Name</th>
1685
+ <th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--fg-muted)' }}>Email</th>
1686
+ <th className="text-left px-4 py-3 text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--fg-muted)' }}>Role</th>
1687
+ <th className="px-4 py-3"></th>
1688
+ </tr>
1689
+ </thead>
1690
+ <tbody>
1691
+ {users.map((user) => (
1692
+ <tr key={user.id} style={{ borderBottom: '1px solid var(--border)' }}>
1693
+ <td className="px-4 py-3 font-medium" style={{ color: 'var(--fg)' }}>{user.name}</td>
1694
+ <td className="px-4 py-3" style={{ color: 'var(--fg-muted)' }}>{user.email}</td>
1695
+ <td className="px-4 py-3">
1696
+ {user.role && (
1697
+ <span className="px-2 py-0.5 rounded-full text-xs font-semibold"
1698
+ style={user.role === 'admin'
1699
+ ? { background: 'rgba(251,191,36,0.15)', color: '#fbbf24' }
1700
+ : { background: 'var(--bg-subtle)', color: 'var(--fg-muted)' }}>
1701
+ {user.role}
1702
+ </span>
1703
+ )}
1704
+ </td>
1705
+ <td className="px-4 py-3 text-right">
1706
+ <button onClick={() => void deleteUser(user.id)}
1707
+ className="p-1.5 rounded transition-colors"
1708
+ style={{ color: 'var(--fg-subtle)' }}>
1709
+ <Trash2 className="w-4 h-4" />
1710
+ </button>
1711
+ </td>
1712
+ </tr>
1713
+ ))}
1714
+ </tbody>
1715
+ </table>
1716
+ )}
1717
+ </div>
1718
+ </div>
1719
+ );
1720
+ }
1721
+ `;
1722
+ }
1723
+ function generatePostsPage() {
1724
+ return `import { useState } from 'react';
1725
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
1726
+ import { postsQuery, type Post } from '@/lib/queries';
1727
+ import { apiFetch } from '@/lib/api';
1728
+ import { useUIStore } from '@/store/ui';
1729
+ import { Plus, Trash2, Loader2, Globe, Lock } from 'lucide-react';
1730
+ import { Skeleton } from '@/components/Skeleton';
1731
+
1732
+ function PostsSkeleton() {
1733
+ return (
1734
+ <div>
1735
+ <div className="mb-6"><Skeleton className="h-8 w-28 mb-2" /><Skeleton className="h-4 w-20" /></div>
1736
+ <div className="card mb-4"><Skeleton className="h-5 w-24 mb-3" /><Skeleton className="h-10 w-full mb-2" /><Skeleton className="h-20 w-full" /></div>
1737
+ {Array.from({ length: 2 }).map((_, i) => (
1738
+ <div key={i} className="card mb-3"><Skeleton className="h-5 w-3/4 mb-2" /><Skeleton className="h-4 w-full" /></div>
1739
+ ))}
1740
+ </div>
1741
+ );
1742
+ }
1743
+
1744
+ export default function PostsPage() {
1745
+ const queryClient = useQueryClient();
1746
+ const notify = useUIStore((s) => s.notify);
1747
+ const [form, setForm] = useState({ title: '', content: '', published: false });
1748
+ const [loading, setLoading] = useState(false);
1749
+ const [error, setError] = useState('');
1750
+
1751
+ const { data: posts = [], isLoading } = useQuery(postsQuery);
1752
+
1753
+ const createPost = async () => {
1754
+ if (!form.title) { setError('Title required'); return; }
1755
+ setLoading(true); setError('');
1756
+ try {
1757
+ const res = await apiFetch('/api/posts', { method: 'POST', body: JSON.stringify(form) });
1758
+ if (res.status >= 400) throw new Error('Failed');
1759
+ setForm({ title: '', content: '', published: false });
1760
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
1761
+ notify('success', 'Post created');
1762
+ } catch { setError('Failed to create post'); notify('error', 'Failed to create post'); }
1763
+ finally { setLoading(false); }
1764
+ };
1765
+
1766
+ const deletePost = async (id: string | number) => {
1767
+ await apiFetch(\`/api/posts/\${id}\`, { method: 'DELETE' });
1768
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
1769
+ notify('success', 'Post deleted');
1770
+ };
1771
+
1772
+ if (isLoading) return <PostsSkeleton />;
1773
+
1774
+ return (
1775
+ <div>
1776
+ <div className="mb-6">
1777
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight" style={{ color: 'var(--fg)' }}>Posts</h1>
1778
+ <p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>{posts.length} total</p>
1779
+ </div>
1780
+
1781
+ <div className="card mb-4">
1782
+ <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--fg)' }}>New Post</h3>
1783
+ <div className="space-y-3">
1784
+ <input
1785
+ placeholder="Post title"
1786
+ value={form.title}
1787
+ onChange={e => setForm({ ...form, title: e.target.value })}
1788
+ className="w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1789
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1790
+ />
1791
+ <textarea
1792
+ placeholder="Content (optional)"
1793
+ value={form.content}
1794
+ onChange={e => setForm({ ...form, content: e.target.value })}
1795
+ rows={2}
1796
+ className="w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none resize-none"
1797
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1798
+ />
1799
+ <div className="flex items-center justify-between">
1800
+ <label className="flex items-center gap-2 text-sm cursor-pointer" style={{ color: 'var(--fg-muted)' }}>
1801
+ <input type="checkbox" checked={form.published}
1802
+ onChange={e => setForm({ ...form, published: e.target.checked })} />
1803
+ Publish immediately
1804
+ </label>
1805
+ <button onClick={() => void createPost()} disabled={loading}
1806
+ className="px-4 py-2 rounded-lg text-sm font-semibold transition flex items-center gap-2 disabled:opacity-50"
1807
+ style={{ background: 'var(--accent)', color: 'var(--accent-fg)' }}>
1808
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
1809
+ Create
1810
+ </button>
1811
+ </div>
1812
+ </div>
1813
+ {error && <p className="text-xs mt-2" style={{ color: 'var(--destructive)' }}>{error}</p>}
1814
+ </div>
1815
+
1816
+ <div className="space-y-3">
1817
+ {posts.length === 0 ? (
1818
+ <div className="text-center text-sm py-8" style={{ color: 'var(--fg-muted)' }}>No posts yet. Create one above.</div>
1819
+ ) : (
1820
+ posts.map((post) => (
1821
+ <div key={post.id} className="card flex items-start justify-between">
1822
+ <div className="flex-1 min-w-0">
1823
+ <div className="flex items-center gap-2 mb-1">
1824
+ {post.published
1825
+ ? <Globe className="w-3.5 h-3.5 flex-shrink-0" style={{ color: '#34d399' }} />
1826
+ : <Lock className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--fg-subtle)' }} />
1827
+ }
1828
+ <h3 className="font-semibold truncate text-sm" style={{ color: 'var(--fg)' }}>{post.title}</h3>
1829
+ </div>
1830
+ {post.content && (
1831
+ <p className="text-sm line-clamp-2 mt-0.5" style={{ color: 'var(--fg-muted)' }}>{post.content}</p>
1832
+ )}
1833
+ </div>
1834
+ <button onClick={() => void deletePost(post.id)}
1835
+ className="ml-3 p-1.5 rounded transition-colors flex-shrink-0"
1836
+ style={{ color: 'var(--fg-subtle)' }}>
1837
+ <Trash2 className="w-4 h-4" />
1838
+ </button>
1839
+ </div>
1840
+ ))
1841
+ )}
1842
+ </div>
1843
+ </div>
1844
+ );
1845
+ }
1846
+ `;
1847
+ }
1848
+ function generateTasksPage() {
1849
+ return `import { useState } from 'react';
1850
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
1851
+ import { tasksQuery, type Task } from '@/lib/queries';
1852
+ import { apiFetch } from '@/lib/api';
1853
+ import { useUIStore } from '@/store/ui';
1854
+ import { Plus, Trash2, Loader2, CheckCircle2, Circle } from 'lucide-react';
1855
+ import { Skeleton } from '@/components/Skeleton';
1856
+
1857
+ const PRIORITY_STYLE: Record<string, { background: string; color: string }> = {
1858
+ high: { background: 'rgba(239,68,68,0.15)', color: '#f87171' },
1859
+ medium: { background: 'rgba(251,191,36,0.15)', color: '#fbbf24' },
1860
+ low: { background: 'var(--bg-subtle)', color: 'var(--fg-muted)' },
1861
+ };
1862
+
1863
+ function TasksSkeleton() {
1864
+ return (
1865
+ <div>
1866
+ <div className="mb-6"><Skeleton className="h-8 w-28 mb-2" /><Skeleton className="h-4 w-24" /></div>
1867
+ <div className="card mb-4"><Skeleton className="h-5 w-24 mb-3" /><div className="flex gap-3"><Skeleton className="h-10 flex-1" /><Skeleton className="h-10 w-28" /><Skeleton className="h-10 w-20" /></div></div>
1868
+ {Array.from({ length: 3 }).map((_, i) => (
1869
+ <div key={i} className="card flex items-center gap-3 mb-2">
1870
+ <Skeleton className="w-5 h-5 rounded-full" /><Skeleton className="h-4 flex-1" /><Skeleton className="h-5 w-14 rounded-full" />
1871
+ </div>
1872
+ ))}
1873
+ </div>
1874
+ );
1875
+ }
1876
+
1877
+ export default function TasksPage() {
1878
+ const queryClient = useQueryClient();
1879
+ const notify = useUIStore((s) => s.notify);
1880
+ const [form, setForm] = useState({ title: '', priority: 'medium' as 'low' | 'medium' | 'high' });
1881
+ const [loading, setLoading] = useState(false);
1882
+ const [error, setError] = useState('');
1883
+
1884
+ const { data: tasks = [], isLoading } = useQuery(tasksQuery);
1885
+
1886
+ const createTask = async () => {
1887
+ if (!form.title) { setError('Title required'); return; }
1888
+ setLoading(true); setError('');
1889
+ try {
1890
+ const res = await apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(form) });
1891
+ if (res.status >= 400) throw new Error('Failed');
1892
+ setForm({ title: '', priority: 'medium' });
1893
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
1894
+ notify('success', 'Task created');
1895
+ } catch { setError('Failed to create task'); notify('error', 'Failed to create task'); }
1896
+ finally { setLoading(false); }
1897
+ };
1898
+
1899
+ const toggleTask = async (id: string | number) => {
1900
+ await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
1901
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
1902
+ };
1903
+
1904
+ const deleteTask = async (id: string | number) => {
1905
+ await apiFetch(\`/api/tasks/\${id}\`, { method: 'DELETE' });
1906
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
1907
+ notify('success', 'Task deleted');
1908
+ };
1909
+
1910
+ const done = tasks.filter(t => t.completed).length;
1911
+
1912
+ if (isLoading) return <TasksSkeleton />;
1913
+
1914
+ return (
1915
+ <div>
1916
+ <div className="mb-6">
1917
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight" style={{ color: 'var(--fg)' }}>Tasks</h1>
1918
+ <p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>{done}/{tasks.length} completed</p>
1919
+ </div>
1920
+
1921
+ <div className="card mb-4">
1922
+ <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--fg)' }}>New Task</h3>
1923
+ <div className="flex gap-3">
1924
+ <input
1925
+ placeholder="Task title"
1926
+ value={form.title}
1927
+ onChange={e => setForm({ ...form, title: e.target.value })}
1928
+ onKeyDown={e => { if (e.key === 'Enter') void createTask(); }}
1929
+ className="flex-1 px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1930
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1931
+ />
1932
+ <select
1933
+ value={form.priority}
1934
+ onChange={e => setForm({ ...form, priority: e.target.value as 'low' | 'medium' | 'high' })}
1935
+ className="px-3 py-2.5 rounded-lg text-sm focus:outline-none"
1936
+ style={{ background: 'var(--input-bg)', border: '1px solid var(--input-border)', color: 'var(--fg)' }}
1937
+ >
1938
+ <option value="low">Low</option>
1939
+ <option value="medium">Medium</option>
1940
+ <option value="high">High</option>
1941
+ </select>
1942
+ <button onClick={() => void createTask()} disabled={loading}
1943
+ className="px-4 py-2 rounded-lg text-sm font-semibold transition flex items-center gap-2 disabled:opacity-50"
1944
+ style={{ background: 'var(--accent)', color: 'var(--accent-fg)' }}>
1945
+ {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
1946
+ Add
1947
+ </button>
1948
+ </div>
1949
+ {error && <p className="text-xs mt-2" style={{ color: 'var(--destructive)' }}>{error}</p>}
1950
+ </div>
1951
+
1952
+ <div className="space-y-2">
1953
+ {tasks.length === 0 ? (
1954
+ <div className="text-center text-sm py-8" style={{ color: 'var(--fg-muted)' }}>No tasks yet. Add one above.</div>
1955
+ ) : (
1956
+ tasks.map((task) => (
1957
+ <div key={task.id} className="card flex items-center justify-between py-3"
1958
+ style={{ opacity: task.completed ? 0.65 : 1 }}>
1959
+ <div className="flex items-center gap-3 flex-1 min-w-0">
1960
+ <button onClick={() => void toggleTask(task.id)}
1961
+ className="flex-shrink-0 transition-colors"
1962
+ style={{ color: task.completed ? '#34d399' : 'var(--fg-subtle)' }}>
1963
+ {task.completed
1964
+ ? <CheckCircle2 className="w-5 h-5" />
1965
+ : <Circle className="w-5 h-5" />
1966
+ }
1967
+ </button>
1968
+ <span className={\`text-sm font-medium truncate \${task.completed ? 'line-through' : ''}\`}
1969
+ style={{ color: task.completed ? 'var(--fg-subtle)' : 'var(--fg)' }}>
1970
+ {task.title}
1971
+ </span>
1972
+ <span className="flex-shrink-0 px-2 py-0.5 rounded-full text-xs font-semibold"
1973
+ style={PRIORITY_STYLE[task.priority] ?? PRIORITY_STYLE.low}>
1974
+ {task.priority}
1975
+ </span>
1976
+ </div>
1977
+ <button onClick={() => void deleteTask(task.id)}
1978
+ className="ml-3 p-1.5 rounded transition-colors flex-shrink-0"
1979
+ style={{ color: 'var(--fg-subtle)' }}>
1980
+ <Trash2 className="w-4 h-4" />
1981
+ </button>
1982
+ </div>
1983
+ ))
1984
+ )}
1985
+ </div>
1986
+ </div>
1987
+ );
1988
+ }
1989
+ `;
1990
+ }
1991
+
1992
+ // src/utils/scaffold/generators/assets.ts
1993
+ function generateSsrServer() {
1994
+ return `import fs from 'node:fs';
1995
+ import path from 'node:path';
1996
+ import { fileURLToPath } from 'node:url';
1997
+ import { createServer as createViteServer } from 'vite';
1998
+
1999
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
2000
+ const isProduction = process.env.NODE_ENV === 'production';
2001
+ const PORT = Number(process.env.PORT ?? 5173);
2002
+
2003
+ async function createServer() {
2004
+ const app = (await import('express')).default();
2005
+
2006
+ let vite: Awaited<ReturnType<typeof createViteServer>> | null = null;
2007
+
2008
+ if (!isProduction) {
2009
+ vite = await createViteServer({
2010
+ server: { middlewareMode: true },
2011
+ appType: 'custom',
2012
+ });
2013
+ app.use(vite.middlewares);
2014
+ } else {
2015
+ const { default: sirv } = await import('sirv');
2016
+ app.use(sirv(path.join(__dirname, 'client'), { gzip: true }));
2017
+ }
2018
+
2019
+ app.use('*', async (req, res, next) => {
2020
+ if (req.originalUrl.startsWith('/api')) return next();
2021
+ try {
2022
+ const url = req.originalUrl;
2023
+ let template: string;
2024
+ let render: (url: string) => Promise<{ html: string; helmet?: { title?: string; description?: string } }>;
2025
+
2026
+ if (!isProduction && vite) {
2027
+ template = fs.readFileSync(path.join(process.cwd(), 'index.html'), 'utf-8');
2028
+ template = await vite.transformIndexHtml(url, template);
2029
+ render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render;
2030
+ } else {
2031
+ template = fs.readFileSync(path.join(__dirname, 'client', 'index.html'), 'utf-8');
2032
+ render = (await import('./server/entry-server.js')).render;
2033
+ }
2034
+
2035
+ const { html: appHtml, helmet = {} } = await render(url);
2036
+ const { title = 'App', description = '' } = helmet;
2037
+
2038
+ const finalHtml = template
2039
+ .replace('<title>App</title>', \`<title>\${title}</title>\`)
2040
+ .replace('<!--description-->', description ? \`<meta name="description" content="\${description}" />\` : '')
2041
+ .replace('<!--app-html-->', appHtml);
2042
+
2043
+ res.status(200).set({ 'Content-Type': 'text/html' }).end(finalHtml);
2044
+ } catch (e) {
2045
+ vite?.ssrFixStacktrace(e as Error);
2046
+ next(e);
2047
+ }
2048
+ });
2049
+
2050
+ app.listen(PORT, () => {
2051
+ console.log(\`\${isProduction ? 'Production' : 'Dev'} server running at http://localhost:\${PORT}\`);
2052
+ });
2053
+ }
2054
+
2055
+ createServer();
2056
+ `;
2057
+ }
2058
+ function generateIndexCss(_projectName) {
2059
+ return `@import "tailwindcss";
2060
+ @import "tw-animate-css";
2061
+
2062
+ @custom-variant dark (&:is(.dark *));
2063
+
2064
+ :root {
2065
+ --bg: #0f0f10;
2066
+ --bg-subtle: #1a1a1e;
2067
+ --card: #18181c;
2068
+ --card-border: #2a2a30;
2069
+ --sidebar-bg: #111114;
2070
+ --fg: #e8e8ec;
2071
+ --fg-muted: #888893;
2072
+ --fg-subtle: #555560;
2073
+ --border: #27272e;
2074
+ --input-bg: #1e1e22;
2075
+ --input-border: #32323a;
2076
+ --accent: #ABF43F;
2077
+ --accent-hover: #c0ff55;
2078
+ --accent-fg: #0a0f00;
2079
+ --accent-subtle: rgba(171,244,63,0.12);
2080
+ --accent-border: rgba(171,244,63,0.3);
2081
+ --destructive: #f87171;
2082
+ --radius: 0.75rem;
2083
+ }
2084
+
2085
+ .light {
2086
+ --bg: #f8f8fa;
2087
+ --bg-subtle: #ededf0;
2088
+ --card: #ffffff;
2089
+ --card-border: #e0e0e6;
2090
+ --sidebar-bg: #f0f0f3;
2091
+ --fg: #121214;
2092
+ --fg-muted: #555560;
2093
+ --fg-subtle: #9999a8;
2094
+ --border: #e2e2e8;
2095
+ --input-bg: #ffffff;
2096
+ --input-border: #d0d0d8;
2097
+ --accent: #4d7c00;
2098
+ --accent-hover: #3d6300;
2099
+ --accent-fg: #ffffff;
2100
+ --accent-subtle: rgba(77,124,0,0.1);
2101
+ --accent-border: rgba(77,124,0,0.3);
2102
+ --destructive: #dc2626;
2103
+ --radius: 0.75rem;
2104
+ }
2105
+
2106
+ *,
2107
+ *::before,
2108
+ *::after {
2109
+ box-sizing: border-box;
2110
+ border-color: var(--border);
2111
+ }
2112
+
2113
+ html {
2114
+ font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
2115
+ font-size: 16px;
2116
+ scroll-behavior: smooth;
2117
+ -webkit-font-smoothing: antialiased;
2118
+ }
2119
+
2120
+ body {
2121
+ margin: 0;
2122
+ background: var(--bg);
2123
+ color: var(--fg);
2124
+ min-height: 100dvh;
2125
+ }
2126
+
2127
+ #root {
2128
+ min-height: 100dvh;
2129
+ }
2130
+
2131
+ .card {
2132
+ background: var(--card);
2133
+ border: 1px solid var(--card-border);
2134
+ border-radius: var(--radius);
2135
+ padding: 1rem;
2136
+ }
2137
+
2138
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
2139
+ ::-webkit-scrollbar-track { background: transparent; }
2140
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
2141
+ ::-webkit-scrollbar-thumb:hover { background: var(--fg-subtle); }
2142
+
2143
+ :focus-visible {
2144
+ outline: 2px solid var(--accent);
2145
+ outline-offset: 2px;
2146
+ border-radius: calc(var(--radius) / 2);
2147
+ }
2148
+ `;
2149
+ }
2150
+ function generateApiLib(auth) {
2151
+ const tokenHelpers = auth ? `
2152
+ const TOKEN_KEY = 'token';
2153
+ export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
2154
+ export const setToken = (t: string): void => { localStorage.setItem(TOKEN_KEY, t); };
2155
+ export const clearToken = (): void => { localStorage.removeItem(TOKEN_KEY); };
2156
+ ` : "";
2157
+ return `export class ApiError extends Error {
2158
+ constructor(
2159
+ public readonly status: number,
2160
+ message: string,
2161
+ public readonly data?: unknown
2162
+ ) {
2163
+ super(message);
2164
+ this.name = 'ApiError';
2165
+ }
2166
+ }
2167
+
2168
+ export const NO_BODY = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
2169
+ ${tokenHelpers}
2170
+ export interface ApiResponse<T = unknown> {
2171
+ data: T;
2172
+ status: number;
2173
+ ok: boolean;
2174
+ }
2175
+
2176
+ export async function apiFetch<T = unknown>(
2177
+ url: string,
2178
+ options: RequestInit = {}
2179
+ ): Promise<ApiResponse<T>> {
2180
+ const headers: Record<string, string> = {
2181
+ ...(options.headers as Record<string, string> ?? {}),
2182
+ };
2183
+
2184
+ if (!NO_BODY.has((options.method ?? 'GET').toUpperCase())) {
2185
+ headers['Content-Type'] ??= 'application/json';
2186
+ }
2187
+ ${auth ? `
2188
+ const token = getToken();
2189
+ if (token) headers['Authorization'] = \`Bearer \${token}\`;
2190
+ ` : ""}
2191
+ const res = await fetch(url, { ...options, headers });
2192
+
2193
+ if (res.status === 401) {
2194
+ window.dispatchEvent(new Event('auth:401'));
2195
+ }
2196
+
2197
+ let data: T;
2198
+ const ct = res.headers.get('content-type') ?? '';
2199
+ if (ct.includes('application/json')) {
2200
+ data = await res.json() as T;
2201
+ } else {
2202
+ data = (await res.text()) as unknown as T;
2203
+ }
2204
+
2205
+ if (!res.ok) {
2206
+ throw new ApiError(res.status, \`HTTP \${res.status}\`, data);
2207
+ }
2208
+
2209
+ return { data, status: res.status, ok: res.ok };
2210
+ }
2211
+ `;
2212
+ }
2213
+ function generateQueriesLib() {
2214
+ return `import { queryOptions } from '@tanstack/react-query';
2215
+ import { apiFetch } from '@/lib/api';
2216
+
2217
+ export interface User {
2218
+ id: string | number;
2219
+ name: string;
2220
+ email: string;
2221
+ role?: 'admin' | 'user' | string;
2222
+ createdAt?: string;
2223
+ }
2224
+
2225
+ export interface Post {
2226
+ id: string | number;
2227
+ title: string;
2228
+ content?: string;
2229
+ published?: boolean;
2230
+ authorId?: string | number;
2231
+ createdAt?: string;
2232
+ }
2233
+
2234
+ export interface Task {
2235
+ id: string | number;
2236
+ title: string;
2237
+ completed: boolean;
2238
+ priority: 'low' | 'medium' | 'high';
2239
+ createdAt?: string;
2240
+ }
2241
+
2242
+ export interface Stats {
2243
+ users: number;
2244
+ posts: number;
2245
+ tasks: number;
2246
+ completedTasks: number;
2247
+ }
2248
+
2249
+ async function fetchList<T>(url: string): Promise<T[]> {
2250
+ const res = await apiFetch<T[] | { data: T[]; items: T[] }>(url);
2251
+ if (Array.isArray(res.data)) return res.data;
2252
+ return (res.data as { data?: T[]; items?: T[] }).data
2253
+ ?? (res.data as { data?: T[]; items?: T[] }).items
2254
+ ?? [];
2255
+ }
2256
+
2257
+ export const healthQuery = queryOptions({
2258
+ queryKey: ['health'],
2259
+ queryFn: async () => {
2260
+ const res = await apiFetch<{ status: string; uptime?: number }>('/api/health');
2261
+ return res.data;
2262
+ },
2263
+ staleTime: 30_000,
2264
+ });
2265
+
2266
+ export const statsQuery = queryOptions({
2267
+ queryKey: ['stats'],
2268
+ queryFn: async () => {
2269
+ const res = await apiFetch<Stats>('/api/stats');
2270
+ return res.data;
2271
+ },
2272
+ });
2273
+
2274
+ export const usersQuery = queryOptions({
2275
+ queryKey: ['users'],
2276
+ queryFn: () => fetchList<User>('/api/users'),
2277
+ });
2278
+
2279
+ export const postsQuery = queryOptions({
2280
+ queryKey: ['posts'],
2281
+ queryFn: () => fetchList<Post>('/api/posts'),
2282
+ });
2283
+
2284
+ export const tasksQuery = queryOptions({
2285
+ queryKey: ['tasks'],
2286
+ queryFn: () => fetchList<Task>('/api/tasks'),
2287
+ });
2288
+ `;
2289
+ }
2290
+ function generateSkeletonComponent() {
2291
+ return `import { cn } from '@/lib/utils';
2292
+
2293
+ interface SkeletonProps {
2294
+ className?: string;
2295
+ }
2296
+
2297
+ export function Skeleton({ className }: SkeletonProps) {
2298
+ return (
2299
+ <div
2300
+ className={cn('animate-pulse rounded-md', className)}
2301
+ style={{ background: 'var(--bg-subtle)' }}
2302
+ />
2303
+ );
2304
+ }
2305
+ `;
2306
+ }
2307
+ function generateEntryClient(projectName, auth) {
2308
+ return `import React from 'react';
2309
+ import { hydrateRoot, createRoot } from 'react-dom/client';
2310
+ import { QueryClientProvider } from '@tanstack/react-query';
2311
+ import { createQueryClient } from './lib/queryClient';
2312
+ import App from './App';
2313
+ import './index.css';
2314
+
2315
+ function revealRoot() {
2316
+ const el = document.getElementById('root');
2317
+ if (el) el.style.visibility = 'visible';
2318
+ }
2319
+
2320
+ // Apply saved theme before first paint to avoid flash
2321
+ const savedTheme = localStorage.getItem('theme-storage');
2322
+ try {
2323
+ const { state } = JSON.parse(savedTheme ?? '{}') as { state?: { theme?: string } };
2324
+ if (state?.theme === 'light') {
2325
+ document.documentElement.classList.add('light');
2326
+ }
2327
+ } catch { /* ignore */ }
2328
+
2329
+ const queryClient = createQueryClient();
2330
+ const rootEl = document.getElementById('root')!;
2331
+
2332
+ if (rootEl.childNodes.length > 0) {
2333
+ hydrateRoot(rootEl, <React.StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></React.StrictMode>);
2334
+ } else {
2335
+ createRoot(rootEl).render(<React.StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></React.StrictMode>);
2336
+ }
2337
+
2338
+ revealRoot();
2339
+ `;
2340
+ }
2341
+ function generateEntryServer(projectName) {
2342
+ return `import React from 'react';
2343
+ import { renderToString } from 'react-dom/server';
2344
+ import App from './App';
2345
+
2346
+ export interface PageMeta {
2347
+ title: string;
2348
+ description: string;
2349
+ }
2350
+
2351
+ const PAGE_META: Record<string, PageMeta> = {
2352
+ '/': { title: '${projectName}', description: 'Dashboard' },
2353
+ '/users': { title: '${projectName} \u2014 Users', description: 'Manage users' },
2354
+ '/posts': { title: '${projectName} \u2014 Posts', description: 'Manage posts' },
2355
+ '/tasks': { title: '${projectName} \u2014 Tasks', description: 'Manage tasks' },
2356
+ };
2357
+
2358
+ export async function render(url: string): Promise<{ html: string; helmet: PageMeta }> {
2359
+ const html = renderToString(
2360
+ <React.StrictMode>
2361
+ <App />
2362
+ </React.StrictMode>
2363
+ );
2364
+ const meta = PAGE_META[url] ?? PAGE_META['/'];
2365
+ return { html, helmet: meta };
2366
+ }
2367
+ `;
2368
+ }
2369
+ function generateAppTest() {
2370
+ return `import { describe, it, expect } from 'vitest';
2371
+ import { render } from '@testing-library/react';
2372
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2373
+ import App from '../App';
2374
+
2375
+ function wrapper({ children }: { children: React.ReactNode }) {
2376
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
2377
+ return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
2378
+ }
2379
+
2380
+ describe('App', () => {
2381
+ it('renders without crashing', () => {
2382
+ render(<App />, { wrapper });
2383
+ expect(document.body).toBeDefined();
2384
+ });
2385
+ });
2386
+ `;
2387
+ }
2388
+
2389
+ // src/utils/scaffold/fullstack-web.ts
2390
+ async function scaffoldFullstackWeb(projectDir, projectName, frontend, auth = false) {
2391
+ const webDir = import_node_path3.default.join(projectDir, "apps", "web");
2392
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "lib"));
2393
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "pages"));
2394
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "store"));
2395
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "components"));
2396
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "hooks"));
2397
+ await import_fs_extra3.default.ensureDir(import_node_path3.default.join(webDir, "src", "__tests__"));
2398
+ const packageJson = {
2399
+ name: `@${projectName}/web`,
2400
+ version: "1.0.0",
2401
+ type: "module",
2402
+ scripts: {
2403
+ dev: "vite",
2404
+ build: "vite build && vite build --ssr src/entry-server.tsx --outDir dist/server",
2405
+ preview: "cross-env NODE_ENV=production tsx server.ts",
2406
+ test: "vitest run",
2407
+ "test:watch": "vitest",
2408
+ "type-check": "tsc --noEmit"
2409
+ },
2410
+ dependencies: {
2411
+ react: "^18.2.0",
2412
+ "react-dom": "^18.2.0",
2413
+ "@tanstack/react-query": "^5.0.0",
2414
+ "lucide-react": "^0.460.0",
2415
+ sonner: "^2.0.7",
2416
+ zustand: "^5.0.11",
2417
+ clsx: "^2.1.1",
2418
+ "tailwind-merge": "^3.5.0",
2419
+ zod: "^4.0.0",
2420
+ ...auth && { "react-hook-form": "^7.71.2", "@hookform/resolvers": "^5.2.2" }
2421
+ },
2422
+ devDependencies: {
2423
+ "@types/react": "^18.2.0",
2424
+ "@types/react-dom": "^18.2.0",
2425
+ "@vitejs/plugin-react": "^4.7.0",
2426
+ "@tailwindcss/vite": "^4.0.0",
2427
+ tailwindcss: "^4.0.0",
2428
+ "tw-animate-css": "^1.4.0",
2429
+ typescript: "^5.6.0",
2430
+ vite: "^5.0.0",
2431
+ tsx: "^4.21.0",
2432
+ "cross-env": "^7.0.3",
2433
+ vitest: "^4.0.18",
2434
+ jsdom: "^28.1.0",
2435
+ "@testing-library/react": "^16.3.2",
2436
+ "@testing-library/jest-dom": "^6.9.1",
2437
+ "@testing-library/user-event": "^14.6.1"
2438
+ }
2439
+ };
2440
+ await import_fs_extra3.default.writeJSON(import_node_path3.default.join(webDir, "package.json"), packageJson, { spaces: 2 });
2441
+ await import_fs_extra3.default.writeJSON(import_node_path3.default.join(webDir, "tsconfig.json"), {
2442
+ compilerOptions: {
2443
+ target: "ES2020",
2444
+ lib: ["ES2020", "DOM", "DOM.Iterable"],
2445
+ module: "ESNext",
2446
+ skipLibCheck: true,
2447
+ moduleResolution: "bundler",
2448
+ allowImportingTsExtensions: true,
2449
+ resolveJsonModule: true,
2450
+ isolatedModules: true,
2451
+ noEmit: true,
2452
+ jsx: "react-jsx",
2453
+ strict: true,
2454
+ noUnusedLocals: true,
2455
+ noUnusedParameters: true,
2456
+ paths: { "@/*": ["./src/*"] }
2457
+ },
2458
+ include: ["src"]
2459
+ }, { spaces: 2 });
2460
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "vite.config.ts"), `import { defineConfig } from 'vite';
2461
+ import react from '@vitejs/plugin-react';
2462
+ import tailwindcss from '@tailwindcss/vite';
2463
+ import path from 'path';
2464
+
2465
+ export default defineConfig({
2466
+ plugins: [react(), tailwindcss()],
2467
+ resolve: {
2468
+ alias: {
2469
+ '@': path.resolve(__dirname, './src'),
2470
+ },
2471
+ },
2472
+ server: {
2473
+ port: 5173,
2474
+ host: true,
2475
+ proxy: {
2476
+ '/api': { target: 'http://localhost:3000', changeOrigin: true },
2477
+ },
2478
+ },
2479
+ build: {
2480
+ outDir: 'dist/client',
2481
+ rollupOptions: {
2482
+ input: 'index.html',
2483
+ },
2484
+ },
2485
+ });
2486
+ `);
2487
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "index.html"), `<!DOCTYPE html>
2488
+ <html lang="en">
2489
+ <head>
2490
+ <meta charset="UTF-8" />
2491
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2492
+ <title>${projectName}</title>
2493
+ </head>
2494
+ <body>
2495
+ <div id="root" style="visibility:hidden"></div>
2496
+ <script type="module" src="/src/entry-client.tsx"></script>
2497
+ </body>
2498
+ </html>
2499
+ `);
2500
+ await import_fs_extra3.default.writeJSON(import_node_path3.default.join(webDir, "components.json"), {
2501
+ $schema: "https://ui.shadcn.com/schema.json",
2502
+ style: "new-york",
2503
+ rsc: false,
2504
+ tsx: true,
2505
+ tailwind: {
2506
+ config: "",
2507
+ css: "src/index.css",
2508
+ baseColor: "neutral",
2509
+ cssVariables: true,
2510
+ prefix: ""
2511
+ },
2512
+ iconLibrary: "lucide",
2513
+ rtl: false,
2514
+ aliases: {
2515
+ components: "@/components",
2516
+ utils: "@/lib/utils",
2517
+ ui: "@/components/ui",
2518
+ lib: "@/lib",
2519
+ hooks: "@/hooks"
2520
+ },
2521
+ registries: {}
2522
+ }, { spaces: 2 });
2523
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "vitest.config.ts"), `import { defineConfig } from 'vitest/config';
2524
+ import react from '@vitejs/plugin-react';
2525
+ import path from 'path';
2526
+
2527
+ export default defineConfig({
2528
+ plugins: [react()],
2529
+ test: {
2530
+ environment: 'jsdom',
2531
+ setupFiles: ['./vitest.setup.ts'],
2532
+ globals: true,
2533
+ },
2534
+ resolve: {
2535
+ alias: {
2536
+ '@': path.resolve(__dirname, './src'),
2537
+ },
2538
+ },
2539
+ });
2540
+ `);
2541
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "vitest.setup.ts"), `import '@testing-library/jest-dom/vitest';
2542
+ `);
2543
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "server.ts"), generateSsrServer());
2544
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "index.css"), generateIndexCss(projectName));
2545
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "lib", "api.ts"), generateApiLib(auth));
2546
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "lib", "utils.ts"), `import { clsx, type ClassValue } from 'clsx';
2547
+ import { twMerge } from 'tailwind-merge';
2548
+
2549
+ export function cn(...inputs: ClassValue[]) {
2550
+ return twMerge(clsx(inputs));
2551
+ }
2552
+ `);
2553
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "lib", "queryClient.ts"), `import { QueryClient } from '@tanstack/react-query';
2554
+ import { ApiError } from './api.js';
2555
+
2556
+ /** Never retry on auth errors \u2014 user must re-authenticate. */
2557
+ function shouldRetry(failureCount: number, error: unknown): boolean {
2558
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false;
2559
+ return failureCount < 1;
2560
+ }
2561
+
2562
+ export function createQueryClient() {
2563
+ return new QueryClient({
2564
+ defaultOptions: {
2565
+ queries: { refetchOnWindowFocus: false, retry: shouldRetry },
2566
+ },
2567
+ });
2568
+ }
2569
+ `);
2570
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "lib", "queries.ts"), generateQueriesLib());
2571
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "store", "ui.ts"), `import { create } from 'zustand';
2572
+ import { toast } from 'sonner';
2573
+
2574
+ interface UIState {
2575
+ globalLoading: boolean;
2576
+ setGlobalLoading: (v: boolean) => void;
2577
+ notify: (type: 'success' | 'error' | 'info', message: string) => void;
2578
+ sidebarOpen: boolean;
2579
+ toggleSidebar: () => void;
2580
+ setSidebarOpen: (v: boolean) => void;
2581
+ }
2582
+
2583
+ export const useUIStore = create<UIState>(() => ({
2584
+ globalLoading: false,
2585
+ setGlobalLoading: (v) => useUIStore.setState({ globalLoading: v }),
2586
+ notify: (type, message) => {
2587
+ if (type === 'success') toast.success(message);
2588
+ else if (type === 'error') toast.error(message);
2589
+ else toast.info(message);
2590
+ },
2591
+ sidebarOpen: false,
2592
+ toggleSidebar: () => useUIStore.setState((s) => ({ sidebarOpen: !s.sidebarOpen })),
2593
+ setSidebarOpen: (v) => useUIStore.setState({ sidebarOpen: v }),
2594
+ }));
2595
+ `);
2596
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "store", "theme.ts"), `import { create } from 'zustand';
2597
+ import { persist } from 'zustand/middleware';
2598
+
2599
+ export type Theme = 'light' | 'dark';
2600
+
2601
+ interface ThemeState {
2602
+ theme: Theme;
2603
+ setTheme: (t: Theme) => void;
2604
+ toggleTheme: () => void;
2605
+ }
2606
+
2607
+ /** Persisted theme store \u2014 applies .dark class to <html> for Tailwind v4. */
2608
+ export const useThemeStore = create<ThemeState>()(
2609
+ persist(
2610
+ (set, get) => ({
2611
+ theme: 'dark',
2612
+ setTheme: (theme) => { applyThemeClass(theme); set({ theme }); },
2613
+ toggleTheme: () => {
2614
+ const next = get().theme === 'dark' ? 'light' : 'dark';
2615
+ applyThemeClass(next);
2616
+ set({ theme: next });
2617
+ },
2618
+ }),
2619
+ {
2620
+ name: '${projectName}_theme',
2621
+ onRehydrateStorage: () => (state) => { if (state) applyThemeClass(state.theme); },
2622
+ },
2623
+ ),
2624
+ );
2625
+
2626
+ function applyThemeClass(theme: Theme) {
2627
+ if (typeof document === 'undefined') return;
2628
+ document.documentElement.classList.toggle('dark', theme === 'dark');
2629
+ document.documentElement.classList.toggle('light', theme === 'light');
2630
+ }
2631
+ `);
2632
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "components", "Skeleton.tsx"), generateSkeletonComponent());
2633
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "components", "PreloadSpinner.tsx"), `import { useUIStore } from '@/store/ui';
2634
+
2635
+ /** Full-viewport spinner overlay shown while globalLoading is true. */
2636
+ export default function PreloadSpinner() {
2637
+ const loading = useUIStore((s) => s.globalLoading);
2638
+ if (!loading) return null;
2639
+ return (
2640
+ <div style={{
2641
+ position: 'fixed', inset: 0, zIndex: 9999,
2642
+ display: 'flex', flexDirection: 'column', alignItems: 'center',
2643
+ justifyContent: 'center', gap: '12px',
2644
+ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
2645
+ }}>
2646
+ <svg width="40" height="40" viewBox="0 0 40 40" fill="none"
2647
+ style={{ animation: 'spin 0.8s linear infinite' }}>
2648
+ <circle cx="20" cy="20" r="16" stroke="rgba(255,255,255,0.2)" strokeWidth="4" />
2649
+ <path d="M20 4 A16 16 0 0 1 36 20" stroke="var(--accent)" strokeWidth="4" strokeLinecap="round" />
2650
+ <style>{\`@keyframes spin { to { transform: rotate(360deg); } }\`}</style>
2651
+ </svg>
2652
+ <span style={{ fontSize: '13px', color: 'rgba(255,255,255,0.6)', letterSpacing: '0.02em' }}>
2653
+ Loading\u2026
2654
+ </span>
2655
+ </div>
2656
+ );
2657
+ }
2658
+ `);
2659
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "entry-client.tsx"), generateEntryClient(projectName, auth));
2660
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "entry-server.tsx"), generateEntryServer(projectName));
2661
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "main.tsx"), `import React from 'react';
2662
+ import ReactDOM from 'react-dom/client';
2663
+ import { QueryClientProvider } from '@tanstack/react-query';
2664
+ import App from './App';
2665
+ import { createQueryClient } from './lib/queryClient';
2666
+ import './index.css';
2667
+
2668
+ const queryClient = createQueryClient();
2669
+
2670
+ ReactDOM.createRoot(document.getElementById('root')!).render(
2671
+ <React.StrictMode>
2672
+ <QueryClientProvider client={queryClient}>
2673
+ <App />
2674
+ </QueryClientProvider>
2675
+ </React.StrictMode>,
2676
+ );
2677
+ `);
2678
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "__tests__", "App.test.tsx"), generateAppTest());
2679
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "pages", "Dashboard.tsx"), generateDashboardPage());
2680
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "pages", "Users.tsx"), generateUsersPage());
2681
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "pages", "Posts.tsx"), generatePostsPage());
2682
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "pages", "Tasks.tsx"), generateTasksPage());
2683
+ if (auth) {
2684
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "pages", "Login.tsx"), generateLoginPage());
2685
+ }
2686
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(webDir, "src", "App.tsx"), generateAppTsx(projectName, auth));
2687
+ }
2688
+ async function scaffoldFullstackReadme(projectDir, projectName) {
2689
+ const readme = `# ${projectName}
2690
+
2691
+ Full-stack application built with **[Kozo](https://github.com/kozojs/kozo)** \u2014 React + Vite frontend with SSR support and a Kozo/Hono API backend.
2692
+
2693
+ ## Stack
2694
+
2695
+ | Layer | Tech |
2696
+ |-------|------|
2697
+ | Frontend | React 18, Vite 5, TailwindCSS v4, TanStack Query v5 |
2698
+ | State | Zustand (UI store + persisted theme) |
2699
+ | Toasts | Sonner |
2700
+ | Icons | Lucide React |
2701
+ | Backend | Kozo (Hono-based), TypeScript, Zod |
2702
+ | Build | tsup, pnpm workspaces |
2703
+
2704
+ ## Project Structure
2705
+
2706
+ \`\`\`
2707
+ apps/
2708
+ \u251C\u2500\u2500 api/ # Backend
2709
+ \u2502 \u2514\u2500\u2500 src/
2710
+ \u2502 \u251C\u2500\u2500 routes/ # File-system routes
2711
+ \u2502 \u2502 \u251C\u2500\u2500 api/health.ts
2712
+ \u2502 \u2502 \u251C\u2500\u2500 api/users/
2713
+ \u2502 \u2502 \u251C\u2500\u2500 api/posts/
2714
+ \u2502 \u2502 \u2514\u2500\u2500 api/tasks/
2715
+ \u2502 \u2514\u2500\u2500 index.ts
2716
+ \u2514\u2500\u2500 web/ # Frontend
2717
+ \u251C\u2500\u2500 server.ts # SSR dev/prod server
2718
+ \u2514\u2500\u2500 src/
2719
+ \u251C\u2500\u2500 App.tsx # Router + layout
2720
+ \u251C\u2500\u2500 entry-client.tsx # SSR-aware hydration
2721
+ \u251C\u2500\u2500 entry-server.tsx # renderToString
2722
+ \u251C\u2500\u2500 index.css # Design system (CSS vars)
2723
+ \u251C\u2500\u2500 lib/
2724
+ \u2502 \u251C\u2500\u2500 api.ts # apiFetch + ApiError
2725
+ \u2502 \u251C\u2500\u2500 queries.ts # TanStack Query registry
2726
+ \u2502 \u251C\u2500\u2500 queryClient.ts
2727
+ \u2502 \u2514\u2500\u2500 utils.ts # cn() utility
2728
+ \u251C\u2500\u2500 store/
2729
+ \u2502 \u251C\u2500\u2500 ui.ts # Sidebar + notify + loading
2730
+ \u2502 \u2514\u2500\u2500 theme.ts # Persisted dark/light
2731
+ \u251C\u2500\u2500 components/
2732
+ \u2502 \u251C\u2500\u2500 Skeleton.tsx
2733
+ \u2502 \u2514\u2500\u2500 PreloadSpinner.tsx
2734
+ \u2514\u2500\u2500 pages/
2735
+ \u251C\u2500\u2500 DashboardPage.tsx
2736
+ \u251C\u2500\u2500 UsersPage.tsx
2737
+ \u251C\u2500\u2500 PostsPage.tsx
2738
+ \u2514\u2500\u2500 TasksPage.tsx
2739
+ \`\`\`
2740
+
2741
+ ## Getting Started
2742
+
2743
+ \`\`\`bash
2744
+ pnpm install
2745
+ pnpm dev # starts both API (3000) and web (5173)
2746
+ \`\`\`
2747
+
2748
+ ## Environment Variables
2749
+
2750
+ \`\`\`
2751
+ # apps/api/.env
2752
+ PORT=3000
2753
+ DATABASE_URL=postgresql://... # if using DB
2754
+
2755
+ # apps/web/.env
2756
+ VITE_API_URL=http://localhost:3000
2757
+ \`\`\`
2758
+
2759
+ ## API Endpoints
2760
+
2761
+ ### Health
2762
+ - \`GET /api/health\` \u2014 health check
2763
+ - \`GET /api/stats\` \u2014 aggregate statistics
2764
+
2765
+ ### Users
2766
+ - \`GET /api/users\` \u2014 list
2767
+ - \`POST /api/users\` \u2014 create \`{ name, email }\`
2768
+ - \`DELETE /api/users/:id\` \u2014 delete
2769
+
2770
+ ### Posts
2771
+ - \`GET /api/posts\` \u2014 list (query: \`?published=true\`)
2772
+ - \`POST /api/posts\` \u2014 create \`{ title, content?, published? }\`
2773
+ - \`DELETE /api/posts/:id\` \u2014 delete
2774
+
2775
+ ### Tasks
2776
+ - \`GET /api/tasks\` \u2014 list (query: \`?completed=true\`)
2777
+ - \`POST /api/tasks\` \u2014 create \`{ title, priority? }\`
2778
+ - \`PATCH /api/tasks/:id/toggle\` \u2014 toggle completion
2779
+ - \`DELETE /api/tasks/:id\` \u2014 delete
2780
+
2781
+ ## Design System
2782
+
2783
+ The app uses CSS custom properties for theming (dark by default, light class-based):
2784
+
2785
+ | Variable | Purpose |
2786
+ |----------|---------|
2787
+ | \`--bg\`, \`--bg-subtle\` | backgrounds |
2788
+ | \`--card\`, \`--card-border\` | card surfaces |
2789
+ | \`--fg\`, \`--fg-muted\` | text |
2790
+ | \`--accent\` (#ABF43F / #4d7c00) | primary CTA |
2791
+ | \`--destructive\` | delete/error |
2792
+
2793
+ ## Build
2794
+
2795
+ \`\`\`bash
2796
+ pnpm build # build all packages
2797
+ pnpm preview # preview SSR production build
2798
+ pnpm test # run vitest
2799
+ \`\`\`
2800
+ `;
2801
+ await import_fs_extra3.default.writeFile(import_node_path3.default.join(projectDir, "README.md"), readme);
2802
+ }
2803
+
2804
+ // src/utils/scaffold/fullstack-api.ts
2805
+ async function scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth, frontend, extras, template) {
2806
+ const hasDb = database !== "none";
2807
+ await import_fs_extra4.default.ensureDir(import_node_path4.default.join(projectDir, "apps", "api", "src", "routes"));
2808
+ await import_fs_extra4.default.ensureDir(import_node_path4.default.join(projectDir, "apps", "api", "src", "data"));
2809
+ if (hasDb) await import_fs_extra4.default.ensureDir(import_node_path4.default.join(projectDir, "apps", "api", "src", "db"));
2810
+ await import_fs_extra4.default.ensureDir(import_node_path4.default.join(projectDir, "apps", "web", "src", "lib"));
2811
+ await import_fs_extra4.default.ensureDir(import_node_path4.default.join(projectDir, ".vscode"));
2812
+ const rootPackageJson = {
2813
+ name: projectName,
2814
+ private: true,
2815
+ scripts: {
2816
+ dev: "pnpm run --parallel dev",
2817
+ build: "pnpm run --recursive build"
2818
+ }
2819
+ };
2820
+ await import_fs_extra4.default.writeJSON(import_node_path4.default.join(projectDir, "package.json"), rootPackageJson, { spaces: 2 });
2821
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(projectDir, "pnpm-workspace.yaml"), `packages:
2822
+ - 'apps/*'
2823
+ `);
2824
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(projectDir, ".gitignore"), "node_modules/\ndist/\n.env\n*.log\n");
2825
+ await scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth);
2826
+ await scaffoldFullstackWeb(projectDir, projectName, frontend, auth);
2827
+ await scaffoldFullstackReadme(projectDir, projectName);
2828
+ if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
2829
+ if (extras.includes("docker")) await createDockerfile(import_node_path4.default.join(projectDir, "apps", "api"), runtime);
2830
+ if (extras.includes("github-actions")) await createGitHubActions(projectDir);
2831
+ }
2832
+ async function scaffoldFullstackApi(projectDir, projectName, kozoCoreDep, runtime, database = "none", dbPort, auth = true) {
2833
+ const apiDir = import_node_path4.default.join(projectDir, "apps", "api");
2834
+ const hasDb = database !== "none";
2835
+ if (hasDb) {
2836
+ await import_fs_extra4.default.ensureDir(import_node_path4.default.join(apiDir, "src", "db"));
2837
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, "src", "db", "schema.ts"), getDatabaseSchema(database));
2838
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, "src", "db", "index.ts"), getDatabaseIndex(database));
2839
+ if (database === "sqlite") {
2840
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, "src", "db", "seed.ts"), getSQLiteSeed());
2841
+ }
2842
+ const dialect = database === "postgresql" ? "postgresql" : database === "mysql" ? "mysql" : "sqlite";
2843
+ const pgPort = dbPort ?? 5436;
2844
+ const dbUrl = database === "postgresql" ? `postgresql://postgres:postgres@localhost:${pgPort}/${projectName}` : database === "mysql" ? `mysql://root:root@localhost:3306/${projectName}` : void 0;
2845
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, "drizzle.config.ts"), `import { defineConfig } from 'drizzle-kit';
2846
+ import 'dotenv/config';
2847
+
2848
+ export default defineConfig({
2849
+ schema: './src/db/schema.ts',
2850
+ out: './drizzle',
2851
+ dialect: '${dialect}',
2852
+ dbCredentials: {
2853
+ ${database === "sqlite" ? "url: './data.db'" : "url: process.env.DATABASE_URL!"}
2854
+ },
2855
+ });
2856
+ `);
2857
+ const envContent = `PORT=3000
2858
+ NODE_ENV=development
2859
+ ${dbUrl ? `DATABASE_URL=${dbUrl}
2860
+ ` : ""}${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
2861
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, ".env"), envContent);
2862
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, ".env.example"), envContent);
2863
+ } else {
2864
+ const envContent = `PORT=3000
2865
+ NODE_ENV=development
2866
+ ${auth ? "JWT_SECRET=change-me-to-a-random-secret-at-least-32-chars\n" : ""}`;
2867
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, ".env"), envContent);
2868
+ await import_fs_extra4.default.writeFile(import_node_path4.default.join(apiDir, ".env.example"), envContent);
2869
+ }
2870
+ const apiPackageJson = {
2871
+ name: `@${projectName}/api`,
2872
+ version: "1.0.0",
2873
+ type: "module",
2874
+ scripts: {
2875
+ dev: runtime === "bun" ? "bun --watch src/index.ts" : "tsx watch src/index.ts",
2876
+ build: "tsc",
2877
+ ...hasDb && {
2878
+ "db:generate": "drizzle-kit generate",
2879
+ "db:push": "drizzle-kit push",
2880
+ "db:studio": "drizzle-kit studio"
2881
+ }
2882
+ },
2883
+ dependencies: {
2884
+ "@kozojs/core": kozoCoreDep,
2885
+ ...auth && { "@kozojs/auth": kozoCoreDep === "workspace:*" ? "workspace:*" : "^0.1.0" },
2886
+ hono: "^4.12.5",
2887
+ zod: "^4.0.0",
2888
+ dotenv: "^16.4.0",
2889
+ ...runtime === "node" && { "@hono/node-server": "^1.19.10" },
2890
+ ...runtime === "node" && { "uWebSockets.js": "github:uNetworking/uWebSockets.js#6609a88ffa9a16ac5158046761356ce03250a0df" },
2891
+ ...hasDb && { "drizzle-orm": "^0.36.0" },
2892
+ ...database === "postgresql" && { postgres: "^3.4.8" },
2893
+ ...database === "mysql" && { mysql2: "^3.11.0" },
2894
+ ...database === "sqlite" && { "better-sqlite3": "^11.0.0" }
2895
+ },
2896
+ devDependencies: {
2897
+ "@types/node": "^22.0.0",
2898
+ ...runtime !== "bun" && { tsx: "^4.21.0" },
2899
+ typescript: "^5.6.0",
2900
+ ...hasDb && { "drizzle-kit": "^0.28.0" },
2901
+ ...database === "sqlite" && { "@types/better-sqlite3": "^7.6.0" }
2902
+ }
1800
2903
  };
1801
- posts.push(newPost);
1802
- return newPost;
1803
- };
2904
+ await import_fs_extra4.default.writeJSON(import_node_path4.default.join(apiDir, "package.json"), apiPackageJson, { spaces: 2 });
2905
+ const tsconfig = {
2906
+ compilerOptions: {
2907
+ target: "ES2022",
2908
+ module: "ESNext",
2909
+ moduleResolution: "bundler",
2910
+ strict: true,
2911
+ esModuleInterop: true,
2912
+ skipLibCheck: true,
2913
+ outDir: "dist",
2914
+ rootDir: "src"
2915
+ },
2916
+ include: ["src/**/*"],
2917
+ exclude: ["node_modules", "dist"]
2918
+ };
2919
+ await import_fs_extra4.default.writeJSON(import_node_path4.default.join(apiDir, "tsconfig.json"), tsconfig, { spaces: 2 });
2920
+ const authImport = auth ? `import { authenticateJWT } from '@kozojs/auth';
2921
+ ` : "";
2922
+ const authMiddleware = auth ? `
2923
+ // JWT protects all /api/* routes except public ones
2924
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
2925
+ const _jwt = authenticateJWT(JWT_SECRET, { prefix: '' });
2926
+ const publicPaths = ['/api/auth/', '/api/health', '/api/stats'];
2927
+ app.getApp().use('/api/*', (c, next) => {
2928
+ if (publicPaths.some(p => c.req.path.startsWith(p))) return next();
2929
+ return _jwt(c, next);
2930
+ });
2931
+ ` : "";
2932
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "index.ts"), `import 'dotenv/config';
2933
+ import { createKozo } from '@kozojs/core';
2934
+ ${authImport}import { fileURLToPath } from 'node:url';
2935
+ import { dirname, join } from 'node:path';
2936
+
2937
+ const __dirname = dirname(fileURLToPath(import.meta.url));
2938
+ const PORT = Number(process.env.PORT) || 3000;
2939
+ const app = createKozo({
2940
+ port: PORT,
2941
+ openapi: {
2942
+ info: {
2943
+ title: '${projectName} API',
2944
+ version: '1.0.0',
2945
+ description: 'API documentation for ${projectName}',
2946
+ },
2947
+ servers: [{ url: \`http://localhost:\${PORT}\`, description: 'Development server' }],
2948
+ },
2949
+ });
2950
+ ${authMiddleware}await app.loadRoutes(join(__dirname, 'routes'));
2951
+
2952
+ export type AppType = typeof app;
2953
+
2954
+ console.log(\`\u{1F525} ${projectName} API on http://localhost:\${PORT}\`);
2955
+ ${runtime === "node" ? "await app.nativeListen();" : "await app.listen();"}
1804
2956
  `);
1805
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "get.ts"), `import { z } from 'zod';
1806
- import { KozoError } from '@kozojs/core';
1807
- import { posts } from '../../../../data/index.js';
1808
- import { PostSchema } from '../../../../schemas/index.js';
2957
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "schemas", "index.ts"), `import { z } from 'zod';
2958
+
2959
+ export const UserSchema = z.object({
2960
+ id: z.string(),
2961
+ name: z.string(),
2962
+ email: z.string().email(),
2963
+ role: z.enum(['admin', 'user']),
2964
+ createdAt: z.string().optional(),
2965
+ });
2966
+
2967
+ export const CreateUserBody = z.object({
2968
+ name: z.string().min(1),
2969
+ email: z.string().email(),
2970
+ role: z.enum(['admin', 'user']).optional(),
2971
+ });
2972
+
2973
+ export const UpdateUserBody = z.object({
2974
+ name: z.string().optional(),
2975
+ email: z.string().email().optional(),
2976
+ role: z.enum(['admin', 'user']).optional(),
2977
+ });
2978
+
2979
+ export const PostSchema = z.object({
2980
+ id: z.string(),
2981
+ title: z.string(),
2982
+ content: z.string(),
2983
+ authorId: z.string(),
2984
+ published: z.boolean(),
2985
+ createdAt: z.string().optional(),
2986
+ });
2987
+
2988
+ export const CreatePostBody = z.object({
2989
+ title: z.string().min(1),
2990
+ content: z.string().optional(),
2991
+ authorId: z.string().optional(),
2992
+ published: z.boolean().optional(),
2993
+ });
2994
+
2995
+ export const UpdatePostBody = z.object({
2996
+ title: z.string().optional(),
2997
+ content: z.string().optional(),
2998
+ published: z.boolean().optional(),
2999
+ });
3000
+
3001
+ export const TaskSchema = z.object({
3002
+ id: z.string(),
3003
+ title: z.string(),
3004
+ completed: z.boolean(),
3005
+ priority: z.enum(['low', 'medium', 'high']),
3006
+ createdAt: z.string(),
3007
+ });
3008
+
3009
+ export const CreateTaskBody = z.object({
3010
+ title: z.string().min(1),
3011
+ priority: z.enum(['low', 'medium', 'high']).optional(),
3012
+ });
3013
+
3014
+ export const UpdateTaskBody = z.object({
3015
+ title: z.string().optional(),
3016
+ completed: z.boolean().optional(),
3017
+ priority: z.enum(['low', 'medium', 'high']).optional(),
3018
+ });
3019
+ `);
3020
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "data", "index.ts"), `export const users = [
3021
+ { id: '1', name: 'Alice', email: 'alice@example.com', role: 'admin' as const, createdAt: new Date().toISOString() },
3022
+ { id: '2', name: 'Bob', email: 'bob@example.com', role: 'user' as const, createdAt: new Date().toISOString() },
3023
+ ];
3024
+
3025
+ export const posts = [
3026
+ { id: '1', title: 'Hello World', content: 'First post!', authorId: '1', published: true, createdAt: new Date().toISOString() },
3027
+ { id: '2', title: 'Draft', content: 'Work in progress', authorId: '2', published: false, createdAt: new Date().toISOString() },
3028
+ ];
3029
+
3030
+ export const tasks = [
3031
+ { id: '1', title: 'Setup project', completed: true, priority: 'high' as const, createdAt: new Date().toISOString() },
3032
+ { id: '2', title: 'Write tests', completed: false, priority: 'medium' as const, createdAt: new Date().toISOString() },
3033
+ { id: '3', title: 'Deploy', completed: false, priority: 'low' as const, createdAt: new Date().toISOString() },
3034
+ ];
3035
+ `);
3036
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "health", "get.ts"), `import { z } from 'zod';
1809
3037
 
1810
3038
  export const schema = {
1811
- params: z.object({ id: z.string() }),
1812
- response: PostSchema,
3039
+ response: z.object({
3040
+ status: z.string(),
3041
+ timestamp: z.string(),
3042
+ version: z.string(),
3043
+ uptime: z.number(),
3044
+ }),
1813
3045
  };
1814
3046
 
1815
- export default async ({ params }: { params: { id: string } }) => {
1816
- const post = posts.find(p => p.id === params.id);
1817
- if (!post) throw new KozoError('Post not found', 404, 'NOT_FOUND');
1818
- return post;
1819
- };
3047
+ export default async () => ({
3048
+ status: 'ok',
3049
+ timestamp: new Date().toISOString(),
3050
+ version: '1.0.0',
3051
+ uptime: process.uptime(),
3052
+ });
1820
3053
  `);
1821
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "put.ts"), `import { z } from 'zod';
1822
- import { KozoError } from '@kozojs/core';
1823
- import { posts } from '../../../../data/index.js';
1824
- import { PostSchema, UpdatePostBody } from '../../../../schemas/index.js';
3054
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "stats", "get.ts"), `import { z } from 'zod';
3055
+ import { users, posts, tasks } from '../../../data/index.js';
1825
3056
 
1826
3057
  export const schema = {
1827
- params: z.object({ id: z.string() }),
1828
- body: UpdatePostBody,
1829
- response: PostSchema,
3058
+ response: z.object({
3059
+ users: z.number(),
3060
+ posts: z.number(),
3061
+ tasks: z.number(),
3062
+ publishedPosts: z.number(),
3063
+ completedTasks: z.number(),
3064
+ }),
1830
3065
  };
1831
3066
 
1832
- export default async ({
1833
- params,
1834
- body,
1835
- }: {
1836
- params: { id: string };
1837
- body: { title?: string; content?: string; published?: boolean };
1838
- }) => {
1839
- const idx = posts.findIndex(p => p.id === params.id);
1840
- if (idx === -1) throw new KozoError('Post not found', 404, 'NOT_FOUND');
1841
- posts[idx] = { ...posts[idx], ...body };
1842
- return posts[idx];
3067
+ export default async () => ({
3068
+ users: users.length,
3069
+ posts: posts.length,
3070
+ tasks: tasks.length,
3071
+ publishedPosts: posts.filter(p => p.published).length,
3072
+ completedTasks: tasks.filter(t => t.completed).length,
3073
+ });
3074
+ `);
3075
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "echo", "get.ts"), `import { z } from 'zod';
3076
+
3077
+ export const schema = {
3078
+ query: z.object({ message: z.string() }),
3079
+ response: z.object({
3080
+ echo: z.string(),
3081
+ timestamp: z.string(),
3082
+ }),
1843
3083
  };
3084
+
3085
+ export default async ({ query }: { query: { message: string } }) => ({
3086
+ echo: query.message,
3087
+ timestamp: new Date().toISOString(),
3088
+ });
1844
3089
  `);
1845
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "delete.ts"), `import { z } from 'zod';
1846
- import { KozoError } from '@kozojs/core';
1847
- import { posts } from '../../../../data/index.js';
3090
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "validate", "post.ts"), `import { z } from 'zod';
1848
3091
 
1849
3092
  export const schema = {
1850
- params: z.object({ id: z.string() }),
1851
- response: z.object({ success: z.boolean(), message: z.string() }),
3093
+ body: z.object({
3094
+ email: z.string().email(),
3095
+ age: z.number().min(0).max(150),
3096
+ }),
3097
+ response: z.object({
3098
+ valid: z.boolean(),
3099
+ data: z.object({ email: z.string(), age: z.number() }),
3100
+ }),
1852
3101
  };
1853
3102
 
1854
- export default async ({ params }: { params: { id: string } }) => {
1855
- const idx = posts.findIndex(p => p.id === params.id);
1856
- if (idx === -1) throw new KozoError('Post not found', 404, 'NOT_FOUND');
1857
- posts.splice(idx, 1);
1858
- return { success: true, message: 'Post deleted' };
1859
- };
3103
+ export default async ({ body }: { body: { email: string; age: number } }) => ({
3104
+ valid: true,
3105
+ data: body,
3106
+ });
1860
3107
  `);
1861
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "get.ts"), `import { z } from 'zod';
1862
- import { tasks } from '../../../data/index.js';
1863
- import { TaskSchema } from '../../../schemas/index.js';
3108
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "get.ts"), `import { z } from 'zod';
3109
+ import { users } from '../../../data/index.js';
3110
+ import { UserSchema } from '../../../schemas/index.js';
1864
3111
 
1865
3112
  export const schema = {
1866
- query: z.object({
1867
- completed: z.coerce.boolean().optional(),
1868
- priority: z.enum(['low', 'medium', 'high']).optional(),
1869
- }),
1870
- response: z.array(TaskSchema),
3113
+ response: z.array(UserSchema),
1871
3114
  };
1872
3115
 
1873
- export default async ({
1874
- query,
1875
- }: {
1876
- query: { completed?: boolean; priority?: 'low' | 'medium' | 'high' };
1877
- }) => {
1878
- let result = [...tasks];
1879
- if (query.completed !== undefined) {
1880
- result = result.filter(t => t.completed === query.completed);
1881
- }
1882
- if (query.priority) {
1883
- result = result.filter(t => t.priority === query.priority);
1884
- }
1885
- return result;
1886
- };
3116
+ export default async () => users;
1887
3117
  `);
1888
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "post.ts"), `import { tasks } from '../../../data/index.js';
1889
- import { TaskSchema, CreateTaskBody } from '../../../schemas/index.js';
3118
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "post.ts"), `import { users } from '../../../data/index.js';
3119
+ import { UserSchema, CreateUserBody } from '../../../schemas/index.js';
1890
3120
 
1891
3121
  export const schema = {
1892
- body: CreateTaskBody,
1893
- response: TaskSchema,
3122
+ body: CreateUserBody,
3123
+ response: UserSchema,
1894
3124
  };
1895
3125
 
1896
- export default async ({
1897
- body,
1898
- }: {
1899
- body: { title: string; priority?: 'low' | 'medium' | 'high' };
1900
- }) => {
1901
- const newTask = {
3126
+ export default async ({ body }: { body: { name: string; email: string; role?: 'admin' | 'user' } }) => {
3127
+ const newUser = {
1902
3128
  id: String(Date.now()),
1903
- title: body.title,
1904
- completed: false,
1905
- priority: body.priority ?? ('medium' as const),
3129
+ name: body.name,
3130
+ email: body.email,
3131
+ role: body.role ?? ('user' as const),
1906
3132
  createdAt: new Date().toISOString(),
1907
3133
  };
1908
- tasks.push(newTask);
1909
- return newTask;
3134
+ users.push(newUser);
3135
+ return newUser;
1910
3136
  };
1911
3137
  `);
1912
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "get.ts"), `import { z } from 'zod';
3138
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "get.ts"), `import { z } from 'zod';
1913
3139
  import { KozoError } from '@kozojs/core';
1914
- import { tasks } from '../../../../data/index.js';
1915
- import { TaskSchema } from '../../../../schemas/index.js';
3140
+ import { users } from '../../../../data/index.js';
3141
+ import { UserSchema } from '../../../../schemas/index.js';
1916
3142
 
1917
3143
  export const schema = {
1918
3144
  params: z.object({ id: z.string() }),
1919
- response: TaskSchema,
3145
+ response: UserSchema,
1920
3146
  };
1921
3147
 
1922
3148
  export default async ({ params }: { params: { id: string } }) => {
1923
- const task = tasks.find(t => t.id === params.id);
1924
- if (!task) throw new KozoError('Task not found', 404, 'NOT_FOUND');
1925
- return task;
3149
+ const user = users.find(u => u.id === params.id);
3150
+ if (!user) throw new KozoError('User not found', 404, 'NOT_FOUND');
3151
+ return user;
1926
3152
  };
1927
3153
  `);
1928
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "put.ts"), `import { z } from 'zod';
3154
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "put.ts"), `import { z } from 'zod';
1929
3155
  import { KozoError } from '@kozojs/core';
1930
- import { tasks } from '../../../../data/index.js';
1931
- import { TaskSchema, UpdateTaskBody } from '../../../../schemas/index.js';
3156
+ import { users } from '../../../../data/index.js';
3157
+ import { UserSchema, UpdateUserBody } from '../../../../schemas/index.js';
1932
3158
 
1933
3159
  export const schema = {
1934
3160
  params: z.object({ id: z.string() }),
1935
- body: UpdateTaskBody,
1936
- response: TaskSchema,
3161
+ body: UpdateUserBody,
3162
+ response: UserSchema,
1937
3163
  };
1938
3164
 
1939
3165
  export default async ({
@@ -1941,17 +3167,17 @@ export default async ({
1941
3167
  body,
1942
3168
  }: {
1943
3169
  params: { id: string };
1944
- body: { title?: string; completed?: boolean; priority?: 'low' | 'medium' | 'high' };
3170
+ body: { name?: string; email?: string; role?: 'admin' | 'user' };
1945
3171
  }) => {
1946
- const idx = tasks.findIndex(t => t.id === params.id);
1947
- if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
1948
- tasks[idx] = { ...tasks[idx], ...body };
1949
- return tasks[idx];
3172
+ const idx = users.findIndex(u => u.id === params.id);
3173
+ if (idx === -1) throw new KozoError('User not found', 404, 'NOT_FOUND');
3174
+ users[idx] = { ...users[idx], ...body };
3175
+ return users[idx];
1950
3176
  };
1951
3177
  `);
1952
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "delete.ts"), `import { z } from 'zod';
3178
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "delete.ts"), `import { z } from 'zod';
1953
3179
  import { KozoError } from '@kozojs/core';
1954
- import { tasks } from '../../../../data/index.js';
3180
+ import { users } from '../../../../data/index.js';
1955
3181
 
1956
3182
  export const schema = {
1957
3183
  params: z.object({ id: z.string() }),
@@ -1959,991 +3185,496 @@ export const schema = {
1959
3185
  };
1960
3186
 
1961
3187
  export default async ({ params }: { params: { id: string } }) => {
1962
- const idx = tasks.findIndex(t => t.id === params.id);
1963
- if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
1964
- tasks.splice(idx, 1);
1965
- return { success: true, message: 'Task deleted' };
3188
+ const idx = users.findIndex(u => u.id === params.id);
3189
+ if (idx === -1) throw new KozoError('User not found', 404, 'NOT_FOUND');
3190
+ users.splice(idx, 1);
3191
+ return { success: true, message: 'User deleted' };
1966
3192
  };
1967
3193
  `);
1968
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "toggle", "patch.ts"), `import { z } from 'zod';
1969
- import { KozoError } from '@kozojs/core';
1970
- import { tasks } from '../../../../../data/index.js';
1971
- import { TaskSchema } from '../../../../../schemas/index.js';
3194
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "posts", "get.ts"), `import { z } from 'zod';
3195
+ import { posts } from '../../../data/index.js';
3196
+ import { PostSchema } from '../../../schemas/index.js';
1972
3197
 
1973
3198
  export const schema = {
1974
- params: z.object({ id: z.string() }),
1975
- response: TaskSchema,
3199
+ query: z.object({ published: z.coerce.boolean().optional() }),
3200
+ response: z.array(PostSchema),
1976
3201
  };
1977
3202
 
1978
- export default async ({ params }: { params: { id: string } }) => {
1979
- const idx = tasks.findIndex(t => t.id === params.id);
1980
- if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
1981
- tasks[idx].completed = !tasks[idx].completed;
1982
- return tasks[idx];
3203
+ export default async ({ query }: { query: { published?: boolean } }) => {
3204
+ if (query.published !== undefined) {
3205
+ return posts.filter(p => p.published === query.published);
3206
+ }
3207
+ return posts;
1983
3208
  };
1984
3209
  `);
1985
- if (auth) {
1986
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "auth", "login", "post.ts"), `import { z } from 'zod';
1987
- import { createJWT, UnauthorizedError } from '@kozojs/auth';
1988
-
1989
- const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
1990
-
1991
- const DEMO_USERS = [
1992
- { email: 'admin@demo.com', password: 'admin123', role: 'admin', name: 'Admin' },
1993
- { email: 'user@demo.com', password: 'user123', role: 'user', name: 'User' },
1994
- ];
3210
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "posts", "post.ts"), `import { posts, users } from '../../../data/index.js';
3211
+ import { PostSchema, CreatePostBody } from '../../../schemas/index.js';
1995
3212
 
1996
3213
  export const schema = {
1997
- body: z.object({
1998
- email: z.string().email(),
1999
- password: z.string(),
2000
- }),
2001
- response: z.object({
2002
- token: z.string(),
2003
- user: z.object({
2004
- email: z.string(),
2005
- role: z.string(),
2006
- name: z.string(),
2007
- }),
2008
- }),
3214
+ body: CreatePostBody,
3215
+ response: PostSchema,
2009
3216
  };
2010
3217
 
2011
- export default async ({ body }: { body: { email: string; password: string } }) => {
2012
- const user = DEMO_USERS.find(u => u.email === body.email && u.password === body.password);
2013
- if (!user) throw new UnauthorizedError('Invalid credentials');
2014
- const token = await createJWT(
2015
- { email: user.email, role: user.role, name: user.name },
2016
- JWT_SECRET,
2017
- { expiresIn: '24h' },
2018
- );
2019
- return { token, user: { email: user.email, role: user.role, name: user.name } };
2020
- };
2021
- `);
2022
- }
2023
- }
2024
- async function scaffoldFullstackWeb(projectDir, projectName, frontend, auth = false) {
2025
- const webDir = import_node_path.default.join(projectDir, "apps", "web");
2026
- await import_fs_extra.default.ensureDir(import_node_path.default.join(webDir, "src", "lib"));
2027
- await import_fs_extra.default.ensureDir(import_node_path.default.join(webDir, "src", "pages"));
2028
- const packageJson = {
2029
- name: `@${projectName}/web`,
2030
- version: "1.0.0",
2031
- type: "module",
2032
- scripts: { dev: "vite", build: "vite build", preview: "vite preview" },
2033
- dependencies: {
2034
- react: "^18.2.0",
2035
- "react-dom": "^18.2.0",
2036
- "@tanstack/react-query": "^5.0.0",
2037
- "lucide-react": "^0.460.0"
2038
- },
2039
- devDependencies: {
2040
- "@types/react": "^18.2.0",
2041
- "@types/react-dom": "^18.2.0",
2042
- "@vitejs/plugin-react": "^4.2.0",
2043
- typescript: "^5.6.0",
2044
- vite: "^5.0.0",
2045
- "@tailwindcss/vite": "^4.0.0",
2046
- tailwindcss: "^4.0.0"
2047
- }
3218
+ export default async ({
3219
+ body,
3220
+ }: {
3221
+ body: { title: string; content?: string; authorId?: string; published?: boolean };
3222
+ }) => {
3223
+ const authorId = body.authorId ?? users[0]?.id ?? 'unknown';
3224
+ const newPost = {
3225
+ id: String(Date.now()),
3226
+ title: body.title,
3227
+ content: body.content ?? '',
3228
+ authorId,
3229
+ published: body.published ?? false,
3230
+ createdAt: new Date().toISOString(),
2048
3231
  };
2049
- await import_fs_extra.default.writeJSON(import_node_path.default.join(webDir, "package.json"), packageJson, { spaces: 2 });
2050
- await import_fs_extra.default.writeJSON(import_node_path.default.join(webDir, "tsconfig.json"), {
2051
- compilerOptions: {
2052
- target: "ES2020",
2053
- lib: ["ES2020", "DOM", "DOM.Iterable"],
2054
- module: "ESNext",
2055
- skipLibCheck: true,
2056
- moduleResolution: "bundler",
2057
- allowImportingTsExtensions: true,
2058
- resolveJsonModule: true,
2059
- isolatedModules: true,
2060
- noEmit: true,
2061
- jsx: "react-jsx",
2062
- strict: true,
2063
- noUnusedLocals: true,
2064
- noUnusedParameters: true
2065
- },
2066
- include: ["src"]
2067
- }, { spaces: 2 });
2068
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "vite.config.ts"), `import { defineConfig } from 'vite';
2069
- import react from '@vitejs/plugin-react';
2070
- import tailwindcss from '@tailwindcss/vite';
2071
-
2072
- export default defineConfig({
2073
- plugins: [react(), tailwindcss()],
2074
- server: {
2075
- port: 5173,
2076
- proxy: {
2077
- '/api': { target: 'http://localhost:3000', changeOrigin: true },
2078
- },
2079
- },
2080
- });
2081
- `);
2082
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "index.html"), `<!DOCTYPE html>
2083
- <html lang="en">
2084
- <head>
2085
- <meta charset="UTF-8" />
2086
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2087
- <title>${projectName}</title>
2088
- </head>
2089
- <body>
2090
- <div id="root"></div>
2091
- <script type="module" src="/src/main.tsx"></script>
2092
- </body>
2093
- </html>
2094
- `);
2095
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "index.css"), `@import "tailwindcss";
2096
-
2097
- body { background-color: rgb(15 23 42); color: rgb(241 245 249); font-family: ui-sans-serif, system-ui, sans-serif; }
2098
- * { box-sizing: border-box; }
3232
+ posts.push(newPost);
3233
+ return newPost;
3234
+ };
2099
3235
  `);
2100
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "lib", "api.ts"), `const API_BASE = '';
2101
-
2102
- ${auth ? `export function getToken(): string | null {
2103
- return localStorage.getItem('kozo_token');
2104
- }
2105
-
2106
- export function setToken(token: string): void {
2107
- localStorage.setItem('kozo_token', token);
2108
- }
3236
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "get.ts"), `import { z } from 'zod';
3237
+ import { KozoError } from '@kozojs/core';
3238
+ import { posts } from '../../../../data/index.js';
3239
+ import { PostSchema } from '../../../../schemas/index.js';
2109
3240
 
2110
- export function clearToken(): void {
2111
- localStorage.removeItem('kozo_token');
2112
- }
2113
- ` : ""}
2114
- export interface ApiResult<T = unknown> {
2115
- data: T;
2116
- status: number;
2117
- ms: number;
2118
- }
3241
+ export const schema = {
3242
+ params: z.object({ id: z.string() }),
3243
+ response: PostSchema,
3244
+ };
2119
3245
 
2120
- export async function apiFetch<T = unknown>(
2121
- path: string,
2122
- options: RequestInit = {},
2123
- ): Promise<ApiResult<T>> {
2124
- const start = performance.now();
2125
- const headers: Record<string, string> = {
2126
- 'Content-Type': 'application/json',
2127
- ...(options.headers as Record<string, string> ?? {}),
2128
- };
2129
- ${auth ? ` const token = getToken();
2130
- if (token) headers['Authorization'] = \`Bearer \${token}\`;
2131
- ` : ""}
2132
- const res = await fetch(\`\${API_BASE}\${path}\`, { ...options, headers });
2133
- const data = await res.json() as T;
2134
- return { data, status: res.status, ms: Math.round(performance.now() - start) };
2135
- }
3246
+ export default async ({ params }: { params: { id: string } }) => {
3247
+ const post = posts.find(p => p.id === params.id);
3248
+ if (!post) throw new KozoError('Post not found', 404, 'NOT_FOUND');
3249
+ return post;
3250
+ };
2136
3251
  `);
2137
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "main.tsx"), `import React from 'react';
2138
- import ReactDOM from 'react-dom/client';
2139
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2140
- import App from './App';
2141
- import './index.css';
3252
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "put.ts"), `import { z } from 'zod';
3253
+ import { KozoError } from '@kozojs/core';
3254
+ import { posts } from '../../../../data/index.js';
3255
+ import { PostSchema, UpdatePostBody } from '../../../../schemas/index.js';
2142
3256
 
2143
- const queryClient = new QueryClient({
2144
- defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
2145
- });
3257
+ export const schema = {
3258
+ params: z.object({ id: z.string() }),
3259
+ body: UpdatePostBody,
3260
+ response: PostSchema,
3261
+ };
2146
3262
 
2147
- ReactDOM.createRoot(document.getElementById('root')!).render(
2148
- <React.StrictMode>
2149
- <QueryClientProvider client={queryClient}>
2150
- <App />
2151
- </QueryClientProvider>
2152
- </React.StrictMode>
2153
- );
3263
+ export default async ({
3264
+ params,
3265
+ body,
3266
+ }: {
3267
+ params: { id: string };
3268
+ body: { title?: string; content?: string; published?: boolean };
3269
+ }) => {
3270
+ const idx = posts.findIndex(p => p.id === params.id);
3271
+ if (idx === -1) throw new KozoError('Post not found', 404, 'NOT_FOUND');
3272
+ posts[idx] = { ...posts[idx], ...body };
3273
+ return posts[idx];
3274
+ };
2154
3275
  `);
2155
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Dashboard.tsx"), generateDashboardPage());
2156
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Users.tsx"), generateUsersPage());
2157
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Posts.tsx"), generatePostsPage());
2158
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Tasks.tsx"), generateTasksPage());
2159
- if (auth) {
2160
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "pages", "Login.tsx"), generateLoginPage());
2161
- }
2162
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "App.tsx"), generateAppTsx(projectName, auth));
2163
- }
2164
- function generateAppTsx(projectName, auth) {
2165
- const authImports = auth ? `import { getToken, clearToken } from './lib/api';` : "";
2166
- const loginImport = auth ? `import Login from './pages/Login';` : "";
2167
- const queryClientImport = auth ? `import { useQueryClient } from '@tanstack/react-query';` : "";
2168
- return `import { useState${auth ? ", useEffect" : ""} } from 'react';
2169
- ${queryClientImport}
2170
- import { LayoutDashboard, Users, FileText, CheckSquare, Server${auth ? ", LogOut" : ""} } from 'lucide-react';
2171
- ${authImports}
2172
- ${loginImport}
2173
- import Dashboard from './pages/Dashboard';
2174
- import UsersPage from './pages/Users';
2175
- import PostsPage from './pages/Posts';
2176
- import TasksPage from './pages/Tasks';
2177
-
2178
- type Page = 'dashboard' | 'users' | 'posts' | 'tasks';
3276
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "posts", "[id]", "delete.ts"), `import { z } from 'zod';
3277
+ import { KozoError } from '@kozojs/core';
3278
+ import { posts } from '../../../../data/index.js';
2179
3279
 
2180
- export default function App() {
2181
- const [page, setPage] = useState<Page>('dashboard');
2182
- ${auth ? ` const [token, setToken] = useState<string | null>(() => getToken());
2183
- const queryClient = useQueryClient();
3280
+ export const schema = {
3281
+ params: z.object({ id: z.string() }),
3282
+ response: z.object({ success: z.boolean(), message: z.string() }),
3283
+ };
2184
3284
 
2185
- useEffect(() => { if (token) { /* token refreshed */ } }, [token]);
3285
+ export default async ({ params }: { params: { id: string } }) => {
3286
+ const idx = posts.findIndex(p => p.id === params.id);
3287
+ if (idx === -1) throw new KozoError('Post not found', 404, 'NOT_FOUND');
3288
+ posts.splice(idx, 1);
3289
+ return { success: true, message: 'Post deleted' };
3290
+ };
3291
+ `);
3292
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "get.ts"), `import { z } from 'zod';
3293
+ import { tasks } from '../../../data/index.js';
3294
+ import { TaskSchema } from '../../../schemas/index.js';
2186
3295
 
2187
- const handleLogin = (t: string) => setToken(t);
2188
- const handleLogout = () => {
2189
- clearToken();
2190
- setToken(null);
2191
- queryClient.clear();
2192
- };
3296
+ export const schema = {
3297
+ query: z.object({
3298
+ completed: z.coerce.boolean().optional(),
3299
+ priority: z.enum(['low', 'medium', 'high']).optional(),
3300
+ }),
3301
+ response: z.array(TaskSchema),
3302
+ };
2193
3303
 
2194
- if (!token) return <Login onLogin={handleLogin} />;
2195
- ` : ""}
2196
- const nav = [
2197
- { id: 'dashboard' as Page, label: 'Dashboard', icon: LayoutDashboard },
2198
- { id: 'users' as Page, label: 'Users', icon: Users },
2199
- { id: 'posts' as Page, label: 'Posts', icon: FileText },
2200
- { id: 'tasks' as Page, label: 'Tasks', icon: CheckSquare },
2201
- ];
3304
+ export default async ({
3305
+ query,
3306
+ }: {
3307
+ query: { completed?: boolean; priority?: 'low' | 'medium' | 'high' };
3308
+ }) => {
3309
+ let result = [...tasks];
3310
+ if (query.completed !== undefined) {
3311
+ result = result.filter(t => t.completed === query.completed);
3312
+ }
3313
+ if (query.priority) {
3314
+ result = result.filter(t => t.priority === query.priority);
3315
+ }
3316
+ return result;
3317
+ };
3318
+ `);
3319
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "post.ts"), `import { tasks } from '../../../data/index.js';
3320
+ import { TaskSchema, CreateTaskBody } from '../../../schemas/index.js';
2202
3321
 
2203
- return (
2204
- <div className="flex min-h-screen">
2205
- {/* Sidebar */}
2206
- <aside className="w-56 bg-slate-900 border-r border-slate-800 flex flex-col p-4">
2207
- <div className="flex items-center gap-2 mb-8 px-2">
2208
- <Server className="w-5 h-5 text-blue-400" />
2209
- <span className="font-bold text-slate-200">${projectName}</span>
2210
- </div>
2211
- <nav className="flex-1 space-y-1">
2212
- {nav.map(({ id, label, icon: Icon }) => (
2213
- <button
2214
- key={id}
2215
- onClick={() => setPage(id)}
2216
- className={\`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition \${
2217
- page === id
2218
- ? 'bg-blue-600 text-white'
2219
- : 'text-slate-400 hover:bg-slate-800 hover:text-white'
2220
- }\`}
2221
- >
2222
- <Icon className="w-4 h-4" />
2223
- {label}
2224
- </button>
2225
- ))}
2226
- </nav>
2227
- ${auth ? ` <button
2228
- onClick={handleLogout}
2229
- className="flex items-center gap-2 px-3 py-2 text-sm text-slate-400 hover:text-red-400 transition"
2230
- >
2231
- <LogOut className="w-4 h-4" /> Logout
2232
- </button>
2233
- ` : ""} </aside>
2234
-
2235
- {/* Main content */}
2236
- <main className="flex-1 p-8 overflow-auto">
2237
- {page === 'dashboard' && <Dashboard />}
2238
- {page === 'users' && <UsersPage />}
2239
- {page === 'posts' && <PostsPage />}
2240
- {page === 'tasks' && <TasksPage />}
2241
- </main>
2242
- </div>
2243
- );
2244
- }
2245
- `;
2246
- }
2247
- function generateLoginPage() {
2248
- return `import { useState, FormEvent } from 'react';
2249
- import { Server, Loader2 } from 'lucide-react';
2250
- import { apiFetch, setToken } from '../lib/api';
3322
+ export const schema = {
3323
+ body: CreateTaskBody,
3324
+ response: TaskSchema,
3325
+ };
2251
3326
 
2252
- interface Props {
2253
- onLogin: (token: string) => void;
2254
- }
3327
+ export default async ({
3328
+ body,
3329
+ }: {
3330
+ body: { title: string; priority?: 'low' | 'medium' | 'high' };
3331
+ }) => {
3332
+ const newTask = {
3333
+ id: String(Date.now()),
3334
+ title: body.title,
3335
+ completed: false,
3336
+ priority: body.priority ?? ('medium' as const),
3337
+ createdAt: new Date().toISOString(),
3338
+ };
3339
+ tasks.push(newTask);
3340
+ return newTask;
3341
+ };
3342
+ `);
3343
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "get.ts"), `import { z } from 'zod';
3344
+ import { KozoError } from '@kozojs/core';
3345
+ import { tasks } from '../../../../data/index.js';
3346
+ import { TaskSchema } from '../../../../schemas/index.js';
2255
3347
 
2256
- export default function Login({ onLogin }: Props) {
2257
- const [email, setEmail] = useState('admin@demo.com');
2258
- const [password, setPassword] = useState('admin123');
2259
- const [error, setError] = useState('');
2260
- const [loading, setLoading] = useState(false);
3348
+ export const schema = {
3349
+ params: z.object({ id: z.string() }),
3350
+ response: TaskSchema,
3351
+ };
2261
3352
 
2262
- const submit = async (e: FormEvent) => {
2263
- e.preventDefault();
2264
- setError('');
2265
- setLoading(true);
2266
- try {
2267
- const res = await apiFetch<{ token: string }>('/api/auth/login', {
2268
- method: 'POST',
2269
- body: JSON.stringify({ email, password }),
2270
- });
2271
- if (res.status === 200 && res.data.token) {
2272
- setToken(res.data.token);
2273
- onLogin(res.data.token);
2274
- } else {
2275
- setError('Invalid credentials');
2276
- }
2277
- } catch {
2278
- setError('Connection failed \u2014 is the API running?');
2279
- } finally {
2280
- setLoading(false);
2281
- }
2282
- };
3353
+ export default async ({ params }: { params: { id: string } }) => {
3354
+ const task = tasks.find(t => t.id === params.id);
3355
+ if (!task) throw new KozoError('Task not found', 404, 'NOT_FOUND');
3356
+ return task;
3357
+ };
3358
+ `);
3359
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "put.ts"), `import { z } from 'zod';
3360
+ import { KozoError } from '@kozojs/core';
3361
+ import { tasks } from '../../../../data/index.js';
3362
+ import { TaskSchema, UpdateTaskBody } from '../../../../schemas/index.js';
2283
3363
 
2284
- return (
2285
- <div className="min-h-screen flex items-center justify-center p-4">
2286
- <div className="w-full max-w-sm">
2287
- <div className="flex flex-col items-center mb-8">
2288
- <div className="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center mb-4">
2289
- <Server className="w-6 h-6 text-white" />
2290
- </div>
2291
- <h1 className="text-2xl font-bold text-slate-200">Sign in</h1>
2292
- <p className="text-slate-400 text-sm mt-1">to your kozo dashboard</p>
2293
- </div>
3364
+ export const schema = {
3365
+ params: z.object({ id: z.string() }),
3366
+ body: UpdateTaskBody,
3367
+ response: TaskSchema,
3368
+ };
2294
3369
 
2295
- <form onSubmit={submit} className="bg-slate-800 rounded-xl border border-slate-700 p-6 space-y-4">
2296
- <div>
2297
- <label className="block text-xs font-medium text-slate-400 mb-1">Email</label>
2298
- <input
2299
- type="email"
2300
- value={email}
2301
- onChange={e => setEmail(e.target.value)}
2302
- className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2303
- required
2304
- />
2305
- </div>
2306
- <div>
2307
- <label className="block text-xs font-medium text-slate-400 mb-1">Password</label>
2308
- <input
2309
- type="password"
2310
- value={password}
2311
- onChange={e => setPassword(e.target.value)}
2312
- className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2313
- required
2314
- />
2315
- </div>
2316
- {error && <p className="text-red-400 text-xs">{error}</p>}
2317
- <button
2318
- type="submit"
2319
- disabled={loading}
2320
- className="w-full py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center justify-center gap-2"
2321
- >
2322
- {loading && <Loader2 className="w-4 h-4 animate-spin" />}
2323
- Sign in
2324
- </button>
2325
- </form>
2326
- </div>
2327
- </div>
2328
- );
2329
- }
2330
- `;
2331
- }
2332
- function generateDashboardPage() {
2333
- return `import { useQuery } from '@tanstack/react-query';
2334
- import { apiFetch } from '../lib/api';
2335
- import { Users, FileText, CheckSquare, Zap } from 'lucide-react';
3370
+ export default async ({
3371
+ params,
3372
+ body,
3373
+ }: {
3374
+ params: { id: string };
3375
+ body: { title?: string; completed?: boolean; priority?: 'low' | 'medium' | 'high' };
3376
+ }) => {
3377
+ const idx = tasks.findIndex(t => t.id === params.id);
3378
+ if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
3379
+ tasks[idx] = { ...tasks[idx], ...body };
3380
+ return tasks[idx];
3381
+ };
3382
+ `);
3383
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "delete.ts"), `import { z } from 'zod';
3384
+ import { KozoError } from '@kozojs/core';
3385
+ import { tasks } from '../../../../data/index.js';
2336
3386
 
2337
- interface Health { status: string; uptime: number; version: string; }
2338
- interface Stats {
2339
- users: number;
2340
- posts: number;
2341
- tasks: number;
2342
- publishedPosts: number;
2343
- completedTasks: number;
2344
- }
3387
+ export const schema = {
3388
+ params: z.object({ id: z.string() }),
3389
+ response: z.object({ success: z.boolean(), message: z.string() }),
3390
+ };
2345
3391
 
2346
- function StatCard({ label, value, sub, icon: Icon, color }: {
2347
- label: string; value: string | number; sub?: string;
2348
- icon: React.ElementType; color: string;
2349
- }) {
2350
- return (
2351
- <div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
2352
- <div className="flex items-start justify-between">
2353
- <div>
2354
- <p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">{label}</p>
2355
- <p className="text-3xl font-bold text-slate-100">{value}</p>
2356
- {sub && <p className="text-xs text-slate-500 mt-1">{sub}</p>}
2357
- </div>
2358
- <div className={\`w-10 h-10 rounded-lg flex items-center justify-center \${color}\`}>
2359
- <Icon className="w-5 h-5 text-white" />
2360
- </div>
2361
- </div>
2362
- </div>
2363
- );
2364
- }
3392
+ export default async ({ params }: { params: { id: string } }) => {
3393
+ const idx = tasks.findIndex(t => t.id === params.id);
3394
+ if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
3395
+ tasks.splice(idx, 1);
3396
+ return { success: true, message: 'Task deleted' };
3397
+ };
3398
+ `);
3399
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "toggle", "patch.ts"), `import { z } from 'zod';
3400
+ import { KozoError } from '@kozojs/core';
3401
+ import { tasks } from '../../../../../data/index.js';
3402
+ import { TaskSchema } from '../../../../../schemas/index.js';
2365
3403
 
2366
- export default function Dashboard() {
2367
- const { data: health } = useQuery({
2368
- queryKey: ['health'],
2369
- queryFn: () => apiFetch<Health>('/api/health').then(r => r.data),
2370
- });
3404
+ export const schema = {
3405
+ params: z.object({ id: z.string() }),
3406
+ response: TaskSchema,
3407
+ };
2371
3408
 
2372
- const { data: stats, isLoading } = useQuery({
2373
- queryKey: ['stats'],
2374
- queryFn: () => apiFetch<Stats>('/api/stats').then(r => r.data),
2375
- });
3409
+ export default async ({ params }: { params: { id: string } }) => {
3410
+ const idx = tasks.findIndex(t => t.id === params.id);
3411
+ if (idx === -1) throw new KozoError('Task not found', 404, 'NOT_FOUND');
3412
+ tasks[idx].completed = !tasks[idx].completed;
3413
+ return tasks[idx];
3414
+ };
3415
+ `);
3416
+ if (auth) {
3417
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "auth", "login", "post.ts"), `import { z } from 'zod';
3418
+ import { createJWT, UnauthorizedError } from '@kozojs/auth';
2376
3419
 
2377
- const uptime = health?.uptime;
2378
- const uptimeStr = uptime !== undefined
2379
- ? uptime > 3600 ? \`\${Math.floor(uptime / 3600)}h \${Math.floor((uptime % 3600) / 60)}m\`
2380
- : uptime > 60 ? \`\${Math.floor(uptime / 60)}m \${Math.floor(uptime % 60)}s\`
2381
- : \`\${Math.floor(uptime)}s\`
2382
- : '\u2014';
3420
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
2383
3421
 
2384
- return (
2385
- <div>
2386
- <div className="mb-6">
2387
- <h1 className="text-2xl font-bold text-slate-100">Dashboard</h1>
2388
- <p className="text-slate-400 text-sm mt-1">Server overview and statistics</p>
2389
- </div>
3422
+ const DEMO_USERS = [
3423
+ { email: 'admin@demo.com', password: 'admin123', role: 'admin', name: 'Admin' },
3424
+ { email: 'user@demo.com', password: 'user123', role: 'user', name: 'User' },
3425
+ ];
2390
3426
 
2391
- {isLoading ? (
2392
- <div className="text-slate-400">Loading stats...</div>
2393
- ) : (
2394
- <div className="grid grid-cols-2 gap-4 mb-8">
2395
- <StatCard
2396
- label="Users"
2397
- value={stats?.users ?? '\u2014'}
2398
- icon={Users} color="bg-blue-600"
2399
- />
2400
- <StatCard
2401
- label="Posts"
2402
- value={stats?.posts ?? '\u2014'}
2403
- sub={stats ? \`\${stats.publishedPosts} published\` : undefined}
2404
- icon={FileText} color="bg-purple-600"
2405
- />
2406
- <StatCard
2407
- label="Tasks"
2408
- value={stats?.tasks ?? '\u2014'}
2409
- sub={stats ? \`\${stats.completedTasks} completed\` : undefined}
2410
- icon={CheckSquare} color="bg-emerald-600"
2411
- />
2412
- <StatCard
2413
- label="Uptime"
2414
- value={uptimeStr}
2415
- sub={health?.version ? \`v\${health.version}\` : undefined}
2416
- icon={Zap} color="bg-amber-600"
2417
- />
2418
- </div>
2419
- )}
3427
+ export const schema = {
3428
+ body: z.object({
3429
+ email: z.string().email(),
3430
+ password: z.string(),
3431
+ }),
3432
+ response: z.object({
3433
+ token: z.string(),
3434
+ user: z.object({
3435
+ email: z.string(),
3436
+ role: z.string(),
3437
+ name: z.string(),
3438
+ }),
3439
+ }),
3440
+ };
2420
3441
 
2421
- <div className="bg-slate-800 rounded-xl border border-slate-700 p-4">
2422
- <h3 className="text-sm font-medium text-slate-300 mb-3">API Status</h3>
2423
- <div className="flex items-center gap-2">
2424
- <div className="flex-1 px-3 py-2 bg-slate-900 rounded-lg text-sm text-slate-400 font-mono">
2425
- GET /api/health
2426
- </div>
2427
- <span className={\`px-2 py-0.5 rounded text-xs font-medium \${
2428
- health?.status === 'ok' ? 'bg-emerald-900 text-emerald-300' : 'bg-slate-700 text-slate-400'
2429
- }\`}>
2430
- {health?.status ?? 'pending'}
2431
- </span>
2432
- </div>
2433
- </div>
2434
- </div>
3442
+ export default async ({ body }: { body: { email: string; password: string } }) => {
3443
+ const user = DEMO_USERS.find(u => u.email === body.email && u.password === body.password);
3444
+ if (!user) throw new UnauthorizedError('Invalid credentials');
3445
+ const token = await createJWT(
3446
+ { email: user.email, role: user.role, name: user.name },
3447
+ JWT_SECRET,
3448
+ { expiresIn: '24h' },
2435
3449
  );
3450
+ return { token, user: { email: user.email, role: user.role, name: user.name } };
3451
+ };
3452
+ `);
3453
+ }
2436
3454
  }
2437
- `;
2438
- }
2439
- function generateUsersPage() {
2440
- return `import { useState } from 'react';
2441
- import { useQuery, useQueryClient } from '@tanstack/react-query';
2442
- import { apiFetch } from '../lib/api';
2443
- import { Plus, Trash2, Loader2 } from 'lucide-react';
2444
-
2445
- interface User {
2446
- id: string | number;
2447
- name: string;
2448
- email: string;
2449
- role?: string;
2450
- createdAt?: string;
2451
- }
2452
-
2453
- export default function UsersPage() {
2454
- const queryClient = useQueryClient();
2455
- const [form, setForm] = useState({ name: '', email: '' });
2456
- const [loading, setLoading] = useState(false);
2457
- const [error, setError] = useState('');
2458
3455
 
2459
- const { data: users = [], isLoading } = useQuery({
2460
- queryKey: ['users'],
2461
- queryFn: async () => {
2462
- const res = await apiFetch<User[] | { users: User[] }>('/api/users');
2463
- return Array.isArray(res.data) ? res.data : res.data.users ?? [];
3456
+ // src/utils/scaffold/index.ts
3457
+ async function scaffoldProject(options) {
3458
+ const { projectName, runtime, database, dbPort, auth, packageSource, template, frontend, extras } = options;
3459
+ const projectDir = import_node_path5.default.resolve(process.cwd(), projectName);
3460
+ const kozoCoreDep = packageSource === "local" ? "workspace:*" : "^0.3.7";
3461
+ if (frontend !== "none") {
3462
+ await scaffoldFullstackProject(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth, frontend, extras, template);
3463
+ return;
3464
+ }
3465
+ if (template === "complete") {
3466
+ await scaffoldCompleteTemplate(projectDir, projectName, kozoCoreDep, runtime, database, dbPort, auth);
3467
+ if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
3468
+ if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
3469
+ if (extras.includes("github-actions")) await createGitHubActions(projectDir);
3470
+ return;
3471
+ }
3472
+ if (template === "api-only") {
3473
+ await scaffoldApiOnlyTemplate(projectDir, projectName, kozoCoreDep, runtime);
3474
+ if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
3475
+ if (extras.includes("github-actions")) await createGitHubActions(projectDir);
3476
+ return;
3477
+ }
3478
+ await import_fs_extra5.default.ensureDir(import_node_path5.default.join(projectDir, "src", "routes"));
3479
+ await import_fs_extra5.default.ensureDir(import_node_path5.default.join(projectDir, "src", "db"));
3480
+ await import_fs_extra5.default.ensureDir(import_node_path5.default.join(projectDir, "src", "services"));
3481
+ const packageJson = {
3482
+ name: projectName,
3483
+ version: "0.1.0",
3484
+ type: "module",
3485
+ scripts: {
3486
+ dev: "tsx watch src/index.ts",
3487
+ build: "tsc",
3488
+ start: "node dist/index.js",
3489
+ "db:generate": "drizzle-kit generate",
3490
+ "db:push": "drizzle-kit push",
3491
+ "db:studio": "drizzle-kit studio"
2464
3492
  },
2465
- });
2466
-
2467
- const createUser = async () => {
2468
- if (!form.name || !form.email) { setError('Name and email required'); return; }
2469
- setLoading(true); setError('');
2470
- try {
2471
- const res = await apiFetch('/api/users', { method: 'POST', body: JSON.stringify(form) });
2472
- if (res.status >= 400) throw new Error('Failed');
2473
- setForm({ name: '', email: '' });
2474
- queryClient.invalidateQueries({ queryKey: ['users'] });
2475
- } catch { setError('Failed to create user'); }
2476
- finally { setLoading(false); }
3493
+ dependencies: {
3494
+ "@kozojs/core": kozoCoreDep,
3495
+ "uWebSockets.js": "github:uNetworking/uWebSockets.js#6609a88ffa9a16ac5158046761356ce03250a0df",
3496
+ hono: "^4.12.5",
3497
+ zod: "^4.0.0",
3498
+ "drizzle-orm": "^0.36.0",
3499
+ ...database === "postgresql" && { postgres: "^3.4.8" },
3500
+ ...database === "mysql" && { mysql2: "^3.11.0" },
3501
+ ...database === "sqlite" && { "better-sqlite3": "^11.0.0" }
3502
+ },
3503
+ devDependencies: {
3504
+ "@types/node": "^22.0.0",
3505
+ tsx: "^4.21.0",
3506
+ typescript: "^5.6.0",
3507
+ "drizzle-kit": "^0.28.0",
3508
+ ...database === "sqlite" && { "@types/better-sqlite3": "^7.6.0" }
3509
+ }
2477
3510
  };
2478
-
2479
- const deleteUser = async (id: string | number) => {
2480
- setLoading(true);
2481
- await apiFetch(\`/api/users/\${id}\`, { method: 'DELETE' });
2482
- queryClient.invalidateQueries({ queryKey: ['users'] });
2483
- setLoading(false);
3511
+ await import_fs_extra5.default.writeJSON(import_node_path5.default.join(projectDir, "package.json"), packageJson, { spaces: 2 });
3512
+ const tsconfig = {
3513
+ compilerOptions: {
3514
+ target: "ES2022",
3515
+ module: "ESNext",
3516
+ moduleResolution: "bundler",
3517
+ strict: true,
3518
+ esModuleInterop: true,
3519
+ skipLibCheck: true,
3520
+ outDir: "dist",
3521
+ rootDir: "src",
3522
+ declaration: true
3523
+ },
3524
+ include: ["src/**/*"],
3525
+ exclude: ["node_modules", "dist"]
2484
3526
  };
3527
+ await import_fs_extra5.default.writeJSON(import_node_path5.default.join(projectDir, "tsconfig.json"), tsconfig, { spaces: 2 });
3528
+ const drizzleConfig = `import { defineConfig } from 'drizzle-kit';
2485
3529
 
2486
- return (
2487
- <div>
2488
- <div className="mb-6">
2489
- <h1 className="text-2xl font-bold text-slate-100">Users</h1>
2490
- <p className="text-slate-400 text-sm mt-1">{users.length} total</p>
2491
- </div>
2492
-
2493
- {/* Create form */}
2494
- <div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
2495
- <h3 className="text-sm font-medium text-slate-300 mb-3">Add User</h3>
2496
- <div className="flex gap-3">
2497
- <input
2498
- placeholder="Full name"
2499
- value={form.name}
2500
- onChange={e => setForm({ ...form, name: e.target.value })}
2501
- className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2502
- />
2503
- <input
2504
- placeholder="email@example.com"
2505
- type="email"
2506
- value={form.email}
2507
- onChange={e => setForm({ ...form, email: e.target.value })}
2508
- className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2509
- />
2510
- <button
2511
- onClick={createUser}
2512
- disabled={loading}
2513
- className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
2514
- >
2515
- {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
2516
- Add
2517
- </button>
2518
- </div>
2519
- {error && <p className="text-red-400 text-xs mt-2">{error}</p>}
2520
- </div>
2521
-
2522
- {/* Users table */}
2523
- <div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
2524
- {isLoading ? (
2525
- <div className="p-8 text-center text-slate-400 text-sm">Loading users...</div>
2526
- ) : users.length === 0 ? (
2527
- <div className="p-8 text-center text-slate-400 text-sm">No users yet. Add one above.</div>
2528
- ) : (
2529
- <table className="w-full">
2530
- <thead>
2531
- <tr className="border-b border-slate-700">
2532
- <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Name</th>
2533
- <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Email</th>
2534
- <th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase">Role</th>
2535
- <th className="px-4 py-3"></th>
2536
- </tr>
2537
- </thead>
2538
- <tbody>
2539
- {users.map((user) => (
2540
- <tr key={user.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
2541
- <td className="px-4 py-3 text-sm font-medium text-slate-200">{user.name}</td>
2542
- <td className="px-4 py-3 text-sm text-slate-400">{user.email}</td>
2543
- <td className="px-4 py-3">
2544
- {user.role && (
2545
- <span className={\`px-2 py-0.5 rounded-full text-xs font-medium \${
2546
- user.role === 'admin' ? 'bg-amber-900 text-amber-300' : 'bg-slate-700 text-slate-300'
2547
- }\`}>
2548
- {user.role}
2549
- </span>
2550
- )}
2551
- </td>
2552
- <td className="px-4 py-3 text-right">
2553
- <button
2554
- onClick={() => deleteUser(user.id)}
2555
- className="p-1.5 text-slate-500 hover:text-red-400 transition rounded"
2556
- >
2557
- <Trash2 className="w-4 h-4" />
2558
- </button>
2559
- </td>
2560
- </tr>
2561
- ))}
2562
- </tbody>
2563
- </table>
2564
- )}
2565
- </div>
2566
- </div>
2567
- );
2568
- }
3530
+ export default defineConfig({
3531
+ schema: './src/db/schema.ts',
3532
+ out: './drizzle',
3533
+ dialect: '${database === "postgresql" ? "postgresql" : database === "mysql" ? "mysql" : "sqlite"}',
3534
+ dbCredentials: {
3535
+ ${database === "sqlite" ? "url: './data.db'" : "url: process.env.DATABASE_URL!"}
3536
+ }
3537
+ });
2569
3538
  `;
2570
- }
2571
- function generatePostsPage() {
2572
- return `import { useState } from 'react';
2573
- import { useQuery, useQueryClient } from '@tanstack/react-query';
2574
- import { apiFetch } from '../lib/api';
2575
- import { Plus, Trash2, Loader2, Globe, Lock } from 'lucide-react';
2576
-
2577
- interface Post {
2578
- id: string | number;
2579
- title: string;
2580
- content?: string;
2581
- published?: boolean;
2582
- authorId?: string | number;
2583
- tags?: string[];
2584
- createdAt?: string;
2585
- }
3539
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "drizzle.config.ts"), drizzleConfig);
3540
+ const envExample = `# Database
3541
+ ${database === "sqlite" ? "# SQLite uses local file, no URL needed" : "DATABASE_URL="}
2586
3542
 
2587
- export default function PostsPage() {
2588
- const queryClient = useQueryClient();
2589
- const [form, setForm] = useState({ title: '', content: '', published: false });
2590
- const [loading, setLoading] = useState(false);
2591
- const [error, setError] = useState('');
3543
+ # Server
3544
+ PORT=3000
3545
+ `;
3546
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, ".env.example"), envExample);
3547
+ const gitignore = `node_modules/
3548
+ dist/
3549
+ .env
3550
+ *.db
3551
+ .turbo/
3552
+ `;
3553
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, ".gitignore"), gitignore);
3554
+ const indexTs = `import { createKozo } from '@kozojs/core';
3555
+ import { services } from './services/index.js';
2592
3556
 
2593
- const { data: posts = [], isLoading } = useQuery({
2594
- queryKey: ['posts'],
2595
- queryFn: async () => {
2596
- const res = await apiFetch<Post[] | { posts: Post[] }>('/api/posts');
2597
- return Array.isArray(res.data) ? res.data : res.data.posts ?? [];
3557
+ const app = createKozo({
3558
+ services,
3559
+ port: Number(process.env.PORT) || 3000,
3560
+ openapi: {
3561
+ info: {
3562
+ title: '${projectName} API',
3563
+ version: '1.0.0',
3564
+ description: 'API documentation for ${projectName}'
2598
3565
  },
2599
- });
2600
-
2601
- const createPost = async () => {
2602
- if (!form.title) { setError('Title required'); return; }
2603
- setLoading(true); setError('');
2604
- try {
2605
- const res = await apiFetch('/api/posts', { method: 'POST', body: JSON.stringify(form) });
2606
- if (res.status >= 400) throw new Error('Failed');
2607
- setForm({ title: '', content: '', published: false });
2608
- queryClient.invalidateQueries({ queryKey: ['posts'] });
2609
- } catch { setError('Failed to create post'); }
2610
- finally { setLoading(false); }
2611
- };
2612
-
2613
- const deletePost = async (id: string | number) => {
2614
- setLoading(true);
2615
- await apiFetch(\`/api/posts/\${id}\`, { method: 'DELETE' });
2616
- queryClient.invalidateQueries({ queryKey: ['posts'] });
2617
- setLoading(false);
2618
- };
3566
+ servers: [
3567
+ {
3568
+ url: 'http://localhost:3000',
3569
+ description: 'Development server'
3570
+ }
3571
+ ]
3572
+ }
3573
+ });
2619
3574
 
2620
- return (
2621
- <div>
2622
- <div className="mb-6">
2623
- <h1 className="text-2xl font-bold text-slate-100">Posts</h1>
2624
- <p className="text-slate-400 text-sm mt-1">{posts.length} total</p>
2625
- </div>
3575
+ await app.nativeListen();
3576
+ `;
3577
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "src", "index.ts"), indexTs);
3578
+ const servicesTs = `import { db } from '../db/index.js';
2626
3579
 
2627
- {/* Create form */}
2628
- <div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
2629
- <h3 className="text-sm font-medium text-slate-300 mb-3">New Post</h3>
2630
- <div className="space-y-3">
2631
- <input
2632
- placeholder="Post title"
2633
- value={form.title}
2634
- onChange={e => setForm({ ...form, title: e.target.value })}
2635
- className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2636
- />
2637
- <textarea
2638
- placeholder="Content (optional)"
2639
- value={form.content}
2640
- onChange={e => setForm({ ...form, content: e.target.value })}
2641
- rows={2}
2642
- className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none resize-none"
2643
- />
2644
- <div className="flex items-center justify-between">
2645
- <label className="flex items-center gap-2 text-sm text-slate-400 cursor-pointer">
2646
- <input
2647
- type="checkbox"
2648
- checked={form.published}
2649
- onChange={e => setForm({ ...form, published: e.target.checked })}
2650
- className="rounded"
2651
- />
2652
- Publish immediately
2653
- </label>
2654
- <button
2655
- onClick={createPost}
2656
- disabled={loading}
2657
- className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
2658
- >
2659
- {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
2660
- Create
2661
- </button>
2662
- </div>
2663
- </div>
2664
- {error && <p className="text-red-400 text-xs mt-2">{error}</p>}
2665
- </div>
3580
+ export const services = {
3581
+ db
3582
+ };
2666
3583
 
2667
- {/* Posts list */}
2668
- <div className="space-y-3">
2669
- {isLoading ? (
2670
- <div className="text-center text-slate-400 text-sm py-8">Loading posts...</div>
2671
- ) : posts.length === 0 ? (
2672
- <div className="text-center text-slate-400 text-sm py-8">No posts yet. Create one above.</div>
2673
- ) : (
2674
- posts.map((post) => (
2675
- <div key={post.id} className="bg-slate-800 rounded-xl border border-slate-700 p-4 flex items-start justify-between">
2676
- <div className="flex-1 min-w-0">
2677
- <div className="flex items-center gap-2 mb-1">
2678
- {post.published
2679
- ? <Globe className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0" />
2680
- : <Lock className="w-3.5 h-3.5 text-slate-500 flex-shrink-0" />
2681
- }
2682
- <h3 className="font-medium text-slate-200 truncate">{post.title}</h3>
2683
- </div>
2684
- {post.content && (
2685
- <p className="text-sm text-slate-400 line-clamp-2">{post.content}</p>
2686
- )}
2687
- {post.tags && post.tags.length > 0 && (
2688
- <div className="flex gap-1.5 mt-2">
2689
- {post.tags.map(tag => (
2690
- <span key={tag} className="px-2 py-0.5 bg-slate-700 rounded text-xs text-slate-300">{tag}</span>
2691
- ))}
2692
- </div>
2693
- )}
2694
- </div>
2695
- <button
2696
- onClick={() => deletePost(post.id)}
2697
- className="ml-3 p-1.5 text-slate-500 hover:text-red-400 transition rounded flex-shrink-0"
2698
- >
2699
- <Trash2 className="w-4 h-4" />
2700
- </button>
2701
- </div>
2702
- ))
2703
- )}
2704
- </div>
2705
- </div>
2706
- );
3584
+ // Type augmentation for autocomplete
3585
+ declare module '@kozojs/core' {
3586
+ interface Services {
3587
+ db: typeof db;
3588
+ }
2707
3589
  }
2708
3590
  `;
2709
- }
2710
- function generateTasksPage() {
2711
- return `import { useState } from 'react';
2712
- import { useQuery, useQueryClient } from '@tanstack/react-query';
2713
- import { apiFetch } from '../lib/api';
2714
- import { Plus, Trash2, Loader2, CheckCircle2, Circle } from 'lucide-react';
3591
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "src", "services", "index.ts"), servicesTs);
3592
+ const schemaTs = getDatabaseSchema(database);
3593
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "src", "db", "schema.ts"), schemaTs);
3594
+ const dbIndexTs = getDatabaseIndex(database);
3595
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "src", "db", "index.ts"), dbIndexTs);
3596
+ if (database === "sqlite") {
3597
+ const seedTs = getSQLiteSeed();
3598
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "src", "db", "seed.ts"), seedTs);
3599
+ }
3600
+ await createExampleRoutes(projectDir);
3601
+ const readme = `# ${projectName}
2715
3602
 
2716
- interface Task {
2717
- id: string | number;
2718
- title: string;
2719
- completed: boolean;
2720
- priority: 'low' | 'medium' | 'high';
2721
- createdAt?: string;
2722
- }
3603
+ Built with \u{1F525} **Kozo Framework**
2723
3604
 
2724
- const priorityColor: Record<string, string> = {
2725
- high: 'bg-red-900 text-red-300',
2726
- medium: 'bg-amber-900 text-amber-300',
2727
- low: 'bg-slate-700 text-slate-300',
2728
- };
3605
+ ## Getting Started
2729
3606
 
2730
- export default function TasksPage() {
2731
- const queryClient = useQueryClient();
2732
- const [form, setForm] = useState({ title: '', priority: 'medium' as 'low' | 'medium' | 'high' });
2733
- const [loading, setLoading] = useState(false);
2734
- const [error, setError] = useState('');
3607
+ \`\`\`bash
3608
+ # Install dependencies
3609
+ pnpm install
2735
3610
 
2736
- const { data: tasks = [], isLoading } = useQuery({
2737
- queryKey: ['tasks'],
2738
- queryFn: async () => {
2739
- const res = await apiFetch<Task[] | { tasks: Task[] }>('/api/tasks');
2740
- return Array.isArray(res.data) ? res.data : res.data.tasks ?? [];
2741
- },
2742
- });
3611
+ # Start development server
3612
+ pnpm dev
3613
+ \`\`\`
2743
3614
 
2744
- const createTask = async () => {
2745
- if (!form.title) { setError('Title required'); return; }
2746
- setLoading(true); setError('');
2747
- try {
2748
- const res = await apiFetch('/api/tasks', { method: 'POST', body: JSON.stringify(form) });
2749
- if (res.status >= 400) throw new Error('Failed');
2750
- setForm({ title: '', priority: 'medium' });
2751
- queryClient.invalidateQueries({ queryKey: ['tasks'] });
2752
- } catch { setError('Failed to create task'); }
2753
- finally { setLoading(false); }
2754
- };
3615
+ The server will start at http://localhost:3000
2755
3616
 
2756
- const toggleTask = async (id: string | number) => {
2757
- await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
2758
- queryClient.invalidateQueries({ queryKey: ['tasks'] });
2759
- };
3617
+ ## Try the API
2760
3618
 
2761
- const deleteTask = async (id: string | number) => {
2762
- setLoading(true);
2763
- await apiFetch(\`/api/tasks/\${id}\`, { method: 'DELETE' });
2764
- queryClient.invalidateQueries({ queryKey: ['tasks'] });
2765
- setLoading(false);
2766
- };
3619
+ \`\`\`bash
3620
+ # Get all users
3621
+ curl http://localhost:3000/users
2767
3622
 
2768
- const done = tasks.filter(t => t.completed).length;
3623
+ # Create a new user
3624
+ curl -X POST http://localhost:3000/users \\
3625
+ -H "Content-Type: application/json" \\
3626
+ -d '{"name":"Alice","email":"alice@example.com"}'
2769
3627
 
2770
- return (
2771
- <div>
2772
- <div className="mb-6">
2773
- <h1 className="text-2xl font-bold text-slate-100">Tasks</h1>
2774
- <p className="text-slate-400 text-sm mt-1">{done}/{tasks.length} completed</p>
2775
- </div>
3628
+ # Health check
3629
+ curl http://localhost:3000
2776
3630
 
2777
- {/* Create form */}
2778
- <div className="bg-slate-800 rounded-xl border border-slate-700 p-4 mb-6">
2779
- <h3 className="text-sm font-medium text-slate-300 mb-3">New Task</h3>
2780
- <div className="flex gap-3">
2781
- <input
2782
- placeholder="Task title"
2783
- value={form.title}
2784
- onChange={e => setForm({ ...form, title: e.target.value })}
2785
- onKeyDown={e => { if (e.key === 'Enter') { void createTask(); } }}
2786
- className="flex-1 px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2787
- />
2788
- <select
2789
- value={form.priority}
2790
- onChange={e => setForm({ ...form, priority: e.target.value as 'low' | 'medium' | 'high' })}
2791
- className="px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm focus:border-blue-500 focus:outline-none"
2792
- >
2793
- <option value="low">Low</option>
2794
- <option value="medium">Medium</option>
2795
- <option value="high">High</option>
2796
- </select>
2797
- <button
2798
- onClick={() => { void createTask(); }}
2799
- disabled={loading}
2800
- className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition flex items-center gap-2"
2801
- >
2802
- {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
2803
- Add
2804
- </button>
2805
- </div>
2806
- {error && <p className="text-red-400 text-xs mt-2">{error}</p>}
2807
- </div>
3631
+ # Open Swagger UI
3632
+ open http://localhost:3000/swagger
3633
+ \`\`\`
2808
3634
 
2809
- {/* Tasks list */}
2810
- <div className="space-y-2">
2811
- {isLoading ? (
2812
- <div className="text-center text-slate-400 text-sm py-8">Loading tasks...</div>
2813
- ) : tasks.length === 0 ? (
2814
- <div className="text-center text-slate-400 text-sm py-8">No tasks yet. Add one above.</div>
2815
- ) : (
2816
- tasks.map((task) => (
2817
- <div
2818
- key={task.id}
2819
- className={\`flex items-center justify-between p-3 rounded-xl border transition \${
2820
- task.completed ? 'bg-slate-800/50 border-slate-700/50' : 'bg-slate-800 border-slate-700'
2821
- }\`}
2822
- >
2823
- <div className="flex items-center gap-3 flex-1 min-w-0">
2824
- <button
2825
- onClick={() => { void toggleTask(task.id); }}
2826
- className="flex-shrink-0 text-slate-400 hover:text-blue-400 transition"
2827
- >
2828
- {task.completed
2829
- ? <CheckCircle2 className="w-5 h-5 text-emerald-400" />
2830
- : <Circle className="w-5 h-5" />
2831
- }
2832
- </button>
2833
- <span className={\`text-sm font-medium truncate \${task.completed ? 'line-through text-slate-500' : 'text-slate-200'}\`}>
2834
- {task.title}
2835
- </span>
2836
- <span className={\`flex-shrink-0 px-2 py-0.5 rounded-full text-xs font-medium \${priorityColor[task.priority] ?? ''}\`}>
2837
- {task.priority}
2838
- </span>
2839
- </div>
2840
- <button
2841
- onClick={() => { void deleteTask(task.id); }}
2842
- className="ml-3 p-1.5 text-slate-500 hover:text-red-400 transition rounded flex-shrink-0"
2843
- >
2844
- <Trash2 className="w-4 h-4" />
2845
- </button>
2846
- </div>
2847
- ))
2848
- )}
2849
- </div>
2850
- </div>
2851
- );
2852
- }
2853
- `;
2854
- }
2855
- async function scaffoldFullstackReadme(projectDir, projectName) {
2856
- const readme = `# ${projectName}
3635
+ ## API Documentation
2857
3636
 
2858
- Full-stack application built with **Kozo** framework and React.
3637
+ Once the server is running, visit:
3638
+ - **Swagger UI**: http://localhost:3000/swagger
3639
+ - **OpenAPI JSON**: http://localhost:3000/doc
2859
3640
 
2860
3641
  ## Project Structure
2861
3642
 
2862
3643
  \`\`\`
2863
- apps/
2864
- \u251C\u2500\u2500 api/ # Backend Kozo
2865
- \u2502 \u2514\u2500\u2500 src/
2866
- \u2502 \u251C\u2500\u2500 data/ # Schemas e dati in-memory
2867
- \u2502 \u2502 \u2514\u2500\u2500 index.ts
2868
- \u2502 \u251C\u2500\u2500 routes/ # Routes organizzate per risorsa
2869
- \u2502 \u2502 \u251C\u2500\u2500 health.ts # Health check e stats
2870
- \u2502 \u2502 \u251C\u2500\u2500 users.ts # CRUD Users
2871
- \u2502 \u2502 \u251C\u2500\u2500 posts.ts # CRUD Posts
2872
- \u2502 \u2502 \u251C\u2500\u2500 tasks.ts # CRUD Tasks
2873
- \u2502 \u2502 \u251C\u2500\u2500 tools.ts # Utility endpoints
2874
- \u2502 \u2502 \u2514\u2500\u2500 index.ts # Route registration
2875
- \u2502 \u2514\u2500\u2500 index.ts # Entry point
2876
- \u2514\u2500\u2500 web/ # Frontend React
2877
- \u2514\u2500\u2500 src/
2878
- \u251C\u2500\u2500 App.tsx # UI principale con tabs
2879
- \u251C\u2500\u2500 main.tsx # Entry point
2880
- \u2514\u2500\u2500 index.css # TailwindCSS v4
2881
- \`\`\`
2882
-
2883
- ## Technologies
2884
-
2885
- - **Backend**: Kozo (Hono-based), TypeScript, Zod
2886
- - **Frontend**: React 18, TanStack Query, TailwindCSS v4, Lucide Icons
2887
- - **Build**: Vite, pnpm workspace
2888
-
2889
- ## Installation
2890
-
2891
- \`\`\`bash
2892
- pnpm install
3644
+ src/
3645
+ \u251C\u2500\u2500 db/
3646
+ \u2502 \u251C\u2500\u2500 schema.ts # Drizzle schema
3647
+ \u2502 \u251C\u2500\u2500 seed.ts # Database initialization${database === "sqlite" ? " (SQLite)" : ""}
3648
+ \u2502 \u2514\u2500\u2500 index.ts # Database client
3649
+ \u251C\u2500\u2500 routes/
3650
+ \u2502 \u251C\u2500\u2500 index.ts # GET /
3651
+ \u2502 \u2514\u2500\u2500 users/
3652
+ \u2502 \u251C\u2500\u2500 get.ts # GET /users
3653
+ \u2502 \u2514\u2500\u2500 post.ts # POST /users
3654
+ \u251C\u2500\u2500 services/
3655
+ \u2502 \u2514\u2500\u2500 index.ts # Service definitions
3656
+ \u2514\u2500\u2500 index.ts # Entry point
2893
3657
  \`\`\`
2894
3658
 
2895
- ## Development
3659
+ ## Database Commands
2896
3660
 
2897
3661
  \`\`\`bash
2898
- # Start API and Web in parallel
2899
- pnpm dev
2900
-
2901
- # Or separately:
2902
- pnpm --filter @${projectName}/api dev # API on http://localhost:3000
2903
- pnpm --filter @${projectName}/web dev # Web on http://localhost:5173
3662
+ pnpm db:generate # Generate migrations
3663
+ pnpm db:push # Push schema to database
3664
+ pnpm db:studio # Open Drizzle Studio
2904
3665
  \`\`\`
2905
3666
 
2906
- ## API Endpoints
3667
+ ${database === "sqlite" ? "## SQLite Notes\n\nThe database is automatically initialized with example data on first run.\nDatabase file: `./data.db`\n" : ""}
3668
+ ## Documentation
2907
3669
 
2908
- ### Health & Stats
2909
- - \`GET /api/health\` - Health check with uptime
2910
- - \`GET /api/stats\` - Global statistics
2911
-
2912
- ### Users (Full CRUD)
2913
- - \`GET /api/users\` - List all users
2914
- - \`GET /api/users/:id\` - Get user by ID
2915
- - \`POST /api/users\` - Create user
2916
- - \`PUT /api/users/:id\` - Update user
2917
- - \`DELETE /api/users/:id\` - Delete user
2918
-
2919
- ### Posts (Full CRUD)
2920
- - \`GET /api/posts?published=true\` - List posts (optional filter)
2921
- - \`GET /api/posts/:id\` - Get post by ID
2922
- - \`POST /api/posts\` - Create post
2923
- - \`PUT /api/posts/:id\` - Update post
2924
- - \`DELETE /api/posts/:id\` - Delete post
2925
-
2926
- ### Tasks (Full CRUD)
2927
- - \`GET /api/tasks?completed=true&priority=high\` - List tasks (optional filters)
2928
- - \`GET /api/tasks/:id\` - Get task by ID
2929
- - \`POST /api/tasks\` - Create task
2930
- - \`PUT /api/tasks/:id\` - Update task
2931
- - \`PATCH /api/tasks/:id/toggle\` - Toggle completion
2932
- - \`DELETE /api/tasks/:id\` - Delete task
2933
-
2934
- ### Tools
2935
- - \`GET /api/echo?message=hello\` - Echo test
2936
- - \`POST /api/validate\` - Zod validation (email + age)
2937
-
2938
- ## Type Safety
2939
-
2940
- The frontend uses Hono RPC client for type-safe API calls:
2941
- \`\`\`typescript
2942
- const res = await client.api.users.$get();
2943
- const users = await res.json(); // Fully typed!
2944
- \`\`\`
3670
+ - [Kozo Docs](https://kozo-docs.vercel.app)
3671
+ - [Drizzle ORM](https://orm.drizzle.team)
3672
+ - [Hono](https://hono.dev)
2945
3673
  `;
2946
- await import_fs_extra.default.writeFile(import_node_path.default.join(projectDir, "README.md"), readme);
3674
+ await import_fs_extra5.default.writeFile(import_node_path5.default.join(projectDir, "README.md"), readme);
3675
+ if (database !== "none" && database !== "sqlite") await createDockerCompose(projectDir, projectName, database, dbPort);
3676
+ if (extras.includes("docker")) await createDockerfile(projectDir, runtime);
3677
+ if (extras.includes("github-actions")) await createGitHubActions(projectDir);
2947
3678
  }
2948
3679
 
2949
3680
  // src/utils/ascii-art.ts
@@ -3114,18 +3845,18 @@ ${import_picocolors2.default.dim("Documentation:")} ${import_picocolors2.default
3114
3845
  // src/commands/build.ts
3115
3846
  var import_execa2 = require("execa");
3116
3847
  var import_picocolors3 = __toESM(require("picocolors"));
3117
- var import_fs_extra2 = __toESM(require("fs-extra"));
3118
- var import_node_path4 = __toESM(require("path"));
3848
+ var import_fs_extra6 = __toESM(require("fs-extra"));
3849
+ var import_node_path8 = __toESM(require("path"));
3119
3850
 
3120
3851
  // src/routing/manifest.ts
3121
3852
  var import_node_crypto = require("crypto");
3122
3853
  var import_node_fs2 = require("fs");
3123
- var import_node_path3 = require("path");
3854
+ var import_node_path7 = require("path");
3124
3855
  var import_glob2 = require("glob");
3125
3856
 
3126
3857
  // src/routing/scan.ts
3127
3858
  var import_glob = require("glob");
3128
- var import_node_path2 = require("path");
3859
+ var import_node_path6 = require("path");
3129
3860
  var import_node_fs = require("fs");
3130
3861
  var HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
3131
3862
  function fileToRoute(filePath) {
@@ -3149,8 +3880,8 @@ function fileToRoute(filePath) {
3149
3880
  if (seg.startsWith("[") && seg.endsWith("]")) return ":" + seg.slice(1, -1);
3150
3881
  return seg;
3151
3882
  });
3152
- const path3 = "/" + urlSegments.join("/");
3153
- return { path: path3, method };
3883
+ const path7 = "/" + urlSegments.join("/");
3884
+ return { path: path7, method };
3154
3885
  }
3155
3886
  function extractParams(urlPath) {
3156
3887
  return urlPath.split("/").filter((seg) => seg.startsWith(":")).map((seg) => seg.slice(1));
@@ -3194,7 +3925,7 @@ async function scanRoutes(options) {
3194
3925
  if (!isRouteFile(file)) continue;
3195
3926
  const parsed = fileToRoute(file);
3196
3927
  if (!parsed) continue;
3197
- const absolutePath = (0, import_node_path2.join)(routesDir, file);
3928
+ const absolutePath = (0, import_node_path6.join)(routesDir, file);
3198
3929
  const { hasBodySchema, hasQuerySchema } = detectSchemas(absolutePath);
3199
3930
  const params = extractParams(parsed.path);
3200
3931
  routes.push({
@@ -3229,7 +3960,7 @@ async function hashRouteFiles(routesDir) {
3229
3960
  for (const file of files) {
3230
3961
  hash.update(file);
3231
3962
  try {
3232
- const content = (0, import_node_fs2.readFileSync)((0, import_node_path3.join)(routesDir, file));
3963
+ const content = (0, import_node_fs2.readFileSync)((0, import_node_path7.join)(routesDir, file));
3233
3964
  hash.update(content);
3234
3965
  } catch {
3235
3966
  }
@@ -3249,7 +3980,7 @@ async function generateManifest(options) {
3249
3980
  const {
3250
3981
  routesDir,
3251
3982
  projectRoot,
3252
- outputPath = (0, import_node_path3.join)(projectRoot, "routes-manifest.json"),
3983
+ outputPath = (0, import_node_path7.join)(projectRoot, "routes-manifest.json"),
3253
3984
  cache = true,
3254
3985
  verbose = false
3255
3986
  } = options;
@@ -3282,7 +4013,7 @@ async function generateManifest(options) {
3282
4013
  contentHash,
3283
4014
  routes: entries
3284
4015
  };
3285
- const dir = (0, import_node_path3.dirname)(outputPath);
4016
+ const dir = (0, import_node_path7.dirname)(outputPath);
3286
4017
  if (!(0, import_node_fs2.existsSync)(dir)) {
3287
4018
  (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
3288
4019
  }
@@ -3325,11 +4056,11 @@ async function buildCommand(options = {}) {
3325
4056
  let currentStep = 0;
3326
4057
  currentStep++;
3327
4058
  step(currentStep, TOTAL_STEPS, "Checking project structure\u2026");
3328
- if (!import_fs_extra2.default.existsSync(import_node_path4.default.join(cwd, "package.json"))) {
4059
+ if (!import_fs_extra6.default.existsSync(import_node_path8.default.join(cwd, "package.json"))) {
3329
4060
  fail("No package.json found. Run this command inside a Kozo project.");
3330
4061
  process.exit(1);
3331
4062
  }
3332
- if (!import_fs_extra2.default.existsSync(import_node_path4.default.join(cwd, "node_modules"))) {
4063
+ if (!import_fs_extra6.default.existsSync(import_node_path8.default.join(cwd, "node_modules"))) {
3333
4064
  fail("Dependencies not installed. Run `npm install` first.");
3334
4065
  process.exit(1);
3335
4066
  }
@@ -3337,7 +4068,7 @@ async function buildCommand(options = {}) {
3337
4068
  currentStep++;
3338
4069
  step(currentStep, TOTAL_STEPS, "Cleaning previous build\u2026");
3339
4070
  try {
3340
- await import_fs_extra2.default.remove(import_node_path4.default.join(cwd, "dist"));
4071
+ await import_fs_extra6.default.remove(import_node_path8.default.join(cwd, "dist"));
3341
4072
  ok("dist/ cleaned");
3342
4073
  } catch (err) {
3343
4074
  fail("Failed to clean dist/", err);
@@ -3347,12 +4078,12 @@ async function buildCommand(options = {}) {
3347
4078
  currentStep++;
3348
4079
  step(currentStep, TOTAL_STEPS, "Generating routes manifest\u2026");
3349
4080
  const routesDirRel = options.routesDir ?? "src/routes";
3350
- const routesDirAbs = import_node_path4.default.join(cwd, routesDirRel);
3351
- if (!import_fs_extra2.default.existsSync(routesDirAbs)) {
4081
+ const routesDirAbs = import_node_path8.default.join(cwd, routesDirRel);
4082
+ if (!import_fs_extra6.default.existsSync(routesDirAbs)) {
3352
4083
  console.log(import_picocolors3.default.dim(` \u26A0 Routes directory not found (${routesDirRel}), skipping manifest.`));
3353
4084
  } else {
3354
4085
  try {
3355
- const manifestOutAbs = options.manifestOut ? import_node_path4.default.join(cwd, options.manifestOut) : import_node_path4.default.join(cwd, "routes-manifest.json");
4086
+ const manifestOutAbs = options.manifestOut ? import_node_path8.default.join(cwd, options.manifestOut) : import_node_path8.default.join(cwd, "routes-manifest.json");
3356
4087
  const manifest = await generateManifest({
3357
4088
  routesDir: routesDirAbs,
3358
4089
  projectRoot: cwd,