tina4-nodejs 3.13.63 → 3.13.64

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.63",
3
+ "version": "3.13.64",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -35,14 +35,25 @@ const HELP = `
35
35
 
36
36
  Generators:
37
37
  tina4nodejs generate model <Name> [--fields "name:string,price:float"]
38
- tina4nodejs generate route <name> [--model Name]
39
- tina4nodejs generate crud <Name> [--fields "..."] Model + migration + routes + form + view + test
38
+ tina4nodejs generate route <name> [--model Name] [--public] Writes secure by default; --public opens them
39
+ tina4nodejs generate crud <Name> [--fields "..."] [--public] Model + migration + routes + form + view + test
40
40
  tina4nodejs generate migration <description>
41
41
  tina4nodejs generate middleware <Name>
42
42
  tina4nodejs generate test <name>
43
43
  tina4nodejs generate form <Name> [--fields "..."] Form template with inputs matching model fields
44
44
  tina4nodejs generate view <Name> [--fields "..."] List + detail templates for viewing records
45
- tina4nodejs generate auth Login/register/logout routes + User model + templates
45
+ tina4nodejs generate auth Login/register routes (public) + User model + templates
46
+ tina4nodejs generate service <Name> [--every 5m | --cron "..."] Scheduled ServiceRunner task (src/services/)
47
+ tina4nodejs generate queue <topic> Producer + consumer daemon worker (src/services/)
48
+ tina4nodejs generate validator <Name> Request-body Validator (src/validators/)
49
+ tina4nodejs generate seeder <Model> FakeData + seedOrm seeder (src/seeds/)
50
+ tina4nodejs generate websocket <path> websocket() handler (src/routes/)
51
+ tina4nodejs generate listener <event> Events.on(event) listener (src/listeners/)
52
+
53
+ Scaffolding-first: logic-shaped generators (route without --model, service,
54
+ queue, validator, seeder, websocket, listener) emit real wiring + an AI-FILL
55
+ placeholder (throws until filled); CRUD-shaped ones emit working code. Writes
56
+ are secure by default — use --public to open them.
46
57
 
47
58
  Field types: string, int, float, bool, text, datetime
48
59
  Table names: singular by default (Product → product)
@@ -1,19 +1,38 @@
1
1
  /**
2
- * CLI command: generate — Rich scaffolding for models, routes, migrations,
3
- * middleware, tests, forms, views, CRUD stacks, and auth.
2
+ * CLI command: generate — Rich, scaffolding-first code generation.
3
+ *
4
+ * Two shapes of generator:
5
+ * • CRUD-shaped (model, crud, migration, middleware, form, view, test, auth):
6
+ * emit WORKING code — the boilerplate IS the feature. A working extension
7
+ * point is flagged with a light `EXTEND:` marker (no throw).
8
+ * • LOGIC-shaped (custom route body, service, queue, validator, seeder,
9
+ * websocket, listener): emit the real WIRING (imports + registration +
10
+ * signature) plus a single greppable `AI-FILL` fill-spec that ends in
11
+ * `throw new Error("… not implemented")`, so an unfilled scaffold fails LOUD.
12
+ *
13
+ * Secure by default: the router gates every scaffolded WRITE (POST/PUT/DELETE)
14
+ * behind a Bearer token automatically; reads (GET) are public automatically.
15
+ * `--public` re-adds the opt-out (`export const secure = false;`) on the WRITE
16
+ * method files only — mirroring the AutoCrud `public` opt-in.
4
17
  *
5
18
  * Usage:
6
19
  * 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"
20
+ * tina4nodejs generate route products --model Product [--public]
21
+ * tina4nodejs generate crud Product --fields "name:string,price:float" [--public]
9
22
  * tina4nodejs generate migration create_product
10
23
  * tina4nodejs generate middleware Auth
11
24
  * tina4nodejs generate test products --model Product
12
25
  * tina4nodejs generate form Product --fields "name:string,price:float"
13
26
  * tina4nodejs generate view Product --fields "name:string,price:float"
14
27
  * tina4nodejs generate auth
28
+ * tina4nodejs generate service Cleanup --every 5m | --cron "0 3 * * *"
29
+ * tina4nodejs generate queue order-emails
30
+ * tina4nodejs generate validator CreateUser
31
+ * tina4nodejs generate seeder Product
32
+ * tina4nodejs generate websocket chat
33
+ * tina4nodejs generate listener user.created
15
34
  */
16
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
35
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
17
36
  import { join, resolve } from "node:path";
18
37
 
19
38
  // ── Field type mapping ──────────────────────────────────────────────
@@ -72,6 +91,15 @@ function toCamel(name: string): string {
72
91
  return name.charAt(0).toLowerCase() + name.slice(1);
73
92
  }
74
93
 
94
+ /** slug-of-anything → PascalCase (order-emails → OrderEmails). */
95
+ export function toPascal(name: string): string {
96
+ return name
97
+ .split(/[^0-9a-zA-Z]+/)
98
+ .filter(Boolean)
99
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
100
+ .join("");
101
+ }
102
+
75
103
  export function parseFields(fieldsStr: string): Array<[string, string]> {
76
104
  if (!fieldsStr || !fieldsStr.trim()) return [];
77
105
  const result: Array<[string, string]> = [];
@@ -79,7 +107,7 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
79
107
  const trimmed = part.trim();
80
108
  if (trimmed.includes(":")) {
81
109
  const [fname, ftype] = trimmed.split(":", 2);
82
- result.push([fname.trim(), ftype.trim().toLowerCase()]);
110
+ if (fname.trim()) result.push([fname.trim(), ftype.trim().toLowerCase()]);
83
111
  } else if (trimmed) {
84
112
  result.push([trimmed, "string"]);
85
113
  }
@@ -88,8 +116,11 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
88
116
  }
89
117
 
90
118
  export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
91
- // Boolean-only flags that never take a value argument
92
- const booleanFlags = new Set(["no-browser", "no-reload", "production", "managed", "all", "clear"]);
119
+ // Boolean-only flags that never take a value argument.
120
+ const booleanFlags = new Set([
121
+ "no-browser", "no-reload", "production", "managed", "all", "clear",
122
+ "public", "no-migration",
123
+ ]);
93
124
 
94
125
  const flags: Record<string, string | boolean> = {};
95
126
  const positional: string[] = [];
@@ -115,6 +146,59 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
115
146
  return { flags, positional };
116
147
  }
117
148
 
149
+ /**
150
+ * Parse a `--every` duration ("5m", "30s", "2h", "1d", or bare seconds) → seconds.
151
+ * Falls back to 60s on an empty/unparseable value so a scaffold always has a
152
+ * valid ServiceRunner interval.
153
+ */
154
+ export function parseEvery(every: string | boolean | undefined): number {
155
+ if (!every || every === true) return 60;
156
+ const s = String(every).trim().toLowerCase();
157
+ const units: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
158
+ const unit = s.slice(-1);
159
+ if (unit in units) {
160
+ const n = parseFloat(s.slice(0, -1));
161
+ return Number.isFinite(n) ? Math.max(1, Math.round(n * units[unit])) : 60;
162
+ }
163
+ const n = parseFloat(s);
164
+ return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 60;
165
+ }
166
+
167
+ /**
168
+ * The canonical AI-FILL placeholder for a LOGIC-shaped stub — a tight, grounded
169
+ * fill-spec (not a vague `// TODO`) so a coding agent (or dev) completes it
170
+ * correctly. `throw new Error(...)` makes an unfilled scaffold fail LOUD; the
171
+ * greppable `AI-FILL` banner lets a human/agent jump to every gap. `use` names
172
+ * only REAL tina4-nodejs symbols (verified in source).
173
+ */
174
+ export function aiFill(
175
+ fn: string,
176
+ spec: { intent: string; given?: string; use: string; ret?: string; ground: string; raise: string },
177
+ indent = " ",
178
+ ): string {
179
+ const rule = (label: string) => "─".repeat(Math.max(4, 46 - label.length));
180
+ const lines = [`${indent}// ─── AI-FILL: ${fn} ${rule(fn)}`];
181
+ lines.push(`${indent}// Intent: ${spec.intent}`);
182
+ if (spec.given) lines.push(`${indent}// Given: ${spec.given}`);
183
+ lines.push(`${indent}// Use: ${spec.use}`);
184
+ if (spec.ret) lines.push(`${indent}// Return: ${spec.ret}`);
185
+ lines.push(`${indent}// Ground: ${spec.ground}`);
186
+ lines.push(`${indent}throw new Error(${JSON.stringify(spec.raise)}); // remove when implemented`);
187
+ lines.push(`${indent}// ${"─".repeat(52)}`);
188
+ return lines.join("\n") + "\n";
189
+ }
190
+
191
+ /**
192
+ * The lighter EXTEND marker for CRUD-shaped WORKING code — no throw (the
193
+ * boilerplate IS the feature); just a greppable hint at the natural extension
194
+ * point (custom validation / business rules / authorization).
195
+ */
196
+ export function extend(note: string, hint = "", indent = " "): string {
197
+ let out = `${indent}// ─── EXTEND: ${note} ${"─".repeat(Math.max(4, 46 - note.length))}\n`;
198
+ if (hint) out += `${indent}// ${hint}\n`;
199
+ return out;
200
+ }
201
+
118
202
  function timestamp(): string {
119
203
  const now = new Date();
120
204
  return (
@@ -131,13 +215,19 @@ function isoNow(): string {
131
215
  return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
132
216
  }
133
217
 
218
+ const GENERATORS =
219
+ "model, route, crud, migration, middleware, test, form, view, auth, " +
220
+ "service, queue, validator, seeder, websocket, listener";
221
+
134
222
  // ── Main entry point ────────────────────────────────────────────────
135
223
 
136
224
  export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
137
225
  if (!what) {
138
226
  console.error(" Usage: tina4nodejs generate <what> <name> [options]");
139
- console.error(" Generators: model, route, crud, migration, middleware, test, form, view, auth");
227
+ console.error(` Generators: ${GENERATORS}`);
140
228
  console.error(' Options: --fields "name:string,price:float" --model ModelName');
229
+ console.error(" --public open a route's writes (default: secure)");
230
+ console.error(' --every 5m | --cron "…" service schedule');
141
231
  process.exit(1);
142
232
  }
143
233
 
@@ -178,9 +268,27 @@ export async function generate(what: string, name: string, extraArgs: string[] =
178
268
  case "auth":
179
269
  generateAuth(flags);
180
270
  break;
271
+ case "service":
272
+ generateService(name, flags);
273
+ break;
274
+ case "queue":
275
+ generateQueue(name, flags);
276
+ break;
277
+ case "validator":
278
+ generateValidator(name, flags);
279
+ break;
280
+ case "seeder":
281
+ generateSeeder(name, flags);
282
+ break;
283
+ case "websocket":
284
+ generateWebsocket(name, flags);
285
+ break;
286
+ case "listener":
287
+ generateListener(name, flags);
288
+ break;
181
289
  default:
182
290
  console.error(` Unknown generator: ${what}`);
183
- console.error(" Available: model, route, crud, migration, middleware, test, form, view, auth");
291
+ console.error(` Available: ${GENERATORS}`);
184
292
  process.exit(1);
185
293
  }
186
294
  }
@@ -208,7 +316,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>): v
208
316
  }
