tina4-nodejs 3.13.118 → 3.13.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +218 -40
- package/packages/cli/src/bin.ts +8 -4
- package/packages/cli/src/commands/generate.ts +287 -26
- package/types/cli/src/commands/generate.d.ts +21 -1
package/CLAUDE.md
CHANGED
|
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
|
|
|
13
13
|
|
|
14
14
|
The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
|
|
15
15
|
|
|
16
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
16
|
+
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.120)
|
|
17
17
|
|
|
18
18
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
19
19
|
|
|
20
20
|
## What This Project Is
|
|
21
21
|
|
|
22
|
-
Tina4 for Node.js/TypeScript v3.13.
|
|
22
|
+
Tina4 for Node.js/TypeScript v3.13.120 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
23
23
|
|
|
24
24
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
25
25
|
|
package/package.json
CHANGED
package/packages/cli/dist/bin.js
CHANGED
|
@@ -1832,15 +1832,15 @@ function findOutsideQuotes(expr, needle) {
|
|
|
1832
1832
|
}
|
|
1833
1833
|
return -1;
|
|
1834
1834
|
}
|
|
1835
|
-
function splitOutsideQuotes(expr,
|
|
1836
|
-
if (!expr.includes(
|
|
1835
|
+
function splitOutsideQuotes(expr, sep7) {
|
|
1836
|
+
if (!expr.includes(sep7)) return [expr];
|
|
1837
1837
|
const parts = [];
|
|
1838
1838
|
let currentStart = 0;
|
|
1839
1839
|
let inQuote = null;
|
|
1840
1840
|
let depth = 0;
|
|
1841
1841
|
let bracketDepth = 0;
|
|
1842
1842
|
let i = 0;
|
|
1843
|
-
const sepLen =
|
|
1843
|
+
const sepLen = sep7.length;
|
|
1844
1844
|
const lastStart = expr.length - sepLen;
|
|
1845
1845
|
while (i <= lastStart) {
|
|
1846
1846
|
const ch = expr[i];
|
|
@@ -1861,7 +1861,7 @@ function splitOutsideQuotes(expr, sep6) {
|
|
|
1861
1861
|
else if (ch === ")") depth--;
|
|
1862
1862
|
else if (ch === "[") bracketDepth++;
|
|
1863
1863
|
else if (ch === "]") bracketDepth--;
|
|
1864
|
-
if (depth === 0 && bracketDepth === 0 && expr.startsWith(
|
|
1864
|
+
if (depth === 0 && bracketDepth === 0 && expr.startsWith(sep7, i)) {
|
|
1865
1865
|
parts.push(expr.slice(currentStart, i));
|
|
1866
1866
|
i += sepLen;
|
|
1867
1867
|
currentStart = i;
|
|
@@ -2637,8 +2637,8 @@ var init_engine = __esm({
|
|
|
2637
2637
|
},
|
|
2638
2638
|
first: (v) => Array.isArray(v) ? v[0] ?? null : null,
|
|
2639
2639
|
last: (v) => Array.isArray(v) ? v[v.length - 1] ?? null : null,
|
|
2640
|
-
join: (v,
|
|
2641
|
-
split: (v,
|
|
2640
|
+
join: (v, sep7) => Array.isArray(v) ? v.map(String).join(sep7 !== void 0 ? String(sep7) : ", ") : String(v),
|
|
2641
|
+
split: (v, sep7) => String(v).split(sep7 !== void 0 ? String(sep7) : " "),
|
|
2642
2642
|
replace: (v, from, to) => {
|
|
2643
2643
|
const s = String(v);
|
|
2644
2644
|
if (from !== void 0 && typeof from === "object" && from !== null && !Array.isArray(from)) {
|
|
@@ -36205,8 +36205,8 @@ function writeMcpDiscovery(projectRoot3, port) {
|
|
|
36205
36205
|
const lines = contents.split(/\r?\n/);
|
|
36206
36206
|
const already = lines.some((l) => l.trim() === GITIGNORE_LINE || l.trim() === ".tina4");
|
|
36207
36207
|
if (!already) {
|
|
36208
|
-
const
|
|
36209
|
-
fs8.writeFileSync(gitignorePath, `${contents}${
|
|
36208
|
+
const sep7 = contents.endsWith("\n") || contents === "" ? "" : "\n";
|
|
36209
|
+
fs8.writeFileSync(gitignorePath, `${contents}${sep7}${GITIGNORE_LINE}
|
|
36210
36210
|
`, "utf-8");
|
|
36211
36211
|
}
|
|
36212
36212
|
}
|
|
@@ -45400,8 +45400,8 @@ async function runTests(testPath) {
|
|
|
45400
45400
|
console.log(` Found ${testFiles.length} test file(s)
|
|
45401
45401
|
`);
|
|
45402
45402
|
for (const file of testFiles) {
|
|
45403
|
-
const
|
|
45404
|
-
console.log(` Running: ${
|
|
45403
|
+
const relative11 = file.replace(cwd + "/", "");
|
|
45404
|
+
console.log(` Running: ${relative11}`);
|
|
45405
45405
|
try {
|
|
45406
45406
|
execSync3(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
|
|
45407
45407
|
} catch {
|
|
@@ -45413,7 +45413,7 @@ async function runTests(testPath) {
|
|
|
45413
45413
|
|
|
45414
45414
|
// src/commands/generate.ts
|
|
45415
45415
|
import { existsSync as existsSync33, mkdirSync as mkdirSync23, writeFileSync as writeFileSync20 } from "node:fs";
|
|
45416
|
-
import { join as join35, resolve as resolve28 } from "node:path";
|
|
45416
|
+
import { join as join35, relative as relative10, resolve as resolve28, sep as sep6 } from "node:path";
|
|
45417
45417
|
var FIELD_TYPE_MAP = {
|
|
45418
45418
|
string: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
45419
45419
|
str: { orm: '"string"', sql: "TEXT", defaultVal: "''" },
|
|
@@ -45436,6 +45436,7 @@ function ensureDir(dir) {
|
|
|
45436
45436
|
}
|
|
45437
45437
|
}
|
|
45438
45438
|
function writeFileSafe(path8, content) {
|
|
45439
|
+
captureEditHints(path8, content);
|
|
45439
45440
|
if (__resolution.dryRun) {
|
|
45440
45441
|
return;
|
|
45441
45442
|
}
|
|
@@ -45551,7 +45552,7 @@ function toTableName(name) {
|
|
|
45551
45552
|
}
|
|
45552
45553
|
return raw;
|
|
45553
45554
|
}
|
|
45554
|
-
var RESOLUTION_ENVELOPE_VERSION = "
|
|
45555
|
+
var RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
|
|
45555
45556
|
var __resolution = {
|
|
45556
45557
|
target: "",
|
|
45557
45558
|
input: { name: "", fields: null },
|
|
@@ -45572,14 +45573,27 @@ function recordTransformation(t) {
|
|
|
45572
45573
|
__resolution.body.transformations.push(t);
|
|
45573
45574
|
}
|
|
45574
45575
|
function currentResolution() {
|
|
45576
|
+
const body = {
|
|
45577
|
+
...__resolution.body,
|
|
45578
|
+
transformations: [...__resolution.body.transformations]
|
|
45579
|
+
};
|
|
45580
|
+
if (__resolution.body.edit_hints) {
|
|
45581
|
+
body.edit_hints = __resolution.body.edit_hints.map((h) => ({ ...h }));
|
|
45582
|
+
}
|
|
45583
|
+
if (__resolution.body.next) {
|
|
45584
|
+
body.next = [...__resolution.body.next];
|
|
45585
|
+
}
|
|
45586
|
+
if (__resolution.body.test_paths) {
|
|
45587
|
+
body.test_paths = [...__resolution.body.test_paths];
|
|
45588
|
+
}
|
|
45589
|
+
if (__resolution.body.routes) {
|
|
45590
|
+
body.routes = [...__resolution.body.routes];
|
|
45591
|
+
}
|
|
45575
45592
|
return {
|
|
45576
45593
|
command: "generate",
|
|
45577
45594
|
target: __resolution.target,
|
|
45578
45595
|
input: { ...__resolution.input },
|
|
45579
|
-
resolution:
|
|
45580
|
-
...__resolution.body,
|
|
45581
|
-
transformations: [...__resolution.body.transformations]
|
|
45582
|
-
},
|
|
45596
|
+
resolution: body,
|
|
45583
45597
|
actions_taken: [...__resolution.actionsTaken],
|
|
45584
45598
|
dry_run: __resolution.dryRun
|
|
45585
45599
|
};
|
|
@@ -45595,6 +45609,32 @@ function pushTestPath(path8) {
|
|
|
45595
45609
|
if (!__resolution.body.test_paths) __resolution.body.test_paths = [];
|
|
45596
45610
|
__resolution.body.test_paths.push(path8);
|
|
45597
45611
|
}
|
|
45612
|
+
function pushEditHint(hint) {
|
|
45613
|
+
if (!__resolution.body.edit_hints) __resolution.body.edit_hints = [];
|
|
45614
|
+
__resolution.body.edit_hints.push(hint);
|
|
45615
|
+
}
|
|
45616
|
+
function setNextSteps(steps) {
|
|
45617
|
+
if (steps.length === 0) return;
|
|
45618
|
+
__resolution.body.next = [...steps];
|
|
45619
|
+
}
|
|
45620
|
+
function toRelPath(absPath) {
|
|
45621
|
+
const cwd = process.cwd();
|
|
45622
|
+
const rel = relative10(cwd, absPath);
|
|
45623
|
+
if (!rel) return absPath;
|
|
45624
|
+
return sep6 === "/" ? rel : rel.split(sep6).join("/");
|
|
45625
|
+
}
|
|
45626
|
+
var TINA4_EDIT_MARKER = /^\s*\/\/\s*tina4:edit\s+(.+?)\s*$/;
|
|
45627
|
+
function captureEditHints(absPath, content) {
|
|
45628
|
+
if (!/\.(ts|tsx|js|mjs|cjs|jsx)$/.test(absPath)) return;
|
|
45629
|
+
const relPath = toRelPath(absPath);
|
|
45630
|
+
const lines = content.split("\n");
|
|
45631
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45632
|
+
const match = TINA4_EDIT_MARKER.exec(lines[i]);
|
|
45633
|
+
if (match) {
|
|
45634
|
+
pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() });
|
|
45635
|
+
}
|
|
45636
|
+
}
|
|
45637
|
+
}
|
|
45598
45638
|
function printResolution() {
|
|
45599
45639
|
if (__resolution.jsonMode) {
|
|
45600
45640
|
process.stdout.write(JSON.stringify(currentResolution(), null, 2) + "\n");
|
|
@@ -45625,6 +45665,23 @@ function printResolution() {
|
|
|
45625
45665
|
lines.push(` To keep the raw name '${reserved.from}' as the table:`);
|
|
45626
45666
|
lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
|
|
45627
45667
|
}
|
|
45668
|
+
if (b.test_paths && b.test_paths.length > 0) {
|
|
45669
|
+
lines.push("");
|
|
45670
|
+
lines.push(" Tests:");
|
|
45671
|
+
for (const testPath of b.test_paths) lines.push(` ${testPath}`);
|
|
45672
|
+
}
|
|
45673
|
+
if (b.edit_hints && b.edit_hints.length > 0) {
|
|
45674
|
+
lines.push("");
|
|
45675
|
+
lines.push(" Edit these lines:");
|
|
45676
|
+
for (const hint of b.edit_hints) {
|
|
45677
|
+
lines.push(` ${hint.file}:${hint.line} ${hint.label}`);
|
|
45678
|
+
}
|
|
45679
|
+
}
|
|
45680
|
+
if (b.next && b.next.length > 0) {
|
|
45681
|
+
lines.push("");
|
|
45682
|
+
lines.push(" Next:");
|
|
45683
|
+
for (const step of b.next) lines.push(` ${step}`);
|
|
45684
|
+
}
|
|
45628
45685
|
lines.push("");
|
|
45629
45686
|
process.stderr.write(lines.join("\n"));
|
|
45630
45687
|
}
|
|
@@ -45752,6 +45809,93 @@ var GENERATORS = {
|
|
|
45752
45809
|
listener: { handler: generateListener, usage: "<event>", summary: "Events.on(event) listener (src/listeners/)" }
|
|
45753
45810
|
};
|
|
45754
45811
|
var GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
|
|
45812
|
+
var NEXT_STEPS = {
|
|
45813
|
+
model: ({ name, table: table2 }) => [
|
|
45814
|
+
`Edit src/models/${name}.ts to add fields beyond the default 'name'`,
|
|
45815
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
45816
|
+
`Run its test: npx tsx tests/${table2}_model.test.ts`,
|
|
45817
|
+
`Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`
|
|
45818
|
+
],
|
|
45819
|
+
route: ({ name, table: table2 }) => [
|
|
45820
|
+
`Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`,
|
|
45821
|
+
`Run its test: npx tsx tests/${table2}.test.ts`,
|
|
45822
|
+
`Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`
|
|
45823
|
+
],
|
|
45824
|
+
crud: ({ name, table: table2 }) => [
|
|
45825
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
45826
|
+
`Serve and try: npx tina4nodejs serve -> visit /swagger`,
|
|
45827
|
+
`Run the gate test: npx tsx tests/${toPlural(table2)}.test.ts`,
|
|
45828
|
+
`Change fields: edit src/models/${name}.ts then re-run generate crud`
|
|
45829
|
+
],
|
|
45830
|
+
migration: () => [
|
|
45831
|
+
`Apply pending migrations: npx tina4nodejs migrate`,
|
|
45832
|
+
`Check status: npx tina4nodejs migrate:status`,
|
|
45833
|
+
`Roll back the batch: npx tina4nodejs migrate:rollback`
|
|
45834
|
+
],
|
|
45835
|
+
middleware: ({ name }) => [
|
|
45836
|
+
`Wire it: router.middleware(before${name}, after${name}) \u2014 or bind per-route`,
|
|
45837
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`
|
|
45838
|
+
],
|
|
45839
|
+
test: ({ name }) => [
|
|
45840
|
+
`Fill the TODOs in tests/${toSnake(name)}.test.ts`,
|
|
45841
|
+
`Run it: npx tsx tests/${toSnake(name)}.test.ts`
|
|
45842
|
+
],
|
|
45843
|
+
form: ({ name, table: table2 }) => [
|
|
45844
|
+
`Render from a route: res.render("forms/${table2}.twig", { item })`,
|
|
45845
|
+
`Add the POST route: npx tina4nodejs generate route ${toPlural(table2)} --model ${name}`
|
|
45846
|
+
],
|
|
45847
|
+
view: ({ table: table2 }) => [
|
|
45848
|
+
`Wire routes to render list -> ${toPlural(table2)}.twig, detail -> ${table2}.twig`,
|
|
45849
|
+
`Customize the templates in src/templates/pages/`
|
|
45850
|
+
],
|
|
45851
|
+
auth: () => [
|
|
45852
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
45853
|
+
`Run the auth test: npx tsx tests/auth.test.ts`,
|
|
45854
|
+
`Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
|
|
45855
|
+
`Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`
|
|
45856
|
+
],
|
|
45857
|
+
service: ({ name }) => [
|
|
45858
|
+
`Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`,
|
|
45859
|
+
`Fill the task body in src/services/${toSnake(name)}.ts`,
|
|
45860
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`
|
|
45861
|
+
],
|
|
45862
|
+
queue: ({ name }) => {
|
|
45863
|
+
const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
|
|
45864
|
+
return [
|
|
45865
|
+
`Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`,
|
|
45866
|
+
`Produce a job: publish${toPascal(name)}({ ... })`,
|
|
45867
|
+
`Run the worker: npx tina4nodejs queue work ${name}`,
|
|
45868
|
+
`Run its test: npx tsx tests/${slug}.test.ts`
|
|
45869
|
+
];
|
|
45870
|
+
},
|
|
45871
|
+
validator: ({ name }) => [
|
|
45872
|
+
`Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`,
|
|
45873
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`
|
|
45874
|
+
],
|
|
45875
|
+
seeder: ({ name, table: table2 }) => [
|
|
45876
|
+
`Override any fields that need a specific shape in src/seeds/${table2}_seeder.ts`,
|
|
45877
|
+
`Seed the table: npx tina4nodejs seed`,
|
|
45878
|
+
`Run its test: npx tsx tests/${table2}_seeder.test.ts`
|
|
45879
|
+
],
|
|
45880
|
+
websocket: ({ name }) => {
|
|
45881
|
+
const raw = name.trim();
|
|
45882
|
+
const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
|
|
45883
|
+
const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw;
|
|
45884
|
+
return [
|
|
45885
|
+
`Import once in app.ts to register: import "./src/routes/ws_${base}.js";`,
|
|
45886
|
+
`Fill the "message" branch in src/routes/ws_${base}.ts`,
|
|
45887
|
+
`Run its test: npx tsx tests/ws_${base}.test.ts`
|
|
45888
|
+
];
|
|
45889
|
+
},
|
|
45890
|
+
listener: ({ name }) => {
|
|
45891
|
+
const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
|
|
45892
|
+
return [
|
|
45893
|
+
`Import once in app.ts to register: import "./src/listeners/${slug}.js";`,
|
|
45894
|
+
`Fill the reaction in src/listeners/${slug}.ts`,
|
|
45895
|
+
`Run its test: npx tsx tests/${slug}.test.ts`
|
|
45896
|
+
];
|
|
45897
|
+
}
|
|
45898
|
+
};
|
|
45755
45899
|
async function generate2(what, name, extraArgs = []) {
|
|
45756
45900
|
if (!what) {
|
|
45757
45901
|
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
@@ -45764,6 +45908,10 @@ async function generate2(what, name, extraArgs = []) {
|
|
|
45764
45908
|
process.exit(1);
|
|
45765
45909
|
}
|
|
45766
45910
|
const noNameGenerators = /* @__PURE__ */ new Set(["auth"]);
|
|
45911
|
+
if (noNameGenerators.has(what) && name.startsWith("--")) {
|
|
45912
|
+
extraArgs = [name, ...extraArgs];
|
|
45913
|
+
name = "";
|
|
45914
|
+
}
|
|
45767
45915
|
if (!noNameGenerators.has(what) && !name) {
|
|
45768
45916
|
console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
|
|
45769
45917
|
process.exit(1);
|
|
@@ -45780,6 +45928,11 @@ async function generate2(what, name, extraArgs = []) {
|
|
|
45780
45928
|
console.error(` Available: ${GENERATOR_LIST}`);
|
|
45781
45929
|
process.exit(1);
|
|
45782
45930
|
}
|
|
45931
|
+
const nextFn = NEXT_STEPS[what];
|
|
45932
|
+
if (nextFn) {
|
|
45933
|
+
const resolvedTable = __resolution.body.table_name ?? (name ? SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name) : "");
|
|
45934
|
+
setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
|
|
45935
|
+
}
|
|
45783
45936
|
printResolution();
|
|
45784
45937
|
}
|
|
45785
45938
|
function generateModel(name, flags, emitTest = true) {
|
|
@@ -45793,7 +45946,8 @@ function generateModel(name, flags, emitTest = true) {
|
|
|
45793
45946
|
setResolutionField("file_path", `src/models/${name}.ts`);
|
|
45794
45947
|
pushTestPath(`tests/${table2}_model.test.ts`);
|
|
45795
45948
|
const fieldLines = [
|
|
45796
|
-
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true }
|
|
45949
|
+
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
|
|
45950
|
+
` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`
|
|
45797
45951
|
];
|
|
45798
45952
|
for (const [fname, ftype] of fields) {
|
|
45799
45953
|
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
@@ -45848,6 +46002,7 @@ ${modelImportBase}
|
|
|
45848
46002
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
45849
46003
|
|
|
45850
46004
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46005
|
+
// tina4:edit tune pagination defaults or add filter/sort parsing here
|
|
45851
46006
|
const page = parseInt(req.query.page as string) || 1;
|
|
45852
46007
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
45853
46008
|
const offset = (page - 1) * limit;
|
|
@@ -45883,6 +46038,7 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
|
|
|
45883
46038
|
|
|
45884
46039
|
// ${writeDoc}
|
|
45885
46040
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46041
|
+
// tina4:edit validate the body before persist (Validator or hand-checks)
|
|
45886
46042
|
${extend(
|
|
45887
46043
|
"validate / business rules before persist",
|
|
45888
46044
|
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`
|
|
@@ -45906,6 +46062,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular
|
|
|
45906
46062
|
|
|
45907
46063
|
// ${writeDoc}
|
|
45908
46064
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46065
|
+
// tina4:edit fill the create handler (see AI-FILL fill-spec below)
|
|
45909
46066
|
${aiFill(`create_${singular}`, {
|
|
45910
46067
|
intent: `validate the body and persist a new ${singular}`,
|
|
45911
46068
|
given: "req.body -> the posted fields",
|
|
@@ -45968,6 +46125,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
45968
46125
|
res.json({ error: "Not found" }, 404);
|
|
45969
46126
|
return;
|
|
45970
46127
|
}
|
|
46128
|
+
// tina4:edit guard which fields may be updated and who may update this row
|
|
45971
46129
|
${extend(
|
|
45972
46130
|
"guard which fields / who may update",
|
|
45973
46131
|
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`
|
|
@@ -45991,6 +46149,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by
|
|
|
45991
46149
|
|
|
45992
46150
|
// ${writeDoc}
|
|
45993
46151
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46152
|
+
// tina4:edit fill the update handler (see AI-FILL fill-spec below)
|
|
45994
46153
|
${aiFill(`update_${singular}`, {
|
|
45995
46154
|
intent: `load, mutate and save an existing ${singular}`,
|
|
45996
46155
|
given: "req.params.id -> id; req.body -> changed fields",
|
|
@@ -46053,7 +46212,7 @@ function generateCrud(name, flags) {
|
|
|
46053
46212
|
const table2 = toTableName(name);
|
|
46054
46213
|
const routeName = toPlural(table2);
|
|
46055
46214
|
const isPublic = Boolean(flags.public);
|
|
46056
|
-
console.log(`
|
|
46215
|
+
if (!__resolution.jsonMode) console.log(`
|
|
46057
46216
|
Generating CRUD for ${name}...
|
|
46058
46217
|
`);
|
|
46059
46218
|
generateModel(name, flags, false);
|
|
@@ -46061,10 +46220,12 @@ function generateCrud(name, flags) {
|
|
|
46061
46220
|
generateForm(name, flags);
|
|
46062
46221
|
generateView(name, flags);
|
|
46063
46222
|
generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
|
|
46064
|
-
|
|
46223
|
+
if (!__resolution.jsonMode) {
|
|
46224
|
+
console.log(`
|
|
46065
46225
|
CRUD generation complete for ${name}.`);
|
|
46066
|
-
|
|
46067
|
-
|
|
46226
|
+
console.log(" Run: tina4nodejs migrate");
|
|
46227
|
+
console.log(" Visit: /swagger to see the API docs");
|
|
46228
|
+
}
|
|
46068
46229
|
}
|
|
46069
46230
|
function generateMigration(name, flags, fieldsOverride, tableOverride, emitTest = true) {
|
|
46070
46231
|
const ts = timestamp();
|
|
@@ -46153,6 +46314,7 @@ export async function before${name}(
|
|
|
46153
46314
|
res: Tina4Response,
|
|
46154
46315
|
next: () => Promise<void>,
|
|
46155
46316
|
): Promise<void> {
|
|
46317
|
+
// tina4:edit replace the Authorization check with the real pre-request rule
|
|
46156
46318
|
const auth = req.headers["authorization"];
|
|
46157
46319
|
if (!auth) {
|
|
46158
46320
|
res.json({ error: "Unauthorized" }, 401);
|
|
@@ -46166,7 +46328,7 @@ export async function after${name}(
|
|
|
46166
46328
|
res: Tina4Response,
|
|
46167
46329
|
next: () => Promise<void>,
|
|
46168
46330
|
): Promise<void> {
|
|
46169
|
-
//
|
|
46331
|
+
// tina4:edit add post-processing (logging, header injection, telemetry)
|
|
46170
46332
|
await next();
|
|
46171
46333
|
}
|
|
46172
46334
|
`;
|
|
@@ -46242,35 +46404,35 @@ process.exit(fail > 0 ? 1 : 0);
|
|
|
46242
46404
|
const list${model}s = tests(
|
|
46243
46405
|
assertTrue([]),
|
|
46244
46406
|
)(function list${model}s() {
|
|
46245
|
-
//
|
|
46407
|
+
// tina4:edit assert against a real GET /api/${toSnake(name)} response (rows, count)
|
|
46246
46408
|
return true;
|
|
46247
46409
|
});
|
|
46248
46410
|
|
|
46249
46411
|
const get${model} = tests(
|
|
46250
46412
|
assertTrue([]),
|
|
46251
46413
|
)(function get${model}() {
|
|
46252
|
-
//
|
|
46414
|
+
// tina4:edit assert against GET /api/${toSnake(name)}/{id} for one seeded row
|
|
46253
46415
|
return true;
|
|
46254
46416
|
});
|
|
46255
46417
|
|
|
46256
46418
|
const create${model} = tests(
|
|
46257
46419
|
assertTrue([]),
|
|
46258
46420
|
)(function create${model}() {
|
|
46259
|
-
//
|
|
46421
|
+
// tina4:edit POST a valid + an invalid body, assert 201 vs 400
|
|
46260
46422
|
return true;
|
|
46261
46423
|
});
|
|
46262
46424
|
|
|
46263
46425
|
const update${model} = tests(
|
|
46264
46426
|
assertTrue([]),
|
|
46265
46427
|
)(function update${model}() {
|
|
46266
|
-
//
|
|
46428
|
+
// tina4:edit PUT changed fields, assert the row was persisted
|
|
46267
46429
|
return true;
|
|
46268
46430
|
});
|
|
46269
46431
|
|
|
46270
46432
|
const delete${model} = tests(
|
|
46271
46433
|
assertTrue([]),
|
|
46272
46434
|
)(function delete${model}() {
|
|
46273
|
-
//
|
|
46435
|
+
// tina4:edit DELETE the id, assert 200 then GET returns 404
|
|
46274
46436
|
return true;
|
|
46275
46437
|
});
|
|
46276
46438
|
|
|
@@ -46287,7 +46449,7 @@ void [list${model}s, get${model}, create${model}, update${model}, delete${model}
|
|
|
46287
46449
|
const test${titleName} = tests(
|
|
46288
46450
|
assertTrue([]),
|
|
46289
46451
|
)(function test${titleName}() {
|
|
46290
|
-
//
|
|
46452
|
+
// tina4:edit assert against the real behaviour under test (no mocks)
|
|
46291
46453
|
return true;
|
|
46292
46454
|
});
|
|
46293
46455
|
|
|
@@ -46424,7 +46586,7 @@ ${detailFields}
|
|
|
46424
46586
|
writeFileSafe(detailPath, detailContent);
|
|
46425
46587
|
}
|
|
46426
46588
|
function generateAuth(_flags) {
|
|
46427
|
-
console.log("\n Generating authentication scaffolding...\n");
|
|
46589
|
+
if (!__resolution.jsonMode) console.log("\n Generating authentication scaffolding...\n");
|
|
46428
46590
|
generateModel("User", { fields: "email:string,password:string,role:string" }, false);
|
|
46429
46591
|
const registerDir = resolve28("src/routes/api/auth/register");
|
|
46430
46592
|
const loginDir = resolve28("src/routes/api/auth/login");
|
|
@@ -46444,6 +46606,7 @@ export const secure = false;
|
|
|
46444
46606
|
export const meta = { summary: "Register a new user", tags: ["auth"] };
|
|
46445
46607
|
|
|
46446
46608
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46609
|
+
// tina4:edit add password-strength / email-format / captcha rules before mint
|
|
46447
46610
|
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
46448
46611
|
|
|
46449
46612
|
if (!email || !password) {
|
|
@@ -46475,6 +46638,7 @@ export const secure = false;
|
|
|
46475
46638
|
export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
|
|
46476
46639
|
|
|
46477
46640
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
46641
|
+
// tina4:edit add rate-limit / lock-after-N-failures / 2FA before password check
|
|
46478
46642
|
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
46479
46643
|
|
|
46480
46644
|
if (!email || !password) {
|
|
@@ -46489,6 +46653,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
46489
46653
|
}
|
|
46490
46654
|
|
|
46491
46655
|
const data = user.toObject();
|
|
46656
|
+
// tina4:edit set token TTL (getToken(payload, secret, expiresInMinutes)) and add scopes if needed
|
|
46492
46657
|
const token = getToken({ userId: data.id, email: data.email, role: data.role });
|
|
46493
46658
|
res.json({ token });
|
|
46494
46659
|
}
|
|
@@ -46562,11 +46727,13 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
46562
46727
|
`
|
|
46563
46728
|
);
|
|
46564
46729
|
emitAuthTest();
|
|
46565
|
-
|
|
46566
|
-
|
|
46567
|
-
|
|
46568
|
-
|
|
46569
|
-
|
|
46730
|
+
if (!__resolution.jsonMode) {
|
|
46731
|
+
console.log("\n Authentication scaffolding complete.");
|
|
46732
|
+
console.log(" Run: tina4nodejs migrate");
|
|
46733
|
+
console.log(" POST /api/auth/register \u2014 create account (public)");
|
|
46734
|
+
console.log(" POST /api/auth/login \u2014 get JWT token (public)");
|
|
46735
|
+
console.log(" GET /api/auth/me \u2014 get profile (requires token)");
|
|
46736
|
+
}
|
|
46570
46737
|
}
|
|
46571
46738
|
function generateService(name, flags) {
|
|
46572
46739
|
const snake = toSnake(name);
|
|
@@ -46606,6 +46773,7 @@ function generateService(name, flags) {
|
|
|
46606
46773
|
*/
|
|
46607
46774
|
|
|
46608
46775
|
export async function ${camel}Task(context: ServiceContext): Promise<void> {
|
|
46776
|
+
// tina4:edit replace the AI-FILL stub below with the scheduled work
|
|
46609
46777
|
${body}}
|
|
46610
46778
|
|
|
46611
46779
|
// Discovered by ServiceRunner.discover("src/services") \u2014 it reads name/handler
|
|
@@ -46651,6 +46819,7 @@ export function publish${pascal}(payload: Record<string, unknown>): string {
|
|
|
46651
46819
|
|
|
46652
46820
|
/** Process ONE ${topic} job payload. */
|
|
46653
46821
|
export async function handle${pascal}(payload: unknown): Promise<void> {
|
|
46822
|
+
// tina4:edit implement the per-job handler; return to ack, throw to nack
|
|
46654
46823
|
${body}}
|
|
46655
46824
|
|
|
46656
46825
|
/** Long-running ${topic} worker \u2014 consume() yields jobs; ack/nack each. */
|
|
@@ -46701,6 +46870,7 @@ function generateValidator(name, _flags) {
|
|
|
46701
46870
|
*/
|
|
46702
46871
|
export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
|
|
46703
46872
|
const validator = new Validator(data);
|
|
46873
|
+
// tina4:edit add rules for this payload (.email/.minLength/.integer/.inList/.pattern)
|
|
46704
46874
|
${rules} validator.required("name"); // starter rule (matches the model's default field)
|
|
46705
46875
|
return validator;
|
|
46706
46876
|
}
|
|
@@ -46728,6 +46898,7 @@ import ${name} from "../models/${name}.js";
|
|
|
46728
46898
|
* specific shape below. Each callable receives a FakeData instance.
|
|
46729
46899
|
*/
|
|
46730
46900
|
export function fieldOverrides(fake: FakeData): Record<string, unknown> {
|
|
46901
|
+
// tina4:edit override any fields that need a specific shape (seedOrm auto-fills the rest)
|
|
46731
46902
|
${overrides} void fake; // available for overrides above
|
|
46732
46903
|
return {};
|
|
46733
46904
|
}
|
|
@@ -46785,6 +46956,7 @@ export async function ${handlerName}(
|
|
|
46785
46956
|
data: string,
|
|
46786
46957
|
): Promise<void> {
|
|
46787
46958
|
if (event === "open") {
|
|
46959
|
+
// tina4:edit customize the welcome frame (or drop it)
|
|
46788
46960
|
connection.sendJson({ type: "welcome" });
|
|
46789
46961
|
return;
|
|
46790
46962
|
}
|
|
@@ -46792,6 +46964,7 @@ export async function ${handlerName}(
|
|
|
46792
46964
|
return;
|
|
46793
46965
|
}
|
|
46794
46966
|
// event === "message"
|
|
46967
|
+
// tina4:edit handle the inbound "message" frame (broadcast, echo, route, etc.)
|
|
46795
46968
|
${body}}
|
|
46796
46969
|
|
|
46797
46970
|
websocket("${wsPath}", ${handlerName});
|
|
@@ -46826,6 +46999,7 @@ function generateListener(name, _flags) {
|
|
|
46826
46999
|
* Fires when something calls Events.emit("${event}", ...args).
|
|
46827
47000
|
*/
|
|
46828
47001
|
export function ${handlerName}(...args: unknown[]): void {
|
|
47002
|
+
// tina4:edit implement the reaction to '${event}' (email, ORM write, follow-up emit)
|
|
46829
47003
|
${body}}
|
|
46830
47004
|
|
|
46831
47005
|
Events.on("${event}", ${handlerName});
|
|
@@ -47261,8 +47435,8 @@ async function runSeeds(seedPath) {
|
|
|
47261
47435
|
`);
|
|
47262
47436
|
let failed = false;
|
|
47263
47437
|
for (const file of seedFiles) {
|
|
47264
|
-
const
|
|
47265
|
-
console.log(` Seeding: ${
|
|
47438
|
+
const relative11 = file.replace(cwd + "/", "");
|
|
47439
|
+
console.log(` Seeding: ${relative11}`);
|
|
47266
47440
|
try {
|
|
47267
47441
|
execSync4(`npx tsx "${file}"`, { cwd, stdio: "inherit" });
|
|
47268
47442
|
} catch {
|
|
@@ -47596,10 +47770,14 @@ function buildCommandManifest() {
|
|
|
47596
47770
|
framework: "nodejs",
|
|
47597
47771
|
version: readCliVersion(),
|
|
47598
47772
|
commands,
|
|
47599
|
-
//
|
|
47600
|
-
//
|
|
47601
|
-
//
|
|
47602
|
-
|
|
47773
|
+
// Declare the resolution envelope this framework emits for
|
|
47774
|
+
// `generate <what> --json`. Consumers read this to know which schema to
|
|
47775
|
+
// parse — never hard-code the shape.
|
|
47776
|
+
// 3.13.117: `generate_v1` at version "1"
|
|
47777
|
+
// 3.13.120: `generate_v1_1` at version "1.1" — additive superset
|
|
47778
|
+
// (adds edit_hints[] + next[]; surfaces existing test_paths[]
|
|
47779
|
+
// in the human block). ADR-0063.
|
|
47780
|
+
resolution_contract: { version: "1.1", envelope: RESOLUTION_ENVELOPE_VERSION }
|
|
47603
47781
|
};
|
|
47604
47782
|
}
|
|
47605
47783
|
function runCommands(args = []) {
|
package/packages/cli/src/bin.ts
CHANGED
|
@@ -138,10 +138,14 @@ export function buildCommandManifest(): CommandManifest {
|
|
|
138
138
|
framework: "nodejs",
|
|
139
139
|
version: readCliVersion(),
|
|
140
140
|
commands,
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
141
|
+
// Declare the resolution envelope this framework emits for
|
|
142
|
+
// `generate <what> --json`. Consumers read this to know which schema to
|
|
143
|
+
// parse — never hard-code the shape.
|
|
144
|
+
// 3.13.117: `generate_v1` at version "1"
|
|
145
|
+
// 3.13.120: `generate_v1_1` at version "1.1" — additive superset
|
|
146
|
+
// (adds edit_hints[] + next[]; surfaces existing test_paths[]
|
|
147
|
+
// in the human block). ADR-0063.
|
|
148
|
+
resolution_contract: { version: "1.1", envelope: RESOLUTION_ENVELOPE_VERSION },
|
|
145
149
|
};
|
|
146
150
|
}
|
|
147
151
|
|
|
@@ -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:
|
|
67
|
-
//
|
|
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 = "
|
|
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,57 @@ 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.
|
|
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*$/;
|
|
316
|
+
|
|
317
|
+
/**
|
|
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).
|
|
324
|
+
*/
|
|
325
|
+
function captureEditHints(absPath: string, content: string): void {
|
|
326
|
+
if (!/\.(ts|tsx|js|mjs|cjs|jsx)$/.test(absPath)) return;
|
|
327
|
+
const relPath = toRelPath(absPath);
|
|
328
|
+
const lines = content.split("\n");
|
|
329
|
+
for (let i = 0; i < lines.length; i++) {
|
|
330
|
+
const match = TINA4_EDIT_MARKER.exec(lines[i]);
|
|
331
|
+
if (match) {
|
|
332
|
+
pushEditHint({ file: relPath, line: i + 1, label: match[1].trim() });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
246
337
|
/**
|
|
247
338
|
* Emit the resolution — as JSON on STDOUT for `--json`, otherwise as a human
|
|
248
339
|
* block on STDERR (stderr so a caller piping stdout for other output isn't
|
|
@@ -282,6 +373,28 @@ function printResolution(): void {
|
|
|
282
373
|
lines.push(` To keep the raw name '${reserved.from}' as the table:`);
|
|
283
374
|
lines.push(` tina4nodejs generate ${__resolution.target} ${__resolution.input.name} ${reserved.override}`);
|
|
284
375
|
}
|
|
376
|
+
// v1.1 (ADR-0063): surface the already-populated test_paths[], and the two
|
|
377
|
+
// new arrays (edit_hints, next) when either is non-empty. Sections stay
|
|
378
|
+
// absent when the corresponding array is empty — a listener/service
|
|
379
|
+
// scaffold prints exactly what a model scaffold prints, minus what does
|
|
380
|
+
// not apply.
|
|
381
|
+
if (b.test_paths && b.test_paths.length > 0) {
|
|
382
|
+
lines.push("");
|
|
383
|
+
lines.push(" Tests:");
|
|
384
|
+
for (const testPath of b.test_paths) lines.push(` ${testPath}`);
|
|
385
|
+
}
|
|
386
|
+
if (b.edit_hints && b.edit_hints.length > 0) {
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push(" Edit these lines:");
|
|
389
|
+
for (const hint of b.edit_hints) {
|
|
390
|
+
lines.push(` ${hint.file}:${hint.line} ${hint.label}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (b.next && b.next.length > 0) {
|
|
394
|
+
lines.push("");
|
|
395
|
+
lines.push(" Next:");
|
|
396
|
+
for (const step of b.next) lines.push(` ${step}`);
|
|
397
|
+
}
|
|
285
398
|
lines.push("");
|
|
286
399
|
process.stderr.write(lines.join("\n"));
|
|
287
400
|
}
|
|
@@ -477,6 +590,105 @@ export const GENERATORS: Record<string, GeneratorSpec> = {
|
|
|
477
590
|
/** Comma-separated generator names for usage/error output — derived, never a hand-kept list. */
|
|
478
591
|
const GENERATOR_LIST = Object.keys(GENERATORS).join(", ");
|
|
479
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Curated per-verb next steps — populates `resolution.next[]` (envelope) and
|
|
595
|
+
* the "Next:" block on stderr (human). Grounded on the real code paths a
|
|
596
|
+
* developer takes after each generator. Cap of 5 (short, actionable).
|
|
597
|
+
*
|
|
598
|
+
* The context carries the RESOLVED name and table (the reserved-word
|
|
599
|
+
* pluraliser has already run at dispatch time), so a step references the
|
|
600
|
+
* same table/route the generated files bind to. `name` is the CLI positional
|
|
601
|
+
* as-typed; `table` is `toTableName(name)`.
|
|
602
|
+
*/
|
|
603
|
+
interface NextContext { name: string; table: string; }
|
|
604
|
+
const NEXT_STEPS: Record<string, (c: NextContext) => string[]> = {
|
|
605
|
+
model: ({ name, table }) => [
|
|
606
|
+
`Edit src/models/${name}.ts to add fields beyond the default 'name'`,
|
|
607
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
608
|
+
`Run its test: npx tsx tests/${table}_model.test.ts`,
|
|
609
|
+
`Add CRUD scaffolding: npx tina4nodejs generate crud ${name}`,
|
|
610
|
+
],
|
|
611
|
+
route: ({ name, table }) => [
|
|
612
|
+
`Fill the AI-FILL stubs in src/routes/api/${name.replace(/^\//, "")}/`,
|
|
613
|
+
`Run its test: npx tsx tests/${table}.test.ts`,
|
|
614
|
+
`Serve and try: npx tina4nodejs serve -> curl http://localhost:7148/api/${name.replace(/^\//, "")}`,
|
|
615
|
+
],
|
|
616
|
+
crud: ({ name, table }) => [
|
|
617
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
618
|
+
`Serve and try: npx tina4nodejs serve -> visit /swagger`,
|
|
619
|
+
`Run the gate test: npx tsx tests/${toPlural(table)}.test.ts`,
|
|
620
|
+
`Change fields: edit src/models/${name}.ts then re-run generate crud`,
|
|
621
|
+
],
|
|
622
|
+
migration: () => [
|
|
623
|
+
`Apply pending migrations: npx tina4nodejs migrate`,
|
|
624
|
+
`Check status: npx tina4nodejs migrate:status`,
|
|
625
|
+
`Roll back the batch: npx tina4nodejs migrate:rollback`,
|
|
626
|
+
],
|
|
627
|
+
middleware: ({ name }) => [
|
|
628
|
+
`Wire it: router.middleware(before${name}, after${name}) — or bind per-route`,
|
|
629
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
|
|
630
|
+
],
|
|
631
|
+
test: ({ name }) => [
|
|
632
|
+
`Fill the TODOs in tests/${toSnake(name)}.test.ts`,
|
|
633
|
+
`Run it: npx tsx tests/${toSnake(name)}.test.ts`,
|
|
634
|
+
],
|
|
635
|
+
form: ({ name, table }) => [
|
|
636
|
+
`Render from a route: res.render("forms/${table}.twig", { item })`,
|
|
637
|
+
`Add the POST route: npx tina4nodejs generate route ${toPlural(table)} --model ${name}`,
|
|
638
|
+
],
|
|
639
|
+
view: ({ table }) => [
|
|
640
|
+
`Wire routes to render list -> ${toPlural(table)}.twig, detail -> ${table}.twig`,
|
|
641
|
+
`Customize the templates in src/templates/pages/`,
|
|
642
|
+
],
|
|
643
|
+
auth: () => [
|
|
644
|
+
`Apply the migration: npx tina4nodejs migrate`,
|
|
645
|
+
`Run the auth test: npx tsx tests/auth.test.ts`,
|
|
646
|
+
`Try register: curl -X POST http://localhost:7148/api/auth/register -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
|
|
647
|
+
`Login: curl -X POST http://localhost:7148/api/auth/login -d '{"email":"a@b.c","password":"secret12"}' -H 'content-type: application/json'`,
|
|
648
|
+
],
|
|
649
|
+
service: ({ name }) => [
|
|
650
|
+
`Wire ServiceRunner in app.ts: await ServiceRunner.discover("src/services"); ServiceRunner.start();`,
|
|
651
|
+
`Fill the task body in src/services/${toSnake(name)}.ts`,
|
|
652
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
|
|
653
|
+
],
|
|
654
|
+
queue: ({ name }) => {
|
|
655
|
+
const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
|
|
656
|
+
return [
|
|
657
|
+
`Fill handle${toPascal(name)}() in src/services/${slug}_consumer.ts`,
|
|
658
|
+
`Produce a job: publish${toPascal(name)}({ ... })`,
|
|
659
|
+
`Run the worker: npx tina4nodejs queue work ${name}`,
|
|
660
|
+
`Run its test: npx tsx tests/${slug}.test.ts`,
|
|
661
|
+
];
|
|
662
|
+
},
|
|
663
|
+
validator: ({ name }) => [
|
|
664
|
+
`Add rules in src/validators/${toSnake(name)}.ts (.email/.minLength/.integer/.inList/.pattern)`,
|
|
665
|
+
`Run its test: npx tsx tests/${toSnake(name)}.test.ts`,
|
|
666
|
+
],
|
|
667
|
+
seeder: ({ name, table }) => [
|
|
668
|
+
`Override any fields that need a specific shape in src/seeds/${table}_seeder.ts`,
|
|
669
|
+
`Seed the table: npx tina4nodejs seed`,
|
|
670
|
+
`Run its test: npx tsx tests/${table}_seeder.test.ts`,
|
|
671
|
+
],
|
|
672
|
+
websocket: ({ name }) => {
|
|
673
|
+
const raw = name.trim();
|
|
674
|
+
const slugRaw = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
|
|
675
|
+
const base = slugRaw.startsWith("ws_") ? slugRaw.slice(3) : slugRaw;
|
|
676
|
+
return [
|
|
677
|
+
`Import once in app.ts to register: import "./src/routes/ws_${base}.js";`,
|
|
678
|
+
`Fill the "message" branch in src/routes/ws_${base}.ts`,
|
|
679
|
+
`Run its test: npx tsx tests/ws_${base}.test.ts`,
|
|
680
|
+
];
|
|
681
|
+
},
|
|
682
|
+
listener: ({ name }) => {
|
|
683
|
+
const slug = toSnake(name.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
|
|
684
|
+
return [
|
|
685
|
+
`Import once in app.ts to register: import "./src/listeners/${slug}.js";`,
|
|
686
|
+
`Fill the reaction in src/listeners/${slug}.ts`,
|
|
687
|
+
`Run its test: npx tsx tests/${slug}.test.ts`,
|
|
688
|
+
];
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
|
|
480
692
|
// ── Main entry point ────────────────────────────────────────────────
|
|
481
693
|
|
|
482
694
|
export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
|
|
@@ -491,8 +703,17 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
491
703
|
process.exit(1);
|
|
492
704
|
}
|
|
493
705
|
|
|
494
|
-
// Auth doesn't require a name
|
|
706
|
+
// Auth doesn't require a name.
|
|
495
707
|
const noNameGenerators = new Set(["auth"]);
|
|
708
|
+
// bin.ts always passes argv[1] as `name`. For a no-name generator that means
|
|
709
|
+
// `generate auth --json` arrives here as name="--json", extraArgs=[]. Rescue
|
|
710
|
+
// the flag: shift a leading `--foo` name into extraArgs so parseCliArgs
|
|
711
|
+
// actually sees it. Fixes `--json` (and any other flag) being silently
|
|
712
|
+
// eaten by the no-name verbs.
|
|
713
|
+
if (noNameGenerators.has(what) && name.startsWith("--")) {
|
|
714
|
+
extraArgs = [name, ...extraArgs];
|
|
715
|
+
name = "";
|
|
716
|
+
}
|
|
496
717
|
if (!noNameGenerators.has(what) && !name) {
|
|
497
718
|
console.error(` Usage: tina4nodejs generate ${what} <name> [options]`);
|
|
498
719
|
process.exit(1);
|
|
@@ -520,6 +741,21 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
520
741
|
process.exit(1);
|
|
521
742
|
}
|
|
522
743
|
|
|
744
|
+
// v1.1 (ADR-0063): populate `resolution.next[]` from the per-verb curator
|
|
745
|
+
// AFTER dispatch, so the table name reflects any reserved-word pluralisation
|
|
746
|
+
// that fired during the run (Order -> orders). Prefer the resolution's own
|
|
747
|
+
// table_name (already set by generateModel/generateMigration) so we do NOT
|
|
748
|
+
// re-invoke toTableName() — that would record a DUPLICATE
|
|
749
|
+
// reserved_word_pluralize transformation on the envelope.
|
|
750
|
+
const nextFn = NEXT_STEPS[what];
|
|
751
|
+
if (nextFn) {
|
|
752
|
+
const resolvedTable = __resolution.body.table_name
|
|
753
|
+
?? (name
|
|
754
|
+
? (SQL_RESERVED_TABLE_NAMES.has(toSnake(name)) ? pluralizeReserved(toSnake(name)) : toSnake(name))
|
|
755
|
+
: "");
|
|
756
|
+
setNextSteps(nextFn({ name: name || "", table: resolvedTable }));
|
|
757
|
+
}
|
|
758
|
+
|
|
523
759
|
// Emit the resolution AFTER dispatch so `actions_taken` reflects the real
|
|
524
760
|
// writes (or the empty list under `--dry-run`).
|
|
525
761
|
printResolution();
|
|
@@ -549,6 +785,7 @@ function generateModel(name: string, flags: Record<string, string | boolean>, em
|
|
|
549
785
|
// Build field definitions
|
|
550
786
|
const fieldLines: string[] = [
|
|
551
787
|
` id: { type: "integer" as const, primaryKey: true, autoIncrement: true },`,
|
|
788
|
+
` // tina4:edit add or change fields for this model (string,int,float,bool,text,datetime)`,
|
|
552
789
|
];
|
|
553
790
|
for (const [fname, ftype] of fields) {
|
|
554
791
|
const info = FIELD_TYPE_MAP[ftype] || FIELD_TYPE_MAP.string;
|
|
@@ -637,6 +874,7 @@ ${modelImportBase}
|
|
|
637
874
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
638
875
|
|
|
639
876
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
877
|
+
// tina4:edit tune pagination defaults or add filter/sort parsing here
|
|
640
878
|
const page = parseInt(req.query.page as string) || 1;
|
|
641
879
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
642
880
|
const offset = (page - 1) * limit;
|
|
@@ -674,6 +912,7 @@ ${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Creat
|
|
|
674
912
|
|
|
675
913
|
// ${writeDoc}
|
|
676
914
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
915
|
+
// tina4:edit validate the body before persist (Validator or hand-checks)
|
|
677
916
|
${extend("validate / business rules before persist",
|
|
678
917
|
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
|
|
679
918
|
// save() returns false on failure rather than throwing - check it, or a failed
|
|
@@ -695,6 +934,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular
|
|
|
695
934
|
|
|
696
935
|
// ${writeDoc}
|
|
697
936
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
937
|
+
// tina4:edit fill the create handler (see AI-FILL fill-spec below)
|
|
698
938
|
${aiFill(`create_${singular}`, {
|
|
699
939
|
intent: `validate the body and persist a new ${singular}`,
|
|
700
940
|
given: "req.body -> the posted fields",
|
|
@@ -761,6 +1001,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
761
1001
|
res.json({ error: "Not found" }, 404);
|
|
762
1002
|
return;
|
|
763
1003
|
}
|
|
1004
|
+
// tina4:edit guard which fields may be updated and who may update this row
|
|
764
1005
|
${extend("guard which fields / who may update",
|
|
765
1006
|
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
|
|
766
1007
|
// save() returns false on failure rather than throwing - check it, or a failed
|
|
@@ -782,6 +1023,7 @@ ${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by
|
|
|
782
1023
|
|
|
783
1024
|
// ${writeDoc}
|
|
784
1025
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
1026
|
+
// tina4:edit fill the update handler (see AI-FILL fill-spec below)
|
|
785
1027
|
${aiFill(`update_${singular}`, {
|
|
786
1028
|
intent: `load, mutate and save an existing ${singular}`,
|
|
787
1029
|
given: "req.params.id -> id; req.body -> changed fields",
|
|
@@ -855,7 +1097,10 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
|
|
|
855
1097
|
const routeName = toPlural(table);
|
|
856
1098
|
const isPublic = Boolean(flags.public);
|
|
857
1099
|
|
|
858
|
-
|
|
1100
|
+
// Human-only banners; suppressed under --json to keep stdout parseable
|
|
1101
|
+
// (a console.log during --json produced invalid JSON). writeFileSafe already
|
|
1102
|
+
// gates its own "Created …" lines the same way.
|
|
1103
|
+
if (!__resolution.jsonMode) console.log(`\n Generating CRUD for ${name}...\n`);
|
|
859
1104
|
|
|
860
1105
|
// 1. Model + migration (its own model test is suppressed — the gate test
|
|
861
1106
|
// below is CRUD's single, broader co-emitted test).
|
|
@@ -875,9 +1120,11 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
|
|
|
875
1120
|
// 5. Test — real secure-by-default boot-gate (reads public, writes gated).
|
|
876
1121
|
generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
|
|
877
1122
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1123
|
+
if (!__resolution.jsonMode) {
|
|
1124
|
+
console.log(`\n CRUD generation complete for ${name}.`);
|
|
1125
|
+
console.log(" Run: tina4nodejs migrate");
|
|
1126
|
+
console.log(" Visit: /swagger to see the API docs");
|
|
1127
|
+
}
|
|
881
1128
|
}
|
|
882
1129
|
|
|
883
1130
|
// ── Migration ───────────────────────────────────────────────────────
|
|
@@ -1008,6 +1255,7 @@ export async function before${name}(
|
|
|
1008
1255
|
res: Tina4Response,
|
|
1009
1256
|
next: () => Promise<void>,
|
|
1010
1257
|
): Promise<void> {
|
|
1258
|
+
// tina4:edit replace the Authorization check with the real pre-request rule
|
|
1011
1259
|
const auth = req.headers["authorization"];
|
|
1012
1260
|
if (!auth) {
|
|
1013
1261
|
res.json({ error: "Unauthorized" }, 401);
|
|
@@ -1021,7 +1269,7 @@ export async function after${name}(
|
|
|
1021
1269
|
res: Tina4Response,
|
|
1022
1270
|
next: () => Promise<void>,
|
|
1023
1271
|
): Promise<void> {
|
|
1024
|
-
//
|
|
1272
|
+
// tina4:edit add post-processing (logging, header injection, telemetry)
|
|
1025
1273
|
await next();
|
|
1026
1274
|
}
|
|
1027
1275
|
`;
|
|
@@ -1113,35 +1361,35 @@ process.exit(fail > 0 ? 1 : 0);
|
|
|
1113
1361
|
const list${model}s = tests(
|
|
1114
1362
|
assertTrue([]),
|
|
1115
1363
|
)(function list${model}s() {
|
|
1116
|
-
//
|
|
1364
|
+
// tina4:edit assert against a real GET /api/${toSnake(name)} response (rows, count)
|
|
1117
1365
|
return true;
|
|
1118
1366
|
});
|
|
1119
1367
|
|
|
1120
1368
|
const get${model} = tests(
|
|
1121
1369
|
assertTrue([]),
|
|
1122
1370
|
)(function get${model}() {
|
|
1123
|
-
//
|
|
1371
|
+
// tina4:edit assert against GET /api/${toSnake(name)}/{id} for one seeded row
|
|
1124
1372
|
return true;
|
|
1125
1373
|
});
|
|
1126
1374
|
|
|
1127
1375
|
const create${model} = tests(
|
|
1128
1376
|
assertTrue([]),
|
|
1129
1377
|
)(function create${model}() {
|
|
1130
|
-
//
|
|
1378
|
+
// tina4:edit POST a valid + an invalid body, assert 201 vs 400
|
|
1131
1379
|
return true;
|
|
1132
1380
|
});
|
|
1133
1381
|
|
|
1134
1382
|
const update${model} = tests(
|
|
1135
1383
|
assertTrue([]),
|
|
1136
1384
|
)(function update${model}() {
|
|
1137
|
-
//
|
|
1385
|
+
// tina4:edit PUT changed fields, assert the row was persisted
|
|
1138
1386
|
return true;
|
|
1139
1387
|
});
|
|
1140
1388
|
|
|
1141
1389
|
const delete${model} = tests(
|
|
1142
1390
|
assertTrue([]),
|
|
1143
1391
|
)(function delete${model}() {
|
|
1144
|
-
//
|
|
1392
|
+
// tina4:edit DELETE the id, assert 200 then GET returns 404
|
|
1145
1393
|
return true;
|
|
1146
1394
|
});
|
|
1147
1395
|
|
|
@@ -1158,7 +1406,7 @@ void [list${model}s, get${model}, create${model}, update${model}, delete${model}
|
|
|
1158
1406
|
const test${titleName} = tests(
|
|
1159
1407
|
assertTrue([]),
|
|
1160
1408
|
)(function test${titleName}() {
|
|
1161
|
-
//
|
|
1409
|
+
// tina4:edit assert against the real behaviour under test (no mocks)
|
|
1162
1410
|
return true;
|
|
1163
1411
|
});
|
|
1164
1412
|
|
|
@@ -1322,7 +1570,8 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
|
|
|
1322
1570
|
// ── Auth (login/register stay PUBLIC) ───────────────────────────────
|
|
1323
1571
|
|
|
1324
1572
|
function generateAuth(_flags: Record<string, string | boolean>): void {
|
|
1325
|
-
|
|
1573
|
+
// Human-only banners; suppressed under --json to keep stdout parseable.
|
|
1574
|
+
if (!__resolution.jsonMode) console.log("\n Generating authentication scaffolding...\n");
|
|
1326
1575
|
|
|
1327
1576
|
// 1. User model + migration (model test suppressed — the auth test below is
|
|
1328
1577
|
// the composite, broader co-emitted test).
|
|
@@ -1352,6 +1601,7 @@ export const secure = false;
|
|
|
1352
1601
|
export const meta = { summary: "Register a new user", tags: ["auth"] };
|
|
1353
1602
|
|
|
1354
1603
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
1604
|
+
// tina4:edit add password-strength / email-format / captcha rules before mint
|
|
1355
1605
|
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
1356
1606
|
|
|
1357
1607
|
if (!email || !password) {
|
|
@@ -1385,6 +1635,7 @@ export const secure = false;
|
|
|
1385
1635
|
export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
|
|
1386
1636
|
|
|
1387
1637
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
1638
|
+
// tina4:edit add rate-limit / lock-after-N-failures / 2FA before password check
|
|
1388
1639
|
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
1389
1640
|
|
|
1390
1641
|
if (!email || !password) {
|
|
@@ -1399,6 +1650,7 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
1399
1650
|
}
|
|
1400
1651
|
|
|
1401
1652
|
const data = user.toObject();
|
|
1653
|
+
// tina4:edit set token TTL (getToken(payload, secret, expiresInMinutes)) and add scopes if needed
|
|
1402
1654
|
const token = getToken({ userId: data.id, email: data.email, role: data.role });
|
|
1403
1655
|
res.json({ token });
|
|
1404
1656
|
}
|
|
@@ -1482,11 +1734,13 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
1482
1734
|
// 5. Auth test — real register / login / me end-to-end (no mocks).
|
|
1483
1735
|
emitAuthTest();
|
|
1484
1736
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1737
|
+
if (!__resolution.jsonMode) {
|
|
1738
|
+
console.log("\n Authentication scaffolding complete.");
|
|
1739
|
+
console.log(" Run: tina4nodejs migrate");
|
|
1740
|
+
console.log(" POST /api/auth/register — create account (public)");
|
|
1741
|
+
console.log(" POST /api/auth/login — get JWT token (public)");
|
|
1742
|
+
console.log(" GET /api/auth/me — get profile (requires token)");
|
|
1743
|
+
}
|
|
1490
1744
|
}
|
|
1491
1745
|
|
|
1492
1746
|
// ── Service (scheduled background task — ServiceRunner) ──────────────
|
|
@@ -1537,6 +1791,7 @@ function generateService(name: string, flags: Record<string, string | boolean>):
|
|
|
1537
1791
|
*/
|
|
1538
1792
|
|
|
1539
1793
|
export async function ${camel}Task(context: ServiceContext): Promise<void> {
|
|
1794
|
+
// tina4:edit replace the AI-FILL stub below with the scheduled work
|
|
1540
1795
|
${body}}
|
|
1541
1796
|
|
|
1542
1797
|
// Discovered by ServiceRunner.discover("src/services") — it reads name/handler
|
|
@@ -1593,6 +1848,7 @@ export function publish${pascal}(payload: Record<string, unknown>): string {
|
|
|
1593
1848
|
|
|
1594
1849
|
/** Process ONE ${topic} job payload. */
|
|
1595
1850
|
export async function handle${pascal}(payload: unknown): Promise<void> {
|
|
1851
|
+
// tina4:edit implement the per-job handler; return to ack, throw to nack
|
|
1596
1852
|
${body}}
|
|
1597
1853
|
|
|
1598
1854
|
/** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
|
|
@@ -1655,6 +1911,7 @@ function generateValidator(name: string, _flags: Record<string, string | boolean
|
|
|
1655
1911
|
*/
|
|
1656
1912
|
export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
|
|
1657
1913
|
const validator = new Validator(data);
|
|
1914
|
+
// tina4:edit add rules for this payload (.email/.minLength/.integer/.inList/.pattern)
|
|
1658
1915
|
${rules} validator.required("name"); // starter rule (matches the model's default field)
|
|
1659
1916
|
return validator;
|
|
1660
1917
|
}
|
|
@@ -1695,6 +1952,7 @@ import ${name} from "../models/${name}.js";
|
|
|
1695
1952
|
* specific shape below. Each callable receives a FakeData instance.
|
|
1696
1953
|
*/
|
|
1697
1954
|
export function fieldOverrides(fake: FakeData): Record<string, unknown> {
|
|
1955
|
+
// tina4:edit override any fields that need a specific shape (seedOrm auto-fills the rest)
|
|
1698
1956
|
${overrides} void fake; // available for overrides above
|
|
1699
1957
|
return {};
|
|
1700
1958
|
}
|
|
@@ -1763,6 +2021,7 @@ export async function ${handlerName}(
|
|
|
1763
2021
|
data: string,
|
|
1764
2022
|
): Promise<void> {
|
|
1765
2023
|
if (event === "open") {
|
|
2024
|
+
// tina4:edit customize the welcome frame (or drop it)
|
|
1766
2025
|
connection.sendJson({ type: "welcome" });
|
|
1767
2026
|
return;
|
|
1768
2027
|
}
|
|
@@ -1770,6 +2029,7 @@ export async function ${handlerName}(
|
|
|
1770
2029
|
return;
|
|
1771
2030
|
}
|
|
1772
2031
|
// event === "message"
|
|
2032
|
+
// tina4:edit handle the inbound "message" frame (broadcast, echo, route, etc.)
|
|
1773
2033
|
${body}}
|
|
1774
2034
|
|
|
1775
2035
|
websocket("${wsPath}", ${handlerName});
|
|
@@ -1814,6 +2074,7 @@ function generateListener(name: string, _flags: Record<string, string | boolean>
|
|
|
1814
2074
|
* Fires when something calls Events.emit("${event}", ...args).
|
|
1815
2075
|
*/
|
|
1816
2076
|
export function ${handlerName}(...args: unknown[]): void {
|
|
2077
|
+
// tina4:edit implement the reaction to '${event}' (email, ORM write, follow-up emit)
|
|
1817
2078
|
${body}}
|
|
1818
2079
|
|
|
1819
2080
|
Events.on("${event}", ${handlerName});
|
|
@@ -23,6 +23,17 @@ export interface ResolutionInput {
|
|
|
23
23
|
name: string;
|
|
24
24
|
fields: string | null;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* One `// tina4:edit …` marker found in a written (or would-be-written)
|
|
28
|
+
* template file. `file` is repo-relative POSIX (matches the rest of the
|
|
29
|
+
* envelope's paths); `line` is 1-based; `label` is the short imperative label
|
|
30
|
+
* that followed the marker on the same line.
|
|
31
|
+
*/
|
|
32
|
+
export interface EditHint {
|
|
33
|
+
file: string;
|
|
34
|
+
line: number;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
26
37
|
export interface ResolutionBody {
|
|
27
38
|
class_name?: string;
|
|
28
39
|
table_name?: string;
|
|
@@ -30,6 +41,8 @@ export interface ResolutionBody {
|
|
|
30
41
|
migration_path?: string;
|
|
31
42
|
routes?: string[];
|
|
32
43
|
test_paths?: string[];
|
|
44
|
+
edit_hints?: EditHint[];
|
|
45
|
+
next?: string[];
|
|
33
46
|
transformations: ResolutionTransformation[];
|
|
34
47
|
}
|
|
35
48
|
export interface ResolutionEnvelope {
|
|
@@ -45,8 +58,15 @@ export interface ResolutionEnvelope {
|
|
|
45
58
|
* `resolution_contract.envelope` so the tina4 client (or any consumer) can
|
|
46
59
|
* discover the exact contract this framework speaks. Bump when a breaking
|
|
47
60
|
* key rename / removal lands; keep unchanged when new OPTIONAL keys are added.
|
|
61
|
+
*
|
|
62
|
+
* `generate_v1_1` (ADR-0063, 3.13.120) is a PURELY ADDITIVE superset of
|
|
63
|
+
* `generate_v1`: every v1 field is preserved, and two new optional arrays
|
|
64
|
+
* appear — `resolution.edit_hints[]` (one entry per `// tina4:edit` marker
|
|
65
|
+
* baked into a template) and `resolution.next[]` (curated per-verb actionable
|
|
66
|
+
* next steps). `resolution.test_paths[]` was already in v1; v1.1 surfaces it
|
|
67
|
+
* in the human stderr block too.
|
|
48
68
|
*/
|
|
49
|
-
export declare const RESOLUTION_ENVELOPE_VERSION = "
|
|
69
|
+
export declare const RESOLUTION_ENVELOPE_VERSION = "generate_v1_1";
|
|
50
70
|
/** Read-only snapshot of the current resolution — exported for tests that
|
|
51
71
|
* want to inspect it in-process (the CLI itself uses only the envelope). */
|
|
52
72
|
export declare function currentResolution(): ResolutionEnvelope;
|