@nifrajs/cli 3.3.0 → 4.0.0

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.
Files changed (425) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +7 -15
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +162 -713
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +58 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +323 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts +1 -0
  30. package/dist/check.d.ts.map +1 -1
  31. package/dist/check.js +145 -54
  32. package/dist/check.js.map +1 -1
  33. package/dist/cli.d.ts +42 -16
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +306 -87
  36. package/dist/cli.js.map +1 -1
  37. package/dist/command-catalog.d.ts +60 -5
  38. package/dist/command-catalog.d.ts.map +1 -1
  39. package/dist/command-catalog.js +298 -33
  40. package/dist/command-catalog.js.map +1 -1
  41. package/dist/config-target.d.ts +8 -0
  42. package/dist/config-target.d.ts.map +1 -0
  43. package/dist/config-target.js +45 -0
  44. package/dist/config-target.js.map +1 -0
  45. package/dist/contracts.d.ts +3 -2
  46. package/dist/contracts.d.ts.map +1 -1
  47. package/dist/contracts.js +56 -16
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db-audit.d.ts +37 -0
  50. package/dist/db-audit.d.ts.map +1 -0
  51. package/dist/db-audit.js +99 -0
  52. package/dist/db-audit.js.map +1 -0
  53. package/dist/db-child.d.ts +64 -0
  54. package/dist/db-child.d.ts.map +1 -0
  55. package/dist/db-child.js +213 -0
  56. package/dist/db-child.js.map +1 -0
  57. package/dist/db-config.d.ts +60 -0
  58. package/dist/db-config.d.ts.map +1 -0
  59. package/dist/db-config.js +176 -0
  60. package/dist/db-config.js.map +1 -0
  61. package/dist/db-tool.d.ts +84 -0
  62. package/dist/db-tool.d.ts.map +1 -0
  63. package/dist/db-tool.js +537 -0
  64. package/dist/db-tool.js.map +1 -0
  65. package/dist/dev-bun-config.d.ts +20 -13
  66. package/dist/dev-bun-config.d.ts.map +1 -1
  67. package/dist/dev-bun-config.js +98 -41
  68. package/dist/dev-bun-config.js.map +1 -1
  69. package/dist/dev-feed-tool.d.ts +106 -0
  70. package/dist/dev-feed-tool.d.ts.map +1 -0
  71. package/dist/dev-feed-tool.js +713 -0
  72. package/dist/dev-feed-tool.js.map +1 -0
  73. package/dist/dev-server-client.d.ts +67 -0
  74. package/dist/dev-server-client.d.ts.map +1 -0
  75. package/dist/dev-server-client.js +195 -0
  76. package/dist/dev-server-client.js.map +1 -0
  77. package/dist/diagnostics.d.ts +86 -0
  78. package/dist/diagnostics.d.ts.map +1 -1
  79. package/dist/diagnostics.js +85 -0
  80. package/dist/diagnostics.js.map +1 -1
  81. package/dist/diff-tool.d.ts +3 -3
  82. package/dist/diff-tool.d.ts.map +1 -1
  83. package/dist/diff-tool.js +6 -5
  84. package/dist/diff-tool.js.map +1 -1
  85. package/dist/docs-search.d.ts.map +1 -1
  86. package/dist/docs-search.js +12 -4
  87. package/dist/docs-search.js.map +1 -1
  88. package/dist/doctor.d.ts +17 -0
  89. package/dist/doctor.d.ts.map +1 -1
  90. package/dist/doctor.js +76 -15
  91. package/dist/doctor.js.map +1 -1
  92. package/dist/env-file.d.ts +15 -0
  93. package/dist/env-file.d.ts.map +1 -1
  94. package/dist/env-file.js +73 -1
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/fix-recipes.d.ts.map +1 -1
  97. package/dist/fix-recipes.js +41 -21
  98. package/dist/fix-recipes.js.map +1 -1
  99. package/dist/frontend-guidance.js +5 -5
  100. package/dist/frontend-guidance.js.map +1 -1
  101. package/dist/i18n-check.d.ts +16 -0
  102. package/dist/i18n-check.d.ts.map +1 -0
  103. package/dist/i18n-check.js +154 -0
  104. package/dist/i18n-check.js.map +1 -0
  105. package/dist/init-agents.d.ts +49 -13
  106. package/dist/init-agents.d.ts.map +1 -1
  107. package/dist/init-agents.js +239 -34
  108. package/dist/init-agents.js.map +1 -1
  109. package/dist/internal/code-unit-order.d.ts +4 -0
  110. package/dist/internal/code-unit-order.d.ts.map +1 -0
  111. package/dist/internal/code-unit-order.js +6 -0
  112. package/dist/internal/code-unit-order.js.map +1 -0
  113. package/dist/internal/script-kind.d.ts +7 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -0
  115. package/dist/internal/script-kind.js +12 -0
  116. package/dist/internal/script-kind.js.map +1 -0
  117. package/dist/internal/source-facts.d.ts +5 -0
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +61 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +49 -24
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +507 -67
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +6 -4
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +93 -74
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/learn.js +3 -3
  130. package/dist/learn.js.map +1 -1
  131. package/dist/levels-tool.d.ts +10 -0
  132. package/dist/levels-tool.d.ts.map +1 -1
  133. package/dist/levels-tool.js +33 -3
  134. package/dist/levels-tool.js.map +1 -1
  135. package/dist/load.d.ts +71 -16
  136. package/dist/load.d.ts.map +1 -1
  137. package/dist/load.js +78 -18
  138. package/dist/load.js.map +1 -1
  139. package/dist/manifest-tool.d.ts.map +1 -1
  140. package/dist/manifest-tool.js +3 -2
  141. package/dist/manifest-tool.js.map +1 -1
  142. package/dist/mcp-app-tool.d.ts.map +1 -1
  143. package/dist/mcp-app-tool.js +2 -0
  144. package/dist/mcp-app-tool.js.map +1 -1
  145. package/dist/mcp-context.d.ts.map +1 -1
  146. package/dist/mcp-context.js +5 -6
  147. package/dist/mcp-context.js.map +1 -1
  148. package/dist/mcp-delegate.d.ts +57 -0
  149. package/dist/mcp-delegate.d.ts.map +1 -0
  150. package/dist/mcp-delegate.js +130 -0
  151. package/dist/mcp-delegate.js.map +1 -0
  152. package/dist/mcp-docs-tools.d.ts.map +1 -1
  153. package/dist/mcp-docs-tools.js +23 -5
  154. package/dist/mcp-docs-tools.js.map +1 -1
  155. package/dist/mcp-exec.d.ts +3 -0
  156. package/dist/mcp-exec.d.ts.map +1 -1
  157. package/dist/mcp-exec.js +263 -131
  158. package/dist/mcp-exec.js.map +1 -1
  159. package/dist/mcp-http.d.ts.map +1 -1
  160. package/dist/mcp-http.js +20 -5
  161. package/dist/mcp-http.js.map +1 -1
  162. package/dist/mcp-io.d.ts +26 -0
  163. package/dist/mcp-io.d.ts.map +1 -1
  164. package/dist/mcp-io.js +113 -0
  165. package/dist/mcp-io.js.map +1 -1
  166. package/dist/mcp-isolate.d.ts +48 -0
  167. package/dist/mcp-isolate.d.ts.map +1 -0
  168. package/dist/mcp-isolate.js +222 -0
  169. package/dist/mcp-isolate.js.map +1 -0
  170. package/dist/mcp-path.d.ts +10 -0
  171. package/dist/mcp-path.d.ts.map +1 -0
  172. package/dist/mcp-path.js +51 -0
  173. package/dist/mcp-path.js.map +1 -0
  174. package/dist/mcp-project-child.d.ts +74 -0
  175. package/dist/mcp-project-child.d.ts.map +1 -0
  176. package/dist/mcp-project-child.js +130 -0
  177. package/dist/mcp-project-child.js.map +1 -0
  178. package/dist/mcp-reflect.d.ts.map +1 -1
  179. package/dist/mcp-reflect.js +2 -2
  180. package/dist/mcp-reflect.js.map +1 -1
  181. package/dist/mcp-render.d.ts.map +1 -1
  182. package/dist/mcp-render.js +64 -45
  183. package/dist/mcp-render.js.map +1 -1
  184. package/dist/mcp-root.d.ts +37 -9
  185. package/dist/mcp-root.d.ts.map +1 -1
  186. package/dist/mcp-root.js +124 -25
  187. package/dist/mcp-root.js.map +1 -1
  188. package/dist/mcp-run.d.ts +18 -1
  189. package/dist/mcp-run.d.ts.map +1 -1
  190. package/dist/mcp-run.js +159 -55
  191. package/dist/mcp-run.js.map +1 -1
  192. package/dist/mcp-ws.js +4 -1
  193. package/dist/mcp-ws.js.map +1 -1
  194. package/dist/mcp.d.ts +18 -5
  195. package/dist/mcp.d.ts.map +1 -1
  196. package/dist/mcp.js +165 -82
  197. package/dist/mcp.js.map +1 -1
  198. package/dist/migrate-layout.d.ts +44 -0
  199. package/dist/migrate-layout.d.ts.map +1 -0
  200. package/dist/migrate-layout.js +982 -0
  201. package/dist/migrate-layout.js.map +1 -0
  202. package/dist/openapi-tool.d.ts +2 -2
  203. package/dist/openapi-tool.d.ts.map +1 -1
  204. package/dist/openapi-tool.js.map +1 -1
  205. package/dist/openapi-types.d.ts +2 -1
  206. package/dist/openapi-types.d.ts.map +1 -1
  207. package/dist/openapi-types.js +240 -12
  208. package/dist/openapi-types.js.map +1 -1
  209. package/dist/pipeline-report.d.ts +3 -2
  210. package/dist/pipeline-report.d.ts.map +1 -1
  211. package/dist/pipeline-report.js +19 -16
  212. package/dist/pipeline-report.js.map +1 -1
  213. package/dist/port.d.ts +5 -4
  214. package/dist/port.d.ts.map +1 -1
  215. package/dist/port.js +46 -29
  216. package/dist/port.js.map +1 -1
  217. package/dist/project-facts.d.ts +28 -4
  218. package/dist/project-facts.d.ts.map +1 -1
  219. package/dist/project-facts.js +12 -2
  220. package/dist/project-facts.js.map +1 -1
  221. package/dist/recipes/4.0.0.d.ts +9 -0
  222. package/dist/recipes/4.0.0.d.ts.map +1 -0
  223. package/dist/recipes/4.0.0.js +34 -0
  224. package/dist/recipes/4.0.0.js.map +1 -0
  225. package/dist/recipes/index.d.ts.map +1 -1
  226. package/dist/recipes/index.js +2 -0
  227. package/dist/recipes/index.js.map +1 -1
  228. package/dist/release-verification.d.ts +11 -1
  229. package/dist/release-verification.d.ts.map +1 -1
  230. package/dist/release-verification.js +47 -4
  231. package/dist/release-verification.js.map +1 -1
  232. package/dist/review.d.ts +21 -0
  233. package/dist/review.d.ts.map +1 -0
  234. package/dist/review.js +823 -0
  235. package/dist/review.js.map +1 -0
  236. package/dist/route-types.d.ts +28 -0
  237. package/dist/route-types.d.ts.map +1 -0
  238. package/dist/route-types.js +106 -0
  239. package/dist/route-types.js.map +1 -0
  240. package/dist/rules/codes.d.ts +9 -0
  241. package/dist/rules/codes.d.ts.map +1 -1
  242. package/dist/rules/codes.js +9 -0
  243. package/dist/rules/codes.js.map +1 -1
  244. package/dist/rules/data-guard.d.ts +12 -0
  245. package/dist/rules/data-guard.d.ts.map +1 -0
  246. package/dist/rules/data-guard.js +213 -0
  247. package/dist/rules/data-guard.js.map +1 -0
  248. package/dist/rules/index.d.ts +3 -0
  249. package/dist/rules/index.d.ts.map +1 -1
  250. package/dist/rules/index.js +17 -5
  251. package/dist/rules/index.js.map +1 -1
  252. package/dist/rules/islands.d.ts.map +1 -1
  253. package/dist/rules/islands.js +7 -4
  254. package/dist/rules/islands.js.map +1 -1
  255. package/dist/rules/legacy.d.ts +3 -1
  256. package/dist/rules/legacy.d.ts.map +1 -1
  257. package/dist/rules/legacy.js +829 -34
  258. package/dist/rules/legacy.js.map +1 -1
  259. package/dist/rules/nano.d.ts.map +1 -1
  260. package/dist/rules/nano.js +15 -6
  261. package/dist/rules/nano.js.map +1 -1
  262. package/dist/rules/pages.d.ts +15 -0
  263. package/dist/rules/pages.d.ts.map +1 -0
  264. package/dist/rules/pages.js +88 -0
  265. package/dist/rules/pages.js.map +1 -0
  266. package/dist/rules/routes.d.ts +11 -0
  267. package/dist/rules/routes.d.ts.map +1 -1
  268. package/dist/rules/routes.js +180 -2
  269. package/dist/rules/routes.js.map +1 -1
  270. package/dist/rules/secrets.d.ts +10 -0
  271. package/dist/rules/secrets.d.ts.map +1 -0
  272. package/dist/rules/secrets.js +169 -0
  273. package/dist/rules/secrets.js.map +1 -0
  274. package/dist/rules/security.d.ts +7 -0
  275. package/dist/rules/security.d.ts.map +1 -1
  276. package/dist/rules/security.js +171 -65
  277. package/dist/rules/security.js.map +1 -1
  278. package/dist/rules/typescript.d.ts +16 -0
  279. package/dist/rules/typescript.d.ts.map +1 -0
  280. package/dist/rules/typescript.js +59 -0
  281. package/dist/rules/typescript.js.map +1 -0
  282. package/dist/rules/zones.d.ts +17 -0
  283. package/dist/rules/zones.d.ts.map +1 -0
  284. package/dist/rules/zones.js +174 -0
  285. package/dist/rules/zones.js.map +1 -0
  286. package/dist/scaffold.d.ts +25 -12
  287. package/dist/scaffold.d.ts.map +1 -1
  288. package/dist/scaffold.js +154 -90
  289. package/dist/scaffold.js.map +1 -1
  290. package/dist/sdk.d.ts +17 -3
  291. package/dist/sdk.d.ts.map +1 -1
  292. package/dist/sdk.js +941 -153
  293. package/dist/sdk.js.map +1 -1
  294. package/dist/search-terms.d.ts +2 -0
  295. package/dist/search-terms.d.ts.map +1 -1
  296. package/dist/search-terms.js +23 -8
  297. package/dist/search-terms.js.map +1 -1
  298. package/dist/smoke.d.ts +79 -0
  299. package/dist/smoke.d.ts.map +1 -0
  300. package/dist/smoke.js +494 -0
  301. package/dist/smoke.js.map +1 -0
  302. package/dist/sync-manifest.d.ts.map +1 -1
  303. package/dist/sync-manifest.js +2 -1
  304. package/dist/sync-manifest.js.map +1 -1
  305. package/dist/sync-routes.d.ts +4 -3
  306. package/dist/sync-routes.d.ts.map +1 -1
  307. package/dist/sync-routes.js +4 -3
  308. package/dist/sync-routes.js.map +1 -1
  309. package/dist/test-tool.d.ts.map +1 -1
  310. package/dist/test-tool.js +30 -11
  311. package/dist/test-tool.js.map +1 -1
  312. package/dist/types-search.d.ts.map +1 -1
  313. package/dist/types-search.js +11 -5
  314. package/dist/types-search.js.map +1 -1
  315. package/dist/upgrade.d.ts +36 -6
  316. package/dist/upgrade.d.ts.map +1 -1
  317. package/dist/upgrade.js +167 -29
  318. package/dist/upgrade.js.map +1 -1
  319. package/dist/verification-plan.d.ts.map +1 -1
  320. package/dist/verification-plan.js +8 -0
  321. package/dist/verification-plan.js.map +1 -1
  322. package/dist/verification.d.ts.map +1 -1
  323. package/dist/verification.js +45 -12
  324. package/dist/verification.js.map +1 -1
  325. package/dist/web-app-options.d.ts +19 -0
  326. package/dist/web-app-options.d.ts.map +1 -0
  327. package/dist/web-app-options.js +49 -0
  328. package/dist/web-app-options.js.map +1 -0
  329. package/dist/work-graph.d.ts +3 -0
  330. package/dist/work-graph.d.ts.map +1 -1
  331. package/dist/work-graph.js +33 -16
  332. package/dist/work-graph.js.map +1 -1
  333. package/docs/examples.json +185 -29
  334. package/docs/llms-full.txt +4123 -548
  335. package/docs/types.json +14391 -10060
  336. package/package.json +19 -11
  337. package/src/app-files.ts +40 -0
  338. package/src/assure-hydration.ts +139 -46
  339. package/src/assure.ts +17 -2
  340. package/src/capabilities-tool.ts +35 -9
  341. package/src/cdn-check.ts +268 -0
  342. package/src/check-diagnostics.ts +199 -797
  343. package/src/check-scan.ts +394 -63
  344. package/src/check.ts +146 -58
  345. package/src/cli.ts +347 -92
  346. package/src/command-catalog.ts +407 -45
  347. package/src/config-target.ts +47 -0
  348. package/src/contracts.ts +66 -15
  349. package/src/db-audit.ts +128 -0
  350. package/src/db-child.ts +285 -0
  351. package/src/db-config.ts +224 -0
  352. package/src/db-tool.ts +690 -0
  353. package/src/dev-bun-config.ts +116 -40
  354. package/src/dev-feed-tool.ts +912 -0
  355. package/src/dev-server-client.ts +267 -0
  356. package/src/diagnostics.ts +179 -0
  357. package/src/diff-tool.ts +6 -5
  358. package/src/docs-search.ts +10 -3
  359. package/src/doctor.ts +98 -15
  360. package/src/env-file.ts +78 -1
  361. package/src/fix-recipes.ts +44 -31
  362. package/src/frontend-guidance.ts +5 -5
  363. package/src/i18n-check.ts +179 -0
  364. package/src/init-agents.ts +301 -38
  365. package/src/internal/code-unit-order.ts +5 -0
  366. package/src/internal/script-kind.ts +11 -0
  367. package/src/internal/source-facts.ts +96 -22
  368. package/src/internal/typescript-import.ts +663 -68
  369. package/src/introspect.ts +93 -82
  370. package/src/learn.ts +3 -3
  371. package/src/levels-tool.ts +47 -2
  372. package/src/load.ts +144 -32
  373. package/src/manifest-tool.ts +3 -2
  374. package/src/mcp-app-tool.ts +2 -0
  375. package/src/mcp-context.ts +5 -7
  376. package/src/mcp-delegate.ts +162 -0
  377. package/src/mcp-docs-tools.ts +23 -5
  378. package/src/mcp-exec.ts +301 -161
  379. package/src/mcp-http.ts +22 -5
  380. package/src/mcp-io.ts +130 -0
  381. package/src/mcp-isolate.ts +287 -0
  382. package/src/mcp-path.ts +50 -0
  383. package/src/mcp-project-child.ts +182 -0
  384. package/src/mcp-reflect.ts +3 -2
  385. package/src/mcp-render.ts +72 -42
  386. package/src/mcp-root.ts +130 -24
  387. package/src/mcp-run.ts +190 -56
  388. package/src/mcp-ws.ts +4 -1
  389. package/src/mcp.ts +193 -91
  390. package/src/migrate-layout.ts +1121 -0
  391. package/src/openapi-tool.ts +7 -3
  392. package/src/openapi-types.ts +315 -13
  393. package/src/pipeline-report.ts +22 -18
  394. package/src/port.ts +53 -30
  395. package/src/project-facts.ts +42 -8
  396. package/src/recipes/4.0.0.ts +35 -0
  397. package/src/recipes/index.ts +2 -0
  398. package/src/release-verification.ts +74 -5
  399. package/src/review.ts +988 -0
  400. package/src/route-types.ts +133 -0
  401. package/src/rules/codes.ts +9 -0
  402. package/src/rules/data-guard.ts +230 -0
  403. package/src/rules/index.ts +18 -5
  404. package/src/rules/islands.ts +12 -4
  405. package/src/rules/legacy.ts +947 -35
  406. package/src/rules/nano.ts +18 -6
  407. package/src/rules/pages.ts +99 -0
  408. package/src/rules/routes.ts +181 -2
  409. package/src/rules/secrets.ts +174 -0
  410. package/src/rules/security.ts +198 -70
  411. package/src/rules/typescript.ts +77 -0
  412. package/src/rules/zones.ts +195 -0
  413. package/src/scaffold.ts +175 -98
  414. package/src/sdk.ts +1215 -158
  415. package/src/search-terms.ts +24 -7
  416. package/src/smoke.ts +607 -0
  417. package/src/sync-manifest.ts +2 -1
  418. package/src/sync-routes.ts +4 -3
  419. package/src/test-tool.ts +32 -11
  420. package/src/types-search.ts +11 -4
  421. package/src/upgrade.ts +205 -25
  422. package/src/verification-plan.ts +27 -0
  423. package/src/verification.ts +50 -12
  424. package/src/web-app-options.ts +57 -0
  425. package/src/work-graph.ts +40 -16
