@kozojs/cli 0.1.30 → 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 +2389 -1658
  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.7";
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,1571 +1175,2506 @@ 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(),
1800
- };
1801
- posts.push(newPost);
1802
- return newPost;
1803
- };
1804
- `);
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';
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';
1809
1731
 
1810
- export const schema = {
1811
- params: z.object({ id: z.string() }),
1812
- response: PostSchema,
1813
- };
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
+ }
1814
1743
 
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
- };
1820
- `);
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';
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('');
1825
1750
 
1826
- export const schema = {
1827
- params: z.object({ id: z.string() }),
1828
- body: UpdatePostBody,
1829
- response: PostSchema,
1830
- };
1751
+ const { data: posts = [], isLoading } = useQuery(postsQuery);
1831
1752
 
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];
1843
- };
1844
- `);
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';
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
+ };
1848
1765
 
1849
- export const schema = {
1850
- params: z.object({ id: z.string() }),
1851
- response: z.object({ success: z.boolean(), message: z.string() }),
1852
- };
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
+ };
1853
1771
 
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
- };
1860
- `);
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';
1772
+ if (isLoading) return <PostsSkeleton />;
1864
1773
 
1865
- 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),
1871
- };
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>
1872
1780
 
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
- };
1887
- `);
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';
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>
1890
1815
 
1891
- export const schema = {
1892
- body: CreateTaskBody,
1893
- response: TaskSchema,
1894
- };
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';
1895
1856
 
1896
- export default async ({
1897
- body,
1898
- }: {
1899
- body: { title: string; priority?: 'low' | 'medium' | 'high' };
1900
- }) => {
1901
- const newTask = {
1902
- id: String(Date.now()),
1903
- title: body.title,
1904
- completed: false,
1905
- priority: body.priority ?? ('medium' as const),
1906
- createdAt: new Date().toISOString(),
1907
- };
1908
- tasks.push(newTask);
1909
- return newTask;
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)' },
1910
1861
  };
1911
- `);
1912
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "get.ts"), `import { z } from 'zod';
1913
- import { KozoError } from '@kozojs/core';
1914
- import { tasks } from '../../../../data/index.js';
1915
- import { TaskSchema } from '../../../../schemas/index.js';
1916
1862
 
1917
- export const schema = {
1918
- params: z.object({ id: z.string() }),
1919
- response: TaskSchema,
1920
- };
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
+ }
1921
1876
 
1922
- 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;
1926
- };
1927
- `);
1928
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "put.ts"), `import { z } from 'zod';
1929
- import { KozoError } from '@kozojs/core';
1930
- import { tasks } from '../../../../data/index.js';
1931
- import { TaskSchema, UpdateTaskBody } from '../../../../schemas/index.js';
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('');
1932
1883
 
1933
- export const schema = {
1934
- params: z.object({ id: z.string() }),
1935
- body: UpdateTaskBody,
1936
- response: TaskSchema,
1937
- };
1884
+ const { data: tasks = [], isLoading } = useQuery(tasksQuery);
1938
1885
 
1939
- export default async ({
1940
- params,
1941
- body,
1942
- }: {
1943
- params: { id: string };
1944
- body: { title?: string; completed?: boolean; priority?: 'low' | 'medium' | 'high' };
1945
- }) => {
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];
1950
- };
1951
- `);
1952
- await import_fs_extra.default.outputFile(import_node_path.default.join(apiDir, "src", "routes", "api", "tasks", "[id]", "delete.ts"), `import { z } from 'zod';
1953
- import { KozoError } from '@kozojs/core';
1954
- import { tasks } from '../../../../data/index.js';
1955
-
1956
- export const schema = {
1957
- params: z.object({ id: z.string() }),
1958
- response: z.object({ success: z.boolean(), message: z.string() }),
1959
- };
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
+ };
1960
1898
 
1961
- 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' };
1966
- };
1967
- `);
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';
1899
+ const toggleTask = async (id: string | number) => {
1900
+ await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
1901
+ queryClient.invalidateQueries({ queryKey: ['tasks'] });
1902
+ };
1972
1903
 
1973
- export const schema = {
1974
- params: z.object({ id: z.string() }),
1975
- response: TaskSchema,
1976
- };
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
+ };
1977
1909
 
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];
1983
- };
1984
- `);
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';
1910
+ const done = tasks.filter(t => t.completed).length;
1988
1911
 
1989
- const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
1912
+ if (isLoading) return <TasksSkeleton />;
1990
1913
 
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
- ];
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>
1995
1920
 
1996
- 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
- }),
2009
- };
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>
2010
1951
 
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' },
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>
2018
1987
  );
2019
- return { token, user: { email: user.email, role: user.role, name: user.name } };
2020
- };
2021
- `);
2022
- }
2023
1988
  }
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"
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
2047
  }