209
317
  fieldLines.push(` created_at: { type: "datetime" as const },`);
210
318
 
211
- const content = `import { BaseModel } from "tina4-nodejs";
319
+ // BaseModel is exported from tina4-nodejs/orm (NOT the core "tina4-nodejs"
320
+ // entry) — importing it from the core entry yields `undefined` at runtime.
321
+ const content = `import { BaseModel } from "tina4-nodejs/orm";
212
322
 
213
323
  export default class ${name} extends BaseModel {
214
324
  static tableName = "${table}";
@@ -227,34 +337,52 @@ ${fieldLines.join("\n")}
227
337
  }
228
338
 
229
339
  // ── Route ───────────────────────────────────────────────────────────
340
+ //
341
+ // Secure by default. The router marks POST/PUT/DELETE `secure: true` unless the
342
+ // route def opts out; route discovery reads a module's `export const secure`
343
+ // into that def. So a scaffolded write is Bearer-gated with NOTHING emitted;
344
+ // `--public` emits `export const secure = false;` on the write files only.
345
+
346
+ /** The opt-out line for a WRITE method file when --public is set (else ""). */
347
+ function secureOptOut(isPublic: boolean): string {
348
+ return isPublic ? `export const secure = false;\n\n` : "";
349
+ }
230
350
 
