tina4-nodejs 3.10.42 → 3.10.45
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/CLAUDE.md +1 -1
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +48 -4
- package/packages/cli/src/commands/generate.ts +800 -103
- package/packages/cli/src/commands/serve.ts +1 -0
- package/packages/core/src/ai.ts +488 -108
- package/packages/core/src/devAdmin.ts +634 -98
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/metrics.ts +39 -0
- package/packages/core/src/server.ts +18 -2
- package/packages/orm/src/adapters/sqlite.ts +7 -3
- package/packages/orm/src/baseModel.ts +17 -5
|
@@ -1,43 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI command: generate —
|
|
2
|
+
* CLI command: generate — Rich scaffolding for models, routes, migrations,
|
|
3
|
+
* middleware, tests, forms, views, CRUD stacks, and auth.
|
|
3
4
|
*
|
|
4
5
|
* Usage:
|
|
5
|
-
* tina4nodejs generate model
|
|
6
|
-
* tina4nodejs generate route
|
|
7
|
-
* tina4nodejs generate
|
|
6
|
+
* tina4nodejs generate model Product --fields "name:string,price:float"
|
|
7
|
+
* tina4nodejs generate route products --model Product
|
|
8
|
+
* tina4nodejs generate crud Product --fields "name:string,price:float"
|
|
9
|
+
* tina4nodejs generate migration create_product
|
|
8
10
|
* tina4nodejs generate middleware Auth
|
|
11
|
+
* tina4nodejs generate test products --model Product
|
|
12
|
+
* tina4nodejs generate form Product --fields "name:string,price:float"
|
|
13
|
+
* tina4nodejs generate view Product --fields "name:string,price:float"
|
|
14
|
+
* tina4nodejs generate auth
|
|
9
15
|
*/
|
|
10
16
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
11
17
|
import { join, resolve } from "node:path";
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
generateMiddleware(name);
|
|
32
|
-
break;
|
|
33
|
-
default:
|
|
34
|
-
console.error(` Unknown generator: ${what}`);
|
|
35
|
-
console.error(" Available: model, route, migration, middleware");
|
|
36
|
-
process.exit(1);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Helpers ──────────────────────────────────────────────────────
|
|
19
|
+
// ── Field type mapping ──────────────────────────────────────────────
|
|
20
|
+
const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: string }> = {
|
|
21
|
+
string: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
22
|
+
str: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
23
|
+
int: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
|
|
24
|
+
integer: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" },
|
|
25
|
+
float: { orm: '"number"', sql: "REAL", defaultVal: "0" },
|
|
26
|
+
number: { orm: '"number"', sql: "REAL", defaultVal: "0" },
|
|
27
|
+
numeric: { orm: '"number"', sql: "REAL", defaultVal: "0" },
|
|
28
|
+
decimal: { orm: '"number"', sql: "REAL", defaultVal: "0" },
|
|
29
|
+
bool: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
|
|
30
|
+
boolean: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" },
|
|
31
|
+
text: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
32
|
+
datetime: { orm: '"datetime"', sql: "TEXT", defaultVal: "NULL" },
|
|
33
|
+
blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
41
37
|
|
|
42
38
|
function ensureDir(dir: string): void {
|
|
43
39
|
if (!existsSync(dir)) {
|
|
@@ -47,21 +43,28 @@ function ensureDir(dir: string): void {
|
|
|
47
43
|
|
|
48
44
|
function writeFileSafe(path: string, content: string): void {
|
|
49
45
|
if (existsSync(path)) {
|
|
50
|
-
console.
|
|
51
|
-
|
|
46
|
+
console.log(` File already exists: ${path}`);
|
|
47
|
+
return;
|
|
52
48
|
}
|
|
53
49
|
writeFileSync(path, content, "utf-8");
|
|
54
50
|
console.log(` Created ${path}`);
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
function toSnake(name: string): string {
|
|
58
|
-
return name
|
|
53
|
+
export function toSnake(name: string): string {
|
|
54
|
+
return name
|
|
55
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
56
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function toTableName(name: string): string {
|
|
61
|
+
return toSnake(name);
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
function toPlural(name: string): string {
|
|
62
65
|
const lower = name.toLowerCase();
|
|
63
66
|
if (lower.endsWith("s")) return lower;
|
|
64
|
-
if (lower.endsWith("y")) return lower.slice(0, -1) + "ies";
|
|
67
|
+
if (lower.endsWith("y") && !/[aeiou]y$/i.test(lower)) return lower.slice(0, -1) + "ies";
|
|
65
68
|
return lower + "s";
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -69,157 +72,452 @@ function toCamel(name: string): string {
|
|
|
69
72
|
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
76
|
+
if (!fieldsStr || !fieldsStr.trim()) return [];
|
|
77
|
+
const result: Array<[string, string]> = [];
|
|
78
|
+
for (const part of fieldsStr.split(",")) {
|
|
79
|
+
const trimmed = part.trim();
|
|
80
|
+
if (trimmed.includes(":")) {
|
|
81
|
+
const [fname, ftype] = trimmed.split(":", 2);
|
|
82
|
+
result.push([fname.trim(), ftype.trim().toLowerCase()]);
|
|
83
|
+
} else if (trimmed) {
|
|
84
|
+
result.push([trimmed, "string"]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
91
|
+
const flags: Record<string, string | boolean> = {};
|
|
92
|
+
const positional: string[] = [];
|
|
93
|
+
let i = 0;
|
|
94
|
+
while (i < args.length) {
|
|
95
|
+
if (args[i].startsWith("--")) {
|
|
96
|
+
const key = args[i].slice(2);
|
|
97
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
98
|
+
flags[key] = args[i + 1];
|
|
99
|
+
i += 2;
|
|
100
|
+
} else {
|
|
101
|
+
flags[key] = true;
|
|
102
|
+
i += 1;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
positional.push(args[i]);
|
|
106
|
+
i += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { flags, positional };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function timestamp(): string {
|
|
113
|
+
const now = new Date();
|
|
114
|
+
return (
|
|
115
|
+
now.getFullYear().toString() +
|
|
116
|
+
String(now.getMonth() + 1).padStart(2, "0") +
|
|
117
|
+
String(now.getDate()).padStart(2, "0") +
|
|
118
|
+
String(now.getHours()).padStart(2, "0") +
|
|
119
|
+
String(now.getMinutes()).padStart(2, "0") +
|
|
120
|
+
String(now.getSeconds()).padStart(2, "0")
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isoNow(): string {
|
|
125
|
+
return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Main entry point ────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
|
|
131
|
+
if (!what) {
|
|
132
|
+
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
133
|
+
console.error(" Generators: model, route, crud, migration, middleware, test, form, view, auth");
|
|
134
|
+
console.error(' Options: --fields "name:string,price:float" --model ModelName');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Auth doesn't require a name
|
|
139
|
+
const noNameGenerators = new Set(["auth"]);
|
|
140
|
+
if (!noNameGenerators.has(what) && !name) {
|
|
141
|
+
console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { flags } = parseCliArgs(extraArgs);
|
|
146
|
+
|
|
147
|
+
switch (what) {
|
|
148
|
+
case "model":
|
|
149
|
+
generateModel(name, flags);
|
|
150
|
+
break;
|
|
151
|
+
case "route":
|
|
152
|
+
generateRoute(name, flags);
|
|
153
|
+
break;
|
|
154
|
+
case "crud":
|
|
155
|
+
generateCrud(name, flags);
|
|
156
|
+
break;
|
|
157
|
+
case "migration":
|
|
158
|
+
generateMigration(name, flags);
|
|
159
|
+
break;
|
|
160
|
+
case "middleware":
|
|
161
|
+
generateMiddleware(name, flags);
|
|
162
|
+
break;
|
|
163
|
+
case "test":
|
|
164
|
+
generateTest(name, flags);
|
|
165
|
+
break;
|
|
166
|
+
case "form":
|
|
167
|
+
generateForm(name, flags);
|
|
168
|
+
break;
|
|
169
|
+
case "view":
|
|
170
|
+
generateView(name, flags);
|
|
171
|
+
break;
|
|
172
|
+
case "auth":
|
|
173
|
+
generateAuth(flags);
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
console.error(` Unknown generator: ${what}`);
|
|
177
|
+
console.error(" Available: model, route, crud, migration, middleware, test, form, view, auth");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Model ───────────────────────────────────────────────────────────
|
|
73
183
|
|
|
74
|
-
function generateModel(name: string): void {
|
|
184
|
+
function generateModel(name: string, flags: Record<string, string | boolean>): void {
|
|
185
|
+
const fields = parseFields((flags.fields as string) || "");
|
|
186
|
+
const table = toTableName(name);
|
|
75
187
|
const dir = resolve("src/models");
|
|
76
188
|
ensureDir(dir);
|
|
77
|
-
|
|
78
|
-
const table = toPlural(name);
|
|
79
189
|
const path = join(dir, `${name}.ts`);
|
|
80
190
|
|
|
191
|
+
// Build field definitions
|
|
192
|
+
const fieldLines: string[] = [
|
|
193
|
+
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
|
|
194
|
+
];
|
|
195
|
+
if (fields.length > 0) {
|
|
196
|
+
for (const [fname, ftype] of fields) {
|
|
197
|
+
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
198
|
+
fieldLines.push(` ${fname}: { type: ${info.orm} as const },`);
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
fieldLines.push(` name: { type: "string" as const },`);
|
|
202
|
+
}
|
|
203
|
+
fieldLines.push(` created_at: { type: "datetime" as const },`);
|
|
204
|
+
|
|
81
205
|
const content = `import { BaseModel } from "tina4-nodejs";
|
|
82
206
|
|
|
83
|
-
export class ${name} extends BaseModel {
|
|
207
|
+
export default class ${name} extends BaseModel {
|
|
84
208
|
static tableName = "${table}";
|
|
85
209
|
static fields = {
|
|
86
|
-
|
|
87
|
-
name: { type: "string" },
|
|
88
|
-
email: { type: "string" },
|
|
210
|
+
${fieldLines.join("\n")}
|
|
89
211
|
};
|
|
90
212
|
}
|
|
91
213
|
`;
|
|
92
214
|
|
|
93
215
|
writeFileSafe(path, content);
|
|
216
|
+
|
|
217
|
+
// Generate matching migration unless --no-migration
|
|
218
|
+
if (!flags["no-migration"]) {
|
|
219
|
+
generateMigration(`create_${table}`, flags, fields.length > 0 ? fields : undefined, table);
|
|
220
|
+
}
|
|
94
221
|
}
|
|
95
222
|
|
|
96
|
-
// ── Route
|
|
223
|
+
// ── Route ───────────────────────────────────────────────────────────
|
|
97
224
|
|
|
98
|
-
function generateRoute(name: string): void {
|
|
225
|
+
function generateRoute(name: string, flags: Record<string, string | boolean>): void {
|
|
99
226
|
const routePath = name.replace(/^\//, "");
|
|
100
|
-
const
|
|
227
|
+
const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath;
|
|
228
|
+
const model = flags.model as string | undefined;
|
|
229
|
+
const base = resolve("src/routes/api", routePath);
|
|
101
230
|
const idDir = join(base, "[id]");
|
|
102
231
|
ensureDir(base);
|
|
103
232
|
ensureDir(idDir);
|
|
104
233
|
|
|
234
|
+
const modelImport = model
|
|
235
|
+
? `import ${model} from "../../../models/${model}.js";\n`
|
|
236
|
+
: "";
|
|
237
|
+
|
|
105
238
|
// GET list
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
239
|
+
if (model) {
|
|
240
|
+
writeFileSafe(
|
|
241
|
+
join(base, "get.ts"),
|
|
242
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
243
|
+
${modelImport}
|
|
244
|
+
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
109
245
|
|
|
110
|
-
export
|
|
246
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
247
|
+
const page = parseInt(req.query?.page as string) || 1;
|
|
248
|
+
const limit = parseInt(req.query?.limit as string) || 20;
|
|
249
|
+
const offset = (page - 1) * limit;
|
|
250
|
+
const results = await ${model}.select("SELECT * FROM ${toTableName(model)} LIMIT ? OFFSET ?", [limit, offset]);
|
|
251
|
+
res.json({ data: results, page, limit });
|
|
252
|
+
}
|
|
253
|
+
`,
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
writeFileSafe(
|
|
257
|
+
join(base, "get.ts"),
|
|
258
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
259
|
+
|
|
260
|
+
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
111
261
|
|
|
112
262
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
113
263
|
res.json({ data: [] });
|
|
114
264
|
}
|
|
115
265
|
`,
|
|
116
|
-
|
|
266
|
+
);
|
|
267
|
+
}
|
|
117
268
|
|
|
118
269
|
// POST create
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
270
|
+
if (model) {
|
|
271
|
+
writeFileSafe(
|
|
272
|
+
join(base, "post.ts"),
|
|
273
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
274
|
+
${modelImport}
|
|
275
|
+
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
122
276
|
|
|
123
|
-
export
|
|
277
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
278
|
+
const item = new ${model}(req.body);
|
|
279
|
+
await item.save();
|
|
280
|
+
res.json({ data: item.toJSON() }, 201);
|
|
281
|
+
}
|
|
282
|
+
`,
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
writeFileSafe(
|
|
286
|
+
join(base, "post.ts"),
|
|
287
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
288
|
+
|
|
289
|
+
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
124
290
|
|
|
125
291
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
126
|
-
res.json({
|
|
292
|
+
res.json({ data: req.body }, 201);
|
|
127
293
|
}
|
|
128
294
|
`,
|
|
129
|
-
|
|
295
|
+
);
|
|
296
|
+
}
|
|
130
297
|
|
|
131
298
|
// GET by id
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
299
|
+
if (model) {
|
|
300
|
+
writeFileSafe(
|
|
301
|
+
join(idDir, "get.ts"),
|
|
302
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
303
|
+
${modelImport}
|
|
304
|
+
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
305
|
+
|
|
306
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
307
|
+
const { id } = req.params;
|
|
308
|
+
const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
|
|
309
|
+
if (!item) {
|
|
310
|
+
res.json({ error: "Not found" }, 404);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
res.json({ data: item });
|
|
314
|
+
}
|
|
315
|
+
`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
writeFileSafe(
|
|
319
|
+
join(idDir, "get.ts"),
|
|
320
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
135
321
|
|
|
136
|
-
export const meta = { summary: "Get by
|
|
322
|
+
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
137
323
|
|
|
138
324
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
139
325
|
const { id } = req.params;
|
|
140
326
|
res.json({ data: { id } });
|
|
141
327
|
}
|
|
142
328
|
`,
|
|
143
|
-
|
|
329
|
+
);
|
|
330
|
+
}
|
|
144
331
|
|
|
145
332
|
// PUT by id
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
333
|
+
if (model) {
|
|
334
|
+
writeFileSafe(
|
|
335
|
+
join(idDir, "put.ts"),
|
|
336
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
337
|
+
${modelImport}
|
|
338
|
+
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
339
|
+
|
|
340
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
341
|
+
const { id } = req.params;
|
|
342
|
+
const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
|
|
343
|
+
if (!item) {
|
|
344
|
+
res.json({ error: "Not found" }, 404);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const updated = new ${model}({ ...item, ...req.body, id });
|
|
348
|
+
await updated.save();
|
|
349
|
+
res.json({ data: updated.toJSON() });
|
|
350
|
+
}
|
|
351
|
+
`,
|
|
352
|
+
);
|
|
353
|
+
} else {
|
|
354
|
+
writeFileSafe(
|
|
355
|
+
join(idDir, "put.ts"),
|
|
356
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
149
357
|
|
|
150
|
-
export const meta = { summary: "Update by
|
|
358
|
+
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
151
359
|
|
|
152
360
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
153
361
|
const { id } = req.params;
|
|
154
|
-
res.json({
|
|
362
|
+
res.json({ data: { ...req.body, id } });
|
|
155
363
|
}
|
|
156
364
|
`,
|
|
157
|
-
|
|
365
|
+
);
|
|
366
|
+
}
|
|
158
367
|
|
|
159
368
|
// DELETE by id
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
369
|
+
if (model) {
|
|
370
|
+
writeFileSafe(
|
|
371
|
+
join(idDir, "delete.ts"),
|
|
372
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
373
|
+
${modelImport}
|
|
374
|
+
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
163
375
|
|
|
164
|
-
export
|
|
376
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
377
|
+
const { id } = req.params;
|
|
378
|
+
const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
|
|
379
|
+
if (!item) {
|
|
380
|
+
res.json({ error: "Not found" }, 404);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const record = new ${model}(item);
|
|
384
|
+
await record.delete();
|
|
385
|
+
res.json({ message: "deleted", id });
|
|
386
|
+
}
|
|
387
|
+
`,
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
writeFileSafe(
|
|
391
|
+
join(idDir, "delete.ts"),
|
|
392
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
393
|
+
|
|
394
|
+
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
165
395
|
|
|
166
396
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
167
397
|
const { id } = req.params;
|
|
168
398
|
res.json({ message: "deleted", id });
|
|
169
399
|
}
|
|
170
400
|
`,
|
|
171
|
-
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── CRUD ────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
function generateCrud(name: string, flags: Record<string, string | boolean>): void {
|
|
408
|
+
const table = toTableName(name);
|
|
409
|
+
const routeName = toPlural(table);
|
|
410
|
+
|
|
411
|
+
console.log(`\n Generating CRUD for ${name}...\n`);
|
|
412
|
+
|
|
413
|
+
// 1. Model + migration
|
|
414
|
+
generateModel(name, flags);
|
|
415
|
+
|
|
416
|
+
// 2. Routes with model
|
|
417
|
+
generateRoute(routeName, { ...flags, model: name });
|
|
418
|
+
|
|
419
|
+
// 3. Form
|
|
420
|
+
generateForm(name, flags);
|
|
421
|
+
|
|
422
|
+
// 4. View (list + detail)
|
|
423
|
+
generateView(name, flags);
|
|
424
|
+
|
|
425
|
+
// 5. Test
|
|
426
|
+
generateTest(routeName, { ...flags, model: name });
|
|
427
|
+
|
|
428
|
+
console.log(`\n CRUD generation complete for ${name}.`);
|
|
429
|
+
console.log(" Run: tina4nodejs migrate");
|
|
430
|
+
console.log(" Visit: /swagger to see the API docs");
|
|
172
431
|
}
|
|
173
432
|
|
|
174
|
-
// ── Migration
|
|
433
|
+
// ── Migration ───────────────────────────────────────────────────────
|
|
175
434
|
|
|
176
|
-
function generateMigration(
|
|
435
|
+
function generateMigration(
|
|
436
|
+
name: string,
|
|
437
|
+
flags: Record<string, string | boolean>,
|
|
438
|
+
fieldsOverride?: Array<[string, string]>,
|
|
439
|
+
tableOverride?: string,
|
|
440
|
+
): void {
|
|
441
|
+
const ts = timestamp();
|
|
177
442
|
const dir = resolve("migrations");
|
|
178
443
|
ensureDir(dir);
|
|
179
444
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
445
|
+
// Determine table name
|
|
446
|
+
let table: string;
|
|
447
|
+
if (tableOverride) {
|
|
448
|
+
table = tableOverride;
|
|
449
|
+
} else {
|
|
450
|
+
table = name
|
|
451
|
+
.replace(/^create_/, "")
|
|
452
|
+
.replace(/^add_/, "")
|
|
453
|
+
.replace(/^drop_/, "");
|
|
454
|
+
table = toSnake(table);
|
|
455
|
+
}
|
|
188
456
|
|
|
189
|
-
|
|
190
|
-
const
|
|
457
|
+
// Build SQL columns from fields
|
|
458
|
+
const fields = fieldsOverride || parseFields((flags.fields as string) || "");
|
|
459
|
+
const isCreate = name.startsWith("create_") || fieldsOverride !== undefined;
|
|
460
|
+
|
|
461
|
+
const fileName = `${ts}_${name}.sql`;
|
|
191
462
|
const path = join(dir, fileName);
|
|
192
463
|
|
|
193
|
-
|
|
194
|
-
|
|
464
|
+
let upSql: string;
|
|
465
|
+
let downSql: string;
|
|
466
|
+
|
|
467
|
+
if (isCreate) {
|
|
468
|
+
const colLines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"];
|
|
469
|
+
for (const [fname, ftype] of fields) {
|
|
470
|
+
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
471
|
+
const defaultClause = info.defaultVal !== "NULL" ? ` DEFAULT ${info.defaultVal}` : "";
|
|
472
|
+
colLines.push(` ${fname} ${info.sql}${defaultClause}`);
|
|
473
|
+
}
|
|
474
|
+
colLines.push(" created_at TEXT DEFAULT CURRENT_TIMESTAMP");
|
|
475
|
+
|
|
476
|
+
upSql = `CREATE TABLE IF NOT EXISTS ${table} (\n${colLines.join(",\n")}\n);`;
|
|
477
|
+
downSql = `DROP TABLE IF EXISTS ${table};`;
|
|
478
|
+
} else {
|
|
479
|
+
upSql = `-- Write your UP migration SQL here\n-- Example: ALTER TABLE ${table} ADD COLUMN new_col TEXT DEFAULT '';`;
|
|
480
|
+
downSql = `-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE ${table} DROP COLUMN new_col;`;
|
|
481
|
+
}
|
|
195
482
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
`;
|
|
483
|
+
const now = isoNow();
|
|
484
|
+
const content =
|
|
485
|
+
`-- Migration: ${name}\n` +
|
|
486
|
+
`-- Created: ${now}\n\n` +
|
|
487
|
+
`-- UP\n${upSql}\n\n` +
|
|
488
|
+
`-- DOWN\n${downSql}\n`;
|
|
203
489
|
|
|
204
490
|
writeFileSafe(path, content);
|
|
205
|
-
}
|
|
206
491
|
|
|
207
|
-
//
|
|
492
|
+
// Also create .down.sql
|
|
493
|
+
const downPath = join(dir, `${ts}_${name}.down.sql`);
|
|
494
|
+
const downContent =
|
|
495
|
+
`-- Rollback: ${name}\n` +
|
|
496
|
+
`-- Created: ${now}\n\n` +
|
|
497
|
+
`${downSql}\n`;
|
|
208
498
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
499
|
+
writeFileSafe(downPath, downContent);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Middleware ───────────────────────────────────────────────────────
|
|
212
503
|
|
|
504
|
+
function generateMiddleware(name: string, flags: Record<string, string | boolean>): void {
|
|
213
505
|
const snake = toSnake(name);
|
|
214
506
|
const camel = toCamel(name);
|
|
507
|
+
const dir = resolve("src/middleware");
|
|
508
|
+
ensureDir(dir);
|
|
215
509
|
const path = join(dir, `${snake}.ts`);
|
|
216
510
|
|
|
217
511
|
const content = `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
218
512
|
|
|
219
513
|
/**
|
|
220
|
-
* ${name} middleware —
|
|
514
|
+
* ${name} middleware — runs before and after route handlers.
|
|
515
|
+
*
|
|
516
|
+
* Usage:
|
|
517
|
+
* import { before${name}, after${name} } from "../middleware/${snake}.js";
|
|
221
518
|
*/
|
|
222
|
-
|
|
519
|
+
|
|
520
|
+
export async function before${name}(
|
|
223
521
|
req: Tina4Request,
|
|
224
522
|
res: Tina4Response,
|
|
225
523
|
next: () => Promise<void>,
|
|
@@ -231,7 +529,406 @@ export default async function ${camel}(
|
|
|
231
529
|
}
|
|
232
530
|
await next();
|
|
233
531
|
}
|
|
532
|
+
|
|
533
|
+
export async function after${name}(
|
|
534
|
+
req: Tina4Request,
|
|
535
|
+
res: Tina4Response,
|
|
536
|
+
next: () => Promise<void>,
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
// Post-processing logic here (logging, header injection, etc.)
|
|
539
|
+
await next();
|
|
540
|
+
}
|
|
541
|
+
`;
|
|
542
|
+
|
|
543
|
+
writeFileSafe(path, content);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── Test ────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
function generateTest(name: string, flags: Record<string, string | boolean>): void {
|
|
549
|
+
const snake = toSnake(name);
|
|
550
|
+
const singular = snake.endsWith("s") ? snake.slice(0, -1) : snake;
|
|
551
|
+
const model = flags.model as string | undefined;
|
|
552
|
+
|
|
553
|
+
const dir = resolve("tests");
|
|
554
|
+
ensureDir(dir);
|
|
555
|
+
const path = join(dir, `${snake}.test.ts`);
|
|
556
|
+
|
|
557
|
+
let content: string;
|
|
558
|
+
|
|
559
|
+
if (model) {
|
|
560
|
+
content = `import { tests, assertTrue, assertEqual } from "tina4-nodejs";
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Tests for ${name} CRUD operations.
|
|
564
|
+
*/
|
|
565
|
+
|
|
566
|
+
const list${model}s = tests(
|
|
567
|
+
assertTrue([]),
|
|
568
|
+
)(function list${model}s() {
|
|
569
|
+
// TODO: implement list test
|
|
570
|
+
return true;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const get${model} = tests(
|
|
574
|
+
assertTrue([]),
|
|
575
|
+
)(function get${model}() {
|
|
576
|
+
// TODO: implement get test
|
|
577
|
+
return true;
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const create${model} = tests(
|
|
581
|
+
assertTrue([]),
|
|
582
|
+
)(function create${model}() {
|
|
583
|
+
// TODO: implement create test
|
|
584
|
+
return true;
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const update${model} = tests(
|
|
588
|
+
assertTrue([]),
|
|
589
|
+
)(function update${model}() {
|
|
590
|
+
// TODO: implement update test
|
|
591
|
+
return true;
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const delete${model} = tests(
|
|
595
|
+
assertTrue([]),
|
|
596
|
+
)(function delete${model}() {
|
|
597
|
+
// TODO: implement delete test
|
|
598
|
+
return true;
|
|
599
|
+
});
|
|
234
600
|
`;
|
|
601
|
+
} else {
|
|
602
|
+
const titleName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
603
|
+
content = `import { tests, assertTrue, assertEqual } from "tina4-nodejs";
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Tests for ${name}.
|
|
607
|
+
*/
|
|
608
|
+
|
|
609
|
+
const test${titleName} = tests(
|
|
610
|
+
assertTrue([]),
|
|
611
|
+
)(function test${titleName}() {
|
|
612
|
+
// TODO: implement test
|
|
613
|
+
return true;
|
|
614
|
+
});
|
|
615
|
+
`;
|
|
616
|
+
}
|
|
235
617
|
|
|
236
618
|
writeFileSafe(path, content);
|
|
237
619
|
}
|
|
620
|
+
|
|
621
|
+
// ── Form ────────────────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
function generateForm(name: string, flags: Record<string, string | boolean>): void {
|
|
624
|
+
const fields = parseFields((flags.fields as string) || "");
|
|
625
|
+
const table = toTableName(name);
|
|
626
|
+
const routeName = toPlural(table);
|
|
627
|
+
|
|
628
|
+
const inputTypes: Record<string, string> = {
|
|
629
|
+
string: "text", str: "text", text: "textarea",
|
|
630
|
+
int: "number", integer: "number",
|
|
631
|
+
float: "number", numeric: "number", decimal: "number",
|
|
632
|
+
bool: "checkbox", boolean: "checkbox",
|
|
633
|
+
datetime: "datetime-local", blob: "file",
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const dir = resolve("src/templates/forms");
|
|
637
|
+
ensureDir(dir);
|
|
638
|
+
const path = join(dir, `${table}.twig`);
|
|
639
|
+
|
|
640
|
+
// Build form fields
|
|
641
|
+
const fieldEntries = fields.length > 0 ? fields : [["name", "string"] as [string, string]];
|
|
642
|
+
let fieldHtml = "";
|
|
643
|
+
for (const [fname, ftype] of fieldEntries) {
|
|
644
|
+
const itype = inputTypes[ftype] || "text";
|
|
645
|
+
const label = fname.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
646
|
+
const step = ["float", "numeric", "decimal"].includes(ftype) ? ' step="0.01"' : "";
|
|
647
|
+
|
|
648
|
+
if (itype === "textarea") {
|
|
649
|
+
fieldHtml +=
|
|
650
|
+
` <div class="form-group mb-3">\n` +
|
|
651
|
+
` <label for="${fname}">${label}</label>\n` +
|
|
652
|
+
` <textarea id="${fname}" name="${fname}" class="form-control" rows="4"` +
|
|
653
|
+
` placeholder="${label}">{{ item.${fname} }}</textarea>\n` +
|
|
654
|
+
` </div>\n`;
|
|
655
|
+
} else if (itype === "checkbox") {
|
|
656
|
+
fieldHtml +=
|
|
657
|
+
` <div class="form-group mb-3">\n` +
|
|
658
|
+
` <label>\n` +
|
|
659
|
+
` <input type="checkbox" id="${fname}" name="${fname}" value="1"` +
|
|
660
|
+
` {% if item.${fname} %}checked{% endif %}>\n` +
|
|
661
|
+
` ${label}\n` +
|
|
662
|
+
` </label>\n` +
|
|
663
|
+
` </div>\n`;
|
|
664
|
+
} else {
|
|
665
|
+
fieldHtml +=
|
|
666
|
+
` <div class="form-group mb-3">\n` +
|
|
667
|
+
` <label for="${fname}">${label}</label>\n` +
|
|
668
|
+
` <input type="${itype}" id="${fname}" name="${fname}" class="form-control"` +
|
|
669
|
+
`${step} value="{{ item.${fname} }}" placeholder="${label}">\n` +
|
|
670
|
+
` </div>\n`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const content =
|
|
675
|
+
`{% extends "base.twig" %}\n` +
|
|
676
|
+
`{% block title %}${name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}\n` +
|
|
677
|
+
`{% block content %}\n` +
|
|
678
|
+
`<div class="container mt-4">\n` +
|
|
679
|
+
` <h1>{% if item.id %}Edit ${name}{% else %}Create ${name}{% endif %}</h1>\n` +
|
|
680
|
+
` <form method="post" action="/api/${routeName}{% if item.id %}/{{ item.id }}{% endif %}">\n` +
|
|
681
|
+
` {{ form_token() }}\n` +
|
|
682
|
+
fieldHtml +
|
|
683
|
+
` <button type="submit" class="btn btn-primary">\n` +
|
|
684
|
+
` {% if item.id %}Update{% else %}Create{% endif %}\n` +
|
|
685
|
+
` </button>\n` +
|
|
686
|
+
` <a href="/api/${routeName}" class="btn btn-secondary">Cancel</a>\n` +
|
|
687
|
+
` </form>\n` +
|
|
688
|
+
`</div>\n` +
|
|
689
|
+
`{% endblock %}\n`;
|
|
690
|
+
|
|
691
|
+
writeFileSafe(path, content);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── View ────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
function generateView(name: string, flags: Record<string, string | boolean>): void {
|
|
697
|
+
const fields = parseFields((flags.fields as string) || "");
|
|
698
|
+
const table = toTableName(name);
|
|
699
|
+
const routeName = toPlural(table);
|
|
700
|
+
|
|
701
|
+
const cols = fields.length > 0 ? fields.map(([f]) => f) : ["name"];
|
|
702
|
+
|
|
703
|
+
const dir = resolve("src/templates/pages");
|
|
704
|
+
ensureDir(dir);
|
|
705
|
+
|
|
706
|
+
// List view
|
|
707
|
+
const listPath = join(dir, `${routeName}.twig`);
|
|
708
|
+
const th = cols.map((c) => ` <th>${c.replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase())}</th>`).join("\n");
|
|
709
|
+
const td = cols.map((c) => ` <td>{{ item.${c} }}</td>`).join("\n");
|
|
710
|
+
|
|
711
|
+
const listContent =
|
|
712
|
+
`{% extends "base.twig" %}\n` +
|
|
713
|
+
`{% block title %}${name}s{% endblock %}\n` +
|
|
714
|
+
`{% block content %}\n` +
|
|
715
|
+
`<div class="container mt-4">\n` +
|
|
716
|
+
` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
|
|
717
|
+
` <h1>${name}s</h1>\n` +
|
|
718
|
+
` <a href="/${routeName}/create" class="btn btn-primary">Add ${name}</a>\n` +
|
|
719
|
+
` </div>\n` +
|
|
720
|
+
` <table class="table">\n` +
|
|
721
|
+
` <thead>\n` +
|
|
722
|
+
` <tr>\n` +
|
|
723
|
+
` <th>ID</th>\n` +
|
|
724
|
+
`${th}\n` +
|
|
725
|
+
` <th>Actions</th>\n` +
|
|
726
|
+
` </tr>\n` +
|
|
727
|
+
` </thead>\n` +
|
|
728
|
+
` <tbody>\n` +
|
|
729
|
+
` {% for item in items %}\n` +
|
|
730
|
+
` <tr>\n` +
|
|
731
|
+
` <td>{{ item.id }}</td>\n` +
|
|
732
|
+
`${td}\n` +
|
|
733
|
+
` <td>\n` +
|
|
734
|
+
` <a href="/${routeName}/{{ item.id }}" class="btn btn-sm btn-primary">View</a>\n` +
|
|
735
|
+
` <a href="/${routeName}/{{ item.id }}/edit" class="btn btn-sm btn-secondary">Edit</a>\n` +
|
|
736
|
+
` </td>\n` +
|
|
737
|
+
` </tr>\n` +
|
|
738
|
+
` {% endfor %}\n` +
|
|
739
|
+
` </tbody>\n` +
|
|
740
|
+
` </table>\n` +
|
|
741
|
+
`</div>\n` +
|
|
742
|
+
`{% endblock %}\n`;
|
|
743
|
+
|
|
744
|
+
writeFileSafe(listPath, listContent);
|
|
745
|
+
|
|
746
|
+
// Detail view
|
|
747
|
+
const detailPath = join(dir, `${table}.twig`);
|
|
748
|
+
const detailFields = cols
|
|
749
|
+
.map((c) => ` <div class="mb-3"><strong>${c.replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase())}:</strong> {{ item.${c} }}</div>`)
|
|
750
|
+
.join("\n");
|
|
751
|
+
|
|
752
|
+
const detailContent =
|
|
753
|
+
`{% extends "base.twig" %}\n` +
|
|
754
|
+
`{% block title %}${name} Detail{% endblock %}\n` +
|
|
755
|
+
`{% block content %}\n` +
|
|
756
|
+
`<div class="container mt-4">\n` +
|
|
757
|
+
` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
|
|
758
|
+
` <h1>${name} #{{ item.id }}</h1>\n` +
|
|
759
|
+
` <div>\n` +
|
|
760
|
+
` <a href="/${routeName}/{{ item.id }}/edit" class="btn btn-secondary">Edit</a>\n` +
|
|
761
|
+
` <a href="/${routeName}" class="btn btn-outline-secondary">Back</a>\n` +
|
|
762
|
+
` </div>\n` +
|
|
763
|
+
` </div>\n` +
|
|
764
|
+
`${detailFields}\n` +
|
|
765
|
+
`</div>\n` +
|
|
766
|
+
`{% endblock %}\n`;
|
|
767
|
+
|
|
768
|
+
writeFileSafe(detailPath, detailContent);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── Auth ────────────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
function generateAuth(flags: Record<string, string | boolean>): void {
|
|
774
|
+
console.log("\n Generating authentication scaffolding...\n");
|
|
775
|
+
|
|
776
|
+
// 1. User model + migration
|
|
777
|
+
generateModel("User", { fields: "email:string,password:string,role:string" });
|
|
778
|
+
|
|
779
|
+
// 2. Auth routes (file-based)
|
|
780
|
+
const registerDir = resolve("src/routes/api/auth/register");
|
|
781
|
+
const loginDir = resolve("src/routes/api/auth/login");
|
|
782
|
+
const meDir = resolve("src/routes/api/auth/me");
|
|
783
|
+
ensureDir(registerDir);
|
|
784
|
+
ensureDir(loginDir);
|
|
785
|
+
ensureDir(meDir);
|
|
786
|
+
|
|
787
|
+
// POST /api/auth/register
|
|
788
|
+
writeFileSafe(
|
|
789
|
+
join(registerDir, "post.ts"),
|
|
790
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
791
|
+
import User from "../../../../models/User.js";
|
|
792
|
+
|
|
793
|
+
export const meta = { summary: "Register a new user", tags: ["auth"] };
|
|
794
|
+
|
|
795
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
796
|
+
const { email, password } = req.body || {};
|
|
797
|
+
|
|
798
|
+
if (!email || !password) {
|
|
799
|
+
res.json({ error: "Email and password required" }, 400);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Check if user exists
|
|
804
|
+
const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
805
|
+
if (existing) {
|
|
806
|
+
res.json({ error: "Email already registered" }, 409);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Create user (password should be hashed in production)
|
|
811
|
+
const user = new User({ email, password, role: "user" });
|
|
812
|
+
await user.save();
|
|
813
|
+
res.json({ message: "Registered", id: user.toJSON().id }, 201);
|
|
814
|
+
}
|
|
815
|
+
`,
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
// POST /api/auth/login
|
|
819
|
+
writeFileSafe(
|
|
820
|
+
join(loginDir, "post.ts"),
|
|
821
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
822
|
+
import User from "../../../../models/User.js";
|
|
823
|
+
|
|
824
|
+
export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
|
|
825
|
+
|
|
826
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
827
|
+
const { email, password } = req.body || {};
|
|
828
|
+
|
|
829
|
+
if (!email || !password) {
|
|
830
|
+
res.json({ error: "Email and password required" }, 400);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
835
|
+
if (!user) {
|
|
836
|
+
res.json({ error: "Invalid credentials" }, 401);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// In production, compare hashed passwords
|
|
841
|
+
if ((user as any).password !== password) {
|
|
842
|
+
res.json({ error: "Invalid credentials" }, 401);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
res.json({ message: "Logged in", email: (user as any).email });
|
|
847
|
+
}
|
|
848
|
+
`,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
// GET /api/auth/me
|
|
852
|
+
writeFileSafe(
|
|
853
|
+
join(meDir, "get.ts"),
|
|
854
|
+
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
855
|
+
|
|
856
|
+
export const meta = { summary: "Get current authenticated user", tags: ["auth"] };
|
|
857
|
+
|
|
858
|
+
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
859
|
+
const auth = req.headers["authorization"];
|
|
860
|
+
if (!auth) {
|
|
861
|
+
res.json({ error: "Unauthorized" }, 401);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// In production, decode JWT and look up user
|
|
866
|
+
res.json({ message: "Authenticated user profile" });
|
|
867
|
+
}
|
|
868
|
+
`,
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// 3. Login template
|
|
872
|
+
const formsDir = resolve("src/templates/forms");
|
|
873
|
+
ensureDir(formsDir);
|
|
874
|
+
|
|
875
|
+
writeFileSafe(
|
|
876
|
+
join(formsDir, "login.twig"),
|
|
877
|
+
`{% extends "base.twig" %}
|
|
878
|
+
{% block title %}Login{% endblock %}
|
|
879
|
+
{% block content %}
|
|
880
|
+
<div class="container mt-4" style="max-width:400px">
|
|
881
|
+
<h1>Login</h1>
|
|
882
|
+
<form method="post" action="/api/auth/login">
|
|
883
|
+
{{ form_token() }}
|
|
884
|
+
<div class="form-group mb-3">
|
|
885
|
+
<label for="email">Email</label>
|
|
886
|
+
<input type="email" id="email" name="email" class="form-control" placeholder="you@example.com" required>
|
|
887
|
+
</div>
|
|
888
|
+
<div class="form-group mb-3">
|
|
889
|
+
<label for="password">Password</label>
|
|
890
|
+
<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
|
|
891
|
+
</div>
|
|
892
|
+
<button type="submit" class="btn btn-primary w-100">Login</button>
|
|
893
|
+
<p class="mt-3 text-center"><a href="/register">Create an account</a></p>
|
|
894
|
+
</form>
|
|
895
|
+
</div>
|
|
896
|
+
{% endblock %}
|
|
897
|
+
`,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// 4. Register template
|
|
901
|
+
writeFileSafe(
|
|
902
|
+
join(formsDir, "register.twig"),
|
|
903
|
+
`{% extends "base.twig" %}
|
|
904
|
+
{% block title %}Register{% endblock %}
|
|
905
|
+
{% block content %}
|
|
906
|
+
<div class="container mt-4" style="max-width:400px">
|
|
907
|
+
<h1>Register</h1>
|
|
908
|
+
<form method="post" action="/api/auth/register">
|
|
909
|
+
{{ form_token() }}
|
|
910
|
+
<div class="form-group mb-3">
|
|
911
|
+
<label for="email">Email</label>
|
|
912
|
+
<input type="email" id="email" name="email" class="form-control" placeholder="you@example.com" required>
|
|
913
|
+
</div>
|
|
914
|
+
<div class="form-group mb-3">
|
|
915
|
+
<label for="password">Password</label>
|
|
916
|
+
<input type="password" id="password" name="password" class="form-control" placeholder="Password" minlength="8" required>
|
|
917
|
+
</div>
|
|
918
|
+
<button type="submit" class="btn btn-primary w-100">Register</button>
|
|
919
|
+
<p class="mt-3 text-center"><a href="/login">Already have an account?</a></p>
|
|
920
|
+
</form>
|
|
921
|
+
</div>
|
|
922
|
+
{% endblock %}
|
|
923
|
+
`,
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
// 5. Auth test
|
|
927
|
+
generateTest("auth", { model: "User" });
|
|
928
|
+
|
|
929
|
+
console.log("\n Authentication scaffolding complete.");
|
|
930
|
+
console.log(" Run: tina4nodejs migrate");
|
|
931
|
+
console.log(" POST /api/auth/register — create account");
|
|
932
|
+
console.log(" POST /api/auth/login — get JWT token");
|
|
933
|
+
console.log(" GET /api/auth/me — get profile (requires token)");
|
|
934
|
+
}
|