package/src/mcp-exec.ts CHANGED
@@ -6,17 +6,20 @@
6
6
  * replaceable.
7
7
  */
8
8
 
9
- import { stat } from "node:fs/promises"
10
- import { resolve, sep } from "node:path"
9
+ import { type Dirent, realpathSync } from "node:fs"
10
+ import { readdir, stat } from "node:fs/promises"
11
+ import { relative, resolve } from "node:path"
11
12
  import { fileURLToPath } from "node:url"
12
- import { Glob } from "bun"
13
+ import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
13
14
  import {
14
15
  type CommandCatalogEntry,
15
16
  type CommandCtx,
16
17
  type CommandSpec,
17
18
  commandCatalog,
19
+ commandMcpInputSchema,
18
20
  commandMcpName,
19
21
  findCommandSpec,
22
+ toCommandCatalogEntry,
20
23
  } from "./command-catalog.ts"
21
24
  import { collectContractProof } from "./contract-proof.ts"
22
25
  import { loadDocsCorpus } from "./docs-search.ts"
@@ -30,18 +33,35 @@ import {
30
33
  } from "./mcp-context.ts"
31
34
  import { docsTools } from "./mcp-docs-tools.ts"
32
35
  import {
36
+ CHILD_INPUT_MAX_BYTES,
33
37
  CHILD_OUTPUT_MAX_BYTES,
34
38
  CHILD_TIMEOUT_MS,
35
- LOCAL_TOOL_FETCH_TIMEOUT_MS,
36
- notNifraResponse,
37
- readBoundedResponse,
38
39
  readBoundedStream,
39
40
  timeoutMessage,
40
41
  validateLocalPort,
41
42
  } from "./mcp-io.ts"