231
351
  function generateRoute(name: string, flags: Record<string, string | boolean>): void {
232
352
  const routePath = name.replace(/^\//, "");
233
353
  const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath;
234
354
  const model = flags.model as string | undefined;
355
+ const isPublic = Boolean(flags.public);
235
356
  const base = resolve("src/routes/api", routePath);
236
357
  const idDir = join(base, "[id]");
237
358
  ensureDir(base);
238
359
  ensureDir(idDir);
239
360
 
240
- const modelImport = model
241
- ? `import ${model} from "../../../models/${model}.js";\n`
242
- : "";
361
+ const table = model ? toTableName(model) : "";
362
+ // Model import path is RELATIVE to the route file's directory. Files directly
363
+ // under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
364
+ // are one deeper (4 levels).
365
+ const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";\n` : "";
366
+ const modelImportId = model ? `import ${model} from "../../../../models/${model}.js";\n` : "";
367
+
368
+ const writeDoc = isPublic
369
+ ? "Public (--public): no token required."
370
+ : "Secure by default: requires a Bearer token (use --public to open).";
243
371
 
244
- // GET list
372
+ // ── GET list (public) ──────────────────────────────────────────────
245
373
  if (model) {
246
374
  writeFileSafe(
247
375
  join(base, "get.ts"),
248
376
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
249
- ${modelImport}
377
+ ${modelImportBase}
250
378
  export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
251
379
 
252
380
  export default async function (req: Tina4Request, res: Tina4Response) {
253
- const page = parseInt(req.query?.page as string) || 1;
254
- const limit = parseInt(req.query?.limit as string) || 20;
381
+ const page = parseInt(req.query.page as string) || 1;
382
+ const limit = parseInt(req.query.limit as string) || 20;
255
383
  const offset = (page - 1) * limit;
256
- const results = await ${model}.select("SELECT * FROM ${toTableName(model)} LIMIT ? OFFSET ?", [limit, offset]);
257
- res.json({ data: results, page, limit });
384
+ const rows = await ${model}.select("SELECT * FROM ${table} LIMIT ? OFFSET ?", [limit, offset]);
385
+ res.json({ data: rows.map((r) => r.toObject()), page, limit });
258
386
  }
259
387
  `,
260
388
  );
@@ -266,24 +394,31 @@ export default async function (req: Tina4Request, res: Tina4Response) {
266
394
  export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
267
395
 
268
396
  export default async function (req: Tina4Request, res: Tina4Response) {
269
- res.json({ data: [] });
270
- }
397
+ ${aiFill(`list_${routePath}`, {
398
+ intent: `return the ${routePath} collection (add pagination if it grows)`,
399
+ given: "req.query -> filters/paging",
400
+ use: `Model.select("SELECT … LIMIT ? OFFSET ?", [limit, offset]) then r.toObject()`,
401
+ ret: "res.json({ data: rows })",
402
+ ground: `tina4_context("list ORM records with pagination", "nodejs") · skill tina4-developer-nodejs`,
403
+ raise: `${routePath} list not implemented`,
404
+ })}}
271
405
  `,
272
406
  );
273
407
  }
274
408
 
275
- // POST create
409
+ // ── POST create (secure by default; --public opens it) ─────────────
276
410
  if (model) {
277
411
  writeFileSafe(
278
412
  join(base, "post.ts"),
279
413
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
280
- ${modelImport}
281
- export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
414
+ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
282
415
 
416
+ // ${writeDoc}
283
417
  export default async function (req: Tina4Request, res: Tina4Response) {
284
- const item = new ${model}(req.body);
418
+ ${extend("validate / business rules before persist",
419
+ `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
285
420
  await item.save();
286
- res.json({ data: item.toJSON() }, 201);
421
+ res.json({ data: item.toObject() }, 201);
287
422
  }
288
423
  `,
289
424
  );
@@ -292,31 +427,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
292
427
  join(base, "post.ts"),
293
428
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
294
429
 
295
- export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
430
+ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
296
431
 
432
+ // ${writeDoc}
297
433
  export default async function (req: Tina4Request, res: Tina4Response) {
298
- res.json({ data: req.body }, 201);
299
- }
434
+ ${aiFill(`create_${singular}`, {
435
+ intent: `validate the body and persist a new ${singular}`,
436
+ given: "req.body -> the posted fields",
437
+ use: "new Model(req.body).save() then item.toObject() (import your model)",
438
+ ret: "res.json({ data: item }, 201)",
439
+ ground: `tina4_context("create ORM record and return 201", "nodejs") · skill tina4-developer-nodejs`,
440
+ raise: `create ${singular} not implemented`,
441
+ })}}
300
442
  `,
301
443
  );
302
444
  }
303
445
 
304
- // GET by id
446
+ // ── GET by id (public) ─────────────────────────────────────────────
305
447
  if (model) {
306
448
  writeFileSafe(
307
449
  join(idDir, "get.ts"),
308
450
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
309
- ${modelImport}
451
+ ${modelImportId}
310
452
  export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
311
453
 
312
454
  export default async function (req: Tina4Request, res: Tina4Response) {
313
455
  const { id } = req.params;
314
- const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
456
+ const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
315
457
  if (!item) {
316
458
  res.json({ error: "Not found" }, 404);
317
459
  return;
318
460
  }
319
- res.json({ data: item });
461
+ res.json({ data: item.toObject() });
320
462
  }
321
463
  `,
322
464
  );
@@ -328,31 +470,37 @@ export default async function (req: Tina4Request, res: Tina4Response) {
328
470
  export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
329
471
 
330
472
  export default async function (req: Tina4Request, res: Tina4Response) {
331
- const { id } = req.params;
332
- res.json({ data: { id } });
333
- }
473
+ ${aiFill(`get_${singular}`, {
474
+ intent: `fetch one ${singular} by id`,
475
+ given: "req.params.id -> the record id",
476
+ use: `Model.selectOne("SELECT … WHERE id = ?", [req.params.id])`,
477
+ ret: "res.json({ data: item }) or res.json({ error: 'Not found' }, 404)",
478
+ ground: `tina4_context("find ORM record by id", "nodejs") · skill tina4-developer-nodejs`,
479
+ raise: `get ${singular} not implemented`,
480
+ })}}
334
481
  `,
335
482
  );
336
483
  }
337
484
 
338
- // PUT by id
485
+ // ── PUT by id (secure by default; --public opens it) ───────────────
339
486
  if (model) {
340
487
  writeFileSafe(
341
488
  join(idDir, "put.ts"),
342
489
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
343
- ${modelImport}
344
- export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
490
+ ${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
345
491
 
492
+ // ${writeDoc}
346
493
  export default async function (req: Tina4Request, res: Tina4Response) {
347
494
  const { id } = req.params;
348
- const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
495
+ const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
349
496
  if (!item) {
350
497
  res.json({ error: "Not found" }, 404);
351
498
  return;
352
499
  }
353
- const updated = new ${model}({ ...item, ...req.body, id });
354
- await updated.save();
355
- res.json({ data: updated.toJSON() });
500
+ ${extend("guard which fields / who may update",
501
+ `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
502
+ await item.save();
503
+ res.json({ data: item.toObject() });
356
504
  }
357
505
  `,
358
506
  );
@@ -361,33 +509,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
361
509
  join(idDir, "put.ts"),
362
510
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
363
511
 
364
- export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
512
+ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
365
513
 
514
+ // ${writeDoc}
366
515
  export default async function (req: Tina4Request, res: Tina4Response) {
367
- const { id } = req.params;
368
- res.json({ data: { ...req.body, id } });
369
- }
516
+ ${aiFill(`update_${singular}`, {
517
+ intent: `load, mutate and save an existing ${singular}`,
518
+ given: "req.params.id -> id; req.body -> changed fields",
519
+ use: "Model.selectOne(…) then Object.assign(item, req.body) then item.save()",
520
+ ret: "res.json({ data: item }) or 404",
521
+ ground: `tina4_context("update ORM record", "nodejs") · skill tina4-developer-nodejs`,
522
+ raise: `update ${singular} not implemented`,
523
+ })}}
370
524
  `,
371
525
  );
372
526
  }
373
527
 
374
- // DELETE by id
528
+ // ── DELETE by id (secure by default; --public opens it) ────────────
375
529
  if (model) {
376
530
  writeFileSafe(
377
531
  join(idDir, "delete.ts"),
378
532
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
379
- ${modelImport}
380
- export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
533
+ ${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
381
534
 
535
+ // ${writeDoc}
382
536
  export default async function (req: Tina4Request, res: Tina4Response) {
383
537
  const { id } = req.params;
384
- const item = await ${model}.selectOne("SELECT * FROM ${toTableName(model)} WHERE id = ?", [id]);
538
+ const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
385
539
  if (!item) {
386
540
  res.json({ error: "Not found" }, 404);
387
541
  return;
388
542
  }
389
- const record = new ${model}(item);
390
- await record.delete();
543
+ await item.delete();
391
544
  res.json({ message: "deleted", id });
392
545
  }
393
546
  `,
@@ -397,12 +550,18 @@ export default async function (req: Tina4Request, res: Tina4Response) {
397
550
  join(idDir, "delete.ts"),
398
551
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
399
552
 
400
- export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
553
+ ${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
401
554
 
555
+ // ${writeDoc}
402
556
  export default async function (req: Tina4Request, res: Tina4Response) {
403
- const { id } = req.params;
404
- res.json({ message: "deleted", id });
405
- }
557
+ ${aiFill(`delete_${singular}`, {
558
+ intent: `delete a ${singular} by id`,
559
+ given: "req.params.id -> id",
560
+ use: "Model.selectOne(…) then item.delete()",
561
+ ret: "res.json({ message: 'deleted', id }) or 404",
562
+ ground: `tina4_context("delete ORM record", "nodejs") · skill tina4-developer-nodejs`,
563
+ raise: `delete ${singular} not implemented`,
564
+ })}}
406
565
  `,
407
566
  );
408
567
  }
@@ -413,13 +572,15 @@ export default async function (req: Tina4Request, res: Tina4Response) {
413
572
  function generateCrud(name: string, flags: Record<string, string | boolean>): void {
414
573
  const table = toTableName(name);
415
574
  const routeName = toPlural(table);
575
+ const isPublic = Boolean(flags.public);
416
576
 
417
577
  console.log(`\n Generating CRUD for ${name}...\n`);
418
578
 
419
579
  // 1. Model + migration
420
580
  generateModel(name, flags);
421
581
 
422
- // 2. Routes with model
582
+ // 2. Routes with model — secure by default; thread --public through so
583
+ // `generate crud X --public` opens the writes (mirrors AutoCrud public).
423
584
  generateRoute(routeName, { ...flags, model: name });
424
585
 
425
586
  // 3. Form
@@ -428,8 +589,8 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
428
589
  // 4. View (list + detail)
429
590
  generateView(name, flags);
430
591
 
431
- // 5. Test
432
- generateTest(routeName, { ...flags, model: name });
592
+ // 5. Test — real secure-by-default boot-gate (reads public, writes gated).
593
+ generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
433
594
 
434
595
  console.log(`\n CRUD generation complete for ${name}.`);
435
596
  console.log(" Run: tina4nodejs migrate");
@@ -507,9 +668,8 @@ function generateMigration(
507
668
 
508
669
  // ── Middleware ───────────────────────────────────────────────────────
509
670
 
510
- function generateMiddleware(name: string, flags: Record<string, string | boolean>): void {
671
+ function generateMiddleware(name: string, _flags: Record<string, string | boolean>): void {
511
672
  const snake = toSnake(name);
512
- const camel = toCamel(name);
513
673
  const dir = resolve("src/middleware");
514
674
  ensureDir(dir);
515
675
  const path = join(dir, `${snake}.ts`);
@@ -560,10 +720,70 @@ function generateTest(name: string, flags: Record<string, string | boolean>): vo
560
720
  ensureDir(dir);
561
721
  const path = join(dir, `${snake}.test.ts`);
562
722
 
723
+ // Secure-by-default CRUD gate test (emitted by `generate crud`): a REAL
724
+ // boot-gate through TestClient over the generated file-based routes and a
725
+ // real SQLite DB — reads public, writes gated (or open under --public). No
726
+ // mocks: real Router, real auth gate, real DB. Grounded on
727
+ // test/testClientAuth.test.ts + test/autoCrud.test.ts.
728
+ if (model && flags["secure-writes"]) {
729
+ const isPublic = Boolean(flags.public);
730
+ const posture = isPublic ? "open (--public)" : "gated";
731
+ const writeCase = isPublic
732
+ ? ` // --public opened the write: an anonymous POST creates -> 201.
733
+ assert("anonymous POST is public -> 201",
734
+ (await client.post("/api/${snake}", { json: { name: "test" } })).status === 201);`
735
+ : ` // Secure by default: a tokenless POST is rejected with 401.
736
+ assert("anonymous POST is gated -> 401",
737
+ (await client.post("/api/${snake}", { json: { name: "test" } })).status === 401);
738
+ // A valid Bearer token passes the gate and creates -> 201.
739
+ const token = getToken({ userId: 1 });
740
+ assert("authenticated POST creates -> 201",
741
+ (await client.post("/api/${snake}", { json: { name: "test" }, headers: { authorization: \`Bearer \${token}\` } })).status === 201);`;
742
+
743
+ const content = `/**
744
+ * ${name} CRUD — reads public, writes ${posture} (secure by default).
745
+ *
746
+ * Real end-to-end via TestClient: no mocks — real Router, real auth gate, real
747
+ * JWT, real SQLite DB + table. Run with: npx tsx tests/${snake}.test.ts
748
+ */
749
+ import { dirname, resolve } from "node:path";
750
+ import { fileURLToPath } from "node:url";
751
+ import { Router, TestClient, getToken, discoverRoutes } from "tina4-nodejs";
752
+ import { initDatabase } from "tina4-nodejs/orm";
753
+ import ${model} from "../src/models/${model}.js";
754
+
755
+ process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
756
+ const here = dirname(fileURLToPath(import.meta.url));
757
+
758
+ let pass = 0;
759
+ let fail = 0;
760
+ function assert(label: string, ok: boolean): void {
761
+ if (ok) { pass++; console.log(\` PASS \${label}\`); }
762
+ else { fail++; console.log(\` FAIL \${label}\`); }
763
+ }
764
+
765
+ await initDatabase({ url: "sqlite:///data/test_${snake}.db" });
766
+ await ${model}.createTable();
767
+
768
+ const router = new Router();
769
+ for (const def of await discoverRoutes(resolve(here, "../src/routes"))) router.addRoute(def);
770
+ const client = new TestClient(router);
771
+
772
+ // Reads are public.
773
+ assert("GET list is public -> 200", (await client.get("/api/${snake}")).status === 200);
774
+ ${writeCase}
775
+
776
+ console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
777
+ process.exit(fail > 0 ? 1 : 0);
778
+ `;
779
+ writeFileSafe(path, content);
780
+ return;
781
+ }
782
+
563
783
  let content: string;
564
784
 
565
785
  if (model) {
566
- content = `import { tests, assertTrue, assertEqual } from "tina4-nodejs";
786
+ content = `import { tests, assertTrue } from "tina4-nodejs";
567
787
 
568
788
  /**
569
789
  * Tests for ${name} CRUD operations.
@@ -603,10 +823,12 @@ const delete${model} = tests(
603
823
  // TODO: implement delete test
604
824
  return true;
605
825
  });
826
+
827
+ void [list${model}s, get${model}, create${model}, update${model}, delete${model}];
606
828
  `;
607
829
  } else {
608
830
  const titleName = name.charAt(0).toUpperCase() + name.slice(1);
609
- content = `import { tests, assertTrue, assertEqual } from "tina4-nodejs";
831
+ content = `import { tests, assertTrue } from "tina4-nodejs";
610
832
 
611
833
  /**
612
834
  * Tests for ${name}.
@@ -618,10 +840,13 @@ const test${titleName} = tests(
618
840
  // TODO: implement test
619
841
  return true;
620
842
  });
843
+
844
+ void test${titleName};
621
845
  `;
622
846
  }
623
847
 
624
848
  writeFileSafe(path, content);
849
+ void singular;
625
850
  }
626
851
 
627
852
  // ── Form ────────────────────────────────────────────────────────────
@@ -774,15 +999,18 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
774
999
  writeFileSafe(detailPath, detailContent);
775
1000
  }
776
1001
 
777
- // ── Auth ────────────────────────────────────────────────────────────
1002
+ // ── Auth (login/register stay PUBLIC) ───────────────────────────────
778
1003
 
779
- function generateAuth(flags: Record<string, string | boolean>): void {
1004
+ function generateAuth(_flags: Record<string, string | boolean>): void {
780
1005
  console.log("\n Generating authentication scaffolding...\n");
781
1006
 
782
1007
  // 1. User model + migration
783
1008
  generateModel("User", { fields: "email:string,password:string,role:string" });
784
1009
 
785
- // 2. Auth routes (file-based)
1010
+ // 2. Auth routes (file-based). register + login are genuinely public — the
1011
+ // user has no token yet — so they opt out of the write gate with
1012
+ // `export const secure = false;`. `me` is a GET (public route) that
1013
+ // authenticates the Bearer itself.
786
1014
  const registerDir = resolve("src/routes/api/auth/register");
787
1015
  const loginDir = resolve("src/routes/api/auth/login");
788
1016
  const meDir = resolve("src/routes/api/auth/me");
@@ -790,47 +1018,53 @@ function generateAuth(flags: Record<string, string | boolean>): void {
790
1018
  ensureDir(loginDir);
791
1019
  ensureDir(meDir);
792
1020
 
793
- // POST /api/auth/register
1021
+ // POST /api/auth/register (public)
794
1022
  writeFileSafe(
795
1023
  join(registerDir, "post.ts"),
796
1024
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
1025
+ import { hashPassword } from "tina4-nodejs";
797
1026
  import User from "../../../../models/User.js";
798
1027
 
1028
+ // Public: registration mints an account for a user who has no token yet.
1029
+ export const secure = false;
1030
+
799
1031
  export const meta = { summary: "Register a new user", tags: ["auth"] };
800
1032
 
801
1033
  export default async function (req: Tina4Request, res: Tina4Response) {
802
- const { email, password } = req.body || {};
1034
+ const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
803
1035
 
804
1036
  if (!email || !password) {
805
1037
  res.json({ error: "Email and password required" }, 400);
806
1038
  return;
807
1039
  }
808
1040
 
809
- // Check if user exists
810
1041
  const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
811
1042
  if (existing) {
812
1043
  res.json({ error: "Email already registered" }, 409);
813
1044
  return;
814
1045
  }
815
1046
 
816
- // Create user (password should be hashed in production)
817
- const user = new User({ email, password, role: "user" });
1047
+ const user = new User({ email, password: hashPassword(password), role: "user" });
818
1048
  await user.save();
819
- res.json({ message: "Registered", id: user.toJSON().id }, 201);
1049
+ res.json({ message: "Registered", id: user.toObject().id }, 201);
820
1050
  }
821
1051
  `,
822
1052
  );
823
1053
 
824
- // POST /api/auth/login
1054
+ // POST /api/auth/login (public)
825
1055
  writeFileSafe(
826
1056
  join(loginDir, "post.ts"),
827
1057
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
1058
+ import { checkPassword, getToken } from "tina4-nodejs";
828
1059
  import User from "../../../../models/User.js";
829
1060
 
1061
+ // Public: login authenticates by password and mints the token.
1062
+ export const secure = false;
1063
+
830
1064
  export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
831
1065
 
832
1066
  export default async function (req: Tina4Request, res: Tina4Response) {
833
- const { email, password } = req.body || {};
1067
+ const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
834
1068
 
835
1069
  if (!email || !password) {
836
1070
  res.json({ error: "Email and password required" }, 400);
@@ -838,38 +1072,33 @@ export default async function (req: Tina4Request, res: Tina4Response) {
838
1072
  }
839
1073
 
840
1074
  const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
841
- if (!user) {
1075
+ if (!user || !checkPassword(password, user.toObject().password as string)) {
842
1076
  res.json({ error: "Invalid credentials" }, 401);
843
1077
  return;
844
1078
  }
845
1079
 
846
- // In production, compare hashed passwords
847
- if ((user as any).password !== password) {
848
- res.json({ error: "Invalid credentials" }, 401);
849
- return;
850
- }
851
-
852
- res.json({ message: "Logged in", email: (user as any).email });
1080
+ const data = user.toObject();
1081
+ const token = getToken({ userId: data.id, email: data.email, role: data.role });
1082
+ res.json({ token });
853
1083
  }
854
1084
  `,
855
1085
  );
856
1086
 
857
- // GET /api/auth/me
1087
+ // GET /api/auth/me (public route; authenticates the Bearer itself)
858
1088
  writeFileSafe(
859
1089
  join(meDir, "get.ts"),
860
1090
  `import type { Tina4Request, Tina4Response } from "tina4-nodejs";
1091
+ import { authenticateRequest } from "tina4-nodejs";
861
1092
 
862
1093
  export const meta = { summary: "Get current authenticated user", tags: ["auth"] };
863
1094
 
864
1095
  export default async function (req: Tina4Request, res: Tina4Response) {
865
- const auth = req.headers["authorization"];
866
- if (!auth) {
1096
+ const payload = authenticateRequest(req.headers as Record<string, string | string[] | undefined>);
1097
+ if (!payload) {
867
1098
  res.json({ error: "Unauthorized" }, 401);
868
1099
  return;
869
1100
  }
870
-
871
- // In production, decode JWT and look up user
872
- res.json({ message: "Authenticated user profile" });
1101
+ res.json({ user: payload });
873
1102
  }
874
1103
  `,
875
1104
  );
@@ -934,7 +1163,330 @@ export default async function (req: Tina4Request, res: Tina4Response) {
934
1163
 
935
1164
  console.log("\n Authentication scaffolding complete.");
936
1165
  console.log(" Run: tina4nodejs migrate");
937
- console.log(" POST /api/auth/register — create account");
938
- console.log(" POST /api/auth/login — get JWT token");
1166
+ console.log(" POST /api/auth/register — create account (public)");
1167
+ console.log(" POST /api/auth/login — get JWT token (public)");
939
1168
  console.log(" GET /api/auth/me — get profile (requires token)");
940
1169
  }
1170
+
1171
+ // ── Service (scheduled background task — ServiceRunner) ──────────────
1172
+ //
1173
+ // Grounded on packages/core/src/service.ts: ServiceRunner.discover() imports
1174
+ // each src/services/*.ts and reads `mod.default ?? mod` for
1175
+ // { name, handler, interval?/timing?/daemon? }. handler receives a
1176
+ // ServiceContext ({ running, lastRun, name }).
1177
+
1178
+ function generateService(name: string, flags: Record<string, string | boolean>): void {
1179
+ const snake = toSnake(name);
1180
+ const camel = toCamel(toPascal(name)) || snake;
1181
+ const cron = flags.cron;
1182
+ const dir = resolve("src/services");
1183
+ ensureDir(dir);
1184
+ const path = join(dir, `${snake}.ts`);
1185
+
1186
+ let scheduleField: string;
1187
+ let note: string;
1188
+ if (cron && cron !== true) {
1189
+ scheduleField = ` timing: ${JSON.stringify(String(cron))},`;
1190
+ note = `cron '${cron}'`;
1191
+ } else {
1192
+ const seconds = parseEvery(flags.every);
1193
+ scheduleField = ` interval: ${seconds},`;
1194
+ note = `every ${seconds}s`;
1195
+ }
1196
+
1197
+ const body = aiFill(`${camel}Task`, {
1198
+ intent: "do the scheduled work for this service",
1199
+ given: "context -> ServiceContext (.name, .running, .lastRun)",
1200
+ use: "your ORM / Api / Messenger code (re-run on schedule)",
1201
+ ground: `tina4_context("background service scheduled task", "nodejs") · skill tina4-developer-nodejs`,
1202
+ raise: `service ${snake} not implemented`,
1203
+ });
1204
+
1205
+ const content = `import type { ServiceContext } from "tina4-nodejs";
1206
+
1207
+ /**
1208
+ * ${name} background service — runs ${note} via ServiceRunner.
1209
+ *
1210
+ * Wire a runner once (e.g. in app.ts) to actually run it — \`tina4nodejs serve\`
1211
+ * does NOT auto-start services:
1212
+ *
1213
+ * import { ServiceRunner } from "tina4-nodejs";
1214
+ * await ServiceRunner.discover("src/services"); // registers this default export
1215
+ * ServiceRunner.start();
1216
+ */
1217
+
1218
+ export async function ${camel}Task(context: ServiceContext): Promise<void> {
1219
+ ${body}}
1220
+
1221
+ // Discovered by ServiceRunner.discover("src/services") — it reads name/handler
1222
+ // (+ interval or timing) off this default export.
1223
+ export default {
1224
+ name: "${snake}",
1225
+ handler: ${camel}Task,
1226
+ ${scheduleField}
1227
+ };
1228
+ `;
1229
+
1230
+ writeFileSafe(path, content);
1231
+ }
1232
+
1233
+ // ── Queue (producer + consumer worker) ──────────────────────────────
1234
+ //
1235
+ // Grounded on packages/core/src/queue.ts + job.ts: new Queue({ topic }),
1236
+ // queue.produce(topic, payload) -> id, `for await (const job of
1237
+ // queue.consume(topic))` yields QueueJob ({ payload, complete(), fail() }).
1238
+ // The consumer is wired as a ServiceRunner daemon (it owns its own loop).
1239
+
1240
+ function generateQueue(name: string, _flags: Record<string, string | boolean>): void {
1241
+ const topic = name.replace(/^\//, "");
1242
+ const slug = toSnake(topic.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
1243
+ const pascal = toPascal(topic) || "Topic";
1244
+ const dir = resolve("src/services"); // consumer runs as a daemon service
1245
+ ensureDir(dir);
1246
+ const path = join(dir, `${slug}_consumer.ts`);
1247
+
1248
+ const body = aiFill(`handle${pascal}`, {
1249
+ intent: `process ONE ${topic} job payload`,
1250
+ given: "payload -> the produced job data (job.payload)",
1251
+ use: "your ORM / Messenger code; return to ack (job.complete), throw to nack (job.fail)",
1252
+ ground: `tina4_context("process a queue job", "nodejs") · skill tina4-developer-nodejs`,
1253
+ raise: `queue ${topic} handler not implemented`,
1254
+ });
1255
+
1256
+ const content = `import { Queue } from "tina4-nodejs";
1257
+ import type { ServiceContext } from "tina4-nodejs";
1258
+
1259
+ /**
1260
+ * ${topic} queue — producer + consumer worker.
1261
+ *
1262
+ * Produce from anywhere: publish${pascal}({ ... })
1263
+ * The consumer is a long-running worker wired as a ServiceRunner daemon:
1264
+ * await ServiceRunner.discover("src/services"); ServiceRunner.start();
1265
+ */
1266
+
1267
+ /** Enqueue a ${topic} job for the worker below to process. Returns the job id. */
1268
+ export function publish${pascal}(payload: Record<string, unknown>): string {
1269
+ return new Queue({ topic: "${topic}" }).produce("${topic}", payload);
1270
+ }
1271
+
1272
+ /** Process ONE ${topic} job payload. */
1273
+ export async function handle${pascal}(payload: unknown): Promise<void> {
1274
+ ${body}}
1275
+
1276
+ /** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
1277
+ export async function consume${pascal}(_context?: ServiceContext): Promise<void> {
1278
+ const queue = new Queue({ topic: "${topic}" });
1279
+ for await (const job of queue.consume("${topic}")) {
1280
+ const one = Array.isArray(job) ? job[0] : job;
1281
+ try {
1282
+ await handle${pascal}(one.payload);
1283
+ one.complete(); // ack — remove from the queue
1284
+ } catch (err) {
1285
+ one.fail(String(err)); // nack — retry / dead-letter
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // Discovered by ServiceRunner.discover("src/services"); daemon:true because
1291
+ // consume${pascal} owns its own loop.
1292
+ export default {
1293
+ name: "${topic}-consumer",
1294
+ handler: consume${pascal},
1295
+ daemon: true,
1296
+ };
1297
+ `;
1298
+
1299
+ writeFileSafe(path, content);
1300
+ }
1301
+
1302
+ // ── Validator (request-body Validator) ──────────────────────────────
1303
+ //
1304
+ // Grounded on packages/core/src/validator.ts: new Validator(data) is chainable
1305
+ // (.required/.email/.minLength/.integer/.inList) and exposes .isValid()/.errors().
1306
+
1307
+ function generateValidator(name: string, _flags: Record<string, string | boolean>): void {
1308
+ const dir = resolve("src/validators");
1309
+ ensureDir(dir);
1310
+ const path = join(dir, `${toSnake(name)}.ts`);
1311
+
1312
+ const body = aiFill(`validate${toPascal(name)}`, {
1313
+ intent: `declare the validation rules for a ${name} payload`,
1314
+ given: "validator -> Validator(data) (chainable)",
1315
+ use: `validator.required("name").email("email").minLength("name", 2).integer("age")`,
1316
+ ret: "the same validator (caller checks .isValid() / .errors())",
1317
+ ground: `tina4_context("validate request body with Validator", "nodejs") · skill tina4-developer-nodejs`,
1318
+ raise: `validator ${toSnake(name)} not implemented`,
1319
+ });
1320
+
1321
+ const content = `import { Validator } from "tina4-nodejs";
1322
+
1323
+ /**
1324
+ * Validate a ${name} payload. Returns a Validator (chainable rules).
1325
+ *
1326
+ * Usage in a route:
1327
+ * const v = validate${toPascal(name)}(req.body as Record<string, unknown>);
1328
+ * if (!v.isValid()) return res.json({ error: v.errors()[0]?.message }, 400);
1329
+ */
1330
+ export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
1331
+ const validator = new Validator(data);
1332
+ ${body} return validator;
1333
+ }
1334
+ `;
1335
+
1336
+ writeFileSafe(path, content);
1337
+ }
1338
+
1339
+ // ── Seeder (FakeData + seedOrm) ─────────────────────────────────────
1340
+ //
1341
+ // Grounded on packages/orm/src/seeder.ts (seedOrm(model, count, overrides) —
1342
+ // overrides may be static values OR (fake) => value callables) and
1343
+ // packages/cli/src/commands/seed.ts (runs each src/seeds/*.ts as a script).
1344
+
1345
+ function generateSeeder(name: string, _flags: Record<string, string | boolean>): void {
1346
+ const table = toTableName(name);
1347
+ const dir = resolve("src/seeds");
1348
+ ensureDir(dir);
1349
+ const path = join(dir, `${table}_seeder.ts`);
1350
+
1351
+ const body = aiFill("fieldOverrides", {
1352
+ intent: `map ${name} fields to fake-data generators (only those needing a specific shape)`,
1353
+ given: "fake -> FakeData instance",
1354
+ use: "fake.email() / fake.name() / fake.integer(1, 99) / fake.company()",
1355
+ ret: `{ email: (f) => f.email(), status: "active" } (Record<string, unknown>)`,
1356
+ ground: `tina4_context("seed ORM model with FakeData", "nodejs") · skill tina4-developer-nodejs`,
1357
+ raise: `seeder ${name} overrides not implemented`,
1358
+ });
1359
+
1360
+ const content = `import { pathToFileURL } from "node:url";
1361
+ import { FakeData, seedOrm, initDatabase } from "tina4-nodejs/orm";
1362
+ import ${name} from "../models/${name}.js";
1363
+
1364
+ /**
1365
+ * Seeder for ${name} — run with: tina4nodejs seed
1366
+ *
1367
+ * seedOrm auto-fills every field by type/name; override the ones that need a
1368
+ * specific shape below. Each callable receives a FakeData instance.
1369
+ */
1370
+ export function fieldOverrides(fake: FakeData): Record<string, unknown> {
1371
+ ${body}}
1372
+
1373
+ /** Seed rows. Invoked when this file is run directly by \`tina4nodejs seed\`. */
1374
+ export async function run(): Promise<void> {
1375
+ await initDatabase({ url: process.env.TINA4_DATABASE_URL ?? "sqlite:///data/app.db" });
1376
+ const summary = await seedOrm(${name} as never, 20, fieldOverrides(new FakeData()));
1377
+ console.log(\`Seeded \${summary.seeded} ${name} row(s), \${summary.failed} failed\`);
1378
+ }
1379
+
1380
+ // Only seed when executed as a script (\`tina4nodejs seed\` runs it via tsx) —
1381
+ // importing this module (e.g. in a test) must NOT trigger seeding.
1382
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1383
+ await run();
1384
+ }
1385
+ `;
1386
+
1387
+ writeFileSafe(path, content);
1388
+ }
1389
+
1390
+ // ── WebSocket route ─────────────────────────────────────────────────
1391
+ //
1392
+ // Grounded on packages/core/src/router.ts (websocket(path, handler).secure())
1393
+ // + types.ts (handler is (connection, event, data)) + websocketConnection.ts
1394
+ // (.send/.sendJson/.broadcast/.close/.params). Node has NO file-based WS
1395
+ // auto-discovery — the module must be imported to register (see the doc block).
1396
+
1397
+ function generateWebsocket(name: string, _flags: Record<string, string | boolean>): void {
1398
+ const raw = name.trim();
1399
+ const wsPath = raw.startsWith("/") ? raw : "/ws/" + raw.replace(/^\/+/, "");
1400
+ let slug = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
1401
+ const base = slug.startsWith("ws_") ? slug.slice(3) : slug;
1402
+ const handlerName = `${toCamel(toPascal(base))}Ws`;
1403
+ const dir = resolve("src/routes");
1404
+ ensureDir(dir);
1405
+ const path = join(dir, `ws_${base}.ts`);
1406
+
1407
+ const body = aiFill(handlerName, {
1408
+ intent: `handle an inbound "message" frame on ${wsPath}`,
1409
+ given: "data -> the message payload (string); connection -> WebSocketConnection",
1410
+ use: "connection.broadcast(data) or connection.sendJson({ ... })",
1411
+ ground: `tina4_context("websocket broadcast message", "nodejs") · skill tina4-developer-nodejs`,
1412
+ raise: `websocket ${wsPath} not implemented`,
1413
+ });
1414
+
1415
+ const content = `import { websocket } from "tina4-nodejs";
1416
+ import type { WebSocketConnection } from "tina4-nodejs";
1417
+
1418
+ /**
1419
+ * ${wsPath} WebSocket route.
1420
+ *
1421
+ * Registered on import by websocket(). Node has NO file-based WS
1422
+ * auto-discovery, so IMPORT this module once from app.ts to activate it (add
1423
+ * \`.secure()\` to require a JWT on the upgrade):
1424
+ *
1425
+ * import "./src/routes/ws_${base}.js";
1426
+ *
1427
+ * The server invokes the handler as (connection, event, data) for each event:
1428
+ * "open" (connect), "message" (inbound frame), "close" (disconnect).
1429
+ */
1430
+ export async function ${handlerName}(
1431
+ connection: WebSocketConnection,
1432
+ event: "open" | "message" | "close",
1433
+ data: string,
1434
+ ): Promise<void> {
1435
+ if (event === "open") {
1436
+ connection.sendJson({ type: "welcome" });
1437
+ return;
1438
+ }
1439
+ if (event === "close") {
1440
+ return;
1441
+ }
1442
+ // event === "message"
1443
+ ${body}}
1444
+
1445
+ websocket("${wsPath}", ${handlerName});
1446
+ `;
1447
+
1448
+ writeFileSafe(path, content);
1449
+ }
1450
+
1451
+ // ── Event listener ──────────────────────────────────────────────────
1452
+ //
1453
+ // Grounded on packages/core/src/events.ts: Events.on(event, cb) registers,
1454
+ // Events.emit(event, ...args) fires, Events.listeners(event) reads them. Node
1455
+ // has NO src/listeners/ auto-discovery — import the module to register.
1456
+
1457
+ function generateListener(name: string, _flags: Record<string, string | boolean>): void {
1458
+ const event = name.trim();
1459
+ const slug = toSnake(event.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
1460
+ const handlerName = `on${toPascal(slug)}`;
1461
+ const dir = resolve("src/listeners");
1462
+ ensureDir(dir);
1463
+ const path = join(dir, `${slug}.ts`);
1464
+
1465
+ const body = aiFill(handlerName, {
1466
+ intent: `react to the '${event}' event`,
1467
+ given: `args -> whatever Events.emit("${event}", ...args) passed`,
1468
+ use: "your app code — Messenger().send(...), an ORM write, or Events.emit(...) a follow-up",
1469
+ ground: `tina4_context("event listener reaction", "nodejs") · skill tina4-developer-nodejs`,
1470
+ raise: `listener ${event} not implemented`,
1471
+ });
1472
+
1473
+ const content = `import { Events } from "tina4-nodejs";
1474
+
1475
+ /**
1476
+ * Listener for the '${event}' event.
1477
+ *
1478
+ * Registered on import by Events.on(). Node has NO src/listeners/
1479
+ * auto-discovery, so IMPORT this module once from app.ts to activate it:
1480
+ *
1481
+ * import "./src/listeners/${slug}.js";
1482
+ *
1483
+ * Fires when something calls Events.emit("${event}", ...args).
1484
+ */
1485
+ export function ${handlerName}(...args: unknown[]): void {
1486
+ ${body}}
1487
+
1488
+ Events.on("${event}", ${handlerName});
1489
+ `;
1490
+
1491
+ writeFileSafe(path, content);
1492
+ }
@@ -64,8 +64,16 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
64
64
 
65
65
  const meta: RouteMeta | undefined = mod.meta;
66
66
  const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
67
-
68
- definitions.push({ method, pattern, handler, filePath, meta, template });
67
+ // Auth opt-outs a route file can export (parity with the imperative
68
+ // `.noAuth()` / AutoCrud `secure: false`). A generated public write file
69
+ // (`generate route … --public`, or the always-public auth login/register)
70
+ // does `export const secure = false;`; without threading it here the
71
+ // router would keep its secure-by-default write gate and the opt-out
72
+ // would be inert. Only booleans are honoured — anything else is ignored.
73
+ const secure: boolean | undefined = typeof mod.secure === "boolean" ? mod.secure : undefined;
74
+ const noAuth: boolean | undefined = typeof mod.noAuth === "boolean" ? mod.noAuth : undefined;
75
+
76
+ definitions.push({ method, pattern, handler, filePath, meta, template, secure, noAuth });
69
77
  _seenFiles.add(filePath);
70
78
  _seenMtimes.set(filePath, currentMtime);
71
79
  registeredFromThisScan++;