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.
@@ -1,43 +1,39 @@
1
1
  /**
2
- * CLI command: generate — Scaffold models, routes, migrations, and middleware.
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 User
6
- * tina4nodejs generate route /api/users
7
- * tina4nodejs generate migration create_users
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
- export async function generate(what: string, name: string): Promise<void> {
14
- if (!what || !name) {
15
- console.error(" Usage: tina4nodejs generate <what> <name>");
16
- console.error(" Generators: model, route, migration, middleware");
17
- process.exit(1);
18
- }
19
-
20
- switch (what) {
21
- case "model":
22
- generateModel(name);
23
- break;
24
- case "route":
25
- generateRoute(name);
26
- break;
27
- case "migration":
28
- generateMigration(name);
29
- break;
30
- case "middleware":
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.error(` File already exists: ${path}`);
51
- process.exit(1);
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.replace(/([A-Z])/g, (_, ch, i) => (i > 0 ? "_" : "") + ch.toLowerCase());
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
- // ── Model ────────────────────────────────────────────────────────
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
- id: { type: "integer", primaryKey: true, autoIncrement: true },
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 base = resolve("src/routes", routePath);
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
- writeFileSafe(
107
- join(base, "get.ts"),
108
- `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
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 const meta = { summary: "List all", tags: ["auto-generated"] };
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
- writeFileSafe(
120
- join(base, "post.ts"),
121
- `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
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 const meta = { summary: "Create new", tags: ["auto-generated"] };
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({ message: "created" }, 201);
292
+ res.json({ data: req.body }, 201);
127
293
  }
128
294
  `,
129
- );
295
+ );
296
+ }
130
297
 
131
298
  // GET by id
132
- writeFileSafe(
133
- join(idDir, "get.ts"),
134
- `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
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 id", tags: ["auto-generated"] };
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
- writeFileSafe(
147
- join(idDir, "put.ts"),
148
- `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
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 id", tags: ["auto-generated"] };
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({ message: "updated", id });
362
+ res.json({ data: { ...req.body, id } });
155
363
  }
156
364
  `,
157
- );
365
+ );
366
+ }
158
367
 
159
368
  // DELETE by id
160
- writeFileSafe(
161
- join(idDir, "delete.ts"),
162
- `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
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 const meta = { summary: "Delete by id", tags: ["auto-generated"] };
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(name: string): void {
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
- const now = new Date();
181
- const timestamp =
182
- now.getFullYear().toString() +
183
- String(now.getMonth() + 1).padStart(2, "0") +
184
- String(now.getDate()).padStart(2, "0") +
185
- String(now.getHours()).padStart(2, "0") +
186
- String(now.getMinutes()).padStart(2, "0") +
187
- String(now.getSeconds()).padStart(2, "0");
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
- const table = toPlural(name.replace(/^create_/, ""));
190
- const fileName = `${timestamp}_${name}.sql`;
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
- const content = `-- Migration: ${name}
194
- -- Created: ${now.toISOString().replace("T", " ").replace(/\.\d+Z$/, "")}
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
- CREATE TABLE ${table} (
197
- id INTEGER PRIMARY KEY AUTOINCREMENT,
198
- name TEXT NOT NULL,
199
- email TEXT NOT NULL,
200
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
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
- // ── Middleware ────────────────────────────────────────────────────
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
- function generateMiddleware(name: string): void {
210
- const dir = resolve("src/middleware");
211
- ensureDir(dir);
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 — checks for Authorization header.
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
- export default async function ${camel}(
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
+ }