/** * CLI command: generate — Rich, scaffolding-first code generation. * * Two shapes of generator: * • CRUD-shaped (model, crud, migration, middleware, form, view, test, auth): * emit WORKING code — the boilerplate IS the feature. A working extension * point is flagged with a light `EXTEND:` marker (no throw). * • LOGIC-shaped (custom route body, service, queue, validator, seeder, * websocket, listener): emit the real WIRING (imports + registration + * signature) plus a single greppable `AI-FILL` fill-spec that ends in * `throw new Error("… not implemented")`, so an unfilled scaffold fails LOUD. * * Secure by default: the router gates every scaffolded WRITE (POST/PUT/DELETE) * behind a Bearer token automatically; reads (GET) are public automatically. * `--public` re-adds the opt-out (`export const secure = false;`) on the WRITE * method files only — mirroring the AutoCrud `public` opt-in. * * Usage: * tina4nodejs generate model Product --fields "name:string,price:float" * tina4nodejs generate route products --model Product [--public] * tina4nodejs generate crud Product --fields "name:string,price:float" [--public] * tina4nodejs generate migration create_product * tina4nodejs generate middleware Auth * tina4nodejs generate test products --model Product * tina4nodejs generate form Product --fields "name:string,price:float" * tina4nodejs generate view Product --fields "name:string,price:float" * tina4nodejs generate auth * tina4nodejs generate service Cleanup --every 5m | --cron "0 3 * * *" * tina4nodejs generate queue order-emails * tina4nodejs generate validator CreateUser * tina4nodejs generate seeder Product * tina4nodejs generate websocket chat * tina4nodejs generate listener user.created */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; // ── Field type mapping ────────────────────────────────────────────── const FIELD_TYPE_MAP: Record = { string: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" }, str: { orm: '"string"', sql: "VARCHAR(255)", defaultVal: "''" }, int: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" }, integer: { orm: '"integer"', sql: "INTEGER", defaultVal: "0" }, float: { orm: '"number"', sql: "REAL", defaultVal: "0" }, number: { orm: '"number"', sql: "REAL", defaultVal: "0" }, numeric: { orm: '"number"', sql: "REAL", defaultVal: "0" }, decimal: { orm: '"number"', sql: "REAL", defaultVal: "0" }, bool: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" }, boolean: { orm: '"boolean"', sql: "INTEGER", defaultVal: "0" }, text: { orm: '"string"', sql: "TEXT", defaultVal: "''" }, datetime: { orm: '"datetime"', sql: "TIMESTAMP", defaultVal: "NULL" }, blob: { orm: '"string"', sql: "BLOB", defaultVal: "NULL" }, }; // ── Helpers ───────────────────────────────────────────────────────── function ensureDir(dir: string): void { if (__resolution.dryRun) return; // dry-run creates NO directories if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } function writeFileSafe(path: string, content: string): void { // ALWAYS scan the intended content for `// tina4:edit` markers, regardless // of dry-run. The envelope's edit_hints[] MUST promise the same hints in // preview (--dry-run) and post-write so an agent can rely on them before // committing to disk. captureEditHints(path, content); if (__resolution.dryRun) { // Dry-run: touch no disk state and print no per-file line to stdout // (that would leak into --json). return; } if (existsSync(path)) { if (!__resolution.jsonMode) console.log(` File already exists: ${path}`); return; } writeFileSync(path, content, "utf-8"); __resolution.actionsTaken.push(`wrote ${path}`); if (!__resolution.jsonMode) console.log(` Created ${path}`); } export function toSnake(name: string): string { return name .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .toLowerCase(); } // Table names that collide with SQL reserved words. `CREATE TABLE order (...)` // is a syntax error on every engine, and the ORM interpolates table names into // SQL unquoted (and hands the raw name to driver insert/update/delete), so the // safe fix is to never GENERATE one. The plural form is not reserved and reads // naturally as a table name. Mirrors the Python master's SQL_RESERVED_TABLE_NAMES // at tina4-python/tina4_python/cli/__init__.py. export const SQL_RESERVED_TABLE_NAMES: ReadonlySet = new Set([ "order", "group", "user", "table", "select", "from", "where", "index", "key", "values", "column", "constraint", "check", "default", "primary", "foreign", "references", "unique", "join", "union", "having", "limit", "offset", "desc", "asc", "case", "when", "then", "else", "end", "and", "or", "not", "null", "insert", "update", "delete", "create", "drop", "alter", "grant", "revoke", "commit", "rollback", "view", "trigger", "procedure", "function", "database", "schema", "session", "set", "into", "as", "on", "by", "inner", "outer", "left", "right", "full", "natural", "using", "with", "distinct", "between", "exists", "like", "in", "is", "all", "any", "cross", "add", "row", "rows", "range", "current", "to", ]); /** Simple English plural, used to escape a reserved-word table name. */ export function pluralizeReserved(name: string): string { if (name.endsWith("y") && !/[aeiouy]y$/i.test(name)) return name.slice(0, -1) + "ies"; if (/(s|x|z|ch|sh)$/.test(name)) return name + "es"; return name + "s"; } /** * Class name -> table name (singular by default), with a resolution side effect: * a name that collides with a SQL reserved word is pluralised (Order -> orders) * AND recorded on the current run's resolution as a `reserved_word_pluralize` * transformation. Every generator routes through here so the model, migration, * routes and tests all agree on the same table name. */ export function toTableName(name: string): string { const raw = toSnake(name); if (SQL_RESERVED_TABLE_NAMES.has(raw)) { const safe = pluralizeReserved(raw); recordTransformation({ kind: "reserved_word_pluralize", from: raw, to: safe, reason: `SQL reserved word '${raw}' would break CREATE TABLE`, override: `--table-name (table names interpolate unquoted; forcing a reserved name is yours to quote in raw SQL)`, }); return safe; } return raw; } /** * The table name a generator uses (issue #123) — honours `--table-name` and * speaks up instead of renaming SILENTLY. Mirrors the Python master's * `_resolve_table` (tina4-python/tina4_python/cli/__init__.py). * * `announce` prints the note/warning; it is TRUE only for `generateModel` (where * the table is born). Composite generators (crud) let the model sub-call * announce, and generators that target an EXISTING table (route/seeder/form/view/ * migration) still honour `--table-name` but stay quiet so the note is not * repeated — the note prints exactly once per `generate`. * * • `--table-name ` wins verbatim. If that name is ITSELF a reserved word, * warn loudly (when announcing): Tina4 interpolates table names UNQUOTED, so * the ORM's generated SQL will fail on it — quoting it in raw SQL + migrations * is now the developer's job (we do NOT silently quote; identifier quoting is a * global storage invariant, not a local fix). * • Otherwise fall back to `toTableName` (snake + reserved-word pluralise). When * that auto-pluralises a reserved-word class name (`Order` -> `orders`), print a * one-line NOTE (when announcing) naming the rename and the `--table-name` * escape hatch, so the developer is informed rather than surprised. * * The note/warning goes to STDERR (console.error) so a `generate … --json` run * keeps its stdout envelope pristine for a downstream `| jq`. `toTableName`'s * `reserved_word_pluralize` envelope transformation is UNCHANGED (this ADDS the * announce path; it does not touch the envelope contract). */ export function resolveTable( name: string, flags: Record | undefined, opts: { announce?: boolean } = {}, ): string { const announce = opts.announce ?? false; const override = (flags ?? {})["table-name"]; // `--table-name ` wins verbatim. A bare `--table-name` (no value) parses // to `true` — ignore it, exactly like the Python master (falls through to the // pluralise path), rather than letting the boolean become the table name. if (typeof override === "string" && override) { if (announce && SQL_RESERVED_TABLE_NAMES.has(toSnake(override))) { console.error( ` ! table_name '${override}' is a SQL reserved word. Tina4 interpolates ` + `table names UNQUOTED, so the ORM's generated SQL will fail on it -- ` + `quote it yourself in raw SQL and migrations.`, ); } return override; } const bare = toSnake(name); if (!SQL_RESERVED_TABLE_NAMES.has(bare)) { return bare; // non-reserved: singular, silent, no envelope transformation } // Reserved: pluralise via toTableName (which records the envelope // transformation), then announce the rename so it is never a surprise. const table = toTableName(name); if (announce) { console.error( ` · '${bare}' is a SQL reserved word; using table_name '${table}' ` + `(Tina4 interpolates table names unquoted). Override with --table-name .`, ); } return table; } // ── Resolution surface — the machine-readable envelope every generator ─ // populates so `--json` can print it and a human run can print the same // facts to stderr. See the JSDoc on `printResolution` below for the envelope // shape (kept stable under `resolution_contract` in `commands --json`). /** One transformation the resolver made — visible to the caller so an AI * agent (or human) knows exactly why the output differs from the input. */ export interface ResolutionTransformation { kind: string; from?: string; to?: string; reason?: string; override?: string; } export interface ResolutionInput { name: string; fields: string | null; } /** * One `// tina4:edit …` marker found in a written (or would-be-written) * template file. `file` is repo-relative POSIX (matches the rest of the * envelope's paths); `line` is 1-based; `label` is the short imperative label * that followed the marker on the same line. */ export interface EditHint { file: string; line: number; label: string; } export interface ResolutionBody { class_name?: string; table_name?: string; file_path?: string; migration_path?: string; routes?: string[]; test_paths?: string[]; edit_hints?: EditHint[]; next?: string[]; transformations: ResolutionTransformation[]; } export interface ResolutionEnvelope { command: "generate"; target: string; input: ResolutionInput; resolution: ResolutionBody; actions_taken: string[]; dry_run: boolean; } /** * A stable version tag on the JSON envelope. `commands --json` echoes this in * `resolution_contract.envelope` so the tina4 client (or any consumer) can * discover the exact contract this framework speaks. Bump when a breaking * key rename / removal lands; keep unchanged when new OPTIONAL keys are added. * * `generate_v1_1` (ADR-0063, 3.13.120) is a PURELY ADDITIVE superset of * `generate_v1`: every v1 field is preserved, and two new optional arrays * appear — `resolution.edit_hints[]` (one entry per `// tina4:edit` marker * baked into a template) and `resolution.next[]` (curated per-verb actionable * next steps). `resolution.test_paths[]` was already in v1; v1.1 surfaces it * in the human stderr block too. */ export const RESOLUTION_ENVELOPE_VERSION = "generate_v1_1"; /** * Per-run mutable resolution state. Reset by `resetResolution()` on every * top-level `generate()` call so a sub-generator (crud -> model + route + * migration + form + view + test) contributes to ONE envelope, not many. */ const __resolution: { target: string; input: ResolutionInput; body: ResolutionBody; actionsTaken: string[]; dryRun: boolean; jsonMode: boolean; } = { target: "", input: { name: "", fields: null }, body: { transformations: [] }, actionsTaken: [], dryRun: false, jsonMode: false, }; function resetResolution(target: string, input: ResolutionInput, opts: { dryRun: boolean; jsonMode: boolean }): void { __resolution.target = target; __resolution.input = input; __resolution.body = { transformations: [] }; __resolution.actionsTaken = []; __resolution.dryRun = opts.dryRun; __resolution.jsonMode = opts.jsonMode; } function recordTransformation(t: ResolutionTransformation): void { __resolution.body.transformations.push(t); } /** Read-only snapshot of the current resolution — exported for tests that * want to inspect it in-process (the CLI itself uses only the envelope). */ export function currentResolution(): ResolutionEnvelope { const body: ResolutionBody = { ...__resolution.body, transformations: [...__resolution.body.transformations], }; if (__resolution.body.edit_hints) { body.edit_hints = __resolution.body.edit_hints.map((h) => ({ ...h })); } if (__resolution.body.next) { body.next = [...__resolution.body.next]; } if (__resolution.body.test_paths) { body.test_paths = [...__resolution.body.test_paths]; } if (__resolution.body.routes) { body.routes = [...__resolution.body.routes]; } return { command: "generate", target: __resolution.target, input: { ...__resolution.input }, resolution: body, actions_taken: [...__resolution.actionsTaken], dry_run: __resolution.dryRun, }; } function setResolutionField(key: K, value: ResolutionBody[K]): void { __resolution.body[key] = value; } function pushRoute(routePattern: string): void { if (!__resolution.body.routes) __resolution.body.routes = []; __resolution.body.routes.push(routePattern); } function pushTestPath(path: string): void { if (!__resolution.body.test_paths) __resolution.body.test_paths = []; __resolution.body.test_paths.push(path); } function pushEditHint(hint: EditHint): void { if (!__resolution.body.edit_hints) __resolution.body.edit_hints = []; __resolution.body.edit_hints.push(hint); } function setNextSteps(steps: string[]): void { if (steps.length === 0) return; __resolution.body.next = [...steps]; } /** * Convert an absolute path to a repo-relative POSIX path. Every other * envelope path (file_path, migration_path, test_paths) is repo-relative * POSIX ("src/models/Order.ts"), so edit_hints follow the same convention — * one path style across the whole envelope, portable across Windows. */ function toRelPath(absPath: string): string { const cwd = process.cwd(); const rel = relative(cwd, absPath); if (!rel) return absPath; return sep === "/" ? rel : rel.split(sep).join("/"); } // Line-anchored `tina4:edit LABEL` marker regex (multi-style, ADR-0063 v1.1). // // Line-anchored (`^\s*`) so the marker must be at the START of // a code line (after optional whitespace) — a marker embedded inside a // template string literal never falsely matches. LABEL is captured greedily // until end of line (or the closing `#}` for a Twig comment), then trimmed // on push. Four comment styles are recognised, one regex per style — bundled // into one alternation so the scanner is O(lines): // // // tina4:edit LABEL TS/JS/C/Java/Rust (existing) // # tina4:edit LABEL Python/Ruby/shell (parity) // -- tina4:edit LABEL SQL migrations // {# tina4:edit LABEL #} Twig / Frond templates // // Ports the same shape PHP uses in `bin/tina4php::collectEditHintsFromContent` // (`~(?://|--|\{#)\s*tina4:edit\s+(.+?)(?:\s*#\})?\s*$~`). The `#` bare- // comment style is added on top so a Ruby/Python-style template (none exist // today, but the scanner is now language-agnostic) is covered too. const TINA4_EDIT_MARKER = /^\s*(?:\/\/|--|\{#|#)\s*tina4:edit\s+(.+?)(?:\s*#\})?\s*$/; /** * Scan `content` for `tina4:edit …` markers (any of the 4 comment styles) * and record one EditHint per match against the given absolute path. Called * from every `writeFileSafe` — including under `--dry-run` — so the envelope * promises the same hints in preview and post-write. * * File-extension gate keeps the scan cheap: only text/code files where a * marker could reasonably live are opened. TS/JS + SQL + Twig cover every * template the generator emits today; expanded here (from TS/JS-only in * 3.13.120) so `generate form`, `generate view` and `generate migration` * carry `edit_hints[]` rather than returning `[]` (parity with PHP, whose * regex has always been language-agnostic). */ function captureEditHints(absPath: string, content: string): void { if (!/\.(ts|tsx|js|mjs|cjs|jsx|sql|twig|html\.twig)$/.test(absPath)) return; const relPath = toRelPath(absPath); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const match = TINA4_EDIT_MARKER.exec(lines[i]); if (match) { pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() }); } } } function addResolutionSummary(lines: string[], b: ResolutionBody): void { lines.push(""); lines.push(`Generated ${__resolution.target} ${__resolution.input.name}`); if (b.class_name || b.file_path) { const where = b.file_path ? ` (in ${b.file_path})` : ""; lines.push(` class ${b.class_name ?? __resolution.input.name}${where}`); } if (b.table_name) { const t = b.transformations.find((x) => x.kind === "reserved_word_pluralize"); const note = t ? ` (auto-pluralized: '${t.from}' is a SQL reserved word)` : ""; lines.push(` table ${b.table_name}${note}`); } if (b.routes && b.routes.length) { lines.push(` routes ${b.routes.join(", ")}`); } if (b.migration_path) { lines.push(` migration ${b.migration_path}`); } } function addReservedWordGuidance(lines: string[], b: ResolutionBody): void { const reserved = b.transformations.find((t) => t.kind === "reserved_word_pluralize"); if (!reserved?.from) return; lines.push(""); lines.push(` To set the table name yourself:`); lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} --table-name `); lines.push(` Tina4 interpolates table names unquoted; if you force the reserved '${reserved.from}', you own the quoting in raw SQL.`); } function addResolutionLists(lines: string[], b: ResolutionBody): void { // v1.1 (ADR-0063): surface the already-populated test_paths[], and the two // new arrays (edit_hints, next) when either is non-empty. Sections stay // absent when the corresponding array is empty — a listener/service // scaffold prints exactly what a model scaffold prints, minus what does // not apply. if (b.test_paths && b.test_paths.length > 0) { lines.push(""); lines.push(" Tests:"); for (const testPath of b.test_paths) lines.push(` ${testPath}`); } if (b.edit_hints && b.edit_hints.length > 0) { lines.push(""); lines.push(" Edit these lines:"); for (const hint of b.edit_hints) { lines.push(` ${hint.file}:${hint.line} ${hint.label}`); } } if (b.next && b.next.length > 0) { lines.push(""); lines.push(" Next:"); for (const step of b.next) lines.push(` ${step}`); } } /** * Emit the resolution — as JSON on STDOUT for `--json`, otherwise as a human * block on STDERR (stderr so a caller piping stdout for other output isn't * polluted). Called from `generate()` BEFORE the files are written on the * human path so an operator sees WHY the tool made its choices before disk * changes; the JSON path prints after collection so the envelope carries the * completed `actions_taken`. */ function printResolution(): void { if (__resolution.jsonMode) { process.stdout.write(JSON.stringify(currentResolution(), null, 2) + "\n"); return; } // Human block on STDERR, so `command | jq …` on stdout works cleanly. const lines: string[] = []; const b = __resolution.body; addResolutionSummary(lines, b); addReservedWordGuidance(lines, b); addResolutionLists(lines, b); lines.push(""); process.stderr.write(lines.join("\n")); } function toPlural(name: string): string { const lower = name.toLowerCase(); if (lower.endsWith("s")) return lower; if (lower.endsWith("y") && !/[aeiou]y$/i.test(lower)) return lower.slice(0, -1) + "ies"; return lower + "s"; } function toCamel(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1); } /** slug-of-anything → PascalCase (order-emails → OrderEmails). */ export function toPascal(name: string): string { return name .split(/[^0-9a-zA-Z]+/) .filter(Boolean) .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) .join(""); } export function parseFields(fieldsStr: string): Array<[string, string]> { if (!fieldsStr || !fieldsStr.trim()) return []; const result: Array<[string, string]> = []; for (const part of fieldsStr.split(",")) { const trimmed = part.trim(); if (trimmed.includes(":")) { const [fname, ftype] = trimmed.split(":", 2); if (fname.trim()) result.push([fname.trim(), ftype.trim().toLowerCase()]); } else if (trimmed) { result.push([trimmed, "string"]); } } return result; } // Called without --fields, the generators fall back to a single `name` string // column. That default MUST be materialised here, in one place, and then flow // into the model, the migration, the form, the view and the test alike. It used // to live only inside the model template, so `generate model X` / `generate // crud X` wrote a model declaring `name` while the migration - built from the // parsed field list, which was empty - created only id + created_at. The first // write then failed with "table x has no column named name". export const DEFAULT_FIELDS: ReadonlyArray<[string, string]> = [["name", "string"]]; /** Parsed --fields, or the default single `name` column when none given. */ export function fieldsOrDefault(fieldsStr: string): Array<[string, string]> { const parsed = parseFields(fieldsStr); return parsed.length > 0 ? parsed : DEFAULT_FIELDS.map(([f, t]) => [f, t] as [string, string]); } export function parseCliArgs(args: string[]): { flags: Record; positional: string[] } { // Boolean-only flags that never take a value argument. const booleanFlags = new Set([ "no-browser", "no-reload", "production", "managed", "all", "clear", "public", "no-migration", // Suppress the co-emitted migration test (used by the migrate:create // delegation — a plain migrate:create is "just a migration, no test", // matching its pre-3.13.121 UX now that it routes through generate migration). "no-test", // Resolution transparency (Feature B, 3.13.117): both accept NO value. "json", "dry-run", ]); const flags: Record = {}; const positional: string[] = []; let i = 0; while (i < args.length) { if (args[i].startsWith("--")) { const key = args[i].slice(2); if (booleanFlags.has(key)) { flags[key] = true; i += 1; } else if (i + 1 < args.length && !args[i + 1].startsWith("--")) { flags[key] = args[i + 1]; i += 2; } else { flags[key] = true; i += 1; } } else { positional.push(args[i]); i += 1; } } return { flags, positional }; } /** * Parse a `--every` duration ("5m", "30s", "2h", "1d", or bare seconds) → seconds. * Falls back to 60s on an empty/unparseable value so a scaffold always has a * valid ServiceRunner interval. */ export function parseEvery(every: string | boolean | undefined): number { if (!every || every === true) return 60; const s = String(every).trim().toLowerCase(); const units: Record = { s: 1, m: 60, h: 3600, d: 86400 }; const unit = s.slice(-1); if (unit in units) { const n = parseFloat(s.slice(0, -1)); return Number.isFinite(n) ? Math.max(1, Math.round(n * units[unit])) : 60; } const n = parseFloat(s); return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 60; } /** * The canonical AI-FILL placeholder for a LOGIC-shaped stub — a tight, grounded * fill-spec (not a vague `// TODO`) so a coding agent (or dev) completes it * correctly. `throw new Error(...)` makes an unfilled scaffold fail LOUD; the * greppable `AI-FILL` banner lets a human/agent jump to every gap. `use` names * only REAL tina4-nodejs symbols (verified in source). */ export function aiFill( fn: string, spec: { intent: string; given?: string; use: string; ret?: string; ground: string; raise: string }, indent = " ", ): string { const rule = (label: string) => "─".repeat(Math.max(4, 46 - label.length)); const lines = [`${indent}// ─── AI-FILL: ${fn} ${rule(fn)}`]; lines.push(`${indent}// Intent: ${spec.intent}`); if (spec.given) lines.push(`${indent}// Given: ${spec.given}`); lines.push(`${indent}// Use: ${spec.use}`); if (spec.ret) lines.push(`${indent}// Return: ${spec.ret}`); lines.push(`${indent}// Ground: ${spec.ground}`); lines.push(`${indent}throw new Error(${JSON.stringify(spec.raise)}); // remove when implemented`); lines.push(`${indent}// ${"─".repeat(52)}`); return lines.join("\n") + "\n"; } /** * The lighter EXTEND marker for CRUD-shaped WORKING code — no throw (the * boilerplate IS the feature); just a greppable hint at the natural extension * point (custom validation / business rules / authorization). */ export function extend(note: string, hint = "", indent = " "): string { let out = `${indent}// ─── EXTEND: ${note} ${"─".repeat(Math.max(4, 46 - note.length))}\n`; if (hint) out += `${indent}// ${hint}\n`; return out; } function timestamp(): string { const now = new Date(); return ( now.getFullYear().toString() + String(now.getMonth() + 1).padStart(2, "0") + String(now.getDate()).padStart(2, "0") + String(now.getHours()).padStart(2, "0") + String(now.getMinutes()).padStart(2, "0") + String(now.getSeconds()).padStart(2, "0") ); } function isoNow(): string { return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, ""); } // ── Generator registry — the single source of truth ───────────────── // // One entry per generator drives `generate` dispatch (below), the human help // (`bin.ts` Generators section), AND the `commands --json` manifest's // `generate.subcommands`. Add a generator in ONE place and it appears in // dispatch, help, and discovery with no second list to keep in sync. // // Every handler is normalised to `(name, flags) => void`; `auth` takes no name // so it drops it. Mirrors the Python master's GENERATORS registry // (tina4_python/cli/__init__.py). export interface GeneratorSpec { handler: (name: string, flags: Record) => void; /** Arg/flag hint shown in `tina4nodejs help` (human only). */ usage: string; summary: string; } export const GENERATORS: Record = { model: { handler: generateModel, usage: ' [--fields "name:string,price:float"] [--table-name ]', summary: "ORM model + matching migration" }, route: { handler: generateRoute, usage: " [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" }, crud: { handler: generateCrud, usage: ' [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" }, migration: { handler: (n, f) => generateMigration(n, f, undefined, undefined, !f["no-test"]), usage: "", summary: "Timestamped migration file (UP/DOWN)" }, middleware: { handler: generateMiddleware, usage: "", summary: "Middleware with before/after hooks" }, test: { handler: generateTest, usage: " [--model Name]", summary: "Test file" }, form: { handler: generateForm, usage: ' [--fields "..."]', summary: "Form template with inputs matching model fields" }, view: { handler: generateView, usage: ' [--fields "..."]', summary: "List + detail view templates" }, auth: { handler: (_n, f) => generateAuth(f), usage: "", summary: "Login/register routes (public) + User model + templates" }, service: { handler: generateService, usage: ' [--every 5m | --cron "..."]', summary: "Scheduled ServiceRunner task (src/services/)" }, queue: { handler: generateQueue, usage: "", summary: "Producer + consumer daemon worker (src/services/)" }, validator: { handler: generateValidator, usage: "", summary: "Request-body Validator (src/validators/)" }, seeder: { handler: generateSeeder, usage: "", summary: "FakeData + seedOrm seeder (src/seeds/)" }, websocket: { handler: generateWebsocket, usage: "", summary: "websocket() handler (src/routes/)" }, listener: { handler: generateListener, usage: "", summary: "Events.on(event) listener (src/listeners/)" }, }; /** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */ const GENERATOR_LIST = Object.keys(GENERATORS).join(", "); /** * Curated per-verb next steps — populates `resolution.next[]` (envelope) and * the "Next:" block on stderr (human). Grounded on the real code paths a * developer takes after each generator. Cap of 5 (short, actionable). * * The context carries the RESOLVED name and table (the reserved-word * pluraliser has already run at dispatch time), so a step references the * same table/route the generated files bind to. `name` is the CLI positional * as-typed; `table` is `toTableName(name)`. */ interface NextContext { name: string; table: string; } const NEXT_STEPS: Record string[]> = { model: ({ name, table }) => [ `Edit src/models/${name}.ts to add fields beyond the default 'name'`, `Apply the migration: npx tina4nodejs migrate`, `Run its test: npx tsx tests/${table}_model.test.ts`, `Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`, ], route: ({ name, table }) => [ `Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`, `Run its test: npx tsx tests/${table}.test.ts`, `Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`, ], crud: ({ name, table }) => [ `Apply the migration: npx tina4nodejs migrate`, `Serve and try: npx tina4nodejs serve -> visit /swagger`, `Run the gate test: npx tsx tests/${toPlural(table)}.test.ts`, `Change fields: edit src/models/${name}.ts then re-run generate crud`, ], migration: () => [ `Apply pending migrations: npx tina4nodejs migrate`, `Check status: npx tina4nodejs migrate:status`, `Roll back the batch: npx tina4nodejs migrate:rollback`, ], middleware: ({ name }) => [ `Wire it: router.middleware(before${name}, after${name}) — or bind per-route`, `Run its test: npx tsx tests/${toSnake(name)}.test.ts`, ], test: ({ name }) => [ `Fill the TODOs in tests/${toSnake(name)}.test.ts`, `Run it: npx tsx tests/${toSnake(name)}.test.ts`, ], form: ({ name, table }) => [ `Render from a route: res.render("forms/${table}.twig", { item })`, `Add the POST route: npx tina4nodejs generate route ${toPlural(table)} --model ${name}`, ], view: ({ table }) => [ `Wire routes to render list -> ${toPlural(table)}.twig, detail -> ${table}.twig`, `Customize the templates in src/templates/pages/`, ], auth: () => [ `Apply the migration: npx tina4nodejs migrate`, `Run the auth test: npx tsx tests/auth.test.ts`, `Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`, `Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`, ], service: ({ name }) => [ `Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`, `Fill the task body in src/services/${toSnake(name)}.ts`, `Run its test: npx tsx tests/${toSnake(name)}.test.ts`, ], queue: ({ name }) => { const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic"; return [ `Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`, `Produce a job: publish${toPascal(name)}({ ... })`, `Run the worker: npx tina4nodejs queue work ${name}`, `Run its test: npx tsx tests/${slug}.test.ts`, ]; }, validator: ({ name }) => [ `Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`, `Run its test: npx tsx tests/${toSnake(name)}.test.ts`, ], seeder: ({ name, table }) => [ `Override any fields that need a specific shape in src/seeds/${table}_seeder.ts`, `Seed the table: npx tina4nodejs seed`, `Run its test: npx tsx tests/${table}_seeder.test.ts`, ], websocket: ({ name }) => { const raw = name.trim(); const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws"; const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw; return [ `Import once in app.ts to register: import "./src/routes/ws_${base}.js";`, `Fill the "message" branch in src/routes/ws_${base}.ts`, `Run its test: npx tsx tests/ws_${base}.test.ts`, ]; }, listener: ({ name }) => { const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event"; return [ `Import once in app.ts to register: import "./src/listeners/${slug}.js";`, `Fill the reaction in src/listeners/${slug}.ts`, `Run its test: npx tsx tests/${slug}.test.ts`, ]; }, }; // ── Main entry point ──────────────────────────────────────────────── export async function generate(what: string, name: string, extraArgs: string[] = []): Promise { if (!what) { console.error(" Usage: tina4nodejs generate [options]"); console.error(` Generators: ${GENERATOR_LIST}`); console.error(' Options: --fields "name:string,price:float" --model ModelName'); console.error(" --table-name force the model's table name (else derived; reserved words auto-pluralise)"); console.error(" --public open a route's writes (default: secure)"); console.error(' --every 5m | --cron "…" service schedule'); console.error(" --json emit machine-readable resolution envelope on stdout"); console.error(" --dry-run report resolution without writing any files"); process.exit(1); } // Auth doesn't require a name. const noNameGenerators = new Set(["auth"]); // bin.ts always passes argv[1] as `name`. For a no-name generator that means // `generate auth --json` arrives here as name="--json", extraArgs=[]. Rescue // the flag: shift a leading `--foo` name into extraArgs so parseCliArgs // actually sees it. Fixes `--json` (and any other flag) being silently // eaten by the no-name verbs. if (noNameGenerators.has(what) && name.startsWith("--")) { extraArgs = [name, ...extraArgs]; name = ""; } if (!noNameGenerators.has(what) && !name) { console.error(` Usage: tina4nodejs generate ${what} [options]`); process.exit(1); } const { flags } = parseCliArgs(extraArgs); // Feature B (3.13.117): resolution transparency. `--json` emits a stable // envelope on STDOUT (see `RESOLUTION_ENVELOPE_VERSION` / `commands --json` // -> `resolution_contract`); the human path prints the same facts to STDERR. // `--dry-run` short-circuits every file write so an agent can preview the // resolution and then rerun without the flag to commit. const jsonMode = Boolean(flags.json); const dryRun = Boolean(flags["dry-run"]); resetResolution(what, { name, fields: (flags.fields as string) ?? null }, { dryRun, jsonMode }); // Dispatch from the single-source-of-truth GENERATORS registry (also feeds // `bin.ts` help + the `commands --json` manifest subcommands). const spec = GENERATORS[what]; if (spec) { spec.handler(name, flags); } else { console.error(` Unknown generator: ${what}`); console.error(` Available: ${GENERATOR_LIST}`); process.exit(1); } // v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator // AFTER dispatch, so the table name reflects any reserved-word pluralisation // that fired during the run (Order -> orders). Prefer the resolution's own // table_name (already set by generateModel/generateMigration) so we do NOT // re-invoke toTableName() — that would record a DUPLICATE // reserved_word_pluralize transformation on the envelope. const nextFn = NEXT_STEPS[what]; if (nextFn) { const resolvedTable = __resolution.body.table_name ?? (name ? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name)) : ""); setNextSteps(nextFn({ name: name || "", table: resolvedTable })); } // Emit the resolution AFTER dispatch so `actions_taken` reflects the real // writes (or the empty list under `--dry-run`). printResolution(); } /** * Programmatic entry point for in-process consumers (MCP tools, tests, hosted * agents) — does everything `generate()` does EXCEPT print. * * Reset the resolution → dispatch to the requested generator → populate `next[]` * → return the envelope. Files still land on disk (unless `--dry-run` is passed * in `extraArgs`); only the human "Created …" per-file log and the * `printResolution()` output are suppressed (via `jsonMode: true`, the same * suppression `--json` uses on the CLI). * * Used by the MCP `migration_create` tool (packages/core/src/mcp.ts) so the * ADR-0063 `generate_v1_1` envelope drives every surface (CLI, MCP, tests) * without a subprocess round-trip. */ export async function generateProgrammatic( what: string, name: string, extraArgs: string[] = [], ): Promise { const spec = GENERATORS[what]; if (!spec) throw new Error(`Unknown generator: ${what} (available: ${GENERATOR_LIST})`); const { flags } = parseCliArgs(extraArgs); const dryRun = Boolean(flags["dry-run"]); // jsonMode:true suppresses writeFileSafe's per-file console.log so nothing // leaks to stdout (which the JSON-RPC caller would parse as tool output). // Files are still written — jsonMode gates PRINTS only, not disk writes. resetResolution(what, { name, fields: (flags.fields as string) ?? null }, { dryRun, jsonMode: true }); spec.handler(name, flags); // v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator — // same logic `generate()` runs, using the RESOLVED table_name when the // dispatched handler recorded one (avoids a duplicate reserved_word // transformation on the envelope). const nextFn = NEXT_STEPS[what]; if (nextFn) { const resolvedTable = __resolution.body.table_name ?? (name ? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name)) : ""); setNextSteps(nextFn({ name: name || "", table: resolvedTable })); } return currentResolution(); } // ── Model ─────────────────────────────────────────────────────────── function generateModel(name: string, flags: Record, emitTest = true): void { const fields = fieldsOrDefault((flags.fields as string) || ""); // The table is BORN here — announce=true, so a reserved-word rename (Order -> // orders) or a forced-reserved --table-name is said out loud, not silent (#123). const table = resolveTable(name, flags, { announce: true }); const dir = resolve("src/models"); ensureDir(dir); const path = join(dir, `${name}.ts`); // Populate the resolution — the top-level `generate()` prints this AFTER // dispatch (as JSON on stdout for `--json`, or as a human block on stderr). // Fields set here are relative paths (portable across cwd) — path.resolve // above uses cwd, then we express the file path relative to it for the // envelope, matching the Python master's `src/models/Order.ts` string. setResolutionField("class_name", name); setResolutionField("table_name", table); setResolutionField("file_path", `src/models/${name}.ts`); // Matches the real path emitted by emitModelTest() so the envelope never // lies about where a generated test lands. pushTestPath(`tests/${table}_model.test.ts`); // Build field definitions const fieldLines: string[] = [ ` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`, ` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`, ]; for (const [fname, ftype] of fields) { const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string; fieldLines.push(` ${fname}: { type: ${info.orm} as const },`); } fieldLines.push(` created_at: { type: "datetime" as const },`); // BaseModel is exported from tina4-nodejs/orm (NOT the core "tina4-nodejs" // entry) — importing it from the core entry yields `undefined` at runtime. const content = `import { BaseModel } from "tina4-nodejs/orm"; export default class ${name} extends BaseModel { static tableName = "${table}"; static fields = { ${fieldLines.join("\n")} }; } `; writeFileSafe(path, content); // Generate matching migration unless --no-migration. The model's own test // (below) proves the schema through the real ORM, so the migration sub-call // does NOT also co-emit a migration test (emitTest=false). if (!flags["no-migration"]) { // Always hand over the RESOLVED field list. Passing `undefined` when the // parsed list was empty made the migration fall back to its own // parseFields() - also empty - so the table got only id + created_at while // the model above declared `name`, and the first write 500'd. generateMigration(`create_${table}`, flags, fields, table, false); } // Co-emit a real SQLite roundtrip test next to the model. Composite // generators (crud/auth) pass emitTest=false and emit their own broader test. if (emitTest) emitModelTest(name, table, fields); } // ── Route ─────────────────────────────────────────────────────────── // // Secure by default. The router marks POST/PUT/DELETE `secure: true` unless the // route def opts out; route discovery reads a module's `export const secure` // into that def. So a scaffolded write is Bearer-gated with NOTHING emitted; // `--public` emits `export const secure = false;` on the write files only. /** The opt-out line for a WRITE method file when --public is set (else ""). */ function secureOptOut(isPublic: boolean): string { return isPublic ? `export const secure = false;\n\n` : ""; } function generateRoute(name: string, flags: Record, emitTest = true): void { const routePath = name.replace(/^\//, ""); const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath; const model = flags.model as string | undefined; const isPublic = Boolean(flags.public); const base = resolve("src/routes/api", routePath); const idDir = join(base, "[id]"); ensureDir(base); ensureDir(idDir); // Populate the resolution — routes AND file paths always safe to add; only // set the primary file_path when THIS is the top-level target so a `generate // model` running us as a sub-step doesn't overwrite the model's file_path. pushRoute(`/api/${routePath}`); pushRoute(`/api/${routePath}/{id}`); if (__resolution.target === "route") { setResolutionField("file_path", `src/routes/api/${routePath}/get.ts`); } // Route targets an EXISTING table — honour --table-name, stay quiet (announce=false). const table = model ? resolveTable(model, flags, { announce: false }) : ""; // Model import path is RELATIVE to the route file's directory. Files directly // under src/routes/api// are 3 levels above src/models/; the [id]/ files // are one deeper (4 levels). const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";\n` : ""; const modelImportId = model ? `import ${model} from "../../../../models/${model}.js";\n` : ""; const writeDoc = isPublic ? "Public (--public): no token required." : "Secure by default: requires a Bearer token (use --public to open)."; // ── GET list (public) ────────────────────────────────────────────── if (model) { writeFileSafe( join(base, "get.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${modelImportBase} export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] }; export default async function (req: Tina4Request, res: Tina4Response) { // tina4:edit tune pagination defaults or add filter/sort parsing here const page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 20; const offset = (page - 1) * limit; const rows = await ${model}.select("SELECT * FROM ${table} LIMIT ? OFFSET ?", [limit, offset]); res.json({ data: rows.map((r) => r.toObject()), page, limit }); } `, ); } else { writeFileSafe( join(base, "get.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] }; export default async function (req: Tina4Request, res: Tina4Response) { ${aiFill(`list_${routePath}`, { intent: `return the ${routePath} collection (add pagination if it grows)`, given: "req.query -> filters/paging", use: `Model.select("SELECT … LIMIT ? OFFSET ?", [limit, offset]) then r.toObject()`, ret: "res.json({ data: rows })", ground: `tina4_context("list ORM records with pagination", "nodejs") · skill tina4-developer-nodejs`, raise: `${routePath} list not implemented`, })}} `, ); } // ── POST create (secure by default; --public opens it) ───────────── if (model) { writeFileSafe( join(base, "post.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { // tina4:edit validate the body before persist (Validator or hand-checks) ${extend("validate / business rules before persist", `e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record); // save() returns false on failure rather than throwing - check it, or a failed // write is reported to the client as a 201 carrying unsaved data. if ((await item.save()) === false) { res.json({ error: "Could not create ${singular}" }, 400); return; } res.json({ data: item.toObject() }, 201); } `, ); } else { writeFileSafe( join(base, "post.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { // tina4:edit fill the create handler (see AI-FILL fill-spec below) ${aiFill(`create_${singular}`, { intent: `validate the body and persist a new ${singular}`, given: "req.body -> the posted fields", use: "new Model(req.body).save() then item.toObject() (import your model)", ret: "res.json({ data: item }, 201)", ground: `tina4_context("create ORM record and return 201", "nodejs") · skill tina4-developer-nodejs`, raise: `create ${singular} not implemented`, })}} `, ); } // ── GET by id (public) ───────────────────────────────────────────── if (model) { writeFileSafe( join(idDir, "get.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${modelImportId} export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] }; export default async function (req: Tina4Request, res: Tina4Response) { const { id } = req.params; const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]); if (!item) { res.json({ error: "Not found" }, 404); return; } res.json({ data: item.toObject() }); } `, ); } else { writeFileSafe( join(idDir, "get.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] }; export default async function (req: Tina4Request, res: Tina4Response) { ${aiFill(`get_${singular}`, { intent: `fetch one ${singular} by id`, given: "req.params.id -> the record id", use: `Model.selectOne("SELECT … WHERE id = ?", [req.params.id])`, ret: "res.json({ data: item }) or res.json({ error: 'Not found' }, 404)", ground: `tina4_context("find ORM record by id", "nodejs") · skill tina4-developer-nodejs`, raise: `get ${singular} not implemented`, })}} `, ); } // ── PUT by id (secure by default; --public opens it) ─────────────── if (model) { writeFileSafe( join(idDir, "put.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { const { id } = req.params; const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]); if (!item) { res.json({ error: "Not found" }, 404); return; } // tina4:edit guard which fields may be updated and who may update this row ${extend("guard which fields / who may update", `e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record); // save() returns false on failure rather than throwing - check it, or a failed // write is reported to the client as a 200 carrying unsaved data. if ((await item.save()) === false) { res.json({ error: "Could not update ${singular}" }, 400); return; } res.json({ data: item.toObject() }); } `, ); } else { writeFileSafe( join(idDir, "put.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { // tina4:edit fill the update handler (see AI-FILL fill-spec below) ${aiFill(`update_${singular}`, { intent: `load, mutate and save an existing ${singular}`, given: "req.params.id -> id; req.body -> changed fields", use: "Model.selectOne(…) then Object.assign(item, req.body) then item.save()", ret: "res.json({ data: item }) or 404", ground: `tina4_context("update ORM record", "nodejs") · skill tina4-developer-nodejs`, raise: `update ${singular} not implemented`, })}} `, ); } // ── DELETE by id (secure by default; --public opens it) ──────────── if (model) { writeFileSafe( join(idDir, "delete.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { const { id } = req.params; const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]); if (!item) { res.json({ error: "Not found" }, 404); return; } await item.delete(); res.json({ message: "deleted", id }); } `, ); } else { writeFileSafe( join(idDir, "delete.ts"), `import type { Tina4Request, Tina4Response } from "tina4-nodejs"; ${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] }; // ${writeDoc} export default async function (req: Tina4Request, res: Tina4Response) { ${aiFill(`delete_${singular}`, { intent: `delete a ${singular} by id`, given: "req.params.id -> id", use: "Model.selectOne(…) then item.delete()", ret: "res.json({ message: 'deleted', id }) or 404", ground: `tina4_context("delete ORM record", "nodejs") · skill tina4-developer-nodejs`, raise: `delete ${singular} not implemented`, })}} `, ); } // Co-emit a real test. A --model route is working code → the secure-by-default // gate test (reads public, writes gated, or open under --public). A no-model // route's handlers are loud AI-FILL stubs → the Router-registration + live-stub // test. Composite generators (crud) pass emitTest=false and emit their own. if (emitTest) { if (model) { generateTest(routePath, { model, "secure-writes": true, public: isPublic }); } else { emitRouteStubTest(routePath); } } } // ── CRUD ──────────────────────────────────────────────────────────── function generateCrud(name: string, flags: Record): void { // Composite: quiet here (announce=false); the generateModel sub-call below is // the one that announces, so the reserved-word note prints exactly once. const table = resolveTable(name, flags, { announce: false }); const routeName = toPlural(table); const isPublic = Boolean(flags.public); // Human-only banners; suppressed under --json to keep stdout parseable // (a console.log during --json produced invalid JSON). writeFileSafe already // gates its own "Created …" lines the same way. if (!__resolution.jsonMode) console.log(`\n Generating CRUD for ${name}...\n`); // 1. Model + migration (its own model test is suppressed — the gate test // below is CRUD's single, broader co-emitted test). generateModel(name, flags, false); // 2. Routes with model — secure by default; thread --public through so // `generate crud X --public` opens the writes (mirrors AutoCrud public). // Route test suppressed (the gate test below covers the routes). generateRoute(routeName, { ...flags, model: name }, false); // 3. Form generateForm(name, flags); // 4. View (list + detail) generateView(name, flags); // 5. Test — real secure-by-default boot-gate (reads public, writes gated). generateTest(routeName, { model: name, "secure-writes": true, public: isPublic }); if (!__resolution.jsonMode) { console.log(`\n CRUD generation complete for ${name}.`); console.log(" Run: tina4nodejs migrate"); console.log(" Visit: /swagger to see the API docs"); } } // ── Migration ─────────────────────────────────────────────────────── export function generateMigration( name: string, flags: Record, fieldsOverride?: Array<[string, string]>, tableOverride?: string, emitTest = true, ): void { const ts = timestamp(); const dir = resolve("migrations"); ensureDir(dir); // Determine table name. When called from `generateModel` (tableOverride set), // the model already ran toTableName() and recorded any reserved-word // pluralisation — reuse that resolved name so the two files agree. When // called directly (`generate migration create_order`), strip the prefix and // route through toTableName() so a reserved word is caught HERE too. let table: string; if (tableOverride) { table = tableOverride; } else { const raw = name .replace(/^create_/, "") .replace(/^add_/, "") .replace(/^drop_/, ""); // resolveTable honours --table-name and (via toTableName) records the // `reserved_word_pluralize` transformation when the raw form collides with a // SQL reserved word. Quiet (announce=false): a direct migration targets an // existing table, so it does not repeat the model's note. table = resolveTable(raw, flags, { announce: false }); } // Only set table_name / migration_path when THIS is the top-level target. // A migration produced by generateModel is a side effect of `generate model`, // and the model already populated `table_name` / `file_path` for that // resolution — overwriting them here would lie about what the caller asked // for. `migration_path` is always safe to record either way. if (__resolution.target === "migration") { setResolutionField("table_name", table); } // Build SQL columns from fields const fields = fieldsOverride || parseFields((flags.fields as string) || ""); const isCreate = name.startsWith("create_") || fieldsOverride !== undefined; const fileName = `${ts}_${name}.sql`; const path = join(dir, fileName); // Record on the resolution — always safe (a `generate model` run overwrites // this with each nested migration; the last write wins, which is the one // the operator actually gets on disk). setResolutionField("migration_path", `migrations/${fileName}`); if (__resolution.target === "migration") { setResolutionField("file_path", `migrations/${fileName}`); // Matches emitMigrationTest's real write path. pushTestPath(`tests/${table}_migration.test.ts`); } let upSql: string; let downSql: string; // ADR-0063 (scaffolding envelope v1.1): `-- tina4:edit