2048
- };
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';
2048
+ });
2071
2049
 
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";
2050
+ app.listen(PORT, () => {
2051
+ console.log(\`\${isProduction ? 'Production' : 'Dev'} server running at http://localhost:\${PORT}\`);
2052
+ });
2053
+ }
2096
2054
 
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; }
2099
- `);
2100
- await import_fs_extra.default.writeFile(import_node_path.default.join(webDir, "src", "lib", "api.ts"), `const API_BASE = '';
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
+ }
2101
2084
 
2102
- ${auth ? `export function getToken(): string | null {
2103
- return localStorage.getItem('kozo_token');
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
2104
  }
2105
2105
 
2106
- export function setToken(token: string): void {
2107
- localStorage.setItem('kozo_token', token);
2106
+ *,
2107
+ *::before,
2108
+ *::after {
2109
+ box-sizing: border-box;
2110
+ border-color: var(--border);
2108
2111
  }
2109
2112
 
2110
- export function clearToken(): void {
2111
- localStorage.removeItem('kozo_token');
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;
2112
2118
  }
2113
- ` : ""}
2114
- export interface ApiResult<T = unknown> {
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> {
2115
2171
  data: T;
2116
2172
  status: number;
2117
- ms: number;
2173
+ ok: boolean;
2118
2174
  }
2119
2175
 
2120
2176
  export async function apiFetch<T = unknown>(
2121
- path: string,
2122
- options: RequestInit = {},
2123
- ): Promise<ApiResult<T>> {
2124
- const start = performance.now();
2177
+ url: string,
2178
+ options: RequestInit = {}
2179
+ ): Promise<ApiResponse<T>> {
2125
2180
  const headers: Record<string, string> = {
2126
- 'Content-Type': 'application/json',
2127
2181
  ...(options.headers as Record<string, string> ?? {}),
2128
2182
  };
2129
- ${auth ? ` const token = getToken();
2183
+
2184
+ if (!NO_BODY.has((options.method ?? 'GET').toUpperCase())) {
2185
+ headers['Content-Type'] ??= 'application/json';
2186
+ }
2187
+ ${auth ? `
2188
+ const token = getToken();
2130
2189
  if (token) headers['Authorization'] = \`Bearer \${token}\`;
2131
2190
  ` : ""}
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
- }
2136
- `);
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';
2191
+ const res = await fetch(url, { ...options, headers });
2142
2192
 
2143
- const queryClient = new QueryClient({
2144
- defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
2145
- });
2193
+ if (res.status === 401) {
2194
+ window.dispatchEvent(new Event('auth:401'));
2195
+ }
2146
2196
 
2147
- ReactDOM.createRoot(document.getElementById('root')!).render(
2148
- <React.StrictMode>
2149
- <QueryClientProvider client={queryClient}>
2150
- <App />
2151
- </QueryClientProvider>
2152
- </React.StrictMode>
2153
- );
2154
- `);
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());
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;
2161
2203
  }
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
2204
 
2178
- type Page = 'dashboard' | 'users' | 'posts' | 'tasks';
2205
+ if (!res.ok) {
2206
+ throw new ApiError(res.status, \`HTTP \${res.status}\`, data);
2207
+ }
2179
2208
 
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();
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';
2184
2216
 
2185
- useEffect(() => { if (token) { /* token refreshed */ } }, [token]);
2217
+ export interface User {
2218
+ id: string | number;
2219
+ name: string;
2220
+ email: string;
2221
+ role?: 'admin' | 'user' | string;
2222
+ createdAt?: string;
2223
+ }
2186
2224
 
2187
- const handleLogin = (t: string) => setToken(t);
2188
- const handleLogout = () => {
2189
- clearToken();
2190
- setToken(null);
2191
- queryClient.clear();
2192
- };
2193
-
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
- ];
2202
-
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';
2251
-
2252
- interface Props {
2253
- onLogin: (token: string) => void;
2225
+ export interface Post {
2226
+ id: string | number;
2227
+ title: string;
2228
+ content?: string;
2229
+ published?: boolean;
2230
+ authorId?: string | number;
2231
+ createdAt?: string;
2254
2232
  }
2255
2233
 
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);
2261
-
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
- };
2283
-
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>
2294
-
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
- `;
2234
+ export interface Task {
2235
+ id: string | number;
2236
+ title: string;
2237
+ completed: boolean;
2238
+ priority: 'low' | 'medium' | 'high';
2239
+ createdAt?: string;
2331
2240
  }
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';
2336
2241
 
2337
- interface Health { status: string; uptime: number; version: string; }
2338
- interface Stats {
2242
+ export interface Stats {
2339
2243
  users: number;
2340
2244
  posts: number;
2341
2245
  tasks: number;
2342
- publishedPosts: number;
2343
2246
  completedTasks: number;
2344
2247
  }
2345
2248
 
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
- );
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
+ ?? [];
2364
2255
  }
2365
2256
 
2366
- export default function Dashboard() {
2367
- const { data: health } = useQuery({
2368
- queryKey: ['health'],
2369
- queryFn: () => apiFetch<Health>('/api/health').then(r => r.data),
2370
- });
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
+ });
2371
2265
 
