@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/dist/mcp-exec.js CHANGED
@@ -5,18 +5,36 @@
5
5
  * loading. It has no JSON-RPC transport or root-trust policy, so those seams remain independently
6
6
  * replaceable.
7
7
  */
8
- import { stat } from "node:fs/promises";
9
- import { resolve, sep } from "node:path";
8
+ import { realpathSync } from "node:fs";
9
+ import { readdir, stat } from "node:fs/promises";
10
+ import { relative, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
11
- import { Glob } from "bun";
12
- import { commandCatalog, commandMcpName, findCommandSpec, } from "./command-catalog.js";
12
+ import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.js";
13
+ import { commandCatalog, commandMcpInputSchema, commandMcpName, findCommandSpec, toCommandCatalogEntry, } from "./command-catalog.js";
13
14
  import { collectContractProof } from "./contract-proof.js";
14
15
  import { loadDocsCorpus } from "./docs-search.js";
15
16
  import { loadExamplesCorpus } from "./examples.js";
16
17
  import { dirError, openApiHandler, resolveProjectDir, } from "./mcp-context.js";
17
18
  import { docsTools } from "./mcp-docs-tools.js";
18
- import { CHILD_OUTPUT_MAX_BYTES, CHILD_TIMEOUT_MS, LOCAL_TOOL_FETCH_TIMEOUT_MS, notNifraResponse, readBoundedResponse, readBoundedStream, timeoutMessage, validateLocalPort, } from "./mcp-io.js";
19
+ import { CHILD_INPUT_MAX_BYTES, CHILD_OUTPUT_MAX_BYTES, CHILD_TIMEOUT_MS, readBoundedStream, timeoutMessage, validateLocalPort, } from "./mcp-io.js";
20
+ import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.js";
19
21
  import { loadTypesCorpus } from "./types-search.js";
22
+ const CODEFRAME_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i;
23
+ /** The files a stack the caller supplies may show a codeframe from: project source, never a dotfile
24
+ * or anything under a dot directory (`.env`, `.git/`, `.nifra/`). */
25
+ function explainSourceGate(root) {
26
+ // The codeframe reads a native realpath, which expands a Windows short name (`RUNNER~1`); the root
27
+ // has to be spelled the same way or every file looks like it sits outside it.
28
+ let base = resolve(root);
29
+ try {
30
+ base = realpathSync.native(base);
31
+ }
32
+ catch { }
33
+ return (file) => CODEFRAME_SOURCE.test(file) &&
34
+ !relative(base, file)
35
+ .split(/[\\/]/)
36
+ .some((segment) => segment.startsWith("."));
37
+ }
20
38
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
21
39
  * dev, `.js` once built). Each runs in a FRESH subprocess per call so the project's current code loads. */
