tina4-nodejs 3.13.120 → 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.
@@ -345,10 +345,13 @@ export const COMMANDS: Record<string, CommandSpec> = {
345
345
  summary: "Run pending SQL migrations",
346
346
  },
347
347
  "migrate:create": {
348
- handler: async (a) => { await createMigration(a.join(" ") || undefined); },
348
+ // Pass the full argv through so `migrate:create "add users" --json --dry-run`
349
+ // reaches the same envelope machinery as `generate migration ...` (ADR-0063).
350
+ // The delegation itself lives in commands/migrateCreate.ts.
351
+ handler: async (a) => { await createMigration(a); },
349
352
  usage: "<desc>",
350
353
  args: ["description"],
351
- summary: "Create a new migration file",
354
+ summary: "Create a new migration file (delegates to generate migration)",
352
355
  },
353
356
  "migrate:status": {
354
357
  handler: async (a) => { await migrateStatus(a[0]); },
@@ -306,24 +306,41 @@ function toRelPath(absPath: string): string {
306
306
  return sep === "/" ? rel : rel.split(sep).join("/");
307
307
  }
308
308
 
309
- // Line-anchored `// tina4:edit LABEL` marker regex.
309
+ // Line-anchored `tina4:edit LABEL` marker regex (multi-style, ADR-0063 v1.1).
310
310
  //
311
- // Line-anchored (`^\s*//`) so the marker must be at the START of a code line
312
- // (after optional whitespace) — a marker embedded inside a template string
313
- // literal never falsely matches. LABEL is captured greedily until end of
314
- // line, then trimmed on push.
315
- const TINA4_EDIT_MARKER = /^\s*\/\/\s*tina4:edit\s+(.+?)\s*$/;
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*$/;
316
328
 
317
329
  /**
318
- * Scan `content` for `// tina4:edit …` markers and record one EditHint per
319
- * match against the given absolute path. Called from every `writeFileSafe`
320
- * — including under `--dry-run` — so the envelope promises the same hints in
321
- * preview and post-write. Files whose extension is not TS/JS are skipped
322
- * (the marker syntax is TS/JS specific — `.sql` and `.twig` templates never
323
- * carry markers).
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).
324
341
  */
325
342
  function captureEditHints(absPath: string, content: string): void {
326
- if (!/\.(ts|tsx|js|mjs|cjs|jsx)$/.test(absPath)) return;
343
+ if (!/\.(ts|tsx|js|mjs|cjs|jsx|sql|twig|html\.twig)$/.test(absPath)) return;
327
344
  const relPath = toRelPath(absPath);
328
345
  const lines = content.split("\n");
329
346
  for (let i = 0; i < lines.length; i++) {
@@ -454,6 +471,10 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
454
471
  const booleanFlags = new Set([
455
472
  "no-browser", "no-reload", "production", "managed", "all", "clear",
456
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",
457
478
  // Resolution transparency (Feature B, 3.13.117): both accept NO value.
458
479
  "json", "dry-run",
459
480
  ]);
@@ -573,7 +594,7 @@ export const GENERATORS: Record<string, GeneratorSpec> = {
573
594
  model: { handler: generateModel, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
574
595
  route: { handler: generateRoute, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
575
596
  crud: { handler: generateCrud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
576
- 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)" },
577
598
  middleware: { handler: generateMiddleware, usage: "<Name>", summary: "Middleware with before/after hooks" },
578
599
  test: { handler: generateTest, usage: "<name> [--model Name]", summary: "Test file" },
579
600
  form: { handler: generateForm, usage: '<Name> [--fields "..."]', summary: "Form template with inputs matching model fields" },
@@ -761,6 +782,53 @@ export async function generate(what: string, name: string, extraArgs: string[] =
761
782
  printResolution();
762
783
  }
763
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
+
764
832
  // ── Model ───────────────────────────────────────────────────────────
765
833
 
766
834
  function generateModel(name: string, flags: Record<string, string | boolean>, emitTest = true): void {
@@ -1129,7 +1197,7 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
1129
1197
 
1130
1198
  // ── Migration ───────────────────────────────────────────────────────
1131
1199
 
1132
- function generateMigration(
1200
+ export function generateMigration(
1133
1201
  name: string,
1134
1202
  flags: Record<string, string | boolean>,
1135
1203
  fieldsOverride?: Array<[string, string]>,
@@ -1185,6 +1253,15 @@ function generateMigration(
1185
1253
  let upSql: string;
1186
1254
  let downSql: string;
1187
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).
1188
1265
  if (isCreate) {
1189
1266
  const colLines = [" id INTEGER PRIMARY KEY AUTOINCREMENT"];
1190
1267
  for (const [fname, ftype] of fields) {
@@ -1194,11 +1271,20 @@ function generateMigration(
1194
1271
  }
1195
1272
  colLines.push(" created_at TEXT DEFAULT CURRENT_TIMESTAMP");
1196
1273
 
1197
- upSql = `CREATE TABLE IF NOT EXISTS ${table} (\n${colLines.join(",\n")}\n);`;
1198
- 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};`;
1199
1281
  } else {
1200
- upSql = `-- Write your UP migration SQL here\n-- Example: ALTER TABLE ${table} ADD COLUMN new_col TEXT DEFAULT '';`;
1201
- 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;`;
1202
1288
  }
1203
1289
 
1204
1290
  const now = isoNow();
@@ -1470,12 +1556,18 @@ function generateForm(name: string, flags: Record<string, string | boolean>): vo
1470
1556
  }
1471
1557
  }
1472
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.
1473
1564
  const content =
1474
1565
  `{% extends "base.twig" %}\n` +
1475
1566
  `{% block title %}${name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}\n` +
1476
1567
  `{% block content %}\n` +
1477
1568
  `<div class="container mt-4">\n` +
1478
1569
  ` <h1>{% if item.id %}Edit ${name}{% else %}Create ${name}{% endif %}</h1>\n` +
1570
+ ` {# tina4:edit restyle the form beyond the scaffolded defaults #}\n` +
1479
1571
  ` <form method="post" action="/api/${routeName}{% if item.id %}/{{ item.id }}{% endif %}">\n` +
1480
1572
  ` {{ form_token() }}\n` +
1481
1573
  fieldHtml +
@@ -1507,11 +1599,16 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1507
1599
  const th = cols.map((c) => ` <th>${c.replace(/_/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase())}</th>`).join("\n");
1508
1600
  const td = cols.map((c) => ` <td>{{ item.${c} }}</td>`).join("\n");
1509
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.
1510
1606
  const listContent =
1511
1607
  `{% extends "base.twig" %}\n` +
1512
1608
  `{% block title %}${name}s{% endblock %}\n` +
1513
1609
  `{% block content %}\n` +
1514
1610
  `<div class="container mt-4">\n` +
1611
+ ` {# tina4:edit add sort / filter / pagination controls to the list #}\n` +
1515
1612
  ` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
1516
1613
  ` <h1>${name}s</h1>\n` +
1517
1614
  ` <a href="/${routeName}/create" class="btn btn-primary">Add ${name}</a>\n` +
@@ -1553,6 +1650,7 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
1553
1650
  `{% block title %}${name} Detail{% endblock %}\n` +
1554
1651
  `{% block content %}\n` +
1555
1652
  `<div class="container mt-4">\n` +
1653
+ ` {# tina4:edit extend the detail view with related records or actions #}\n` +
1556
1654
  ` <div class="d-flex justify-content-between align-items-center mb-3">\n` +
1557
1655
  ` <h1>${name} #{{ item.id }}</h1>\n` +
1558
1656
  ` <div>\n` +
@@ -8,53 +8,48 @@
8
8
  * Creates both:
9
9
  * migrations/YYYYMMDDHHMMSS_description.sql (up migration)
10
10
  * migrations/YYYYMMDDHHMMSS_description.down.sql (rollback)
11
+ *
12
+ * 3.13.121 (ADR-0063): this is a thin delegation to `generate migration` so a
13
+ * scaffolded migration has ONE shape and ONE envelope regardless of whether
14
+ * the developer typed `tina4 migrate:create` or `tina4 generate migration`.
15
+ * The two file paths emitted here (`_desc.sql` + `_desc.down.sql`) and the
16
+ * schema-awareness on `create_X` names are unchanged; the delegation adds the
17
+ * ADR-0063 `generate_v1_1` envelope, `--json` / `--dry-run` support, and the
18
+ * `edit_hints[]` / `next[]` machinery for free. `--no-test` preserves the
19
+ * pre-3.13.121 UX (just a migration, no co-emitted test).
11
20
  */
12
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
13
- import { join, resolve } from "node:path";
21
+ import { generate } from "./generate.js";
22
+
23
+ export async function createMigration(args: string[] = []): Promise<void> {
24
+ // Split flags from the description so `tina4 migrate:create "add users"
25
+ // --json --dry-run` reaches the same envelope machinery as its
26
+ // `tina4 generate migration` twin (see test/migrateCreateEnvelopeParity.test.ts).
27
+ const positionals: string[] = [];
28
+ const flags: string[] = [];
29
+ for (const arg of args) {
30
+ if (arg.startsWith("--")) flags.push(arg);
31
+ else positionals.push(arg);
32
+ }
33
+ const description = positionals.join(" ").trim();
14
34
 
15
- export async function createMigration(description?: string): Promise<void> {
16
35
  if (!description) {
17
36
  console.error(" Usage: tina4 migrate:create <description>");
18
37
  console.error(' Example: tina4 migrate:create "create users table"');
19
38
  process.exit(1);
20
39
  }
21
40
 
22
- const dir = resolve("migrations");
23
-
24
- // Ensure migrations/ exists
25
- if (!existsSync(dir)) {
26
- mkdirSync(dir, { recursive: true });
27
- }
28
-
29
- // Sanitise description for filename
30
- const safeName = description
41
+ // Sanitise the description into a filename-safe slug BEFORE delegation.
42
+ // Preserves migrate:create's pre-3.13.121 UX: `migrate:create "add users"`
43
+ // still yields `${ts}_add_users.sql` (space -> underscore, lowercased),
44
+ // never a filename with a bare space. `generate migration` takes its name
45
+ // verbatim by design; migrate:create is the human-friendly front door.
46
+ const safeDescription = description
31
47
  .toLowerCase()
32
48
  .replace(/[^a-z0-9]+/g, "_")
33
49
  .replace(/^_|_$/g, "");
34
50
 
35
- // Use YYYYMMDDHHMMSS timestamp prefix
36
- const now = new Date();
37
- const timestamp = [
38
- now.getFullYear(),
39
- String(now.getMonth() + 1).padStart(2, "0"),
40
- String(now.getDate()).padStart(2, "0"),
41
- String(now.getHours()).padStart(2, "0"),
42
- String(now.getMinutes()).padStart(2, "0"),
43
- String(now.getSeconds()).padStart(2, "0"),
44
- ].join("");
45
-
46
- const upFileName = `${timestamp}_${safeName}.sql`;
47
- const downFileName = `${timestamp}_${safeName}.down.sql`;
48
- const upPath = join(dir, upFileName);
49
- const downPath = join(dir, downFileName);
50
-
51
- const upTemplate = `-- Migration: ${description}\n-- Created: ${now.toISOString()}\n\n`;
52
- const downTemplate = `-- Rollback: ${description}\n-- Created: ${now.toISOString()}\n\n`;
53
-
54
- writeFileSync(upPath, upTemplate, "utf-8");
55
- writeFileSync(downPath, downTemplate, "utf-8");
56
-
57
- console.log(` Created migration: ${upFileName}`);
58
- console.log(` Created rollback: ${downFileName}`);
59
- console.log(` Path: ${dir}`);
51
+ // Delegate to `generate migration` with --no-test so migrate:create stays
52
+ // "just a migration, no test" — its pre-3.13.121 semantics — while getting
53
+ // the ADR-0063 envelope, edit_hints[], next[] and --json/--dry-run for free.
54
+ await generate("migration", safeDescription, ["--no-test", ...flags]);
60
55
  }