2372
- const { data: stats, isLoading } = useQuery({
2373
- queryKey: ['stats'],
2374
- queryFn: () => apiFetch<Stats>('/api/stats').then(r => r.data),
2375
- });
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
+ });
2376
2273
 
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';
2274
+ export const usersQuery = queryOptions({
2275
+ queryKey: ['users'],
2276
+ queryFn: () => fetchList<User>('/api/users'),
2277
+ });
2383
2278
 
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>
2279
+ export const postsQuery = queryOptions({
2280
+ queryKey: ['posts'],
2281
+ queryFn: () => fetchList<Post>('/api/posts'),
2282
+ });
2390
2283
 
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
- )}
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';
2420
2292
 
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>
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
+ />
2435
2303
  );
2436
2304
  }
2437
2305
  `;
2438
2306
  }
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';
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';
2444
2314
 
2445
- interface User {
2446
- id: string | number;
2447
- name: string;
2448
- email: string;
2449
- role?: string;
2450
- createdAt?: string;
2315
+ function revealRoot() {
2316
+ const el = document.getElementById('root');
2317
+ if (el) el.style.visibility = 'visible';
2451
2318
  }
2452
2319
 
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('');
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 */ }
2458
2328
 
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 ?? [];
2464
- },
2465
- });
2329
+ const queryClient = createQueryClient();
2330
+ const rootEl = document.getElementById('root')!;
2466
2331
 
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); }
2477
- };
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
+ }
2478
2337
 
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);
2484
- };
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';
2485
2345
 
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>
2346
+ export interface PageMeta {
2347
+ title: string;
2348
+ description: string;
2349
+ }
2492
2350
 
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>
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
+ };
2521
2357
 
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>
2358
+ export async function render(url: string): Promise<{ html: string; helmet: PageMeta }> {
2359
+ const html = renderToString(
2360
+ <React.StrictMode>
2361
+ <App />
2362
+ </React.StrictMode>
2567
2363
  );
2364
+ const meta = PAGE_META[url] ?? PAGE_META['/'];
2365
+ return { html, helmet: meta };
2568
2366
  }
2569
2367
  `;
2570
2368
  }
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';
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';
2576
2374
 
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;
2375
+ function wrapper({ children }: { children: React.ReactNode }) {
2376
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
2377
+ return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
2585
2378
  }
