tina4-nodejs 3.13.119 → 3.13.121

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.
@@ -33,7 +33,7 @@
33
33
  * tina4nodejs generate listener user.created
34
34
  */
35
35
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
36
- import { join, resolve } from "node:path";
36
+ import { join, relative, resolve, sep } from "node:path";
37
37
 
38
38
  // ── Field type mapping ──────────────────────────────────────────────
39
39
  const FIELD_TYPE_MAP: Record<string, { orm: string; sql: string; defaultVal: string }> = {
@@ -62,9 +62,15 @@ function ensureDir(dir: string): void {
62
62
  }
63
63
 
64
64
  function writeFileSafe(path: string, content: string): void {
65
+ // ALWAYS scan the intended content for `// tina4:edit` markers, regardless
66
+ // of dry-run. The envelope's edit_hints[] MUST promise the same hints in
67
+ // preview (--dry-run) and post-write so an agent can rely on them before
68
+ // committing to disk.
69
+ captureEditHints(path, content);
70
+
65
71
  if (__resolution.dryRun) {
66
- // Dry-run: record what WOULD have been written, but touch no disk state
67
- // and print no per-file line to stdout (that would leak into --json).
72
+ // Dry-run: touch no disk state and print no per-file line to stdout
73
+ // (that would leak into --json).
68
74
  return;
69
75
  }
70
76
  if (existsSync(path)) {
@@ -152,6 +158,18 @@ export interface ResolutionInput {
152
158
  fields: string | null;
153
159
  }
154
160
 
161
+ /**
162
+ * One `// tina4:edit …` marker found in a written (or would-be-written)
163
+ * template file. `file` is repo-relative POSIX (matches the rest of the
164
+ * envelope's paths); `line` is 1-based; `label` is the short imperative label
165
+ * that followed the marker on the same line.
166
+ */
167
+ export interface EditHint {
168
+ file: string;
169
+ line: number;
170
+ label: string;
171
+ }
172
+
155
173
  export interface ResolutionBody {
156
174
  class_name?: string;
157
175
  table_name?: string;
@@ -159,6 +177,8 @@ export interface ResolutionBody {
159
177
  migration_path?: string;
160
178
  routes?: string[];
161
179
  test_paths?: string[];
180
+ edit_hints?: EditHint[];
181
+ next?: string[];
162
182
  transformations: ResolutionTransformation[];
163
183
  }
164
184
 
@@ -176,8 +196,15 @@ export interface ResolutionEnvelope {
176
196
  * `resolution_contract.envelope` so the tina4 client (or any consumer) can
177
197
  * discover the exact contract this framework speaks. Bump when a breaking
178
198
  * key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
199
+ *
200
+ * `generate_v1_1` (ADR-0063, 3.13.120) is a PURELY ADDITIVE superset of
201
+ * `generate_v1`: every v1 field is preserved, and two new optional arrays
202
+ * appear — `resolution.edit_hints[]` (one entry per `// tina4:edit` marker
203
+ * baked into a template) and `resolution.next[]` (curated per-verb actionable
204
+ * next steps). `resolution.test_paths[]` was already in v1; v1.1 surfaces it
205
+ * in the human stderr block too.
179
206
  */
180
- export const RESOLUTION_ENVELOPE_VERSION = "generate_v1";
207
+ export const RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
181
208
 
182
209
  /**
183
210
  * Per-run mutable resolution state. Reset by `resetResolution()` on every
@@ -216,14 +243,27 @@ function recordTransformation(t: ResolutionTransformation): void {
216
243
  /** Read-only snapshot of the current resolution — exported for tests that
217
244
  * want to inspect it in-process (the CLI itself uses only the envelope). */
218
245
  export function currentResolution(): ResolutionEnvelope {
246
+ const body: ResolutionBody = {
247
+ ...__resolution.body,
248
+ transformations: [...__resolution.body.transformations],
249
+ };
250
+ if (__resolution.body.edit_hints) {
251
+ body.edit_hints = __resolution.body.edit_hints.map((h) => ({ ...h }));
252
+ }
253
+ if (__resolution.body.next) {
254
+ body.next = [...__resolution.body.next];
255
+ }
256
+ if (__resolution.body.test_paths) {
257
+ body.test_paths = [...__resolution.body.test_paths];
258
+ }
259
+ if (__resolution.body.routes) {
260
+ body.routes = [...__resolution.body.routes];
261
+ }
219
262
  return {
220
263
  command: "generate",
221
264
  target: __resolution.target,
222
265
  input: { ...__resolution.input },
223
- resolution: {
224
- ...__resolution.body,
225
- transformations: [...__resolution.body.transformations],
226
- },
266
+ resolution: body,
227
267
  actions_taken: [...__resolution.actionsTaken],
228
268
  dry_run: __resolution.dryRun,
229
269
  };
@@ -243,6 +283,74 @@ function pushTestPath(path: string): void {
243
283
  __resolution.body.test_paths.push(path);
244
284
  }
245
285
 
286
+ function pushEditHint(hint: EditHint): void {
287
+ if (!__resolution.body.edit_hints) __resolution.body.edit_hints = [];
288
+ __resolution.body.edit_hints.push(hint);
289
+ }
290
+
291
+ function setNextSteps(steps: string[]): void {
292
+ if (steps.length === 0) return;
293
+ __resolution.body.next = [...steps];
294
+ }
295
+
296
+ /**
297
+ * Convert an absolute path to a repo-relative POSIX path. Every other
298
+ * envelope path (file_path, migration_path, test_paths) is repo-relative
299
+ * POSIX ("src/models/Order.ts"), so edit_hints follow the same convention —
300
+ * one path style across the whole envelope, portable across Windows.
301
+ */
302
+ function toRelPath(absPath: string): string {
303
+ const cwd = process.cwd();
304
+ const rel = relative(cwd, absPath);
305
+ if (!rel) return absPath;
306
+ return sep === "/" ? rel : rel.split(sep).join("/");
307
+ }
308
+
309
+ // Line-anchored `tina4:edit LABEL` marker regex (multi-style, ADR-0063 v1.1).
310
+ //
311
+ // Line-anchored (`^\s*<comment-lead>`) so the marker must be at the START of
312
+ // a code line (after optional whitespace) — a marker embedded inside a
313
+ // template string literal never falsely matches. LABEL is captured greedily
314
+ // until end of line (or the closing `#}` for a Twig comment), then trimmed
315
+ // on push. Four comment styles are recognised, one regex per style — bundled
316
+ // into one alternation so the scanner is O(lines):
317
+ //
318
+ // // tina4:edit LABEL TS/JS/C/Java/Rust (existing)
319
+ // # tina4:edit LABEL Python/Ruby/shell (parity)
320
+ // -- tina4:edit LABEL SQL migrations
321
+ // {# tina4:edit LABEL #} Twig / Frond templates
322
+ //
323
+ // Ports the same shape PHP uses in `bin/tina4php::collectEditHintsFromContent`
324
+ // (`~(?://|--|\{#)\s*tina4:edit\s+(.+?)(?:\s*#\})?\s*$~`). The `#` bare-
325
+ // comment style is added on top so a Ruby/Python-style template (none exist
326
+ // today, but the scanner is now language-agnostic) is covered too.
327
+ const TINA4_EDIT_MARKER = /^\s*(?:\/\/|--|\{#|#)\s*tina4:edit\s+(.+?)(?:\s*#\})?\s*$/;
328
+
329
+ /**
330
+ * Scan `content` for `tina4:edit …` markers (any of the 4 comment styles)
331
+ * and record one EditHint per match against the given absolute path. Called
332
+ * from every `writeFileSafe` — including under `--dry-run` — so the envelope
333
+ * promises the same hints in preview and post-write.
334
+ *
335
+ * File-extension gate keeps the scan cheap: only text/code files where a
336
+ * marker could reasonably live are opened. TS/JS + SQL + Twig cover every
337
+ * template the generator emits today; expanded here (from TS/JS-only in
338
+ * 3.13.120) so `generate form`, `generate view` and `generate migration`
339
+ * carry `edit_hints[]` rather than returning `[]` (parity with PHP, whose
340
+ * regex has always been language-agnostic).
341
+ */
342
+ function captureEditHints(absPath: string, content: string): void {
343
+ if (!/\.(ts|tsx|js|mjs|cjs|jsx|sql|twig|html\.twig)$/.test(absPath)) return;
344
+ const relPath = toRelPath(absPath);
345
+ const lines = content.split("\n");
346
+ for (let i = 0; i < lines.length; i++) {
347
+ const match = TINA4_EDIT_MARKER.exec(lines[i]);
348
+ if (match) {
349
+ pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() });
350
+ }
351
+ }
352
+ }
353
+
246
354
  /**
247
355
  * Emit the resolution — as JSON on STDOUT for `--json`, otherwise as a human
248
356
  * block on STDERR (stderr so a caller piping stdout for other output isn't
@@ -282,6 +390,28 @@ function printResolution(): void {
282
390
  lines.push(` To keep the raw name '${reserved.from}' as the table:`);
283
391
  lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
284
392
  }
393
+ // v1.1 (ADR-0063): surface the already-populated test_paths[], and the two
394
+ // new arrays (edit_hints, next) when either is non-empty. Sections stay
395
+ // absent when the corresponding array is empty — a listener/service
396
+ // scaffold prints exactly what a model scaffold prints, minus what does
397
+ // not apply.
398
+ if (b.test_paths && b.test_paths.length > 0) {
399
+ lines.push("");
400
+ lines.push(" Tests:");
401
+ for (const testPath of b.test_paths) lines.push(` ${testPath}`);
402
+ }
403
+ if (b.edit_hints && b.edit_hints.length > 0) {
404
+ lines.push("");
405
+ lines.push(" Edit these lines:");
406
+ for (const hint of b.edit_hints) {
407
+ lines.push(` ${hint.file}:${hint.line} ${hint.label}`);
408
+ }
409
+ }
410
+ if (b.next && b.next.length > 0) {
411
+ lines.push("");
412
+ lines.push(" Next:");
413
+ for (const step of b.next) lines.push(` ${step}`);
414
+ }
285
415
  lines.push("");
286
416
  process.stderr.write(lines.join("\n"));
287
417
  }
@@ -341,6 +471,10 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
341
471
  const booleanFlags = new Set([
342
472
  "no-browser", "no-reload", "production", "managed", "all", "clear",
343
473
  "public", "no-migration",
474
+ // Suppress the co-emitted migration test (used by the migrate:create
475
+ // delegation — a plain migrate:create is "just a migration, no test",
476
+ // matching its pre-3.13.121 UX now that it routes through generate migration).
477
+ "no-test",
344
478
  // Resolution transparency (Feature B, 3.13.117): both accept NO value.
345
479
  "json", "dry-run",
346
480
  ]);
@@ -460,7 +594,7 @@ export const GENERATORS: Record<string, GeneratorSpec> = {
460
594
  model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
461
595
  route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
462
596
  crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
463
- migration: { handler: (n, f) => generateMigration(n, f), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
597
+ migration: { handler: (n, f) => generateMigration(n, f, undefined, undefined, !f["no-test"]), usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
464
598
  middleware: { handler: generateMiddleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
465
599
  test: { handler: generateTest, usage: "<name> [--model Name]", summary: "Test file" },
466
600
  form: { handler: generateForm, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
@@ -477,6 +611,105 @@ export const GENERATORS: Record<string, GeneratorSpec> = {
477
611
  /** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */
478
612
  const GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
479
613
 
614
+ /**
615
+ * Curated per-verb next steps — populates `resolution.next[]` (envelope) and
616
+ * the "Next:" block on stderr (human). Grounded on the real code paths a
617
+ * developer takes after each generator. Cap of 5 (short, actionable).
618
+ *
619
+ * The context carries the RESOLVED name and table (the reserved-word
620
+ * pluraliser has already run at dispatch time), so a step references the
621
+ * same table/route the generated files bind to. `name` is the CLI positional
622
+ * as-typed; `table` is `toTableName(name)`.
623
+ */
624
+ interface NextContext { name: string; table: string; }
625
+ const NEXT_STEPS: Record<string, (c: NextContext) => string[]> = {
626
+ model: ({ name, table }) => [
627
+ `Edit src/models/${name}.ts to add fields beyond the default 'name'`,
628
+ `Apply the migration: npx tina4nodejs migrate`,
629
+ `Run its test: npx tsx tests/${table}_model.test.ts`,
630
+ `Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`,
631
+ ],
632
+ route: ({ name, table }) => [
633
+ `Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`,
634
+ `Run its test: npx tsx tests/${table}.test.ts`,
635
+ `Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`,
636
+ ],
637
+ crud: ({ name, table }) => [
638
+ `Apply the migration: npx tina4nodejs migrate`,
639
+ `Serve and try: npx tina4nodejs serve -> visit /swagger`,
640
+ `Run the gate test: npx tsx tests/${toPlural(table)}.test.ts`,
641
+ `Change fields: edit src/models/${name}.ts then re-run generate crud`,
642
+ ],
643
+ migration: () => [
644
+ `Apply pending migrations: npx tina4nodejs migrate`,
645
+ `Check status: npx tina4nodejs migrate:status`,
646
+ `Roll back the batch: npx tina4nodejs migrate:rollback`,
647
+ ],
648
+ middleware: ({ name }) => [
649
+ `Wire it: router.middleware(before${name}, after${name}) — or bind per-route`,
650
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
651
+ ],
652
+ test: ({ name }) => [
653
+ `Fill the TODOs in tests/${toSnake(name)}.test.ts`,
654
+ `Run it: npx tsx tests/${toSnake(name)}.test.ts`,
655
+ ],
656
+ form: ({ name, table }) => [
657
+ `Render from a route: res.render("forms/${table}.twig", { item })`,
658
+ `Add the POST route: npx tina4nodejs generate route ${toPlural(table)} --model ${name}`,
659
+ ],
660
+ view: ({ table }) => [
661
+ `Wire routes to render list -> ${toPlural(table)}.twig, detail -> ${table}.twig`,
662
+ `Customize the templates in src/templates/pages/`,
663
+ ],
664
+ auth: () => [
665
+ `Apply the migration: npx tina4nodejs migrate`,
666
+ `Run the auth test: npx tsx tests/auth.test.ts`,
667
+ `Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
668
+ `Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
669
+ ],
670
+ service: ({ name }) => [
671
+ `Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`,
672
+ `Fill the task body in src/services/${toSnake(name)}.ts`,
673
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
674
+ ],
675
+ queue: ({ name }) => {
676
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
677
+ return [
678
+ `Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`,
679
+ `Produce a job: publish${toPascal(name)}({ ... })`,
680
+ `Run the worker: npx tina4nodejs queue work ${name}`,
681
+ `Run its test: npx tsx tests/${slug}.test.ts`,
682
+ ];
683
+ },
684
+ validator: ({ name }) => [
685
+ `Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`,
686
+ `Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
687
+ ],
688
+ seeder: ({ name, table }) => [
689
+ `Override any fields that need a specific shape in src/seeds/${table}_seeder.ts`,
690
+ `Seed the table: npx tina4nodejs seed`,
691
+ `Run its test: npx tsx tests/${table}_seeder.test.ts`,
692
+ ],
693
+ websocket: ({ name }) => {
694
+ const raw = name.trim();
695
+ const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
696
+ const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw;
697
+ return [
698
+ `Import once in app.ts to register: import "./src/routes/ws_${base}.js";`,
699
+ `Fill the "message" branch in src/routes/ws_${base}.ts`,
700
+ `Run its test: npx tsx tests/ws_${base}.test.ts`,
701
+ ];
702
+ },
703
+ listener: ({ name }) => {
704
+ const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
705
+ return [
706
+ `Import once in app.ts to register: import "./src/listeners/${slug}.js";`,
707
+ `Fill the reaction in src/listeners/${slug}.ts`,
708
+ `Run its test: npx tsx tests/${slug}.test.ts`,
709
+ ];
710
+ },
711
+ };
712
+
480
713
  // ── Main entry point ────────────────────────────────────────────────
481
714
 
482
715
  export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
@@ -491,8 +724,17 @@ export async function generate(what: string, name: string, extraArgs: string[] =
491
724
  process.exit(1);
492
725
  }
493
726
 
494
- // Auth doesn't require a name
727
+ // Auth doesn't require a name.
495
728
  const noNameGenerators = new Set(["auth"]);
729
+ // bin.ts always passes argv[1] as `name`. For a no-name generator that means
730
+ // `generate auth --json` arrives here as name="--json", extraArgs=[]. Rescue
731
+ // the flag: shift a leading `--foo` name into extraArgs so parseCliArgs
732
+ // actually sees it. Fixes `--json` (and any other flag) being silently
733
+ // eaten by the no-name verbs.
734
+ if (noNameGenerators.has(what) && name.startsWith("--")) {
735
+ extraArgs = [name, ...extraArgs];
736
+ name = "";
737
+ }
496
738
  if (!noNameGenerators.has(what) && !name) {
497
739
  console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
498
740
  process.exit(1);
@@ -520,11 +762,73 @@ export async function generate(what: string, name: string, extraArgs: string[] =
520
762
  process.exit(1);
521
763
  }
522
764
 
765
+ // v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator
766
+ // AFTER dispatch, so the table name reflects any reserved-word pluralisation
767
+ // that fired during the run (Order -> orders). Prefer the resolution's own
768
+ // table_name (already set by generateModel/generateMigration) so we do NOT
769
+ // re-invoke toTableName() — that would record a DUPLICATE
770
+ // reserved_word_pluralize transformation on the envelope.
771
+ const nextFn = NEXT_STEPS[what];
772
+ if (nextFn) {
773
+ const resolvedTable = __resolution.body.table_name
774
+ ?? (name
775
+ ? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name))
776
+ : "");
777
+ setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
778
+ }
779
+
523
780
  // Emit the resolution AFTER dispatch so `actions_taken` reflects the real
524
781
  // writes (or the empty list under `--dry-run`).
525
782
  printResolution();
526
783
  }
527
784
 
785
+ /**
786
+ * Programmatic entry point for in-process consumers (MCP tools, tests, hosted
787
+ * agents) — does everything `generate()` does EXCEPT print.
788
+ *
789
+ * Reset the resolution → dispatch to the requested generator → populate `next[]`
790
+ * → return the envelope. Files still land on disk (unless `--dry-run` is passed
791
+ * in `extraArgs`); only the human "Created …" per-file log and the
792
+ * `printResolution()` output are suppressed (via `jsonMode: true`, the same
793
+ * suppression `--json` uses on the CLI).
794
+ *
795
+ * Used by the MCP `migration_create` tool (packages/core/src/mcp.ts) so the
796
+ * ADR-0063 `generate_v1_1` envelope drives every surface (CLI, MCP, tests)
797
+ * without a subprocess round-trip.
798
+ */
799
+ export async function generateProgrammatic(
800
+ what: string,
801
+ name: string,
802
+ extraArgs: string[] = [],
803
+ ): Promise<ResolutionEnvelope> {
804
+ const spec = GENERATORS[what];
805
+ if (!spec) throw new Error(`Unknown generator: ${what} (available: ${GENERATOR_LIST})`);
806
+
807
+ const { flags } = parseCliArgs(extraArgs);
808
+ const dryRun = Boolean(flags["dry-run"]);
809
+ // jsonMode:true suppresses writeFileSafe's per-file console.log so nothing
810
+ // leaks to stdout (which the JSON-RPC caller would parse as tool output).
811
+ // Files are still written — jsonMode gates PRINTS only, not disk writes.
812
+ resetResolution(what, { name, fields: (flags.fields as string) ?? null }, { dryRun, jsonMode: true });
813
+
814
+ spec.handler(name, flags);
815
+
816
+ // v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator —
817
+ // same logic `generate()` runs, using the RESOLVED table_name when the
818
+ // dispatched handler recorded one (avoids a duplicate reserved_word
819
+ // transformation on the envelope).
820
+ const nextFn = NEXT_STEPS[what];
821
+ if (nextFn) {
822
+ const resolvedTable = __resolution.body.table_name
823
+ ?? (name
824
+ ? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name))
825
+ : "");
826
+ setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
827
+ }
828
+
829
+ return currentResolution();
830
+ }
831
+
528
832
  // ── Model ───────────────────────────────────────────────────────────
529
833
 
530
834
  function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
@@ -549,6 +853,7 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
549
853
  // Build field definitions
550
854
  const fieldLines: string[] = [
551
855
  ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
856
+ ` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`,
552
857
  ];
553
858
  for (const [fname, ftype] of fields) {
554
859
  const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
@@ -637,6 +942,7 @@ ${modelImportBase}
637
942
  export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
638
943
 
639
944
  export default async function (req: Tina4Request, res: Tina4Response) {
945
+ // tina4:edit tune pagination defaults or add filter/sort parsing here
640
946
  const page = parseInt(req.query.page as string) || 1;
641
947
  const limit = parseInt(req.query.limit as string) || 20;
642
948
  const offset = (page - 1) * limit;
@@ -674,6 +980,7 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
674
980
 
675
981
  // ${writeDoc}
676
982
  export default async function (req: Tina4Request, res: Tina4Response) {
983
+ // tina4:edit validate the body before persist (Validator or hand-checks)
677
984
  ${extend("validate / business rules before persist",
678
985
  `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
679
986
  // save() returns false on failure rather than throwing - check it, or a failed
@@ -695,6 +1002,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular
695
1002
 
696
1003
  // ${writeDoc}
697
1004
  export default async function (req: Tina4Request, res: Tina4Response) {
1005
+ // tina4:edit fill the create handler (see AI-FILL fill-spec below)
698
1006
  ${aiFill(`create_${singular}`, {
699
1007
  intent: `validate the body and persist a new ${singular}`,
700
1008
  given: "req.body -> the posted fields",
@@ -761,6 +1069,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
761
1069
  res.json({ error: "Not found" }, 404);
762
1070
  return;
763
1071
  }
1072
+ // tina4:edit guard which fields may be updated and who may update this row
764
1073
  ${extend("guard which fields / who may update",
765
1074
  `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
766
1075
  // save() returns false on failure rather than throwing - check it, or a failed
@@ -782,6 +1091,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by
782
1091
 
783
1092
  // ${writeDoc}
784
1093
  export default async function (req: Tina4Request, res: Tina4Response) {
1094
+ // tina4:edit fill the update handler (see AI-FILL fill-spec below)
785
1095
  ${aiFill(`update_${singular}`, {
786
1096
  intent: `load, mutate and save an existing ${singular}`,
787
1097
  given: "req.params.id -> id; req.body -> changed fields",
@@ -855,7 +1165,10 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
855
1165
  const routeName = toPlural(table);
856
1166
  const isPublic = Boolean(flags.public);
857
1167
 
858
- console.log(`\n Generating CRUD for ${name}...\n`);
1168
+ // Human-only banners; suppressed under --json to keep stdout parseable
1169
+ // (a console.log during --json produced invalid JSON). writeFileSafe already
1170
+ // gates its own "Created …" lines the same way.
1171
+ if (!__resolution.jsonMode) console.log(`\n Generating CRUD for ${name}...\n`);
859
1172
 
860
1173
  // 1. Model + migration (its own model test is suppressed — the gate test
861
1174
  // below is CRUD's single, broader co-emitted test).
@@ -875,14 +1188,16 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
875
1188
  // 5. Test — real secure-by-default boot-gate (reads public, writes gated).
876
1189
  generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
877
1190
 
878
- console.log(`\n CRUD generation complete for ${name}.`);
879
- console.log(" Run: tina4nodejs migrate");
880
- console.log(" Visit: /swagger to see the API docs");
1191
+ if (!__resolution.jsonMode) {
1192
+ console.log(`\n CRUD generation complete for ${name}.`);
1193
+ console.log(" Run: tina4nodejs migrate");
1194
+ console.log(" Visit: /swagger to see the API docs");
1195
+ }
881
1196
  }
882
1197
 
883
1198
  // ── Migration ───────────────────────────────────────────────────────
884
1199
 
885
- function generateMigration(
1200
+ export function generateMigration(
886
1201
  name: string,
887
1202
  flags: Record<string, string | boolean>,
888
1203
  fieldsOverride?: Array<[string, string]>,
@@ -938,6 +1253,15 @@ function generateMigration(
938
1253
  let upSql: string;
939
1254
  let downSql: string;
940
1255
 
1256
+ // ADR-0063 (scaffolding envelope v1.1): `-- tina4:edit <label>` markers
1257
+ // sit above each SQL block so an operator (or an AI agent) can grep to
1258
+ // the genuine first-edit spot. Wording mirrors Ruby's `generate_migration`
1259
+ // + PHP's `sqlEditHintMarker` calls for cross-language parity. The create-
1260
+ // branch marker sits INSIDE the column-list parentheses but OUTSIDE
1261
+ // `colLines.join(",\n")` so its trailing `,` does not become part of the
1262
+ // parsed label (the migration runner's splitStatements strips `--` line
1263
+ // comments, so a comma-less marker line inside a DDL body is still
1264
+ // syntactically inert on every engine).
941
1265
  if (isCreate) {
942
1266
  const colLines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"];
943
1267
  for (const [fname, ftype] of fields) {
@@ -947,11 +1271,20 @@ function generateMigration(
947
1271
  }
948
1272
  colLines.push(" created_at TEXT DEFAULT CURRENT_TIMESTAMP");
949
1273
 
950
- upSql = `CREATE TABLE IF NOT EXISTS ${table} (\n${colLines.join(",\n")}\n);`;
951
- downSql = `DROP TABLE IF EXISTS ${table};`;
1274
+ upSql =
1275
+ `CREATE TABLE IF NOT EXISTS ${table} (\n` +
1276
+ ` -- tina4:edit add columns beyond id + created_at\n` +
1277
+ `${colLines.join(",\n")}\n);`;
1278
+ downSql =
1279
+ `-- tina4:edit mirror the CREATE's added columns in the rollback\n` +
1280
+ `DROP TABLE IF EXISTS ${table};`;
952
1281
  } else {
953
- upSql = `-- Write your UP migration SQL here\n-- Example: ALTER TABLE ${table} ADD COLUMN new_col TEXT DEFAULT '';`;
954
- downSql = `-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE ${table} DROP COLUMN new_col;`;
1282
+ upSql =
1283
+ `-- tina4:edit write your UP migration SQL here\n` +
1284
+ `-- Example: ALTER TABLE ${table} ADD COLUMN new_col TEXT DEFAULT '';`;
1285
+ downSql =
1286
+ `-- tina4:edit write your DOWN rollback SQL here\n` +
1287
+ `-- Example: ALTER TABLE ${table} DROP COLUMN new_col;`;
955
1288
  }
956
1289
 
957
1290
  const now = isoNow();
@@ -1008,6 +1341,7 @@ export async function before${name}(
1008
1341
  res: Tina4Response,
1009
1342
  next: () => Promise<void>,
1010
1343
  ): Promise<void> {
1344
+ // tina4:edit replace the Authorization check with the real pre-request rule
1011
1345
  const auth = req.headers["authorization"];
1012
1346
  if (!auth) {
1013
1347
  res.json({ error: "Unauthorized" }, 401);
@@ -1021,7 +1355,7 @@ export async function after${name}(
1021
1355
  res: Tina4Response,
1022
1356
  next: () => Promise<void>,
1023
1357
  ): Promise<void> {
1024
- // Post-processing logic here (logging, header injection, etc.)
1358
+ // tina4:edit add post-processing (logging, header injection, telemetry)
1025
1359
  await next();
1026
1360
  }
1027
1361
  `;
@@ -1113,35 +1447,35 @@ process.exit(fail > 0 ? 1 : 0);
1113
1447
  const list${model}s = tests(
1114
1448
  assertTrue([]),
1115
1449
  )(function list${model}s() {
1116
- // TODO: implement list test
1450
+ // tina4:edit assert against a real GET /api/${toSnake(name)} response (rows, count)
1117
1451
  return true;
1118
1452
  });
1119
1453
 
1120
1454
  const get${model} = tests(
1121
1455
  assertTrue([]),
1122
1456
  )(function get${model}() {
1123
- // TODO: implement get test
1457
+ // tina4:edit assert against GET /api/${toSnake(name)}/{id} for one seeded row
1124
1458
  return true;
1125
1459
  });
1126
1460
 
1127
1461
  const create${model} = tests(
1128
1462
  assertTrue([]),
1129
1463
  )(function create${model}() {
1130
- // TODO: implement create test
1464
+ // tina4:edit POST a valid + an invalid body, assert 201 vs 400
1131
1465
  return true;
1132
1466
  });
1133
1467
 
1134
1468
  const update${model} = tests(
1135
1469
  assertTrue([]),
1136
1470
  )(function update${model}() {
1137
- // TODO: implement update test
1471
+ // tina4:edit PUT changed fields, assert the row was persisted
1138
1472
  return true;
1139
1473
  });
1140
1474
 
1141
1475
  const delete${model} = tests(
1142
1476
  assertTrue([]),
1143
1477
  )(function delete${model}() {
1144
- // TODO: implement delete test
1478
+ // tina4:edit DELETE the id, assert 200 then GET returns 404
1145
1479
  return true;
1146
1480
  });
1147
1481
 
@@ -1158,7 +1492,7 @@ void [list${model}s, get${model}, create${model}, update${model}, delete${model}
1158
1492
  const test${titleName} = tests(
1159
1493
  assertTrue([]),
1160
1494
  )(function test${titleName}() {
1161
- // TODO: implement test
1495
+ // tina4:edit assert against the real behaviour under test (no mocks)
1162
1496
  return true;
1163
1497
  });
1164
1498
 
@@ -1222,12 +1556,18 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
1222
1556
  }
1223
1557
  }
1224
1558
 
1559
+ // ADR-0063 (scaffolding envelope v1.1): `{# tina4:edit <label> #}` is the
1560
+ // Twig-comment partner of `// tina4:edit` in TS/JS. Baked at a genuine
1561
+ // first-edit spot so an operator (or an AI agent) can grep to a real
1562
+ // customisation point instead of scanning the whole file. Marker wording
1563
+ // mirrors Ruby's `generate_form` for cross-language parity.
1225
1564
  const content =
1226
1565
  `{% extends "base.twig" %}\n` +
1227
1566
  `{% block title %}${name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}\n` +
1228
1567
  `{% block content %}\n` +
1229
1568
  `<div class="container mt-4">\n` +
1230
1569
  ` <h1>{% if item.id %}Edit ${name}{% else %}Create ${name}{% endif %}</h1>\n` +
1570
+ ` {# tina4:edit restyle the form beyond the scaffolded defaults #}\n` +
1231
1571
  ` <form method="post" action="/api/${routeName}{% if item.id %}/{{ item.id }}{% endif %}">\n` +
1232
1572
  ` {{ form_token() }}\n` +
1233
1573
  fieldHtml +
@@ -1259,11 +1599,16 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1259
1599
  const th = cols.map((c) => ` <th>${c.replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase())}</th>`).join("\n");
1260
1600
  const td = cols.map((c) => ` <td>{{ item.${c} }}</td>`).join("\n");
1261
1601
 
1602
+ // ADR-0063 (scaffolding envelope v1.1): `{# tina4:edit <label> #}` markers
1603
+ // sit at each template's genuine first-edit spot (list = customise the
1604
+ // table; detail = extend the record's view). Wording mirrors Ruby's
1605
+ // `generate_view` for cross-language parity.
1262
1606
  const listContent =
1263
1607
  `{% extends "base.twig" %}\n` +
1264
1608
  `{% block title %}${name}s{% endblock %}\n` +
1265
1609
  `{% block content %}\n` +
1266
1610
  `<div class="container mt-4">\n` +
1611
+ ` {# tina4:edit add sort / filter / pagination controls to the list #}\n` +
1267
1612
  ` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
1268
1613
  ` <h1>${name}s</h1>\n` +
1269
1614
  ` <a href="/${routeName}/create" class="btn btn-primary">Add ${name}</a>\n` +
@@ -1305,6 +1650,7 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1305
1650
  `{% block title %}${name} Detail{% endblock %}\n` +
1306
1651
  `{% block content %}\n` +
1307
1652
  `<div class="container mt-4">\n` +
1653
+ ` {# tina4:edit extend the detail view with related records or actions #}\n` +
1308
1654
  ` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
1309
1655
  ` <h1>${name} #{{ item.id }}</h1>\n` +
1310
1656
  ` <div>\n` +
@@ -1322,7 +1668,8 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1322
1668
  // ── Auth (login/register stay PUBLIC) ───────────────────────────────
1323
1669
 
1324
1670
  function generateAuth(_flags: Record<string, string | boolean>): void {
1325
- console.log("\n Generating authentication scaffolding...\n");
1671
+ // Human-only banners; suppressed under --json to keep stdout parseable.
1672
+ if (!__resolution.jsonMode) console.log("\n Generating authentication scaffolding...\n");
1326
1673
 
1327
1674
  // 1. User model + migration (model test suppressed — the auth test below is
1328
1675
  // the composite, broader co-emitted test).
@@ -1352,6 +1699,7 @@ export const secure = false;
1352
1699
  export const meta = { summary: "Register a new user", tags: ["auth"] };
1353
1700
 
1354
1701
  export default async function (req: Tina4Request, res: Tina4Response) {
1702
+ // tina4:edit add password-strength / email-format / captcha rules before mint
1355
1703
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
1356
1704
 
1357
1705
  if (!email || !password) {
@@ -1385,6 +1733,7 @@ export const secure = false;
1385
1733
  export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
1386
1734
 
1387
1735
  export default async function (req: Tina4Request, res: Tina4Response) {
1736
+ // tina4:edit add rate-limit / lock-after-N-failures / 2FA before password check
1388
1737
  const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
1389
1738
 
1390
1739
  if (!email || !password) {
@@ -1399,6 +1748,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1399
1748
  }
1400
1749
 
1401
1750
  const data = user.toObject();
1751
+ // tina4:edit set token TTL (getToken(payload, secret, expiresInMinutes)) and add scopes if needed
1402
1752
  const token = getToken({ userId: data.id, email: data.email, role: data.role });
1403
1753
  res.json({ token });
1404
1754
  }
@@ -1482,11 +1832,13 @@ export default async function (req: Tina4Request, res: Tina4Response) {
1482
1832
  // 5. Auth test — real register / login / me end-to-end (no mocks).
1483
1833
  emitAuthTest();
1484
1834
 
1485
- console.log("\n Authentication scaffolding complete.");
1486
- console.log(" Run: tina4nodejs migrate");
1487
- console.log(" POST /api/auth/register — create account (public)");
1488
- console.log(" POST /api/auth/login get JWT token (public)");
1489
- console.log(" GET /api/auth/me — get profile (requires token)");
1835
+ if (!__resolution.jsonMode) {
1836
+ console.log("\n Authentication scaffolding complete.");
1837
+ console.log(" Run: tina4nodejs migrate");
1838
+ console.log(" POST /api/auth/register create account (public)");
1839
+ console.log(" POST /api/auth/login — get JWT token (public)");
1840
+ console.log(" GET /api/auth/me — get profile (requires token)");
1841
+ }
1490
1842
  }
1491
1843
 
1492
1844
  // ── Service (scheduled background task — ServiceRunner) ──────────────
@@ -1537,6 +1889,7 @@ function generateService(name: string, flags: Record<string, string | boolean>):
1537
1889
  */
1538
1890
 
1539
1891
  export async function ${camel}Task(context: ServiceContext): Promise<void> {
1892
+ // tina4:edit replace the AI-FILL stub below with the scheduled work
1540
1893
  ${body}}
1541
1894
 
1542
1895
  // Discovered by ServiceRunner.discover("src/services") — it reads name/handler
@@ -1593,6 +1946,7 @@ export function publish${pascal}(payload: Record<string, unknown>): string {
1593
1946
 
1594
1947
  /** Process ONE ${topic} job payload. */
1595
1948
  export async function handle${pascal}(payload: unknown): Promise<void> {
1949
+ // tina4:edit implement the per-job handler; return to ack, throw to nack
1596
1950
  ${body}}
1597
1951
 
1598
1952
  /** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
@@ -1655,6 +2009,7 @@ function generateValidator(name: string, _flags: Record<string, string | boolean
1655
2009
  */
1656
2010
  export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
1657
2011
  const validator = new Validator(data);
2012
+ // tina4:edit add rules for this payload (.email/.minLength/.integer/.inList/.pattern)
1658
2013
  ${rules} validator.required("name"); // starter rule (matches the model's default field)
1659
2014
  return validator;
1660
2015
  }
@@ -1695,6 +2050,7 @@ import ${name} from "../models/${name}.js";
1695
2050
  * specific shape below. Each callable receives a FakeData instance.
1696
2051
  */
1697
2052
  export function fieldOverrides(fake: FakeData): Record<string, unknown> {
2053
+ // tina4:edit override any fields that need a specific shape (seedOrm auto-fills the rest)
1698
2054
  ${overrides} void fake; // available for overrides above
1699
2055
  return {};
1700
2056
  }
@@ -1763,6 +2119,7 @@ export async function ${handlerName}(
1763
2119
  data: string,
1764
2120
  ): Promise<void> {
1765
2121
  if (event === "open") {
2122
+ // tina4:edit customize the welcome frame (or drop it)
1766
2123
  connection.sendJson({ type: "welcome" });
1767
2124
  return;
1768
2125
  }
@@ -1770,6 +2127,7 @@ export async function ${handlerName}(
1770
2127
  return;
1771
2128
  }
1772
2129
  // event === "message"
2130
+ // tina4:edit handle the inbound "message" frame (broadcast, echo, route, etc.)
1773
2131
  ${body}}
1774
2132
 
1775
2133
  websocket("${wsPath}", ${handlerName});
@@ -1814,6 +2172,7 @@ function generateListener(name: string, _flags: Record<string, string | boolean>
1814
2172
  * Fires when something calls Events.emit("${event}", ...args).
1815
2173
  */
1816
2174
  export function ${handlerName}(...args: unknown[]): void {
2175
+ // tina4:edit implement the reaction to '${event}' (email, ORM write, follow-up emit)
1817
2176
  ${body}}
1818
2177
 
1819
2178
  Events.on("${event}", ${handlerName});