43
+ import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.ts"
42
44
  import type { McpTool, McpToolContext } from "./mcp-protocol.ts"
43
45
  import { loadTypesCorpus } from "./types-search.ts"
44
46
 
47
+ const CODEFRAME_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i
48
+
49
+ /** The files a stack the caller supplies may show a codeframe from: project source, never a dotfile
50
+ * or anything under a dot directory (`.env`, `.git/`, `.nifra/`). */
51
+ function explainSourceGate(root: string): (file: string) => boolean {
52
+ // The codeframe reads a native realpath, which expands a Windows short name (`RUNNER~1`); the root
53
+ // has to be spelled the same way or every file looks like it sits outside it.
54
+ let base = resolve(root)
55
+ try {
56
+ base = realpathSync.native(base)
57
+ } catch {}
58
+ return (file) =>
59
+ CODEFRAME_SOURCE.test(file) &&
60
+ !relative(base, file)
61
+ .split(/[\\/]/)
62
+ .some((segment) => segment.startsWith("."))
63
+ }
64
+
45
65
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
46
66
  * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
47
67
 
@@ -54,25 +74,49 @@ export function toMcpTool(
54
74
  const entry = "run" in source ? undefined : source
55
75
  const { cwd } = options
56
76
  const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
77
+ const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"] as const
78
+ const catalogEntry = entry ?? toCommandCatalogEntry(spec)
57
79
  return {
58
- name: commandMcpName(entry?.name ?? spec.name),
59
- description: entry?.summary ?? spec.summary,
60
- inputSchema: entry?.inputSchema ?? spec.input.jsonSchema,
80
+ name: commandMcpName(catalogEntry.name),
81
+ description: catalogEntry.summary,
82
+ inputSchema: commandMcpInputSchema(catalogEntry),
61
83
  handler: async (args: Record<string, unknown>, context: McpToolContext) => {
62
84
  const raw = { ...args }
63
- const dir = raw.dir
64
- if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
65
- const target = resolveProjectDir(cwd, dir as string | undefined)
66
- if (target === null) return dirError(dir as string | undefined)
67
- if (typeof raw.config === "string") {
68
- const config = resolve(target, raw.config)
69
- if (config !== target && !config.startsWith(`${target}${sep}`))
85
+ for (const field of catalogEntry.cliOnlyFields) {
86
+ if (raw[field] !== undefined)
70
87
  return JSON.stringify(
71
- { ok: false, error: "config must stay inside the selected project directory" },
88
+ { ok: false, error: `${field} is available only from the nifra CLI` },
72
89
  null,
73
90
  2,
74
91
  )
75
92
  }
93
+ const dir = raw.dir
94
+ if (dir !== undefined && typeof dir !== "string") return dirError(undefined)
95
+ const target = resolveProjectDir(cwd, dir as string | undefined)
96
+ if (target === null) return dirError(dir as string | undefined)
97
+ for (const field of pathFields) {
98
+ const value = raw[field]
99
+ if (typeof value !== "string") continue
100
+ const safe = resolveMcpProjectPath(target, value)
101
+ if (safe === null)
102
+ return JSON.stringify({ ok: false, error: mcpProjectPathError(field, value) }, null, 2)
103
+ raw[field] = safe
104
+ }
105
+ if (Array.isArray(raw.files)) {
106
+ const safeFiles: string[] = []
107
+ for (const value of raw.files) {
108
+ if (typeof value !== "string") continue
109
+ const safe = resolveMcpProjectPath(target, value)
110
+ if (safe === null)
111
+ return JSON.stringify(
112
+ { ok: false, error: mcpProjectPathError("files", value) },
113
+ null,
114
+ 2,
115
+ )
116
+ safeFiles.push(safe)
117
+ }
118
+ raw.files = safeFiles
119
+ }
76
120
  delete raw.dir
77
121
  let input: unknown
78
122
  try {
@@ -121,7 +165,7 @@ export function projectTools(
121
165
  {
122
166
  name: "nifra_verify",
123
167
  description:
124
- "Run the shared repository verification plan. Set release=true for the full release plan; the response preserves the declarative gate order and each gate's remediation.",
168
+ "Run the shared repository verification plan. Set release=true for the full release plan; the response preserves the declarative gate order and each gate's remediation. A gate whose script the project's package.json does not declare is reported as undeclared and not run; the result passes when every gate that ran passed.",
125
169
  inputSchema: {
126
170
  type: "object",
127
171
  properties: {
@@ -194,10 +238,21 @@ export function projectTools(
194
238
  if (baseline !== undefined && baseline.trim() === "") {
195
239
  return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2)
196
240
  }
241
+ const safeBaseline =
242
+ baseline === undefined ? undefined : resolveMcpProjectPath(target, baseline)
243
+ if (baseline !== undefined && safeBaseline === null) {
244
+ return JSON.stringify(
245
+ { ok: false, error: mcpProjectPathError("baseline", baseline) },
246
+ null,
247
+ 2,
248
+ )
249
+ }
197
250
  try {
198
251
  return JSON.stringify(
199
252
  await collectContractProof(target, {
200
- ...(baseline === undefined ? {} : { baselinePath: baseline }),
253
+ ...(safeBaseline === undefined || safeBaseline === null
254
+ ? {}
255
+ : { baselinePath: safeBaseline }),
201
256
  ...(raw.check === undefined ? {} : { check: raw.check === true }),
202
257
  }),
203
258
  null,
@@ -215,7 +270,7 @@ export function projectTools(
215
270
  {
216
271
  name: "nifra_explain",
217
272
  description:
218
- "Turn a nifra error into a STRUCTURED diagnostic instead of eyeballing a stack trace: a stable `code`, the top frame in YOUR source, a codeframe around the offending line, and - when nifra recognises the failure - the plain-language `cause` + `fix` + docs anchor. Pass `error` (and `stack` if you have it, e.g. from nifra_run/nifra_test output or a failing build) to explain a specific failure; or pass `port` to fetch the running dev server's most recent SSR failure from `/__nifra/last-error`. Returns the same JSON the dev overlay renders.",
273
+ "Turn a nifra error into a STRUCTURED diagnostic instead of eyeballing a stack trace: a stable `code`, the top frame in YOUR source, a codeframe around the offending line, and - when nifra recognises the failure - the plain-language `cause` + `fix` + docs anchor. Pass `error` (and `stack` if you have it, e.g. from nifra_run/nifra_test output or a failing build) to explain a specific failure. With no `error`, returns the running dev server's most recent error of any kind (SSR, loader, API, build, browser, hydration) with its request id - the server is found automatically; `port`/`dir` pick one when several run. nifra_errors lists them all. Returns the same JSON the dev overlay renders.",
219
274
  inputSchema: {
220
275
  type: "object",
221
276
  properties: {
@@ -237,144 +292,119 @@ export function projectTools(
237
292
  port: {
238
293
  type: "number",
239
294
  description:
240
- "Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
295
+ "The dev server's port, when the workspace runs several. Optional: the project's `nifra dev` server is found automatically.",
296
+ },
297
+ dir: {
298
+ type: "string",
299
+ description: "The app directory, when the workspace runs more than one dev server.",
241
300
  },
242
301
  },
243
302
  additionalProperties: false,
244
303
  },
245
- handler: async (args) => {
246
- const { error, stack, name, port } = args as {
304
+ handler: async (args, context) => {
305
+ const { error, stack, name, port, dir } = args as {
247
306
  error?: string
248
307
  stack?: string
249
308
  name?: string
250
309
  port?: number
310
+ dir?: string
251
311
  }
252
312
  if (error !== undefined || stack !== undefined) {
253
313
  const { buildDiagnostic } = await import("@nifrajs/web/diagnostic")
254
314
  const e = new Error(error ?? "")
255
315
  if (name !== undefined) e.name = name
256
316
  if (stack !== undefined) e.stack = stack
257
- return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2)
317
+ return JSON.stringify(
318
+ buildDiagnostic(e, { root: cwd, showSource: explainSourceGate(cwd) }),
319
+ null,
320
+ 2,
321
+ )
258
322
  }
259
- if (port !== undefined) {
260
- const validPort = validateLocalPort(port)
261
- if (validPort === undefined) {
262
- return JSON.stringify(
263
- { code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
264
- null,
265
- 2,
266
- )
267
- }
268
- const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic")
269
- try {
270
- const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
271
- signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
272
- })
273
- if (res.headers.get("x-nifra-diagnostic") !== "true")
274
- return notNifraResponse("diagnostic")
275
- if (!res.ok) {
276
- return JSON.stringify(
277
- {
278
- code: "NIFRA_NONE",
279
- message: `dev server at :${validPort} returned ${res.status}`,
280
- },
281
- null,
282
- 2,
283
- )
284
- }
285
- return await readBoundedResponse(res)
286
- } catch (cause) {
287
- return JSON.stringify(
288
- {
289
- code: "NIFRA_NONE",
290
- message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
291
- },
292
- null,
293
- 2,
294
- )
295
- }
323
+ const target = resolveProjectDir(cwd, dir)
324
+ if (target === null) return dirError(dir)
325
+ if (port !== undefined && validateLocalPort(port) === undefined) {
326
+ return JSON.stringify(
327
+ { code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." },
328
+ null,
329
+ 2,
330
+ )
331
+ }
332
+ const { explainLatest } = await import("./dev-feed-tool.ts")
333
+ try {
334
+ return JSON.stringify(await explainLatest(target, port, context.signal), null, 2)
335
+ } catch (cause) {
336
+ return JSON.stringify(
337
+ {
338
+ code: "NIFRA_NONE",
339
+ message: `could not read the dev server's errors - ${cause instanceof Error ? cause.message : String(cause)}`,
340
+ },
341
+ null,
342
+ 2,
343
+ )
296
344
  }
297
- return JSON.stringify(
298
- {
299
- code: "NIFRA_NONE",
300
- message:
301
- "Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
302
- },
303
- null,
304
- 2,
305
- )
306
345
  },
307
346
  },
308
347
  {
309
348
  name: "nifra_inspect",
310
349
  description:
311
- "Observe what your requests ACTUALLY did on the running dev server - the recent request traces the DevTools plugin records: `{ method, path, status, durationMs, isrStatus, bodyBytes }` per request. The read no other tool gives you: after nifra_run or a real browser request, call this to SEE the outcome (which route answered, the status, how long, ISR hit/miss) instead of guessing. Pass `port` (the running dev server); narrow with `path` (a path prefix) or `limit` (most recent N). Requires the app to mount `@nifrajs/web`'s `devtools()` plugin (which auto-enables in development).",
350
+ "Observe what your requests ACTUALLY did on the running dev server: one trace per request, `{ requestId, method, path, status, durationMs, bytes, isr, errorIds, logCount }`. After nifra_run or a real browser request, call this to SEE the outcome (the status, how long, ISR hit/miss, whether it logged or failed) instead of guessing; pass a trace's `requestId` to nifra_errors/nifra_logs for what that request failed with or printed. Every `nifra dev` server keeps these traces and is found automatically; `port`/`dir` pick one when several run. Narrow with `path` (a path prefix), `requestId`, `since` (a cursor from an earlier call) or `limit` (most recent N). An explicit `port` this project did not start falls back to the `@nifrajs/devtools` plugin's traces.",
312
351
  inputSchema: {
313
352
  type: "object",
314
353
  properties: {
315
- port: { type: "number", description: "The running dev server's port." },
354
+ port: {
355
+ type: "number",
356
+ description:
357
+ "The dev server's port. Optional: the project's server is found automatically.",
358
+ },
316
359
  path: { type: "string", description: "Only traces whose path starts with this prefix." },
360
+ requestId: { type: "string", description: "Only the trace with this request id." },
361
+ since: {
362
+ type: "number",
363
+ description: "Only traces after this cursor (the `cursor` an earlier call returned).",
364
+ },
317
365
  limit: { type: "number", description: "Return only the most recent N traces." },
366
+ dir: {
367
+ type: "string",
368
+ description: "The app directory, when the workspace runs more than one dev server.",
369
+ },
318
370
  },
319
- required: ["port"],
320
371
  additionalProperties: false,
321
372
  },
322
- handler: async (args) => {
323
- const { port, path, limit } = args as { port?: number; path?: string; limit?: number }
324
- if (port === undefined) {
373
+ handler: async (args, context) => {
374
+ const { port, path, requestId, since, limit, dir } = args as {
375
+ port?: number
376
+ path?: string
377
+ requestId?: string
378
+ since?: number
379
+ limit?: number
380
+ dir?: string
381
+ }
382
+ const target = resolveProjectDir(cwd, dir)
383
+ if (target === null) return dirError(dir)
384
+ if (port !== undefined && validateLocalPort(port) === undefined) {
325
385
  return JSON.stringify(
326
386
  {
327
- events: [],
328
- note: "Pass `port` - the running dev server whose request traces to read.",
387
+ requests: [],
388
+ code: "NIFRA_INVALID_PORT",
389
+ note: "port must be an integer from 1 to 65535.",
329
390
  },
330
391
  null,
331
392
  2,
332
393
  )
333
394
  }
395
+ const { inspectRequests } = await import("./dev-feed-tool.ts")
334
396
  try {
335
- const validPort = validateLocalPort(port)
336
- if (validPort === undefined) {
337
- return JSON.stringify(
338
- {
339
- events: [],
340
- code: "NIFRA_INVALID_PORT",
341
- note: "port must be an integer from 1 to 65535.",
342
- },
343
- null,
344
- 2,
345
- )
346
- }
347
- const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`)
348
- if (path !== undefined) url.searchParams.set("path", path)
349
- if (limit !== undefined) url.searchParams.set("limit", String(limit))
350
- const res = await fetch(url, {
351
- signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
352
- })
353
- if (res.headers.get("x-nifra-devtools") !== "true") return notNifraResponse("DevTools")
354
- if (res.status === 404) {
355
- return JSON.stringify(
356
- {
357
- events: [],
358
- note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
359
- },
360
- null,
361
- 2,
362
- )
363
- }
364
- if (!res.ok) {
365
- return JSON.stringify(
366
- { events: [], note: `DevTools state returned ${res.status}.` },
367
- null,
368
- 2,
369
- )
370
- }
371
- return await readBoundedResponse(res)
397
+ const result = await inspectRequests(
398
+ target,
399
+ { port, path, requestId, since, limit },
400
+ context.signal,
401
+ )
402
+ return JSON.stringify(result, null, 2)
372
403
  } catch (cause) {
373
- const validPort = validateLocalPort(port)
374
404
  return JSON.stringify(
375
405
  {
376
- events: [],
377
- note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
406
+ requests: [],
407
+ note: `Could not read the dev server's request traces - ${cause instanceof Error ? cause.message : String(cause)}`,
378
408
  },
379
409
  null,
380
410
  2,
@@ -385,7 +415,7 @@ export function projectTools(
385
415
  {
386
416
  name: "nifra_openapi",
387
417
  description:
388
- 'Return this project\'s backend OpenAPI 3.1 document generated from backend.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Pass `path` (a route prefix like /api/orders, mirroring nifra_routes) to narrow a large backend to operations under that prefix instead of the whole document. Frontend-only apps return a valid empty paths object.',
418
+ 'Return this project\'s backend OpenAPI 3.1 document generated from backend/app.ts route schemas via @nifrajs/schema. Use `format:"json"` for machine edits (default) or `format:"yaml"` for humans. Pass `path` (a route prefix like /api/orders, mirroring nifra_routes) to narrow a large backend to operations under that prefix instead of the whole document. Frontend-only apps return a valid empty paths object.',
389
419
  inputSchema: {
390
420
  type: "object",
391
421
  properties: {
@@ -407,7 +437,7 @@ export function projectTools(
407
437
  {
408
438
  name: "nifra_run",
409
439
  description:
410
- "Run HTTP requests through this project's backend and return structured results (status, headers, parsed body, and any thrown error). Use it to verify code after editing: by default the backend is re-loaded in a fresh process each call. Pass warm:true to reuse a hot worker while source files are unchanged; it restarts automatically when files change. Each request: { method?, path, body?, headers? }.",
440
+ "Run HTTP requests through this project's backend and return structured results: status, headers, parsed body, `logs` (what the handler printed, per request, secrets redacted) and `errors` (a Diagnostic with codeframe, cause and fix for a thrown error or the unhandled error behind a bare 500). Use it to verify code after editing: by default the backend is re-loaded in a fresh process each call. Pass warm:true to reuse a hot worker while source files are unchanged; it restarts automatically when files change. Each request: { method?, path, body?, headers? }.",
411
441
  inputSchema: {
412
442
  type: "object",
413
443
  properties: {
@@ -426,7 +456,7 @@ export function projectTools(
426
456
  },
427
457
  entry: {
428
458
  type: "string",
429
- description: "Backend entry file (default: backend.ts | app.ts).",
459
+ description: "Backend entry file (default: backend/app.ts | app.ts).",
430
460
  },
431
461
  warm: {
432
462
  type: "boolean",
@@ -496,7 +526,7 @@ export function projectTools(
496
526
  },
497
527
  entry: {
498
528
  type: "string",
499
- description: "Backend entry file (default: backend.ts | app.ts).",
529
+ description: "Backend entry file (default: backend/app.ts | app.ts).",
500
530
  },
501
531
  },
502
532
  required: ["path"],
@@ -513,7 +543,8 @@ export function projectTools(
513
543
  properties: {
514
544
  pattern: {
515
545
  type: "string",
516
- description: "Optional test file/path pattern passed as an argv item to `bun test`.",
546
+ description:
547
+ "Optional test file/path pattern inside the project, passed as an argv item to `bun test`.",
517
548
  },
518
549
  timeoutMs: {
519
550
  type: "number",
@@ -545,7 +576,7 @@ export function projectTools(
545
576
  {
546
577
  name: "nifra_scaffold",
547
578
  description:
548
- 'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong - this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns the file to create + the route-module contract (loader/action/meta/default) + a stub (ready-to-write for react/preact/solid; path+contract for vue/svelte/vanilla - use nifra_example for those bodies).',
579
+ 'Map a URL path to the CORRECT routes/ files and get a contract-correct route pair: the page and its .backend.ts half (loader + output schema). Agents routinely place file routes wrong - this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns both files + the route contract + stubs (the page is ready-to-write for react/preact/solid/vanilla; for vue/svelte use nifra_example for the page body).',
549
580
  inputSchema: {
550
581
  type: "object",
551
582
  properties: {
@@ -556,7 +587,7 @@ export function projectTools(
556
587
  write: {
557
588
  type: "boolean",
558
589
  description:
559
- "When true, create the file if a verified ready-to-write stub exists. Refuses overwrite.",
590
+ "When true, create both files if a verified ready-to-write page stub exists. Refuses overwrite.",
560
591
  },
561
592
  variant: {
562
593
  type: "string",
@@ -584,7 +615,7 @@ export function projectTools(
584
615
  if (write !== true) return renderScaffold(path, framework, flavour)
585
616
  const result = await writeScaffoldRoute(cwd, path, framework, flavour)
586
617
  const status = result.written
587
- ? `Written: \`${result.file}\``
618
+ ? `Written: \`${result.file}\` and \`${result.backend.file}\``
588
619
  : `Not written: ${result.reason ?? "no write performed"}`
589
620
  return `${status}\n\n${renderScaffold(path, framework, flavour)}`
590
621
  },
@@ -600,13 +631,17 @@ export function projectTools(
600
631
  },
601
632
  additionalProperties: false,
602
633
  },
603
- handler: async (args) => {
634
+ handler: async (args, context) => {
604
635
  const opts = args as { dir?: string; interact?: boolean }
605
636
  const target = resolveProjectDir(cwd, opts.dir)
606
637
  if (target === null) return dirError(opts.dir)
607
638
  const { runHydrationAssurance } = await import("./assure-hydration.ts")
608
639
  return JSON.stringify(
609
- await runHydrationAssurance(target, { interact: opts.interact === true }),
640
+ await runHydrationAssurance(
641
+ target,
642
+ { interact: opts.interact === true },
643
+ { signal: context.signal },
644
+ ),
610
645
  null,
611
646
  2,
612
647
  )
@@ -641,7 +676,19 @@ export async function spawnChild(
641
676
  signal?: AbortSignal,
642
677
  ): Promise<string> {
643
678
  if (signal?.aborted) return `${label} cancelled before it started.`
644
- const proc = Bun.spawn(["bun", childPath(child), cwd], {
679
+ let encodedInput: string
680
+ try {
681
+ encodedInput = JSON.stringify(input)
682
+ } catch {
683
+ return `${label} input is not serializable.`
684
+ }
685
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
686
+ return `${label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes.`
687
+ if (signal?.aborted) return `${label} cancelled${cancellationSuffix(signal)}.`
688
+ const proc = Bun.spawn([process.execPath, childPath(child), cwd], {
689
+ cwd,
690
+ // Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
691
+ env: process.env,
645
692
  stdin: "pipe",
646
693
  stdout: "pipe",
647
694
  stderr: "pipe",
@@ -661,8 +708,11 @@ export async function spawnChild(
661
708
  }, CHILD_TIMEOUT_MS)
662
709
  let outputExceeded = false
663
710
  try {
664
- proc.stdin.write(JSON.stringify(input))
665
- await proc.stdin.end()
711
+ if (signal?.aborted) kill()
712
+ else {
713
+ proc.stdin.write(encodedInput)
714
+ await proc.stdin.end()
715
+ }
666
716
  const [out, err] = await Promise.all([
667
717
  readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
668
718
  outputExceeded = true
@@ -675,7 +725,7 @@ export async function spawnChild(
675
725
  ])
676
726
  await proc.exited
677
727
  if (signal?.aborted) {
678
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
728
+ const reason = cancellationSuffix(signal)
679
729
  return `${label} cancelled${reason}.`
680
730
  }
681
731
  if (outputExceeded) {
@@ -692,23 +742,38 @@ export async function spawnChild(
692
742
  }
693
743
  }
694
744
 
695
- const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}")
696
- const WARM_RUN_IGNORED =
697
- /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
745
+ const WARM_RUN_SOURCE = /\.(?:[cm]?[jt]sx?|json)$/
746
+ // Pruned while walking, not filtered after: a scan that descends into node_modules on every warm call
747
+ // costs more than the cold start the worker saves.
748
+ const WARM_RUN_SKIPPED_DIR = /^(?:\..*|node_modules|dist(?:-[a-z0-9]+)?|build|coverage)$/
698
749
  const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"] as const
750
+ const MAX_WARM_PENDING = 64
699
751
 
700
752
  async function warmRunFingerprint(cwd: string): Promise<string> {
701
753
  const parts: string[] = []
702
- for await (const rawRel of WARM_RUN_GLOB.scan({ cwd, dot: false })) {
703
- const rel = rawRel.replaceAll("\\", "/")
704
- if (WARM_RUN_IGNORED.test(rel)) continue
754
+ const walk = async (dir: string): Promise<void> => {
755
+ let entries: Dirent[]
705
756
  try {
706
- const s = await stat(resolve(cwd, rel))
707
- if (s.isFile()) parts.push(`${rel}:${s.mtimeMs}:${s.size}`)
757
+ entries = await readdir(resolve(cwd, dir), { withFileTypes: true })
708
758
  } catch {
709
- // A file can disappear while an agent is editing. The next call will rescan the settled tree.
759
+ return
760
+ }
761
+ for (const entry of entries) {
762
+ const rel = dir === "" ? entry.name : `${dir}/${entry.name}`
763
+ if (entry.isDirectory()) {
764
+ if (!WARM_RUN_SKIPPED_DIR.test(entry.name)) await walk(rel)
765
+ continue
766
+ }
767
+ if (entry.name.startsWith(".") || !WARM_RUN_SOURCE.test(entry.name)) continue
768
+ try {
769
+ const s = await stat(resolve(cwd, rel))
770
+ if (s.isFile()) parts.push(`${rel}:${s.mtimeMs}:${s.size}`)
771
+ } catch {
772
+ // A file can disappear while an agent is editing. The next call will rescan the settled tree.
773
+ }
710
774
  }
711
775
  }
776
+ await walk("")
712
777
  for (const rel of WARM_RUN_EXTRA_FILES) {
713
778
  parts.push(`${rel}:${await fileFingerprint(resolve(cwd, rel))}`)
714
779
  }
@@ -743,6 +808,7 @@ export class WarmWorker {
743
808
  }
744
809
  >()
745
810
  private stdoutBuffer = ""
811
+ private stdoutBufferBytes = 0
746
812
  private stderrBuffer = ""
747
813
  private nextId = 0
748
814
  private closed = false
@@ -753,7 +819,10 @@ export class WarmWorker {
753
819
  readonly fingerprint: string,
754
820
  private readonly label: string,
755
821
  ) {
756
- this.proc = Bun.spawn(["bun", childPath(child), cwd, "--worker"], {
822
+ this.proc = Bun.spawn([process.execPath, childPath(child), cwd, "--worker"], {
823
+ cwd,
824
+ // Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
825
+ env: process.env,
757
826
  stdin: "pipe",
758
827
  stdout: "pipe",
759
828
  stderr: "pipe",
@@ -780,7 +849,18 @@ export class WarmWorker {
780
849
  async request(input: unknown, signal?: AbortSignal): Promise<string> {
781
850
  if (this.closed) throw new Error(`warm ${this.label} worker is closed`)
782
851
  if (signal?.aborted) return `${this.label} cancelled before it started.`
852
+ if (this.pending.size >= MAX_WARM_PENDING)
853
+ throw new Error(`warm ${this.label} worker request limit reached`)
783
854
  const id = ++this.nextId
855
+ let encodedInput: string
856
+ try {
857
+ encodedInput = JSON.stringify({ id, input })
858
+ } catch {
859
+ throw new Error(`warm ${this.label} input is not serializable`)
860
+ }
861
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
862
+ throw new Error(`warm ${this.label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes`)
863
+ if (signal?.aborted) return `${this.label} cancelled${cancellationSuffix(signal)}.`
784
864
  return new Promise((resolve, reject) => {
785
865
  const abort = (): void => {
786
866
  // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
@@ -789,7 +869,8 @@ export class WarmWorker {
789
869
  // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
790
870
  this.pending.delete(id)
791
871
  clearTimeout(timer)
792
- const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
872
+ signal?.removeEventListener("abort", abort)
873
+ const reason = cancellationSuffix(signal)
793
874
  resolve(`${this.label} cancelled${reason}.`)
794
875
  }
795
876
  // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
@@ -807,8 +888,15 @@ export class WarmWorker {
807
888
  }
808
889
  signal?.addEventListener("abort", abort, { once: true })
809
890
  this.pending.set(id, { resolve, reject, cleanup })
891
+ // JSON serialization above can run user-defined `toJSON` hooks. Recheck after installing the
892
+ // listener and pending entry so an abort during that synchronous work cannot enqueue a request
893
+ // after cancellation, and so an already-aborted signal does not retain the listener.
894
+ if (signal?.aborted) {
895
+ abort()
896
+ return
897
+ }
810
898
  try {
811
- this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`)
899
+ this.proc.stdin.write(`${encodedInput}\n`)
812
900
  } catch (err) {
813
901
  this.pending.delete(id)
814
902
  cleanup()
@@ -831,8 +919,10 @@ export class WarmWorker {
831
919
  this.stop()
832
920
  return
833
921
  }
834
- this.stdoutBuffer += decoder.decode(value, { stream: true })
835
- if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
922
+ const text = decoder.decode(value, { stream: true })
923
+ this.stdoutBuffer += text
924
+ this.stdoutBufferBytes += new TextEncoder().encode(text).byteLength
925
+ if (this.stdoutBufferBytes > CHILD_OUTPUT_MAX_BYTES) {
836
926
  this.stderrBuffer = boundedAppend(
837
927
  this.stderrBuffer,
838
928
  `warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`,
@@ -844,6 +934,7 @@ export class WarmWorker {
844
934
  while (nl !== -1) {
845
935
  const line = this.stdoutBuffer.slice(0, nl).trim()
846
936
  this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1)
937
+ this.stdoutBufferBytes = new TextEncoder().encode(this.stdoutBuffer).byteLength
847
938
  nl = this.stdoutBuffer.indexOf("\n")
848
939
  if (line !== "") this.handleLine(line)
849
940
  }
@@ -861,25 +952,69 @@ export class WarmWorker {
861
952
  }
862
953
 
863
954
  private handleLine(line: string): void {
864
- let message: { id?: unknown; output?: unknown }
955
+ let message: unknown
865
956
  try {
866
- message = JSON.parse(line) as { id?: unknown; output?: unknown }
957
+ message = JSON.parse(line) as unknown
867
958
  } catch {
868
959
  this.stderrBuffer = boundedAppend(
869
960
  this.stderrBuffer,
870
961
  `invalid warm ${this.label} worker line: ${line}\n`,
871
962
  )
963
+ this.stop()
964
+ return
965
+ }
966
+ if (
967
+ message === null ||
968
+ typeof message !== "object" ||
969
+ Array.isArray(message) ||
970
+ typeof (message as { id?: unknown }).id !== "number" ||
971
+ !Number.isSafeInteger((message as { id: number }).id)
972
+ ) {
973
+ this.stderrBuffer = boundedAppend(
974
+ this.stderrBuffer,
975
+ `invalid warm ${this.label} worker response envelope\n`,
976
+ )
977
+ this.stop()
872
978
  return
873
979
  }
874
- if (typeof message.id !== "number") return
875
- const pending = this.pending.get(message.id)
980
+ const response = message as { id: number; output?: unknown }
981
+ const pending = this.pending.get(response.id)
876
982
  if (pending === undefined) return
877
- this.pending.delete(message.id)
983
+ if (!Object.hasOwn(response, "output")) {
984
+ this.pending.delete(response.id)
985
+ pending.cleanup()
986
+ pending.reject(new Error(`warm ${this.label} worker response is malformed`))
987
+ this.stop()
988
+ return
989
+ }
990
+ let output: string
991
+ try {
992
+ output = JSON.stringify(response.output, null, 2)
993
+ } catch {
994
+ this.pending.delete(response.id)
995
+ pending.cleanup()
996
+ pending.reject(new Error(`warm ${this.label} worker response is not serializable`))
997
+ this.stop()
998
+ return
999
+ }
1000
+ if (new TextEncoder().encode(output).byteLength > CHILD_OUTPUT_MAX_BYTES) {
1001
+ this.pending.delete(response.id)
1002
+ pending.cleanup()
1003
+ pending.reject(new Error(`warm ${this.label} worker response exceeded the size limit`))
1004
+ this.stop()
1005
+ return
1006
+ }
1007
+ this.pending.delete(response.id)
878
1008
  pending.cleanup()
879
- pending.resolve(JSON.stringify(message.output, null, 2))
1009
+ pending.resolve(output)
880
1010
  }
881
1011
  }
882
1012
 
1013
+ function cancellationSuffix(signal: AbortSignal | undefined): string {
1014
+ const reason = signal?.reason
1015
+ return typeof reason === "string" && reason.length > 0 ? `: ${reason.slice(0, 512)}` : ""
1016
+ }
1017
+
883
1018
  /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
884
1019
  * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
885
1020
  * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
@@ -892,17 +1027,21 @@ export function createWarmHandler(
892
1027
  let worker: WarmWorker | undefined
893
1028
  return async (input, signal) => {
894
1029
  const fingerprint = await warmRunFingerprint(cwd)
895
- if (worker === undefined || worker.fingerprint !== fingerprint) {
896
- worker?.stop()
897
- worker = new WarmWorker(child, cwd, fingerprint, label)
1030
+ let current = worker
1031
+ if (current === undefined || current.fingerprint !== fingerprint) {
1032
+ current?.stop()
1033
+ current = new WarmWorker(child, cwd, fingerprint, label)
1034
+ worker = current
898
1035
  }
899
1036
  try {
900
- return await worker.request(input, signal)
1037
+ return await current.request(input, signal)
901
1038
  } catch {
902
- worker.stop()
903
- worker = undefined
1039
+ // A source change can replace the shared worker while this request is in flight. Only clear
1040
+ // the slot if this request still owns it; an old request must never tear down the replacement.
1041
+ current.stop()
1042
+ if (worker === current) worker = undefined
904
1043
  if (signal?.aborted) {
905
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
1044
+ const reason = cancellationSuffix(signal)
906
1045
  return `${label} cancelled${reason}.`
907
1046
  }
908
1047
  return spawnChild(child, cwd, input, label, signal)
@@ -969,7 +1108,7 @@ export interface CachedAppLoaderOptions {
969
1108
  readonly fingerprint?: (cwd: string) => Promise<string>
970
1109
  }
971
1110
 
972
- const APP_FINGERPRINT_FILES = ["nifra.config.ts", "framework.ts", "backend.ts"] as const
1111
+ const APP_FINGERPRINT_FILES = [CONFIG_FILE, FRAMEWORK_FILE, BACKEND_APP_FILE] as const
973
1112
 
974
1113
  function cacheToken(input: string): string {
975
1114
  let hash = 2166136261
@@ -992,7 +1131,8 @@ async function fileFingerprint(path: string): Promise<string> {
992
1131
  }
993
1132
  }
994
1133
 
995
- async function appFingerprint(cwd: string): Promise<string> {
1134
+ /** `mtime:size` of the app's config, framework and backend entry files: what notices an edit. */
1135
+ export async function appFingerprint(cwd: string): Promise<string> {
996
1136
  return (
997
1137
  await Promise.all(
998
1138
  APP_FINGERPRINT_FILES.map(