@kozojs/cli 0.1.30 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +2665 -1649
- package/package.json +3 -2
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1244
|
-
await
|
|
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
|
|
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
|
|
1357
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
-
|
|
1565
|
-
|
|
1566
|
-
content: z.string().optional(),
|
|
1567
|
-
published: z.boolean().optional(),
|
|
1568
|
-
});
|
|
1280
|
+
if (!token) return <Login onLogin={handleLogin} />;
|
|
1281
|
+
` : ""}
|
|
1569
1282
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
})
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
|
1673
|
-
|
|
1674
|
-
data:
|
|
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
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
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
|
-
|
|
1768
|
-
query: z.object({ published: z.coerce.boolean().optional() }),
|
|
1769
|
-
response: z.array(PostSchema),
|
|
1770
|
-
};
|
|
1640
|
+
if (isLoading) return <UsersSkeleton />;
|
|
1771
1641
|
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
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
|
|
1816
|
-
const
|
|
1817
|
-
|
|
1818
|
-
|
|
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
|
-
|
|
1827
|
-
params: z.object({ id: z.string() }),
|
|
1828
|
-
body: UpdatePostBody,
|
|
1829
|
-
response: PostSchema,
|
|
1830
|
-
};
|
|
1751
|
+
const { data: posts = [], isLoading } = useQuery(postsQuery);
|
|
1831
1752
|
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
})
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
}
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
-
|
|
1934
|
-
params: z.object({ id: z.string() }),
|
|
1935
|
-
body: UpdateTaskBody,
|
|
1936
|
-
response: TaskSchema,
|
|
1937
|
-
};
|
|
1884
|
+
const { data: tasks = [], isLoading } = useQuery(tasksQuery);
|
|
1938
1885
|
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
})
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1912
|
+
if (isLoading) return <TasksSkeleton />;
|
|
1990
1913
|
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
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
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
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
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
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
|
-
|
|
2103
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
2106
|
+
*,
|
|
2107
|
+
*::before,
|
|
2108
|
+
*::after {
|
|
2109
|
+
box-sizing: border-box;
|
|
2110
|
+
border-color: var(--border);
|
|
2108
2111
|
}
|
|
2109
2112
|
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2173
|
+
ok: boolean;
|
|
2118
2174
|
}
|
|
2119
2175
|
|
|
2120
2176
|
export async function apiFetch<T = unknown>(
|
|
2121
|
-
|
|
2122
|
-
options: RequestInit = {}
|
|
2123
|
-
): Promise<
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2193
|
+
if (res.status === 401) {
|
|
2194
|
+
window.dispatchEvent(new Event('auth:401'));
|
|
2195
|
+
}
|
|
2146
2196
|
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
2205
|
+
if (!res.ok) {
|
|
2206
|
+
throw new ApiError(res.status, \`HTTP \${res.status}\`, data);
|
|
2207
|
+
}
|
|
2179
2208
|
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
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
|
|
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
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
})
|
|
2350
|
-
|
|
2351
|
-
|
|
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
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
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
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
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
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
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
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
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
|
|
2440
|
-
return `import
|
|
2441
|
-
import {
|
|
2442
|
-
import {
|
|
2443
|
-
import {
|
|
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
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
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
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
const
|
|
2457
|
-
|
|
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
|
-
|
|
2460
|
-
|
|
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
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
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
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
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
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
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
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
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
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
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
|
|
2572
|
-
return `import {
|
|
2573
|
-
import {
|
|
2574
|
-
import {
|
|
2575
|
-
import
|
|
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
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
+
}
|
|
2592
2561
|
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
return Array.isArray(res.data) ? res.data : res.data.posts ?? [];
|
|
2562
|
+
export function createQueryClient() {
|
|
2563
|
+
return new QueryClient({
|
|
2564
|
+
defaultOptions: {
|
|
2565
|
+
queries: { refetchOnWindowFocus: false, retry: shouldRetry },
|
|
2598
2566
|
},
|
|
2599
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
|
+
}
|
|
2600
2582
|
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
}
|
|
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';
|
|
2612
2598
|
|
|
2613
|
-
|
|
2614
|
-
setLoading(true);
|
|
2615
|
-
await apiFetch(\`/api/posts/\${id}\`, { method: 'DELETE' });
|
|
2616
|
-
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
|
2617
|
-
setLoading(false);
|
|
2618
|
-
};
|
|
2599
|
+
export type Theme = 'light' | 'dark';
|
|
2619
2600
|
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
</div>
|
|
2601
|
+
interface ThemeState {
|
|
2602
|
+
theme: Theme;
|
|
2603
|
+
setTheme: (t: Theme) => void;
|
|
2604
|
+
toggleTheme: () => void;
|
|
2605
|
+
}
|
|
2626
2606
|
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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>
|
|
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
|
+
);
|
|
2666
2625
|
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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>
|
|
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>
|
|
2705
2655
|
</div>
|
|
2706
2656
|
);
|
|
2707
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
|
+
\`\`\`
|
|
2708
2800
|
`;
|
|
2801
|
+
await import_fs_extra3.default.writeFile(import_node_path3.default.join(projectDir, "README.md"), readme);
|
|
2709
2802
|
}
|
|
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';
|
|
2715
2803
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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';
|
|
3556
|
+
|
|
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}'
|
|
3565
|
+
},
|
|
3566
|
+
servers: [
|
|
3567
|
+
{
|
|
3568
|
+
url: 'http://localhost:3000',
|
|
3569
|
+
description: 'Development server'
|
|
3570
|
+
}
|
|
3571
|
+
]
|
|
3572
|
+
}
|
|
3573
|
+
});
|
|
3574
|
+
|
|
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';
|
|
2723
3579
|
|
|
2724
|
-
const
|
|
2725
|
-
|
|
2726
|
-
medium: 'bg-amber-900 text-amber-300',
|
|
2727
|
-
low: 'bg-slate-700 text-slate-300',
|
|
3580
|
+
export const services = {
|
|
3581
|
+
db
|
|
2728
3582
|
};
|
|
2729
3583
|
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
3584
|
+
// Type augmentation for autocomplete
|
|
3585
|
+
declare module '@kozojs/core' {
|
|
3586
|
+
interface Services {
|
|
3587
|
+
db: typeof db;
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
`;
|
|
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}
|
|
2743
3602
|
|
|
2744
|
-
|
|
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
|
-
};
|
|
3603
|
+
Built with \u{1F525} **Kozo Framework**
|
|
2755
3604
|
|
|
2756
|
-
|
|
2757
|
-
await apiFetch(\`/api/tasks/\${id}/toggle\`, { method: 'PATCH' });
|
|
2758
|
-
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2759
|
-
};
|
|
3605
|
+
## Getting Started
|
|
2760
3606
|
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
|
2765
|
-
setLoading(false);
|
|
2766
|
-
};
|
|
3607
|
+
\`\`\`bash
|
|
3608
|
+
# Install dependencies
|
|
3609
|
+
pnpm install
|
|
2767
3610
|
|
|
2768
|
-
|
|
3611
|
+
# Start development server
|
|
3612
|
+
pnpm dev
|
|
3613
|
+
\`\`\`
|
|
2769
3614
|
|
|
2770
|
-
|
|
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>
|
|
3615
|
+
The server will start at http://localhost:3000
|
|
2776
3616
|
|
|
2777
|
-
|
|
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>
|
|
3617
|
+
## Try the API
|
|
2808
3618
|
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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}
|
|
3619
|
+
\`\`\`bash
|
|
3620
|
+
# Get all users
|
|
3621
|
+
curl http://localhost:3000/users
|
|
2857
3622
|
|
|
2858
|
-
|
|
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"}'
|
|
2859
3627
|
|
|
2860
|
-
|
|
3628
|
+
# Health check
|
|
3629
|
+
curl http://localhost:3000
|
|
2861
3630
|
|
|
2862
|
-
|
|
2863
|
-
|
|
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
|
|
3631
|
+
# Open Swagger UI
|
|
3632
|
+
open http://localhost:3000/swagger
|
|
2881
3633
|
\`\`\`
|
|
2882
3634
|
|
|
2883
|
-
##
|
|
3635
|
+
## API Documentation
|
|
2884
3636
|
|
|
2885
|
-
|
|
2886
|
-
- **
|
|
2887
|
-
- **
|
|
3637
|
+
Once the server is running, visit:
|
|
3638
|
+
- **Swagger UI**: http://localhost:3000/swagger
|
|
3639
|
+
- **OpenAPI JSON**: http://localhost:3000/doc
|
|
2888
3640
|
|
|
2889
|
-
##
|
|
3641
|
+
## Project Structure
|
|
2890
3642
|
|
|
2891
|
-
\`\`\`
|
|
2892
|
-
|
|
3643
|
+
\`\`\`
|
|
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
|
-
##
|
|
3659
|
+
## Database Commands
|
|
2896
3660
|
|
|
2897
3661
|
\`\`\`bash
|
|
2898
|
-
#
|
|
2899
|
-
pnpm
|
|
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
|
-
##
|
|
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
|
-
|
|
2909
|
-
-
|
|
2910
|
-
-
|
|
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
|
|
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
|
|
3118
|
-
var
|
|
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
|
|
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
|
|
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
|
|
3153
|
-
return { path:
|
|
3883
|
+
const path9 = "/" + urlSegments.join("/");
|
|
3884
|
+
return { path: path9, 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,
|
|
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,
|
|
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,
|
|
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,
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 =
|
|
3351
|
-
if (!
|
|
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 ?
|
|
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,
|
|
@@ -3386,6 +4117,285 @@ async function buildCommand(options = {}) {
|
|
|
3386
4117
|
console.log();
|
|
3387
4118
|
}
|
|
3388
4119
|
|
|
4120
|
+
// src/commands/dev.ts
|
|
4121
|
+
var import_child_process = require("child_process");
|
|
4122
|
+
var import_chokidar = __toESM(require("chokidar"));
|
|
4123
|
+
var import_picocolors4 = __toESM(require("picocolors"));
|
|
4124
|
+
var import_fs_extra7 = __toESM(require("fs-extra"));
|
|
4125
|
+
var import_path = __toESM(require("path"));
|
|
4126
|
+
async function devCommand() {
|
|
4127
|
+
console.clear();
|
|
4128
|
+
printBox2("Kozo Development Server");
|
|
4129
|
+
await runStep(1, 4, "Checking project structure...", async () => {
|
|
4130
|
+
if (!import_fs_extra7.default.existsSync(import_path.default.join(process.cwd(), "package.json"))) {
|
|
4131
|
+
throw new Error("No package.json found. Run this command in a Kozo project.");
|
|
4132
|
+
}
|
|
4133
|
+
});
|
|
4134
|
+
await runStep(2, 4, "Checking dependencies...", async () => {
|
|
4135
|
+
if (!import_fs_extra7.default.existsSync(import_path.default.join(process.cwd(), "node_modules"))) {
|
|
4136
|
+
throw new Error("Dependencies not installed. Run: pnpm install");
|
|
4137
|
+
}
|
|
4138
|
+
await sleep(300);
|
|
4139
|
+
});
|
|
4140
|
+
const routesDir = resolveRoutesDir(process.cwd());
|
|
4141
|
+
await runStep(3, 4, "Scanning routes...", async () => {
|
|
4142
|
+
if (routesDir) {
|
|
4143
|
+
await generateManifest({ routesDir, useCache: false, verbose: false });
|
|
4144
|
+
}
|
|
4145
|
+
await sleep(300);
|
|
4146
|
+
});
|
|
4147
|
+
await runStep(4, 4, "Starting server on port 3000...", async () => {
|
|
4148
|
+
await sleep(200);
|
|
4149
|
+
});
|
|
4150
|
+
console.log(import_picocolors4.default.gray("\n\u2139 \u{1F440} Watching for file changes... (Ctrl+C to stop)\n"));
|
|
4151
|
+
console.log(import_picocolors4.default.dim("\u2500".repeat(50)) + "\n");
|
|
4152
|
+
const child = (0, import_child_process.spawn)("npx", ["tsx", "watch", "src/index.ts"], {
|
|
4153
|
+
stdio: "inherit",
|
|
4154
|
+
shell: true,
|
|
4155
|
+
cwd: process.cwd(),
|
|
4156
|
+
env: { ...process.env, FORCE_COLOR: "1" }
|
|
4157
|
+
});
|
|
4158
|
+
child.on("error", (err) => {
|
|
4159
|
+
console.error(import_picocolors4.default.red("\n\u274C Failed to start server"));
|
|
4160
|
+
console.error(err);
|
|
4161
|
+
process.exit(1);
|
|
4162
|
+
});
|
|
4163
|
+
child.on("close", (code) => {
|
|
4164
|
+
if (code === 0 || code === null) {
|
|
4165
|
+
console.log("\n" + import_picocolors4.default.dim("Server stopped"));
|
|
4166
|
+
}
|
|
4167
|
+
process.exit(code ?? 0);
|
|
4168
|
+
});
|
|
4169
|
+
if (routesDir) {
|
|
4170
|
+
startRouteWatcher(routesDir);
|
|
4171
|
+
}
|
|
4172
|
+
process.on("SIGINT", () => {
|
|
4173
|
+
console.log("\n" + import_picocolors4.default.yellow("\u23F9 Stopping Kozo dev server..."));
|
|
4174
|
+
child.kill("SIGTERM");
|
|
4175
|
+
process.exit(0);
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
4178
|
+
function startRouteWatcher(routesDir) {
|
|
4179
|
+
let debounceTimer = null;
|
|
4180
|
+
const watcher = import_chokidar.default.watch(routesDir, {
|
|
4181
|
+
ignored: /(^|[/\\])\..|(\.test\.[tj]s$)|(\.spec\.[tj]s$)/,
|
|
4182
|
+
persistent: true,
|
|
4183
|
+
ignoreInitial: true,
|
|
4184
|
+
// don't fire for files already present at startup
|
|
4185
|
+
awaitWriteFinish: {
|
|
4186
|
+
stabilityThreshold: 80,
|
|
4187
|
+
pollInterval: 50
|
|
4188
|
+
}
|
|
4189
|
+
});
|
|
4190
|
+
const handleChange = (eventType, filePath) => {
|
|
4191
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
4192
|
+
debounceTimer = setTimeout(async () => {
|
|
4193
|
+
try {
|
|
4194
|
+
const manifest = await generateManifest({
|
|
4195
|
+
routesDir,
|
|
4196
|
+
useCache: false,
|
|
4197
|
+
// always regenerate on file change
|
|
4198
|
+
verbose: false
|
|
4199
|
+
});
|
|
4200
|
+
const count = manifest.routes.length;
|
|
4201
|
+
console.log(
|
|
4202
|
+
import_picocolors4.default.cyan("[Kozo]") + " \u2728 Routes updated " + import_picocolors4.default.dim(`(${count} found)`) + import_picocolors4.default.dim(` \u2014 ${import_path.default.relative(process.cwd(), filePath)}`)
|
|
4203
|
+
);
|
|
4204
|
+
} catch (err) {
|
|
4205
|
+
console.error(
|
|
4206
|
+
import_picocolors4.default.red("[Kozo] \u274C Failed to regenerate routes manifest:"),
|
|
4207
|
+
err.message
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
}, 120);
|
|
4211
|
+
};
|
|
4212
|
+
watcher.on("add", (p3) => handleChange("add", p3)).on("change", (p3) => handleChange("change", p3)).on("unlink", (p3) => handleChange("unlink", p3)).on("error", (err) => console.error(import_picocolors4.default.red("[Kozo] Watcher error:"), err));
|
|
4213
|
+
return watcher;
|
|
4214
|
+
}
|
|
4215
|
+
function resolveRoutesDir(cwd) {
|
|
4216
|
+
const candidates = [
|
|
4217
|
+
import_path.default.join(cwd, "src", "routes"),
|
|
4218
|
+
import_path.default.join(cwd, "routes"),
|
|
4219
|
+
import_path.default.join(cwd, "src", "app", "routes"),
|
|
4220
|
+
import_path.default.join(cwd, "app", "routes")
|
|
4221
|
+
];
|
|
4222
|
+
for (const candidate of candidates) {
|
|
4223
|
+
if (import_fs_extra7.default.existsSync(candidate) && import_fs_extra7.default.statSync(candidate).isDirectory()) {
|
|
4224
|
+
return candidate;
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
return null;
|
|
4228
|
+
}
|
|
4229
|
+
function printBox2(title) {
|
|
4230
|
+
const width = 50;
|
|
4231
|
+
const pad = Math.floor((width - title.length) / 2);
|
|
4232
|
+
const line = "\u2500".repeat(width);
|
|
4233
|
+
console.log(import_picocolors4.default.cyan("\u250C" + line + "\u2510"));
|
|
4234
|
+
console.log(import_picocolors4.default.cyan("\u2502") + " ".repeat(pad) + import_picocolors4.default.bold(title) + " ".repeat(width - pad - title.length) + import_picocolors4.default.cyan("\u2502"));
|
|
4235
|
+
console.log(import_picocolors4.default.cyan("\u2514" + line + "\u2518"));
|
|
4236
|
+
console.log();
|
|
4237
|
+
}
|
|
4238
|
+
async function runStep(step2, total, label, fn) {
|
|
4239
|
+
const prefix = import_picocolors4.default.dim(`[${step2}/${total}]`);
|
|
4240
|
+
process.stdout.write(`${prefix} ${label}`);
|
|
4241
|
+
try {
|
|
4242
|
+
await fn();
|
|
4243
|
+
process.stdout.write(" " + import_picocolors4.default.green("\u2713") + "\n");
|
|
4244
|
+
} catch (err) {
|
|
4245
|
+
process.stdout.write(" " + import_picocolors4.default.red("\u2717") + "\n");
|
|
4246
|
+
console.error(import_picocolors4.default.red(`
|
|
4247
|
+
Error: ${err.message}`));
|
|
4248
|
+
process.exit(1);
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
function sleep(ms) {
|
|
4252
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// src/commands/generate.ts
|
|
4256
|
+
var p2 = __toESM(require("@clack/prompts"));
|
|
4257
|
+
var import_picocolors5 = __toESM(require("picocolors"));
|
|
4258
|
+
var import_fs_extra8 = __toESM(require("fs-extra"));
|
|
4259
|
+
var import_node_path9 = __toESM(require("path"));
|
|
4260
|
+
var ROUTE_TEMPLATE = `import { z } from 'zod';
|
|
4261
|
+
import type { HandlerContext } from '@kozo/core';
|
|
4262
|
+
|
|
4263
|
+
// Validation schema (optional)
|
|
4264
|
+
export const schema = {
|
|
4265
|
+
body: z.object({
|
|
4266
|
+
// Define your schema here
|
|
4267
|
+
})
|
|
4268
|
+
};
|
|
4269
|
+
|
|
4270
|
+
type Body = z.infer<typeof schema.body>;
|
|
4271
|
+
|
|
4272
|
+
export default async ({ body, services }: HandlerContext<Body>) => {
|
|
4273
|
+
// TODO: Implement handler
|
|
4274
|
+
return { message: 'Not implemented' };
|
|
4275
|
+
};
|
|
4276
|
+
`;
|
|
4277
|
+
var GET_ROUTE_TEMPLATE = `import type { HandlerContext } from '@kozo/core';
|
|
4278
|
+
|
|
4279
|
+
export default async ({ params, services }: HandlerContext) => {
|
|
4280
|
+
// TODO: Implement handler
|
|
4281
|
+
return { message: 'Not implemented' };
|
|
4282
|
+
};
|
|
4283
|
+
`;
|
|
4284
|
+
var MIDDLEWARE_TEMPLATE = `import type { Context, Next } from 'hono';
|
|
4285
|
+
|
|
4286
|
+
export async function {{name}}(c: Context, next: Next) {
|
|
4287
|
+
// Before handler
|
|
4288
|
+
console.log('{{name}} middleware - before');
|
|
4289
|
+
|
|
4290
|
+
await next();
|
|
4291
|
+
|
|
4292
|
+
// After handler
|
|
4293
|
+
console.log('{{name}} middleware - after');
|
|
4294
|
+
}
|
|
4295
|
+
`;
|
|
4296
|
+
async function generateCommand(type, name) {
|
|
4297
|
+
if (!type) {
|
|
4298
|
+
p2.log.error("Please specify what to generate: route, middleware");
|
|
4299
|
+
process.exit(1);
|
|
4300
|
+
}
|
|
4301
|
+
switch (type.toLowerCase()) {
|
|
4302
|
+
case "route":
|
|
4303
|
+
case "r":
|
|
4304
|
+
await generateRoute(name);
|
|
4305
|
+
break;
|
|
4306
|
+
case "middleware":
|
|
4307
|
+
case "mw":
|
|
4308
|
+
await generateMiddleware(name);
|
|
4309
|
+
break;
|
|
4310
|
+
default:
|
|
4311
|
+
p2.log.error(`Unknown generator: ${type}`);
|
|
4312
|
+
p2.log.info("Available: route, middleware");
|
|
4313
|
+
process.exit(1);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
async function generateRoute(routePath) {
|
|
4317
|
+
let targetPath = routePath;
|
|
4318
|
+
if (!targetPath) {
|
|
4319
|
+
const result = await p2.text({
|
|
4320
|
+
message: "Route path (e.g., users/profile)",
|
|
4321
|
+
placeholder: "users/[id]",
|
|
4322
|
+
validate: (v) => !v ? "Path is required" : void 0
|
|
4323
|
+
});
|
|
4324
|
+
if (p2.isCancel(result)) {
|
|
4325
|
+
p2.cancel("Cancelled");
|
|
4326
|
+
process.exit(0);
|
|
4327
|
+
}
|
|
4328
|
+
targetPath = result;
|
|
4329
|
+
}
|
|
4330
|
+
const method = await p2.select({
|
|
4331
|
+
message: "HTTP method",
|
|
4332
|
+
options: [
|
|
4333
|
+
{ value: "get", label: "GET" },
|
|
4334
|
+
{ value: "post", label: "POST" },
|
|
4335
|
+
{ value: "put", label: "PUT" },
|
|
4336
|
+
{ value: "patch", label: "PATCH" },
|
|
4337
|
+
{ value: "delete", label: "DELETE" }
|
|
4338
|
+
]
|
|
4339
|
+
});
|
|
4340
|
+
if (p2.isCancel(method)) {
|
|
4341
|
+
p2.cancel("Cancelled");
|
|
4342
|
+
process.exit(0);
|
|
4343
|
+
}
|
|
4344
|
+
const routesDir = import_node_path9.default.join(process.cwd(), "src", "routes");
|
|
4345
|
+
const filePath = import_node_path9.default.join(routesDir, targetPath, `${method}.ts`);
|
|
4346
|
+
if (await import_fs_extra8.default.pathExists(filePath)) {
|
|
4347
|
+
const overwrite = await p2.confirm({
|
|
4348
|
+
message: `File ${filePath} already exists. Overwrite?`,
|
|
4349
|
+
initialValue: false
|
|
4350
|
+
});
|
|
4351
|
+
if (p2.isCancel(overwrite) || !overwrite) {
|
|
4352
|
+
p2.cancel("Cancelled");
|
|
4353
|
+
process.exit(0);
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
await import_fs_extra8.default.ensureDir(import_node_path9.default.dirname(filePath));
|
|
4357
|
+
const template = method === "get" ? GET_ROUTE_TEMPLATE : ROUTE_TEMPLATE;
|
|
4358
|
+
await import_fs_extra8.default.writeFile(filePath, template);
|
|
4359
|
+
const relativePath = import_node_path9.default.relative(process.cwd(), filePath);
|
|
4360
|
+
p2.log.success(`Created ${import_picocolors5.default.cyan(relativePath)}`);
|
|
4361
|
+
const urlPath = "/" + targetPath.replace(/\[([^\]]+)\]/g, ":$1");
|
|
4362
|
+
console.log(`
|
|
4363
|
+
${import_picocolors5.default.bold(String(method).toUpperCase())} ${import_picocolors5.default.green(urlPath)}
|
|
4364
|
+
`);
|
|
4365
|
+
}
|
|
4366
|
+
async function generateMiddleware(middlewareName) {
|
|
4367
|
+
let name = middlewareName;
|
|
4368
|
+
if (!name) {
|
|
4369
|
+
const result = await p2.text({
|
|
4370
|
+
message: "Middleware name",
|
|
4371
|
+
placeholder: "auth",
|
|
4372
|
+
validate: (v) => !v ? "Name is required" : void 0
|
|
4373
|
+
});
|
|
4374
|
+
if (p2.isCancel(result)) {
|
|
4375
|
+
p2.cancel("Cancelled");
|
|
4376
|
+
process.exit(0);
|
|
4377
|
+
}
|
|
4378
|
+
name = result;
|
|
4379
|
+
}
|
|
4380
|
+
const middlewareDir = import_node_path9.default.join(process.cwd(), "src", "middleware");
|
|
4381
|
+
const filePath = import_node_path9.default.join(middlewareDir, `${name}.ts`);
|
|
4382
|
+
if (await import_fs_extra8.default.pathExists(filePath)) {
|
|
4383
|
+
const overwrite = await p2.confirm({
|
|
4384
|
+
message: `File ${filePath} already exists. Overwrite?`,
|
|
4385
|
+
initialValue: false
|
|
4386
|
+
});
|
|
4387
|
+
if (p2.isCancel(overwrite) || !overwrite) {
|
|
4388
|
+
p2.cancel("Cancelled");
|
|
4389
|
+
process.exit(0);
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4392
|
+
await import_fs_extra8.default.ensureDir(middlewareDir);
|
|
4393
|
+
const content = MIDDLEWARE_TEMPLATE.replace(/\{\{name\}\}/g, name);
|
|
4394
|
+
await import_fs_extra8.default.writeFile(filePath, content);
|
|
4395
|
+
const relativePath = import_node_path9.default.relative(process.cwd(), filePath);
|
|
4396
|
+
p2.log.success(`Created ${import_picocolors5.default.cyan(relativePath)}`);
|
|
4397
|
+
}
|
|
4398
|
+
|
|
3389
4399
|
// src/index.ts
|
|
3390
4400
|
var program = new import_commander.Command();
|
|
3391
4401
|
program.name("kozo").description("CLI to scaffold new Kozo Framework projects").version("0.2.6");
|
|
@@ -3402,4 +4412,10 @@ program.command("build").description("Build the project (generates routes manife
|
|
|
3402
4412
|
tsupArgs
|
|
3403
4413
|
});
|
|
3404
4414
|
});
|
|
4415
|
+
program.command("dev").description("Start development server with hot reload and route watcher").action(async () => {
|
|
4416
|
+
await devCommand();
|
|
4417
|
+
});
|
|
4418
|
+
program.command("generate [type] [name]").alias("g").description("Generate scaffolding: route, middleware").action(async (type, name) => {
|
|
4419
|
+
await generateCommand(type ?? "", name);
|
|
4420
|
+
});
|
|
3405
4421
|
program.parse();
|