2586
2379
 
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('');
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
+ }
2903
+ };
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();"}
2956
+ `);
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';
3037
+
3038
+ export const schema = {
3039
+ response: z.object({
3040
+ status: z.string(),
3041
+ timestamp: z.string(),
3042
+ version: z.string(),
3043
+ uptime: z.number(),
3044
+ }),
3045
+ };
3046
+
3047
+ export default async () => ({
3048
+ status: 'ok',
3049
+ timestamp: new Date().toISOString(),
3050
+ version: '1.0.0',
3051
+ uptime: process.uptime(),
3052
+ });
3053
+ `);
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';
3056
+
3057
+ export const schema = {
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
+ }),
3065
+ };
3066
+
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
+ }),
3083
+ };
3084
+
3085
+ export default async ({ query }: { query: { message: string } }) => ({
3086
+ echo: query.message,
3087
+ timestamp: new Date().toISOString(),
3088
+ });
3089
+ `);
3090
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "validate", "post.ts"), `import { z } from 'zod';
3091
+
3092
+ export const schema = {
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
+ }),
3101
+ };
3102
+
3103
+ export default async ({ body }: { body: { email: string; age: number } }) => ({
3104
+ valid: true,
3105
+ data: body,
3106
+ });
3107
+ `);
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';
3111
+
3112
+ export const schema = {
3113
+ response: z.array(UserSchema),
3114
+ };
3115
+
3116
+ export default async () => users;
3117
+ `);
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';
3120
+
3121
+ export const schema = {
3122
+ body: CreateUserBody,
3123
+ response: UserSchema,
3124
+ };
3125
+
3126
+ export default async ({ body }: { body: { name: string; email: string; role?: 'admin' | 'user' } }) => {
3127
+ const newUser = {
3128
+ id: String(Date.now()),
3129
+ name: body.name,
3130
+ email: body.email,
3131
+ role: body.role ?? ('user' as const),
3132
+ createdAt: new Date().toISOString(),
3133
+ };
3134
+ users.push(newUser);
3135
+ return newUser;
3136
+ };
3137
+ `);
3138
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "get.ts"), `import { z } from 'zod';
3139
+ import { KozoError } from '@kozojs/core';
3140
+ import { users } from '../../../../data/index.js';
3141
+ import { UserSchema } from '../../../../schemas/index.js';
3142
+
3143
+ export const schema = {
3144
+ params: z.object({ id: z.string() }),
3145
+ response: UserSchema,
3146
+ };
3147
+
3148
+ export default async ({ params }: { params: { id: string } }) => {
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;
3152
+ };
3153
+ `);
3154
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "put.ts"), `import { z } from 'zod';
3155
+ import { KozoError } from '@kozojs/core';
3156
+ import { users } from '../../../../data/index.js';
3157
+ import { UserSchema, UpdateUserBody } from '../../../../schemas/index.js';
3158
+
3159
+ export const schema = {
3160
+ params: z.object({ id: z.string() }),
3161
+ body: UpdateUserBody,
3162
+ response: UserSchema,
3163
+ };
3164
+
3165
+ export default async ({
3166
+ params,
3167
+ body,
3168
+ }: {
3169
+ params: { id: string };
3170
+ body: { name?: string; email?: string; role?: 'admin' | 'user' };
3171
+ }) => {
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];
3176
+ };
3177
+ `);
3178
+ await import_fs_extra4.default.outputFile(import_node_path4.default.join(apiDir, "src", "routes", "api", "users", "[id]", "delete.ts"), `import { z } from 'zod';
3179
+ import { KozoError } from '@kozojs/core';
3180
+ import { users } from '../../../../data/index.js';
3181
+
3182
+ export const schema = {
3183
+ params: z.object({ id: z.string() }),
3184
+ response: z.object({ success: z.boolean(), message: z.string() }),
3185
+ };
3186
+
3187
+ export default async ({ params }: { params: { id: string } }) => {
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' };
3192
+ };
3193
+ `);
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';
3197
+
3198
+ export const schema = {
3199
+ query: z.object({ published: z.coerce.boolean().optional() }),
3200
+ response: z.array(PostSchema),
3201
+ };
3202
+
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;
3208
+ };
3209
+ `);
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';
3212
+
3213
+ export const schema = {
3214
+ body: CreatePostBody,
3215
+ response: PostSchema,
3216
+ };
3217
+
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(),
3231
+ };
3232
+ posts.push(newPost);
3233
+ return newPost;
3234
+ };
3235
+ `);
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';
3240
+
3241
+ export const schema = {
3242
+ params: z.object({ id: z.string() }),
3243
+ response: PostSchema,
3244
+ };
3245
+
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
+ };
3251
+ `);
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';
3256
+
3257
+ export const schema = {
3258
+ params: z.object({ id: z.string() }),
3259
+ body: UpdatePostBody,
3260
+ response: PostSchema,
3261
+ };
3262
+
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
+ };
3275
+ `);
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';
3279
+
3280
+ export const schema = {
3281
+ params: z.object({ id: z.string() }),
3282
+ response: z.object({ success: z.boolean(), message: z.string() }),
3283
+ };
3284
+
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';
3295
+
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
+ };
3303
+
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';
3321
+
3322
+ export const schema = {
3323
+ body: CreateTaskBody,
3324
+ response: TaskSchema,
3325
+ };
3326
+
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';
3347
+
3348
+ export const schema = {
3349
+ params: z.object({ id: z.string() }),
3350
+ response: TaskSchema,
3351
+ };
3352
+
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';
3363
+
3364
+ export const schema = {
3365
+ params: z.object({ id: z.string() }),
3366
+ body: UpdateTaskBody,
3367
+ response: TaskSchema,
3368
+ };
3369
+
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';
3386
+
3387
+ export const schema = {
3388
+ params: z.object({ id: z.string() }),
3389
+ response: z.object({ success: z.boolean(), message: z.string() }),
3390
+ };
3391
+
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';
3403
+
3404
+ export const schema = {
3405
+ params: z.object({ id: z.string() }),
3406
+ response: TaskSchema,
3407
+ };
3408
+
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';
3419
+
3420
+ const JWT_SECRET = process.env.JWT_SECRET || 'change-me';
3421
+
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
+ ];
3426
+
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
+ };
3441
+
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' },
3449
+ );
3450
+ return { token, user: { email: user.email, role: user.role, name: user.name } };
3451
+ };
3452
+ `);
3453
+ }
3454
+ }
3455
+
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"
3492
+ },
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
+ }
3510
+ };
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"]
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';
3529
+
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
+ });
3538
+ `;
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="}
3542
+
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,