tina4-nodejs 3.12.10 → 3.13.1
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 +17 -17
- package/package.json +14 -5
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/api.ts +65 -3
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/graphql.ts +99 -1
- package/packages/core/src/index.ts +15 -2
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/router.ts +75 -16
- package/packages/core/src/server.ts +102 -3
- package/packages/core/src/service.ts +87 -0
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +62 -0
package/packages/core/src/mcp.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import * as os from "node:os";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
20
21
|
|
|
21
22
|
// ── Types ─────────────────────────────────────────────────────
|
|
22
23
|
|
|
@@ -562,6 +563,217 @@ function redactEnv(key: string, value: string): string {
|
|
|
562
563
|
return value;
|
|
563
564
|
}
|
|
564
565
|
|
|
566
|
+
// ── Defensive write helpers (mirrors tina4-python tools.py) ──
|
|
567
|
+
//
|
|
568
|
+
// Five layered guards wrap `file_write` and `file_patch`:
|
|
569
|
+
// 1. agentLog — structured audit log at .tina4/agent.log + stderr
|
|
570
|
+
// 2. looksLikeProse — reject sentences-as-filenames (AI mishap)
|
|
571
|
+
// 3. normalizeCoderPath — rewrite bare routes/, orm/, ... to src/<dir>/
|
|
572
|
+
// 4. agentBackup — copy pre-write content to .tina4/backups/
|
|
573
|
+
// 5. truncation guard (inline in file_write) — refuse suspicious shrinkage
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Append a structured line to `.tina4/agent.log` AND echo to stderr.
|
|
577
|
+
* Cheap — never blocks the caller on I/O failure.
|
|
578
|
+
*/
|
|
579
|
+
function agentLog(projectRoot: string, category: string, message: string): void {
|
|
580
|
+
try {
|
|
581
|
+
const logDir = path.join(projectRoot, ".tina4");
|
|
582
|
+
if (!fs.existsSync(logDir)) {
|
|
583
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
584
|
+
}
|
|
585
|
+
const logPath = path.join(logDir, "agent.log");
|
|
586
|
+
const ts = new Date().toISOString().replace(/\..+/, "Z");
|
|
587
|
+
fs.appendFileSync(logPath, `${ts} [${category}] ${message}\n`, "utf-8");
|
|
588
|
+
} catch {
|
|
589
|
+
// logging must never fail the actual call
|
|
590
|
+
}
|
|
591
|
+
process.stderr.write(` [agent ${category}] ${message}\n`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const SANE_PATH_SEGMENT = /^[A-Za-z0-9._\-]+$/;
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Return an error string if the path looks like prose, else null.
|
|
598
|
+
* AI agents sometimes pass natural language as `path` to file_write
|
|
599
|
+
* and produce folders with prose names — catch the slip here.
|
|
600
|
+
*/
|
|
601
|
+
function looksLikeProse(relPath: string): string | null {
|
|
602
|
+
if (!relPath || !relPath.trim()) {
|
|
603
|
+
return "path is empty";
|
|
604
|
+
}
|
|
605
|
+
if (relPath.length > 300) {
|
|
606
|
+
return `path too long (${relPath.length} chars); use a real filename`;
|
|
607
|
+
}
|
|
608
|
+
const badSequences = ["`", "\n", "\t", " ", " — ", " (", " [", "?", "*", "<", ">", "|"];
|
|
609
|
+
for (const bad of badSequences) {
|
|
610
|
+
if (relPath.includes(bad)) {
|
|
611
|
+
return `path contains illegal character sequence ${JSON.stringify(bad)} — looks like prose, not a filename`;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const seg of relPath.split("/")) {
|
|
615
|
+
if (!seg || seg === "." || seg === "..") {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (seg.length > 80) {
|
|
619
|
+
return `path segment too long: ${JSON.stringify(seg.slice(0, 60))}… — use a short filename`;
|
|
620
|
+
}
|
|
621
|
+
if (!SANE_PATH_SEGMENT.test(seg)) {
|
|
622
|
+
return `path segment ${JSON.stringify(seg)} contains disallowed characters — stick to [A-Za-z0-9._-]`;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Rewrite bare top-level Tina4-conventional directories into their
|
|
630
|
+
* `src/<dir>/` canonical form. The framework's auto-discovery only
|
|
631
|
+
* scans `src/`, so a file at `templates/foo.twig` is dead weight —
|
|
632
|
+
* the framework never loads it. Mirrors Python's _normalize_coder_path.
|
|
633
|
+
*/
|
|
634
|
+
function normalizeCoderPath(projectRoot: string, relPath: string): string {
|
|
635
|
+
const passthroughPrefixes = ["src/", "migrations/", "plan/", "tests/", "test/", ".tina4/"];
|
|
636
|
+
const passthroughFiles = new Set([
|
|
637
|
+
"app.py", "app.ts", "app.rb", "index.php",
|
|
638
|
+
"composer.json", "package.json", "Gemfile",
|
|
639
|
+
"pyproject.toml", "requirements.txt",
|
|
640
|
+
".env", ".env.example",
|
|
641
|
+
]);
|
|
642
|
+
if (passthroughPrefixes.some((p) => relPath.startsWith(p))) {
|
|
643
|
+
return relPath;
|
|
644
|
+
}
|
|
645
|
+
if (passthroughFiles.has(relPath)) {
|
|
646
|
+
return relPath;
|
|
647
|
+
}
|
|
648
|
+
const bareDirs = ["routes", "orm", "templates", "seeds", "controllers", "models", "middleware"];
|
|
649
|
+
for (const d of bareDirs) {
|
|
650
|
+
if (relPath.startsWith(`${d}/`)) {
|
|
651
|
+
const rewritten = `src/${relPath}`;
|
|
652
|
+
agentLog(projectRoot, "write.path_normalized", `${relPath} → ${rewritten}`);
|
|
653
|
+
return rewritten;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return relPath;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Copy `target` into `.tina4/backups/` with a timestamped name.
|
|
661
|
+
* Returns the relative backup path on success, null on failure.
|
|
662
|
+
*/
|
|
663
|
+
function agentBackup(projectRoot: string, target: string): string | null {
|
|
664
|
+
try {
|
|
665
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
const backupDir = path.join(projectRoot, ".tina4", "backups");
|
|
669
|
+
if (!fs.existsSync(backupDir)) {
|
|
670
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
671
|
+
}
|
|
672
|
+
let rel = path.relative(projectRoot, target);
|
|
673
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
674
|
+
rel = path.basename(target);
|
|
675
|
+
}
|
|
676
|
+
const safe = rel.replace(/[/\\]/g, "__");
|
|
677
|
+
const ts = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "Z");
|
|
678
|
+
const backupName = `${safe}.${ts}.bak`;
|
|
679
|
+
const backupPath = path.join(backupDir, backupName);
|
|
680
|
+
fs.writeFileSync(backupPath, fs.readFileSync(target));
|
|
681
|
+
return `.tina4/backups/${backupName}`;
|
|
682
|
+
} catch (e) {
|
|
683
|
+
agentLog(projectRoot, "write.backup_failed", `${target}: ${(e as Error).message}`);
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Try syntax-checking a freshly-written JS/TS module to catch
|
|
690
|
+
* hallucinated framework APIs and broken syntax BEFORE the next
|
|
691
|
+
* request hits the broken handler. Mirrors Python's _verify_python_import.
|
|
692
|
+
*
|
|
693
|
+
* Returns null on success, or the captured error string on failure.
|
|
694
|
+
*
|
|
695
|
+
* - Only checks files under `src/` with extensions .js / .ts / .mjs / .cjs.
|
|
696
|
+
* - Skips test files (*.test.{ts,js}, *.spec.{ts,js}) — they have their
|
|
697
|
+
* own loading patterns and would fail single-file type checking.
|
|
698
|
+
* - .js / .mjs / .cjs → `node --check <file>` (fast, exit 0 = OK).
|
|
699
|
+
* - .ts → `npx --no-install tsc --noEmit --allowJs --skipLibCheck <file>`.
|
|
700
|
+
* If tsc isn't available locally, returns null (don't block the write).
|
|
701
|
+
* - Uses spawnSync with 5-second timeout so a hung subprocess never
|
|
702
|
+
* blocks the MCP server.
|
|
703
|
+
*/
|
|
704
|
+
function verifyNodeSyntax(absPath: string, relPath: string): string | null {
|
|
705
|
+
if (!relPath.startsWith("src/")) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
709
|
+
if (![".js", ".ts", ".mjs", ".cjs"].includes(ext)) {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
const base = path.basename(relPath);
|
|
713
|
+
if (/\.(test|spec)\.(ts|js|mjs|cjs)$/.test(base)) {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
let cmd: string;
|
|
718
|
+
let args: string[];
|
|
719
|
+
if (ext === ".ts") {
|
|
720
|
+
cmd = "npx";
|
|
721
|
+
args = ["--no-install", "tsc", "--noEmit", "--allowJs", "--skipLibCheck", absPath];
|
|
722
|
+
} else {
|
|
723
|
+
cmd = "node";
|
|
724
|
+
args = ["--check", absPath];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
let proc;
|
|
728
|
+
try {
|
|
729
|
+
proc = spawnSync(cmd, args, {
|
|
730
|
+
encoding: "utf-8",
|
|
731
|
+
timeout: 5000,
|
|
732
|
+
cwd: path.dirname(path.dirname(absPath)),
|
|
733
|
+
});
|
|
734
|
+
} catch (e) {
|
|
735
|
+
return `verification subprocess failed: ${(e as Error).message}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// npx may fail to find tsc — gracefully return null instead of blocking.
|
|
739
|
+
if (ext === ".ts" && (proc.error || proc.status === null || proc.status === 127)) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
if (proc.error) {
|
|
743
|
+
return `verification subprocess failed: ${proc.error.message}`;
|
|
744
|
+
}
|
|
745
|
+
if (proc.status === 0) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Errors land on stderr for `node --check`, stdout for tsc.
|
|
750
|
+
const raw = (ext === ".ts" ? (proc.stdout || "") + (proc.stderr || "") : (proc.stderr || "") + (proc.stdout || "")).trim();
|
|
751
|
+
// npx placeholder when tsc isn't installed locally — bail silently
|
|
752
|
+
// rather than block the write with a meaningless banner.
|
|
753
|
+
if (ext === ".ts" && raw.includes("This is not the tsc command")) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
if (!raw) {
|
|
757
|
+
return `syntax check failed (exit ${proc.status}, no output)`;
|
|
758
|
+
}
|
|
759
|
+
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
760
|
+
if (lines.length === 0) {
|
|
761
|
+
return `syntax check failed (exit ${proc.status})`;
|
|
762
|
+
}
|
|
763
|
+
// Strip the absolute path prefix from the first meaningful line so the
|
|
764
|
+
// LLM sees a stable, project-relative error message.
|
|
765
|
+
const stripPath = (line: string): string => line.replace(absPath, relPath);
|
|
766
|
+
// For tsc: the first line is usually "src/foo.ts(3,5): error TS1109: ...".
|
|
767
|
+
// For node --check: the first line is the file path; the actual error is
|
|
768
|
+
// a later "SyntaxError: ..." line. Pick the most informative line.
|
|
769
|
+
for (const line of lines) {
|
|
770
|
+
if (/error|SyntaxError|TS\d+/i.test(line)) {
|
|
771
|
+
return stripPath(line);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return stripPath(lines[0]);
|
|
775
|
+
}
|
|
776
|
+
|
|
565
777
|
/**
|
|
566
778
|
* Register all 24 built-in dev tools on the given McpServer.
|
|
567
779
|
*/
|
|
@@ -734,15 +946,63 @@ export function registerDevTools(server: McpServer): void {
|
|
|
734
946
|
server.registerTool(
|
|
735
947
|
"file_write",
|
|
736
948
|
(args) => {
|
|
737
|
-
|
|
949
|
+
let rawPath = (args.path as string) || "";
|
|
950
|
+
// 1. Prose check first — before path resolution.
|
|
951
|
+
const proseErr = looksLikeProse(rawPath);
|
|
952
|
+
if (proseErr) {
|
|
953
|
+
return { error: `Invalid path ${JSON.stringify(rawPath)}: ${proseErr}` };
|
|
954
|
+
}
|
|
955
|
+
// 2. Coder-path normalization — rewrite bare top-level
|
|
956
|
+
// Tina4 dirs (templates/, routes/, ...) into src/.
|
|
957
|
+
rawPath = normalizeCoderPath(projectRoot, rawPath);
|
|
958
|
+
// 3. Safe path resolution (sandbox check — throws on escape).
|
|
959
|
+
const p = safePath(projectRoot, rawPath);
|
|
960
|
+
// 4. Compute old/new sizes for truncation guard + audit log.
|
|
961
|
+
const oldBytes = fs.existsSync(p) && fs.statSync(p).isFile()
|
|
962
|
+
? fs.readFileSync(p)
|
|
963
|
+
: Buffer.alloc(0);
|
|
964
|
+
const oldSize = oldBytes.length;
|
|
965
|
+
const oldLines = (oldBytes.toString("utf-8").match(/\n/g) || []).length;
|
|
966
|
+
const content = args.content as string;
|
|
967
|
+
const newBytes = Buffer.from(content, "utf-8");
|
|
968
|
+
const newSize = newBytes.length;
|
|
969
|
+
const newLines = (content.match(/\n/g) || []).length;
|
|
970
|
+
const relPath = path.relative(projectRoot, p);
|
|
971
|
+
|
|
972
|
+
// 5. Truncation guard — refuse suspicious shrinkage on non-trivial files.
|
|
973
|
+
if (oldSize > 200 && newSize * 100 < oldSize * 30) {
|
|
974
|
+
const msg = `REFUSED ${relPath} (would shrink ${oldSize} → ${newSize} bytes / ` +
|
|
975
|
+
`${oldLines} → ${newLines} lines, looks truncated)`;
|
|
976
|
+
agentLog(projectRoot, "write.refused", msg);
|
|
977
|
+
return { error: msg, refused: true, old_bytes: oldSize, new_bytes: newSize };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 6. Backup before overwrite.
|
|
981
|
+
const backupRel = oldSize > 0 ? agentBackup(projectRoot, p) : null;
|
|
982
|
+
|
|
983
|
+
// 7. Write.
|
|
738
984
|
const dir = path.dirname(p);
|
|
739
985
|
if (!fs.existsSync(dir)) {
|
|
740
986
|
fs.mkdirSync(dir, { recursive: true });
|
|
741
987
|
}
|
|
742
|
-
const content = args.content as string;
|
|
743
988
|
fs.writeFileSync(p, content, "utf-8");
|
|
744
|
-
|
|
745
|
-
|
|
989
|
+
|
|
990
|
+
agentLog(projectRoot, "write.ok",
|
|
991
|
+
`${relPath} (${oldSize}B/${oldLines}L → ${newSize}B/${newLines}L, ` +
|
|
992
|
+
`backup: ${backupRel || "(no prior file)"})`);
|
|
993
|
+
|
|
994
|
+
const result: Record<string, unknown> = { written: relPath, bytes: newSize };
|
|
995
|
+
if (backupRel) {
|
|
996
|
+
result.backup = backupRel;
|
|
997
|
+
}
|
|
998
|
+
// 8. Post-write syntax verification — catch broken JS/TS inline
|
|
999
|
+
// so the LLM sees the error on its next turn.
|
|
1000
|
+
const importErr = verifyNodeSyntax(p, relPath);
|
|
1001
|
+
if (importErr) {
|
|
1002
|
+
result.import_error = importErr;
|
|
1003
|
+
agentLog(projectRoot, "write.import_failed", `${relPath}: ${importErr}`);
|
|
1004
|
+
}
|
|
1005
|
+
return result;
|
|
746
1006
|
},
|
|
747
1007
|
"Write or update a project file",
|
|
748
1008
|
schemaFromParams([
|
|
@@ -1178,19 +1438,30 @@ export function registerDevTools(server: McpServer): void {
|
|
|
1178
1438
|
"file_patch",
|
|
1179
1439
|
(args) => {
|
|
1180
1440
|
try {
|
|
1181
|
-
|
|
1441
|
+
let rel = (args.path as string) || "";
|
|
1182
1442
|
const oldStr = (args.old_string as string) || "";
|
|
1183
1443
|
const newStr = (args.new_string as string) || "";
|
|
1184
1444
|
const count = (args.count as number) || 1;
|
|
1185
1445
|
const projectRoot = path.resolve(process.cwd());
|
|
1446
|
+
|
|
1447
|
+
// 1. Prose check first — before path resolution.
|
|
1448
|
+
const proseErr = looksLikeProse(rel);
|
|
1449
|
+
if (proseErr) {
|
|
1450
|
+
return { error: `Invalid path ${JSON.stringify(rel)}: ${proseErr}` };
|
|
1451
|
+
}
|
|
1452
|
+
// 2. Coder-path normalization.
|
|
1453
|
+
rel = normalizeCoderPath(projectRoot, rel);
|
|
1454
|
+
// 3. Safe path resolution (sandbox check).
|
|
1186
1455
|
const resolved = path.resolve(projectRoot, rel);
|
|
1187
1456
|
if (!resolved.startsWith(projectRoot)) {
|
|
1188
1457
|
return { error: `Path escapes project directory: ${rel}` };
|
|
1189
1458
|
}
|
|
1459
|
+
// 4. Existence check.
|
|
1190
1460
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
1191
1461
|
return { error: `File not found: ${rel}` };
|
|
1192
1462
|
}
|
|
1193
1463
|
const original = fs.readFileSync(resolved, "utf-8");
|
|
1464
|
+
// 5. Match-count guard.
|
|
1194
1465
|
let occurrences = 0;
|
|
1195
1466
|
let idx = -1;
|
|
1196
1467
|
while ((idx = original.indexOf(oldStr, idx + 1)) !== -1) occurrences++;
|
|
@@ -1204,13 +1475,37 @@ export function registerDevTools(server: McpServer): void {
|
|
|
1204
1475
|
}
|
|
1205
1476
|
let updated = original;
|
|
1206
1477
|
for (let i = 0; i < count; i++) updated = updated.replace(oldStr, newStr);
|
|
1478
|
+
|
|
1479
|
+
// 6. Backup before overwrite — same path as file_write so
|
|
1480
|
+
// recovery is uniform regardless of which tool touched the file.
|
|
1481
|
+
const backupRel = agentBackup(projectRoot, resolved);
|
|
1482
|
+
|
|
1483
|
+
// 7. Write.
|
|
1207
1484
|
fs.writeFileSync(resolved, updated, "utf-8");
|
|
1208
1485
|
try { loadPlan().recordAction("patched", rel); } catch { /* best-effort */ }
|
|
1209
|
-
|
|
1486
|
+
|
|
1487
|
+
const oldSize = Buffer.byteLength(original, "utf-8");
|
|
1488
|
+
const newSize = Buffer.byteLength(updated, "utf-8");
|
|
1489
|
+
agentLog(projectRoot, "patch.ok",
|
|
1490
|
+
`${rel} (replaced ${count}× old_string, ${oldSize}B → ${newSize}B, ` +
|
|
1491
|
+
`backup: ${backupRel || "(none)"})`);
|
|
1492
|
+
|
|
1493
|
+
const result: Record<string, unknown> = {
|
|
1210
1494
|
patched: rel,
|
|
1211
1495
|
replacements: count,
|
|
1212
|
-
bytes:
|
|
1496
|
+
bytes: newSize,
|
|
1213
1497
|
};
|
|
1498
|
+
if (backupRel) {
|
|
1499
|
+
result.backup = backupRel;
|
|
1500
|
+
}
|
|
1501
|
+
// 8. Post-patch syntax verification — same inline check as
|
|
1502
|
+
// file_write so a hallucinated edit surfaces immediately.
|
|
1503
|
+
const importErr = verifyNodeSyntax(resolved, rel);
|
|
1504
|
+
if (importErr) {
|
|
1505
|
+
result.import_error = importErr;
|
|
1506
|
+
agentLog(projectRoot, "patch.import_failed", `${rel}: ${importErr}`);
|
|
1507
|
+
}
|
|
1508
|
+
return result;
|
|
1214
1509
|
} catch (e) {
|
|
1215
1510
|
return { error: (e as Error).message };
|
|
1216
1511
|
}
|
|
@@ -35,6 +35,9 @@ export interface PlanSummary {
|
|
|
35
35
|
steps_total: number;
|
|
36
36
|
steps_done: number;
|
|
37
37
|
is_current: boolean;
|
|
38
|
+
/** Path relative to project root — lets the SPA open the right file
|
|
39
|
+
* regardless of which dir the plan came from (plan/ vs .tina4/plans/). */
|
|
40
|
+
path: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface ExecutionSummary {
|
|
@@ -158,25 +161,63 @@ function loadParsed(name: string): { path: string; plan: ParsedPlan } {
|
|
|
158
161
|
// ── Plan namespace ────────────────────────────────────────────
|
|
159
162
|
|
|
160
163
|
export const Plan = {
|
|
164
|
+
/**
|
|
165
|
+
* All plan files — merged from `plan/` (user-curated canonical) and
|
|
166
|
+
* `.tina4/plans/` (where the Rust supervisor's planner writes).
|
|
167
|
+
*
|
|
168
|
+
* Two directories exist because of a historic split: the framework
|
|
169
|
+
* treats `plan/` as the canonical project location, but the Rust agent's
|
|
170
|
+
* planner writes to `.tina4/plans/` (alongside other AI-state artefacts
|
|
171
|
+
* like chat history). Until those are unified we read both so plans
|
|
172
|
+
* created either way are discoverable. Dedup by filename when a plan
|
|
173
|
+
* exists in both — `plan/` wins on collision.
|
|
174
|
+
*
|
|
175
|
+
* Newest-first by filename (Rust planner uses unix-timestamp prefixes).
|
|
176
|
+
*/
|
|
161
177
|
listPlans(): PlanSummary[] {
|
|
162
|
-
const
|
|
178
|
+
const root = projectRoot();
|
|
163
179
|
const current = Plan.currentName() || "";
|
|
180
|
+
|
|
181
|
+
// Order matters for dedup: user-curated first.
|
|
182
|
+
const dirs: string[] = [planDir()];
|
|
183
|
+
const rustPlans = path.join(root, ".tina4", "plans");
|
|
184
|
+
if (fs.existsSync(rustPlans) && fs.statSync(rustPlans).isDirectory()) {
|
|
185
|
+
dirs.push(rustPlans);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const seen = new Set<string>();
|
|
164
189
|
const out: PlanSummary[] = [];
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
name
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
190
|
+
for (const dir of dirs) {
|
|
191
|
+
let entries: string[];
|
|
192
|
+
try {
|
|
193
|
+
entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
|
|
194
|
+
} catch {
|
|
195
|
+
continue; // dir disappeared between exists() and readdir — skip
|
|
196
|
+
}
|
|
197
|
+
for (const name of entries) {
|
|
198
|
+
if (seen.has(name)) continue; // plan/ wins over .tina4/plans/ on name clash
|
|
199
|
+
const full = path.join(dir, name);
|
|
200
|
+
let stat;
|
|
201
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
202
|
+
if (!stat.isFile()) continue;
|
|
203
|
+
seen.add(name);
|
|
204
|
+
const parsed = parse(fs.readFileSync(full, "utf-8"));
|
|
205
|
+
const total = parsed.steps.length;
|
|
206
|
+
const done = parsed.steps.filter((s) => s.done).length;
|
|
207
|
+
out.push({
|
|
208
|
+
name,
|
|
209
|
+
title: parsed.title || name.replace(/\.md$/, ""),
|
|
210
|
+
steps_total: total,
|
|
211
|
+
steps_done: done,
|
|
212
|
+
is_current: name === current,
|
|
213
|
+
// Relative path from project root — lets the SPA open the right
|
|
214
|
+
// file in the editor regardless of source dir.
|
|
215
|
+
path: path.relative(root, full),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
179
218
|
}
|
|
219
|
+
// Newest first by name (filenames start with unix timestamps).
|
|
220
|
+
out.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
180
221
|
return out;
|
|
181
222
|
},
|
|
182
223
|
|
|
@@ -3,7 +3,16 @@ import type { Tina4Request, UploadedFile } from "./types.js";
|
|
|
3
3
|
|
|
4
4
|
export function createRequest(req: IncomingMessage): Tina4Request {
|
|
5
5
|
const tReq = req as Tina4Request;
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
// Resolve scheme + host honouring proxy headers — parity with PHP/Python/Ruby.
|
|
8
|
+
const xfProto = req.headers["x-forwarded-proto"];
|
|
9
|
+
const proto = (Array.isArray(xfProto) ? xfProto[0] : xfProto)
|
|
10
|
+
?? ((req.socket as { encrypted?: boolean })?.encrypted ? "https" : "http");
|
|
11
|
+
const xfHost = req.headers["x-forwarded-host"];
|
|
12
|
+
const host = (Array.isArray(xfHost) ? xfHost[0] : xfHost)
|
|
13
|
+
?? (req.headers.host ?? "localhost");
|
|
14
|
+
|
|
15
|
+
const url = new URL(req.url ?? "/", `${proto}://${host}`);
|
|
7
16
|
const query: Record<string, string> = {};
|
|
8
17
|
for (const [key, value] of url.searchParams) {
|
|
9
18
|
query[key] = value;
|
|
@@ -11,6 +20,13 @@ export function createRequest(req: IncomingMessage): Tina4Request {
|
|
|
11
20
|
|
|
12
21
|
tReq.params = {};
|
|
13
22
|
tReq.query = query;
|
|
23
|
+
// Path, query string, and full URL — same shape across all four frameworks.
|
|
24
|
+
// `path` is the URL path only; `queryString` is the raw query without "?".
|
|
25
|
+
// `url` is overridden from Node's IncomingMessage.url (path+query) to the
|
|
26
|
+
// full absolute URL — parity with PHP/Python/Ruby.
|
|
27
|
+
tReq.path = url.pathname;
|
|
28
|
+
tReq.queryString = url.search.replace(/^\?/, "");
|
|
29
|
+
tReq.url = url.toString();
|
|
14
30
|
tReq.body = undefined;
|
|
15
31
|
tReq.files = {};
|
|
16
32
|
tReq.contentType = (req.headers["content-type"] ?? "") as string;
|
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import { readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { readdirSync, statSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
2
2
|
import { join, relative, basename, extname } from "node:path";
|
|
3
3
|
import type { RouteDefinition, RouteHandler, RouteMeta } from "./types.js";
|
|
4
4
|
|
|
5
5
|
const VALID_METHODS = new Set(["get", "post", "put", "delete", "patch"]);
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Files already discovered by a prior scan. Lets rediscoverRoutes() pick up
|
|
9
|
+
* only the freshly-added files without double-registering anything that was
|
|
10
|
+
* already loaded.
|
|
11
|
+
*/
|
|
12
|
+
const _seenFiles = new Set<string>();
|
|
13
|
+
|
|
14
|
+
/** The last directory passed to discoverRoutes() — used by rediscoverRoutes(). */
|
|
15
|
+
let _lastRoutesDir = "";
|
|
16
|
+
|
|
7
17
|
export async function discoverRoutes(routesDir: string): Promise<RouteDefinition[]> {
|
|
18
|
+
_lastRoutesDir = routesDir;
|
|
8
19
|
const definitions: RouteDefinition[] = [];
|
|
9
20
|
const files = walkDir(routesDir);
|
|
10
21
|
|
|
22
|
+
let routeFileCount = 0;
|
|
23
|
+
let registeredFromThisScan = 0;
|
|
24
|
+
|
|
11
25
|
for (const filePath of files) {
|
|
12
26
|
const ext = extname(filePath);
|
|
13
27
|
if (ext !== ".ts" && ext !== ".js") continue;
|
|
@@ -15,6 +29,10 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
|
|
|
15
29
|
const name = basename(filePath, ext).toLowerCase();
|
|
16
30
|
if (!VALID_METHODS.has(name)) continue;
|
|
17
31
|
|
|
32
|
+
routeFileCount++;
|
|
33
|
+
|
|
34
|
+
if (_seenFiles.has(filePath)) continue;
|
|
35
|
+
|
|
18
36
|
const method = name.toUpperCase();
|
|
19
37
|
const relativePath = relative(routesDir, filePath);
|
|
20
38
|
const pattern = filePathToPattern(relativePath);
|
|
@@ -34,14 +52,64 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
|
|
|
34
52
|
const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
|
|
35
53
|
|
|
36
54
|
definitions.push({ method, pattern, handler, filePath, meta, template });
|
|
55
|
+
_seenFiles.add(filePath);
|
|
56
|
+
registeredFromThisScan++;
|
|
37
57
|
} catch (err) {
|
|
38
58
|
console.error(` Error loading route ${relativePath}:`, err);
|
|
59
|
+
recordBrokenImport(filePath, err as Error);
|
|
39
60
|
}
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
// Zero-routes warning: src/routes/ has method-named files but none of them
|
|
64
|
+
// produced a route this scan. Could mean every file failed to import, or
|
|
65
|
+
// the handler exports are missing — both situations the user wants to know
|
|
66
|
+
// about loudly. Only fires on the first scan when nothing came back.
|
|
67
|
+
if (routeFileCount > 0 && registeredFromThisScan === 0 && _seenFiles.size === 0) {
|
|
68
|
+
console.warn(
|
|
69
|
+
` Warning: ${routeFileCount} method-named file(s) in ${routesDir} but no routes registered. ` +
|
|
70
|
+
`Each route file must \`export default async function (req, res) { ... }\`.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
42
74
|
return definitions;
|
|
43
75
|
}
|
|
44
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Re-run the most recent route scan — called by POST /__dev/api/reload so a
|
|
79
|
+
* newly-added file in src/routes/ registers without a server restart. Already
|
|
80
|
+
* loaded files are skipped. No-op if discoverRoutes() has never been called.
|
|
81
|
+
*/
|
|
82
|
+
export async function rediscoverRoutes(): Promise<RouteDefinition[]> {
|
|
83
|
+
if (!_lastRoutesDir) return [];
|
|
84
|
+
return discoverRoutes(_lastRoutesDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Test-only: reset the seen-files state so tests can replay the same dir. */
|
|
88
|
+
export function _resetRouteDiscovery(): void {
|
|
89
|
+
_seenFiles.clear();
|
|
90
|
+
_lastRoutesDir = "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Write a .broken sentinel so /health and the dev dashboard surface auto-discover
|
|
95
|
+
* failures rather than swallowing them into a console line nobody reads.
|
|
96
|
+
*/
|
|
97
|
+
function recordBrokenImport(filePath: string, error: Error): void {
|
|
98
|
+
try {
|
|
99
|
+
const brokenDir = join(process.cwd(), "data", ".broken");
|
|
100
|
+
if (!existsSync(brokenDir)) mkdirSync(brokenDir, { recursive: true });
|
|
101
|
+
const slug = filePath.replace(/[/\\]/g, "_");
|
|
102
|
+
const payload = JSON.stringify({
|
|
103
|
+
type: "auto_discover_failure",
|
|
104
|
+
file: filePath,
|
|
105
|
+
error: `${error.name}: ${error.message}`,
|
|
106
|
+
}, null, 2);
|
|
107
|
+
writeFileSync(join(brokenDir, `discover_${slug}.broken`), payload);
|
|
108
|
+
} catch {
|
|
109
|
+
// .broken write itself failed — the original error is already logged.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
45
113
|
function filePathToPattern(relativePath: string): string {
|
|
46
114
|
// Remove the filename (get.ts, post.ts, etc.) to get the directory path
|
|
47
115
|
// Normalise backslashes for Windows compatibility
|