22
40
  export function toMcpTool(source, options) {
@@ -26,22 +44,44 @@ export function toMcpTool(source, options) {
26
44
  const entry = "run" in source ? undefined : source;
27
45
  const { cwd } = options;
28
46
  const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd);
47
+ const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"];
48
+ const catalogEntry = entry ?? toCommandCatalogEntry(spec);
29
49
  return {
30
- name: commandMcpName(entry?.name ?? spec.name),
31
- description: entry?.summary ?? spec.summary,
32
- inputSchema: entry?.inputSchema ?? spec.input.jsonSchema,
50
+ name: commandMcpName(catalogEntry.name),
51
+ description: catalogEntry.summary,
52
+ inputSchema: commandMcpInputSchema(catalogEntry),
33
53
  handler: async (args, context) => {
34
54
  const raw = { ...args };
55
+ for (const field of catalogEntry.cliOnlyFields) {
56
+ if (raw[field] !== undefined)
57
+ return JSON.stringify({ ok: false, error: `${field} is available only from the nifra CLI` }, null, 2);
58
+ }
35
59
  const dir = raw.dir;
36
60
  if (dir !== undefined && typeof dir !== "string")
37
61
  return dirError(undefined);
38
62
  const target = resolveProjectDir(cwd, dir);
39
63
  if (target === null)
40
64
  return dirError(dir);
41
- if (typeof raw.config === "string") {
42
- const config = resolve(target, raw.config);
43
- if (config !== target && !config.startsWith(`${target}${sep}`))
44
- return JSON.stringify({ ok: false, error: "config must stay inside the selected project directory" }, null, 2);
65
+ for (const field of pathFields) {
66
+ const value = raw[field];
67
+ if (typeof value !== "string")
68
+ continue;
69
+ const safe = resolveMcpProjectPath(target, value);
70
+ if (safe === null)
71
+ return JSON.stringify({ ok: false, error: mcpProjectPathError(field, value) }, null, 2);
72
+ raw[field] = safe;
73
+ }
74
+ if (Array.isArray(raw.files)) {
75
+ const safeFiles = [];
76
+ for (const value of raw.files) {
77
+ if (typeof value !== "string")
78
+ continue;
79
+ const safe = resolveMcpProjectPath(target, value);
80
+ if (safe === null)
81
+ return JSON.stringify({ ok: false, error: mcpProjectPathError("files", value) }, null, 2);
82
+ safeFiles.push(safe);
83
+ }
84
+ raw.files = safeFiles;
45
85
  }
46
86
  delete raw.dir;
47
87
  let input;
@@ -80,7 +120,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
80
120
  const legacyTools = [
81
121
  {
82
122
  name: "nifra_verify",
83
- description: "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.",
123
+ description: "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.",
84
124
  inputSchema: {
85
125
  type: "object",
86
126
  properties: {
@@ -149,9 +189,15 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
149
189
  if (baseline !== undefined && baseline.trim() === "") {
150
190
  return JSON.stringify({ ok: false, error: "baseline must not be empty" }, null, 2);
151
191
  }
192
+ const safeBaseline = baseline === undefined ? undefined : resolveMcpProjectPath(target, baseline);
193
+ if (baseline !== undefined && safeBaseline === null) {
194
+ return JSON.stringify({ ok: false, error: mcpProjectPathError("baseline", baseline) }, null, 2);
195
+ }
152
196
  try {
153
197
  return JSON.stringify(await collectContractProof(target, {
154
- ...(baseline === undefined ? {} : { baselinePath: baseline }),
198
+ ...(safeBaseline === undefined || safeBaseline === null
199
+ ? {}
200
+ : { baselinePath: safeBaseline }),
155
201
  ...(raw.check === undefined ? {} : { check: raw.check === true }),
156
202
  }), null, 2);
157
203
  }
@@ -162,7 +208,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
162
208
  },
163
209
  {
164
210
  name: "nifra_explain",
165
- description: "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.",
211
+ description: "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.",
166
212
  inputSchema: {
167
213
  type: "object",
168
214
  properties: {
@@ -180,13 +226,17 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
180
226
  },
181
227
  port: {
182
228
  type: "number",
183
- description: "Instead of a pasted error, fetch the running dev server's most recent SSR failure from this port.",
229
+ description: "The dev server's port, when the workspace runs several. Optional: the project's `nifra dev` server is found automatically.",
230
+ },
231
+ dir: {
232
+ type: "string",
233
+ description: "The app directory, when the workspace runs more than one dev server.",
184
234
  },
185
235
  },
186
236
  additionalProperties: false,
187
237
  },
188
- handler: async (args) => {
189
- const { error, stack, name, port } = args;
238
+ handler: async (args, context) => {
239
+ const { error, stack, name, port, dir } = args;
190
240
  if (error !== undefined || stack !== undefined) {
191
241
  const { buildDiagnostic } = await import("@nifrajs/web/diagnostic");
192
242
  const e = new Error(error ?? "");
@@ -194,104 +244,78 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
194
244
  e.name = name;
195
245
  if (stack !== undefined)
196
246
  e.stack = stack;
197
- return JSON.stringify(buildDiagnostic(e, { root: cwd }), null, 2);
247
+ return JSON.stringify(buildDiagnostic(e, { root: cwd, showSource: explainSourceGate(cwd) }), null, 2);
198
248
  }
199
- if (port !== undefined) {
200
- const validPort = validateLocalPort(port);
201
- if (validPort === undefined) {
202
- return JSON.stringify({ code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." }, null, 2);
203
- }
204
- const { LAST_ERROR_PATH } = await import("@nifrajs/web/diagnostic");
205
- try {
206
- const res = await fetch(`http://127.0.0.1:${validPort}${LAST_ERROR_PATH}`, {
207
- signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
208
- });
209
- if (res.headers.get("x-nifra-diagnostic") !== "true")
210
- return notNifraResponse("diagnostic");
211
- if (!res.ok) {
212
- return JSON.stringify({
213
- code: "NIFRA_NONE",
214
- message: `dev server at :${validPort} returned ${res.status}`,
215
- }, null, 2);
216
- }
217
- return await readBoundedResponse(res);
218
- }
219
- catch (cause) {
220
- return JSON.stringify({
221
- code: "NIFRA_NONE",
222
- message: `could not reach a nifra dev server at :${validPort} - ${cause instanceof Error ? cause.message : String(cause)}`,
223
- }, null, 2);
224
- }
249
+ const target = resolveProjectDir(cwd, dir);
250
+ if (target === null)
251
+ return dirError(dir);
252
+ if (port !== undefined && validateLocalPort(port) === undefined) {
253
+ return JSON.stringify({ code: "NIFRA_INVALID_PORT", message: "port must be an integer from 1 to 65535." }, null, 2);
254
+ }
255
+ const { explainLatest } = await import("./dev-feed-tool.js");
256
+ try {
257
+ return JSON.stringify(await explainLatest(target, port, context.signal), null, 2);
258
+ }
259
+ catch (cause) {
260
+ return JSON.stringify({
261
+ code: "NIFRA_NONE",
262
+ message: `could not read the dev server's errors - ${cause instanceof Error ? cause.message : String(cause)}`,
263
+ }, null, 2);
225
264
  }
226
- return JSON.stringify({
227
- code: "NIFRA_NONE",
228
- message: "Pass `error` (and `stack` if available) to explain a failure, or `port` to fetch the dev server's last error.",
229
- }, null, 2);
230
265
  },
231
266
  },
232
267
  {
233
268
  name: "nifra_inspect",
234
- description: "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).",
269
+ description: "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.",
235
270
  inputSchema: {
236
271
  type: "object",
237
272
  properties: {
238
- port: { type: "number", description: "The running dev server's port." },
273
+ port: {
274
+ type: "number",
275
+ description: "The dev server's port. Optional: the project's server is found automatically.",
276
+ },
239
277
  path: { type: "string", description: "Only traces whose path starts with this prefix." },
278
+ requestId: { type: "string", description: "Only the trace with this request id." },
279
+ since: {
280
+ type: "number",
281
+ description: "Only traces after this cursor (the `cursor` an earlier call returned).",
282
+ },
240
283
  limit: { type: "number", description: "Return only the most recent N traces." },
284
+ dir: {
285
+ type: "string",
286
+ description: "The app directory, when the workspace runs more than one dev server.",
287
+ },
241
288
  },
242
- required: ["port"],
243
289
  additionalProperties: false,
244
290
  },
245
- handler: async (args) => {
246
- const { port, path, limit } = args;
247
- if (port === undefined) {
291
+ handler: async (args, context) => {
292
+ const { port, path, requestId, since, limit, dir } = args;
293
+ const target = resolveProjectDir(cwd, dir);
294
+ if (target === null)
295
+ return dirError(dir);
296
+ if (port !== undefined && validateLocalPort(port) === undefined) {
248
297
  return JSON.stringify({
249
- events: [],
250
- note: "Pass `port` - the running dev server whose request traces to read.",
298
+ requests: [],
299
+ code: "NIFRA_INVALID_PORT",
300
+ note: "port must be an integer from 1 to 65535.",
251
301
  }, null, 2);
252
302
  }
303
+ const { inspectRequests } = await import("./dev-feed-tool.js");
253
304
  try {
254
- const validPort = validateLocalPort(port);
255
- if (validPort === undefined) {
256
- return JSON.stringify({
257
- events: [],
258
- code: "NIFRA_INVALID_PORT",
259
- note: "port must be an integer from 1 to 65535.",
260
- }, null, 2);
261
- }
262
- const url = new URL(`http://127.0.0.1:${validPort}/_nifra/devtools/state`);
263
- if (path !== undefined)
264
- url.searchParams.set("path", path);
265
- if (limit !== undefined)
266
- url.searchParams.set("limit", String(limit));
267
- const res = await fetch(url, {
268
- signal: AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS),
269
- });
270
- if (res.headers.get("x-nifra-devtools") !== "true")
271
- return notNifraResponse("DevTools");
272
- if (res.status === 404) {
273
- return JSON.stringify({
274
- events: [],
275
- note: "No DevTools endpoint on that server. Mount `devtools()` from @nifrajs/web and run in development.",
276
- }, null, 2);
277
- }
278
- if (!res.ok) {
279
- return JSON.stringify({ events: [], note: `DevTools state returned ${res.status}.` }, null, 2);
280
- }
281
- return await readBoundedResponse(res);
305
+ const result = await inspectRequests(target, { port, path, requestId, since, limit }, context.signal);
306
+ return JSON.stringify(result, null, 2);
282
307
  }
283
308
  catch (cause) {
284
- const validPort = validateLocalPort(port);
285
309
  return JSON.stringify({
286
- events: [],
287
- note: `Could not reach a dev server at :${validPort ?? String(port)} - ${cause instanceof Error ? cause.message : String(cause)}`,
310
+ requests: [],
311
+ note: `Could not read the dev server's request traces - ${cause instanceof Error ? cause.message : String(cause)}`,
288
312
  }, null, 2);
289
313
  }
290
314
  },
291
315
  },
292
316
  {
293
317
  name: "nifra_openapi",
294
- description: '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.',
318
+ description: '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.',
295
319
  inputSchema: {
296
320
  type: "object",
297
321
  properties: {
@@ -311,7 +335,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
311
335
  },
312
336
  {
313
337
  name: "nifra_run",
314
- description: "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? }.",
338
+ description: "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? }.",
315
339
  inputSchema: {
316
340
  type: "object",
317
341
  properties: {
@@ -330,7 +354,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
330
354
  },
331
355
  entry: {
332
356
  type: "string",
333
- description: "Backend entry file (default: backend.ts | app.ts).",
357
+ description: "Backend entry file (default: backend/app.ts | app.ts).",
334
358
  },
335
359
  warm: {
336
360
  type: "boolean",
@@ -394,7 +418,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
394
418
  },
395
419
  entry: {
396
420
  type: "string",
397
- description: "Backend entry file (default: backend.ts | app.ts).",
421
+ description: "Backend entry file (default: backend/app.ts | app.ts).",
398
422
  },
399
423
  },
400
424
  required: ["path"],
@@ -410,7 +434,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
410
434
  properties: {
411
435
  pattern: {
412
436
  type: "string",
413
- description: "Optional test file/path pattern passed as an argv item to `bun test`.",
437
+ description: "Optional test file/path pattern inside the project, passed as an argv item to `bun test`.",
414
438
  },
415
439
  timeoutMs: {
416
440
  type: "number",
@@ -437,7 +461,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
437
461
  ...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
438
462
  {
439
463
  name: "nifra_scaffold",
440
- description: '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).',
464
+ description: '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).',
441
465
  inputSchema: {
442
466
  type: "object",
443
467
  properties: {
@@ -447,7 +471,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
447
471
  },
448
472
  write: {
449
473
  type: "boolean",
450
- description: "When true, create the file if a verified ready-to-write stub exists. Refuses overwrite.",
474
+ description: "When true, create both files if a verified ready-to-write page stub exists. Refuses overwrite.",
451
475
  },
452
476
  variant: {
453
477
  type: "string",
@@ -470,7 +494,7 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
470
494
  return renderScaffold(path, framework, flavour);
471
495
  const result = await writeScaffoldRoute(cwd, path, framework, flavour);
472
496
  const status = result.written
473
- ? `Written: \`${result.file}\``
497
+ ? `Written: \`${result.file}\` and \`${result.backend.file}\``
474
498
  : `Not written: ${result.reason ?? "no write performed"}`;
475
499
  return `${status}\n\n${renderScaffold(path, framework, flavour)}`;
476
500
  },
@@ -486,13 +510,13 @@ export function projectTools(cwd, loadAppCached = createCachedAppLoader(cwd)) {
486
510
  },
487
511
  additionalProperties: false,
488
512
  },
489
- handler: async (args) => {
513
+ handler: async (args, context) => {
490
514
  const opts = args;
491
515
  const target = resolveProjectDir(cwd, opts.dir);
492
516
  if (target === null)
493
517
  return dirError(opts.dir);
494
518
  const { runHydrationAssurance } = await import("./assure-hydration.js");
495
- return JSON.stringify(await runHydrationAssurance(target, { interact: opts.interact === true }), null, 2);
519
+ return JSON.stringify(await runHydrationAssurance(target, { interact: opts.interact === true }, { signal: context.signal }), null, 2);
496
520
  },
497
521
  },
498
522
  ];
@@ -517,7 +541,21 @@ function childPath(name) {
517
541
  export async function spawnChild(child, cwd, input, label, signal) {
518
542
  if (signal?.aborted)
519
543
  return `${label} cancelled before it started.`;
520
- const proc = Bun.spawn(["bun", childPath(child), cwd], {
544
+ let encodedInput;
545
+ try {
546
+ encodedInput = JSON.stringify(input);
547
+ }
548
+ catch {
549
+ return `${label} input is not serializable.`;
550
+ }
551
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
552
+ return `${label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes.`;
553
+ if (signal?.aborted)
554
+ return `${label} cancelled${cancellationSuffix(signal)}.`;
555
+ const proc = Bun.spawn([process.execPath, childPath(child), cwd], {
556
+ cwd,
557
+ // Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
558
+ env: process.env,
521
559
  stdin: "pipe",
522
560
  stdout: "pipe",
523
561
  stderr: "pipe",
@@ -538,8 +576,12 @@ export async function spawnChild(child, cwd, input, label, signal) {
538
576
  }, CHILD_TIMEOUT_MS);
539
577
  let outputExceeded = false;
540
578
  try {
541
- proc.stdin.write(JSON.stringify(input));
542
- await proc.stdin.end();
579
+ if (signal?.aborted)
580
+ kill();
581
+ else {
582
+ proc.stdin.write(encodedInput);
583
+ await proc.stdin.end();
584
+ }
543
585
  const [out, err] = await Promise.all([
544
586
  readBoundedStream(proc.stdout, CHILD_OUTPUT_MAX_BYTES, () => {
545
587
  outputExceeded = true;
@@ -552,7 +594,7 @@ export async function spawnChild(child, cwd, input, label, signal) {
552
594
  ]);
553
595
  await proc.exited;
554
596
  if (signal?.aborted) {
555
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : "";
597
+ const reason = cancellationSuffix(signal);
556
598
  return `${label} cancelled${reason}.`;
557
599
  }
558
600
  if (outputExceeded) {
@@ -570,24 +612,42 @@ export async function spawnChild(child, cwd, input, label, signal) {
570
612
  signal?.removeEventListener("abort", abort);
571
613
  }
572
614
  }
573
- const WARM_RUN_GLOB = new Glob("**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,json}");
574
- const WARM_RUN_IGNORED = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//;
615
+ const WARM_RUN_SOURCE = /\.(?:[cm]?[jt]sx?|json)$/;
616
+ // Pruned while walking, not filtered after: a scan that descends into node_modules on every warm call
617
+ // costs more than the cold start the worker saves.
618
+ const WARM_RUN_SKIPPED_DIR = /^(?:\..*|node_modules|dist(?:-[a-z0-9]+)?|build|coverage)$/;
575
619
  const WARM_RUN_EXTRA_FILES = ["bun.lock", "bun.lockb"];
620
+ const MAX_WARM_PENDING = 64;
576
621
  async function warmRunFingerprint(cwd) {
577
622
  const parts = [];
578
- for await (const rawRel of WARM_RUN_GLOB.scan({ cwd, dot: false })) {
579
- const rel = rawRel.replaceAll("\\", "/");
580
- if (WARM_RUN_IGNORED.test(rel))
581
- continue;
623
+ const walk = async (dir) => {
624
+ let entries;
582
625
  try {
583
- const s = await stat(resolve(cwd, rel));
584
- if (s.isFile())
585
- parts.push(`${rel}:${s.mtimeMs}:${s.size}`);
626
+ entries = await readdir(resolve(cwd, dir), { withFileTypes: true });
586
627
  }
587
628
  catch {
588
- // A file can disappear while an agent is editing. The next call will rescan the settled tree.
629
+ return;
589
630
  }
590
- }
631
+ for (const entry of entries) {
632
+ const rel = dir === "" ? entry.name : `${dir}/${entry.name}`;
633
+ if (entry.isDirectory()) {
634
+ if (!WARM_RUN_SKIPPED_DIR.test(entry.name))
635
+ await walk(rel);
636
+ continue;
637
+ }
638
+ if (entry.name.startsWith(".") || !WARM_RUN_SOURCE.test(entry.name))
639
+ continue;
640
+ try {
641
+ const s = await stat(resolve(cwd, rel));
642
+ if (s.isFile())
643
+ parts.push(`${rel}:${s.mtimeMs}:${s.size}`);
644
+ }
645
+ catch {
646
+ // A file can disappear while an agent is editing. The next call will rescan the settled tree.
647
+ }
648
+ }
649
+ };
650
+ await walk("");
591
651
  for (const rel of WARM_RUN_EXTRA_FILES) {
592
652
  parts.push(`${rel}:${await fileFingerprint(resolve(cwd, rel))}`);
593
653
  }
@@ -609,13 +669,17 @@ export class WarmWorker {
609
669
  proc;
610
670
  pending = new Map();
611
671
  stdoutBuffer = "";
672
+ stdoutBufferBytes = 0;
612
673
  stderrBuffer = "";
613
674
  nextId = 0;
614
675
  closed = false;
615
676
  constructor(child, cwd, fingerprint, label) {
616
677
  this.fingerprint = fingerprint;
617
678
  this.label = label;
618
- this.proc = Bun.spawn(["bun", childPath(child), cwd, "--worker"], {
679
+ this.proc = Bun.spawn([process.execPath, childPath(child), cwd, "--worker"], {
680
+ cwd,
681
+ // Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
682
+ env: process.env,
619
683
  stdin: "pipe",
620
684
  stdout: "pipe",
621
685
  stderr: "pipe",
@@ -641,7 +705,20 @@ export class WarmWorker {
641
705
  throw new Error(`warm ${this.label} worker is closed`);
642
706
  if (signal?.aborted)
643
707
  return `${this.label} cancelled before it started.`;
708
+ if (this.pending.size >= MAX_WARM_PENDING)
709
+ throw new Error(`warm ${this.label} worker request limit reached`);
644
710
  const id = ++this.nextId;
711
+ let encodedInput;
712
+ try {
713
+ encodedInput = JSON.stringify({ id, input });
714
+ }
715
+ catch {
716
+ throw new Error(`warm ${this.label} input is not serializable`);
717
+ }
718
+ if (new TextEncoder().encode(encodedInput).byteLength > CHILD_INPUT_MAX_BYTES)
719
+ throw new Error(`warm ${this.label} input exceeded ${CHILD_INPUT_MAX_BYTES} bytes`);
720
+ if (signal?.aborted)
721
+ return `${this.label} cancelled${cancellationSuffix(signal)}.`;
645
722
  return new Promise((resolve, reject) => {
646
723
  const abort = () => {
647
724
  // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
@@ -650,7 +727,8 @@ export class WarmWorker {
650
727
  // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
651
728
  this.pending.delete(id);
652
729
  clearTimeout(timer);
653
- const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : "";
730
+ signal?.removeEventListener("abort", abort);
731
+ const reason = cancellationSuffix(signal);
654
732
  resolve(`${this.label} cancelled${reason}.`);
655
733
  };
656
734
  // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
@@ -669,8 +747,15 @@ export class WarmWorker {
669
747
  };
670
748
  signal?.addEventListener("abort", abort, { once: true });
671
749
  this.pending.set(id, { resolve, reject, cleanup });
750
+ // JSON serialization above can run user-defined `toJSON` hooks. Recheck after installing the
751
+ // listener and pending entry so an abort during that synchronous work cannot enqueue a request
752
+ // after cancellation, and so an already-aborted signal does not retain the listener.
753
+ if (signal?.aborted) {
754
+ abort();
755
+ return;
756
+ }
672
757
  try {
673
- this.proc.stdin.write(`${JSON.stringify({ id, input })}\n`);
758
+ this.proc.stdin.write(`${encodedInput}\n`);
674
759
  }
675
760
  catch (err) {
676
761
  this.pending.delete(id);
@@ -691,8 +776,10 @@ export class WarmWorker {
691
776
  this.stop();
692
777
  return;
693
778
  }
694
- this.stdoutBuffer += decoder.decode(value, { stream: true });
695
- if (this.stdoutBuffer.length > CHILD_OUTPUT_MAX_BYTES) {
779
+ const text = decoder.decode(value, { stream: true });
780
+ this.stdoutBuffer += text;
781
+ this.stdoutBufferBytes += new TextEncoder().encode(text).byteLength;
782
+ if (this.stdoutBufferBytes > CHILD_OUTPUT_MAX_BYTES) {
696
783
  this.stderrBuffer = boundedAppend(this.stderrBuffer, `warm ${this.label} worker stdout exceeded ${CHILD_OUTPUT_MAX_BYTES} bytes\n`);
697
784
  this.stop();
698
785
  return;
@@ -701,6 +788,7 @@ export class WarmWorker {
701
788
  while (nl !== -1) {
702
789
  const line = this.stdoutBuffer.slice(0, nl).trim();
703
790
  this.stdoutBuffer = this.stdoutBuffer.slice(nl + 1);
791
+ this.stdoutBufferBytes = new TextEncoder().encode(this.stdoutBuffer).byteLength;
704
792
  nl = this.stdoutBuffer.indexOf("\n");
705
793
  if (line !== "")
706
794
  this.handleLine(line);
@@ -724,18 +812,56 @@ export class WarmWorker {
724
812
  }
725
813
  catch {
726
814
  this.stderrBuffer = boundedAppend(this.stderrBuffer, `invalid warm ${this.label} worker line: ${line}\n`);
815
+ this.stop();
727
816
  return;
728
817
  }
729
- if (typeof message.id !== "number")
818
+ if (message === null ||
819
+ typeof message !== "object" ||
820
+ Array.isArray(message) ||
821
+ typeof message.id !== "number" ||
822
+ !Number.isSafeInteger(message.id)) {
823
+ this.stderrBuffer = boundedAppend(this.stderrBuffer, `invalid warm ${this.label} worker response envelope\n`);
824
+ this.stop();
730
825
  return;
731
- const pending = this.pending.get(message.id);
826
+ }
827
+ const response = message;
828
+ const pending = this.pending.get(response.id);
732
829
  if (pending === undefined)
733
830
  return;
734
- this.pending.delete(message.id);
831
+ if (!Object.hasOwn(response, "output")) {
832
+ this.pending.delete(response.id);
833
+ pending.cleanup();
834
+ pending.reject(new Error(`warm ${this.label} worker response is malformed`));
835
+ this.stop();
836
+ return;
837
+ }
838
+ let output;
839
+ try {
840
+ output = JSON.stringify(response.output, null, 2);
841
+ }
842
+ catch {
843
+ this.pending.delete(response.id);
844
+ pending.cleanup();
845
+ pending.reject(new Error(`warm ${this.label} worker response is not serializable`));
846
+ this.stop();
847
+ return;
848
+ }
849
+ if (new TextEncoder().encode(output).byteLength > CHILD_OUTPUT_MAX_BYTES) {
850
+ this.pending.delete(response.id);
851
+ pending.cleanup();
852
+ pending.reject(new Error(`warm ${this.label} worker response exceeded the size limit`));
853
+ this.stop();
854
+ return;
855
+ }
856
+ this.pending.delete(response.id);
735
857
  pending.cleanup();
736
- pending.resolve(JSON.stringify(message.output, null, 2));
858
+ pending.resolve(output);
737
859
  }
738
860
  }
861
+ function cancellationSuffix(signal) {
862
+ const reason = signal?.reason;
863
+ return typeof reason === "string" && reason.length > 0 ? `: ${reason.slice(0, 512)}` : "";
864
+ }
739
865
  /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
740
866
  * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
741
867
  * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
@@ -744,18 +870,23 @@ export function createWarmHandler(child, cwd, label) {
744
870
  let worker;
745
871
  return async (input, signal) => {
746
872
  const fingerprint = await warmRunFingerprint(cwd);
747
- if (worker === undefined || worker.fingerprint !== fingerprint) {
748
- worker?.stop();
749
- worker = new WarmWorker(child, cwd, fingerprint, label);
873
+ let current = worker;
874
+ if (current === undefined || current.fingerprint !== fingerprint) {
875
+ current?.stop();
876
+ current = new WarmWorker(child, cwd, fingerprint, label);
877
+ worker = current;
750
878
  }
751
879
  try {
752
- return await worker.request(input, signal);
880
+ return await current.request(input, signal);
753
881
  }
754
882
  catch {
755
- worker.stop();
756
- worker = undefined;
883
+ // A source change can replace the shared worker while this request is in flight. Only clear
884
+ // the slot if this request still owns it; an old request must never tear down the replacement.
885
+ current.stop();
886
+ if (worker === current)
887
+ worker = undefined;
757
888
  if (signal?.aborted) {
758
- const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : "";
889
+ const reason = cancellationSuffix(signal);
759
890
  return `${label} cancelled${reason}.`;
760
891
  }
761
892
  return spawnChild(child, cwd, input, label, signal);
@@ -794,7 +925,7 @@ export async function renderHandler(cwd, args, context, warmRender) {
794
925
  export async function wsHandler(cwd, args, context) {
795
926
  return spawnChild("mcp-ws", cwd, args, "websocket", context.signal);
796
927
  }
797
- const APP_FINGERPRINT_FILES = ["nifra.config.ts", "framework.ts", "backend.ts"];
928
+ const APP_FINGERPRINT_FILES = [CONFIG_FILE, FRAMEWORK_FILE, BACKEND_APP_FILE];
798
929
  function cacheToken(input) {
799
930
  let hash = 2166136261;
800
931
  for (let i = 0; i < input.length; i++) {
@@ -815,7 +946,8 @@ async function fileFingerprint(path) {
815
946
  throw err;
816
947
  }
817
948
  }
818
- async function appFingerprint(cwd) {
949
+ /** `mtime:size` of the app's config, framework and backend entry files: what notices an edit. */
950
+ export async function appFingerprint(cwd) {
819
951
  return (await Promise.all(APP_FINGERPRINT_FILES.map(async (file) => `${file}:${await fileFingerprint(resolve(cwd, file))}`))).join("|");
820
952
  }
821
953
  /** Cache LoadedApp inside one MCP server process, invalidating when config/backend mtimes change. */