@nifrajs/cli 3.5.0 → 4.0.2

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 (404) 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 +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Finds the running `nifra dev` server a tool should read, and reads its agent feed.
3
+ *
4
+ * A dev server writes `.nifra/dev-server.json` (owner-only) with its port and a per-run token. A
5
+ * record alone proves nothing - the process may have died and the port been reused - so a server
6
+ * counts as live only after it answers the identity endpoint with the record's own pid and root.
7
+ */
8
+
9
+ import { existsSync, readdirSync } from "node:fs"
10
+ import { join, resolve } from "node:path"
11
+ import {
12
+ DEV_FEED_HEADER,
13
+ DEV_FEED_PATHS,
14
+ DEV_SERVER_LOG_FILE,
15
+ DEV_SERVER_RECORD_FILE,
16
+ DEV_TOKEN_HEADER,
17
+ type DevPipeline,
18
+ type DevServerIdentity,
19
+ type DevServerRecord,
20
+ isProcessAlive,
21
+ readDevServerRecord,
22
+ } from "@nifrajs/web/dev-feed"
23
+ import { LOCAL_TOOL_FETCH_TIMEOUT_MS, readBoundedResponse, validateLocalPort } from "./mcp-io.ts"
24
+
25
+ export interface LiveDevServer {
26
+ readonly root: string
27
+ readonly port: number
28
+ readonly origin: string
29
+ readonly pipeline: DevPipeline
30
+ readonly pid: number
31
+ readonly startedAt: string
32
+ readonly token: string
33
+ readonly identity: DevServerIdentity
34
+ }
35
+
36
+ /** A dev server whose record survived it: it crashed or was killed before it could clean up. */
37
+ export interface DeadDevServer {
38
+ readonly root: string
39
+ readonly port: number
40
+ readonly pid: number
41
+ readonly startedAt: string
42
+ }
43
+
44
+ export type DevServerLookup =
45
+ | { readonly status: "live"; readonly server: LiveDevServer }
46
+ /** An explicit port no record under the project names: reachable only through token-free paths. */
47
+ | { readonly status: "unverified"; readonly port: number; readonly origin: string }
48
+ | {
49
+ readonly status: "ambiguous"
50
+ readonly servers: readonly Pick<LiveDevServer, "root" | "port" | "pipeline">[]
51
+ }
52
+ | {
53
+ readonly status: "down"
54
+ /** Project roots holding a persisted feed from an earlier run, nearest first. */
55
+ readonly persisted: readonly string[]
56
+ readonly dead: readonly DeadDevServer[]
57
+ /** Servers whose record and process exist but which did not answer as themselves. */
58
+ readonly unreachable: readonly {
59
+ readonly root: string
60
+ readonly port: number
61
+ readonly reason: string
62
+ }[]
63
+ }
64
+ | { readonly status: "invalid-port" }
65
+
66
+ export interface FindDevServerOptions {
67
+ readonly port?: number | undefined
68
+ }
69
+
70
+ // Where a workspace keeps its apps. One level only: the scan runs on every tool call.
71
+ const SCAN_PARENTS = [".", "apps", "packages", "examples"] as const
72
+ const MAX_SCANNED_ROOTS = 512
73
+
74
+ function candidateRoots(cwd: string): string[] {
75
+ const roots = [cwd]
76
+ for (const parent of SCAN_PARENTS) {
77
+ const base = join(cwd, parent)
78
+ let names: string[]
79
+ try {
80
+ names = readdirSync(base, { withFileTypes: true })
81
+ .filter((entry) => entry.isDirectory())
82
+ .map((entry) => entry.name)
83
+ .filter((name) => !name.startsWith(".") && name !== "node_modules")
84
+ .sort()
85
+ } catch {
86
+ continue
87
+ }
88
+ for (const name of names) {
89
+ if (roots.length >= MAX_SCANNED_ROOTS) return roots
90
+ const root = join(base, name)
91
+ if (!roots.includes(root)) roots.push(root)
92
+ }
93
+ }
94
+ return roots
95
+ }
96
+
97
+ const originOf = (port: number): string => `http://127.0.0.1:${port}`
98
+
99
+ function isIdentity(value: unknown): value is DevServerIdentity {
100
+ if (typeof value !== "object" || value === null) return false
101
+ return (
102
+ "pid" in value &&
103
+ typeof value.pid === "number" &&
104
+ "root" in value &&
105
+ typeof value.root === "string" &&
106
+ "port" in value &&
107
+ typeof value.port === "number" &&
108
+ "cursor" in value &&
109
+ typeof value.cursor === "number"
110
+ )
111
+ }
112
+
113
+ type Verified =
114
+ | { readonly ok: true; readonly server: LiveDevServer }
115
+ | { readonly ok: false; readonly reason: string }
116
+
117
+ async function verify(record: DevServerRecord, signal?: AbortSignal): Promise<Verified> {
118
+ const origin = originOf(record.port)
119
+ let body: unknown
120
+ try {
121
+ const response = await fetch(`${origin}${DEV_FEED_PATHS.identity}`, {
122
+ headers: { [DEV_TOKEN_HEADER]: record.token },
123
+ signal: anySignal(signal),
124
+ })
125
+ if (response.headers.get(DEV_FEED_HEADER) !== "true")
126
+ return { ok: false, reason: `port ${record.port} is not a nifra dev server` }
127
+ if (!response.ok) return { ok: false, reason: `identity check returned ${response.status}` }
128
+ body = JSON.parse(await readBoundedResponse(response))
129
+ } catch (cause) {
130
+ return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) }
131
+ }
132
+ // A recycled port can host a different nifra server; its pid and root give it away.
133
+ if (!isIdentity(body) || body.pid !== record.pid || resolve(body.root) !== resolve(record.root))
134
+ return { ok: false, reason: `port ${record.port} now belongs to another server` }
135
+ return {
136
+ ok: true,
137
+ server: {
138
+ root: record.root,
139
+ port: record.port,
140
+ origin,
141
+ pipeline: record.pipeline,
142
+ pid: record.pid,
143
+ startedAt: record.startedAt,
144
+ token: record.token,
145
+ identity: body,
146
+ },
147
+ }
148
+ }
149
+
150
+ function anySignal(signal: AbortSignal | undefined): AbortSignal {
151
+ const timeout = AbortSignal.timeout(LOCAL_TOOL_FETCH_TIMEOUT_MS)
152
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout])
153
+ }
154
+
155
+ /**
156
+ * The dev server for the project at `cwd`: its own record first, else the one live server among the
157
+ * workspace's apps. An explicit `port` picks among them, and still works when no record names it.
158
+ */
159
+ export async function findDevServer(
160
+ cwd: string,
161
+ options: FindDevServerOptions = {},
162
+ signal?: AbortSignal,
163
+ ): Promise<DevServerLookup> {
164
+ let port: number | undefined
165
+ if (options.port !== undefined) {
166
+ port = validateLocalPort(options.port)
167
+ if (port === undefined) return { status: "invalid-port" }
168
+ }
169
+ // The project's own live server wins outright, whatever else the workspace runs.
170
+ const own = readDevServerRecord(cwd)
171
+ if (own !== undefined && (port === undefined || own.port === port) && isProcessAlive(own.pid)) {
172
+ const result = await verify(own, signal)
173
+ if (result.ok) return { status: "live", server: result.server }
174
+ }
175
+ const roots = candidateRoots(cwd)
176
+ const records: DevServerRecord[] = []
177
+ const dead: DeadDevServer[] = []
178
+ for (const root of roots) {
179
+ if (!existsSync(join(root, DEV_SERVER_RECORD_FILE))) continue
180
+ const record = readDevServerRecord(root)
181
+ if (record === undefined) continue
182
+ if (port !== undefined && record.port !== port) continue
183
+ if (isProcessAlive(record.pid)) records.push(record)
184
+ else dead.push({ root, port: record.port, pid: record.pid, startedAt: record.startedAt })
185
+ }
186
+ const checked = await Promise.all(records.map((record) => verify(record, signal)))
187
+ const live: LiveDevServer[] = []
188
+ const unreachable: { root: string; port: number; reason: string }[] = []
189
+ checked.forEach((result, index) => {
190
+ const record = records[index]
191
+ if (record === undefined) return
192
+ if (result.ok) live.push(result.server)
193
+ else unreachable.push({ root: record.root, port: record.port, reason: result.reason })
194
+ })
195
+ const [first] = live
196
+ if (first !== undefined && live.length === 1) return { status: "live", server: first }
197
+ if (live.length > 1) {
198
+ return {
199
+ status: "ambiguous",
200
+ servers: live.map(({ root, port: livePort, pipeline }) => ({
201
+ root,
202
+ port: livePort,
203
+ pipeline,
204
+ })),
205
+ }
206
+ }
207
+ if (port !== undefined && unreachable.length === 0)
208
+ return { status: "unverified", port, origin: originOf(port) }
209
+ const persisted = roots.filter((root) => existsSync(join(root, DEV_SERVER_LOG_FILE)))
210
+ return { status: "down", persisted, dead, unreachable }
211
+ }
212
+
213
+ export type FeedQuery = Readonly<
214
+ Record<string, string | number | boolean | readonly string[] | undefined>
215
+ >
216
+
217
+ /** GET one feed endpoint as the authenticated agent. Throws when the server does not answer with JSON. */
218
+ export async function readDevFeed(
219
+ server: LiveDevServer,
220
+ path: string,
221
+ query: FeedQuery = {},
222
+ signal?: AbortSignal,
223
+ ): Promise<unknown> {
224
+ const url = new URL(path, server.origin)
225
+ for (const [name, value] of Object.entries(query)) {
226
+ if (value === undefined) continue
227
+ url.searchParams.set(name, Array.isArray(value) ? value.join(",") : String(value))
228
+ }
229
+ const response = await fetch(url, {
230
+ headers: { [DEV_TOKEN_HEADER]: server.token },
231
+ signal: anySignal(signal),
232
+ })
233
+ if (response.headers.get(DEV_FEED_HEADER) !== "true")
234
+ throw new Error(`${server.origin} did not answer as a nifra dev server`)
235
+ const text = await readBoundedResponse(response)
236
+ if (!response.ok) throw new Error(`${path} returned ${response.status}: ${text.slice(0, 200)}`)
237
+ return JSON.parse(text)
238
+ }
239
+
240
+ /** One line telling a caller how to get past a lookup that did not yield a single live server. */
241
+ export function describeLookup(
242
+ lookup: Exclude<DevServerLookup, { status: "live" }>,
243
+ cwd: string,
244
+ ): string {
245
+ switch (lookup.status) {
246
+ case "invalid-port":
247
+ return "port must be an integer from 1 to 65535."
248
+ case "unverified":
249
+ return `No ${DEV_SERVER_RECORD_FILE} under ${cwd} names port ${lookup.port}. Pass \`dir\` (the project that server runs) so its token can be read.`
250
+ case "ambiguous":
251
+ return `${lookup.servers.length} dev servers are running: ${lookup.servers.map((s) => `${s.root} (:${s.port})`).join(", ")}. Pass \`dir\` or \`port\` to pick one.`
252
+ case "down": {
253
+ const parts: string[] = []
254
+ for (const server of lookup.dead)
255
+ parts.push(
256
+ `the dev server for ${server.root} (:${server.port}, pid ${server.pid}) is gone without cleaning up - it crashed or was killed`,
257
+ )
258
+ for (const server of lookup.unreachable)
259
+ parts.push(
260
+ `the dev server for ${server.root} (:${server.port}) did not answer: ${server.reason}`,
261
+ )
262
+ if (parts.length === 0)
263
+ parts.push(`no nifra dev server is running for ${cwd}; start one with \`nifra dev\``)
264
+ return `${parts.join("; ")}.`
265
+ }
266
+ }
267
+ }
package/src/diff-tool.ts CHANGED
@@ -8,9 +8,9 @@
8
8
  * The CI loop: commit a snapshot on main, run `nifra diff api-snapshot.json` on every PR, and an
9
9
  * accidental contract break (removed route, new required field, retyped response) fails the build
10
10
  * before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
11
- * this module only loads `backend.ts` and renders the result.
11
+ * this module only loads `backend/app.ts` and renders the result.
12
12
  *
13
- * Loads ONLY `backend.ts` - the API contract - so it works on an API-only project with no
13
+ * Loads ONLY `backend/app.ts` - the API contract - so it works on an API-only project with no
14
14
  * framework config or routes/ directory (unlike the eager `loadApp`).
15
15
  */
16
16
 
@@ -23,6 +23,7 @@ import {
23
23
  type RoutesDiff,
24
24
  snapshotRoutes,
25
25
  } from "@nifrajs/core/diff"
26
+ import { BACKEND_APP_FILE } from "./app-files.ts"
26
27
 
27
28
  /** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
28
29
  export interface SnapshotFile {
@@ -81,12 +82,12 @@ export function formatDiff(diff: RoutesDiff): string {
81
82
  return `${lines.join("\n")}\n\n${summary}`
82
83
  }
83
84
 
84
- /** Import `backend.ts` from `cwd` and snapshot its routes. */
85
+ /** Import `backend/app.ts` from `cwd` and snapshot its routes. */
85
86
  export async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
86
- const backendPath = resolve(cwd, "backend.ts")
87
+ const backendPath = resolve(cwd, BACKEND_APP_FILE)
87
88
  if (!existsSync(backendPath)) {
88
89
  throw new Error(
89
- `[nifra] no backend.ts in ${cwd} - \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
90
+ `[nifra] no backend/app.ts in ${cwd} - \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend/app.ts.`,
90
91
  )
91
92
  }
92
93
  const backend = ((await import(backendPath)) as { backend?: unknown }).backend
@@ -10,7 +10,7 @@
10
10
  * embedding infra would be complexity without a payoff.
11
11
  */
12
12
 
13
- import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-terms.ts"
13
+ import { countBodyHits, queryTermGroups, tokenize, tokenSetScore } from "./search-terms.ts"
14
14
 
15
15
  export interface DocSection {
16
16
  readonly heading: string
@@ -83,10 +83,17 @@ export function searchSections(
83
83
  const prepared =
84
84
  "headingTokens" in section && "bodyLower" in section ? section : prepareSection(section)
85
85
  let score = 0
86
+ let matchedTerms = 0
86
87
  for (const term of terms) {
87
- if (tokenSetHas(prepared.headingTokens, term)) score += 8
88
- score += countBodyHits(prepared.bodyLower, term, 5)
88
+ const headingScore = tokenSetScore(prepared.headingTokens, term)
89
+ if (headingScore > 0) score += 8 + headingScore * 12
90
+ const bodyHits = countBodyHits(prepared.bodyLower, term, 5)
91
+ if (headingScore > 0 || bodyHits > 0) matchedTerms += 1
92
+ score += bodyHits
89
93
  }
94
+ // A result that matches every query concept should beat a section that only happens to repeat
95
+ // one highly frequent word, even when that single word appears in a curated heading.
96
+ score += matchedTerms * 26
90
97
  if (score > 0) scored.push({ heading: section.heading, body: section.body, score })
91
98
  }
92
99
  return scored.sort((a, b) => b.score - a.score).slice(0, limit)
package/src/doctor.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import type { Dirent } from "node:fs"
20
20
  import { readdir, stat } from "node:fs/promises"
21
21
  import { builtinModules } from "node:module"
22
- import { dirname, join, relative, sep } from "node:path"
22
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
23
23
  import {
24
24
  collectIdentityParity,
25
25
  displayPath,
@@ -29,7 +29,9 @@ import {
29
29
  resolvedInstalledCopy,
30
30
  } from "@nifrajs/web/internal/parity"
31
31
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
32
- import { detectToolingDrift, type ToolingDrift } from "./mcp-root.ts"
32
+ import { collectStaleMcpPins, type StaleMcpPin } from "./init-agents.ts"
33
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
34
+ import { detectToolingDrift, syncMcpCommand, type ToolingDrift } from "./mcp-root.ts"
33
35
  import { collectPipelineReport, type PipelineReport } from "./pipeline-report.ts"
34
36
  import { type ResolvedTarget, resolveTarget } from "./port.ts"
35
37
  import { buildScriptName } from "./workspace-link.ts"
@@ -46,8 +48,10 @@ const BUILTINS: ReadonlySet<string> = new Set<string>([
46
48
  // import, and CJS require. Anchored with `(?<![.\w$])` so `myimport`/`.import`/`foorequire` never match.
47
49
  // Comments are stripped before these run (see stripComments) - else a doc-comment usage example would be
48
50
  // flagged as a real import.
51
+ // The clause before `from` is spelled out rather than scanned lazily: a lazy scan restarts at every
52
+ // `export const` line and is quadratic on a long module with no string literal.
49
53
  const IMPORT_PATTERNS: readonly RegExp[] = [
50
- /(?<![.\w$])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g,
54
+ /(?<![.\w$])(?:import|export)\b\s*(?:type\b\s*)?(?:[\w$]+\s*,?\s*)?(?:\*\s*(?:as\b\s*[\w$]+\s*)?|\{[^{}'"]*\}\s*)?from\s*['"]([^'"]+)['"]/g,
51
55
  /(?<![.\w$])import\s+['"]([^'"]+)['"]/g,
52
56
  /(?<![.\w$])import\s*\(\s*['"]([^'"]+)['"]/g,
53
57
  /(?<![.\w$])require\s*\(\s*['"]([^'"]+)['"]/g,
@@ -126,7 +130,7 @@ export function scanUndeclaredImports(
126
130
  out.push({ file, line, snippet: pkg })
127
131
  }
128
132
  }
129
- return out.sort((a, b) => a.line - b.line || a.snippet.localeCompare(b.snippet))
133
+ return out.sort((a, b) => a.line - b.line || codeUnitOrder(a.snippet, b.snippet))
130
134
  }
131
135
 
132
136
  export interface DoctorFinding {
@@ -174,8 +178,16 @@ export interface DoctorResult {
174
178
  * supplied and the feature versions disagree. Computed by {@link detectToolingDrift}.
175
179
  */
176
180
  readonly toolingDrift?: ToolingDrift
181
+ /**
182
+ * Agent files whose MCP launch pins an `@nifrajs/cli` other than the one the project installs, so
183
+ * the agent's server answers for a release the code no longer builds with. Advisory (never folded
184
+ * into `ok`). Present only when at least one file is stale. Computed by {@link collectStaleMcpPins}.
185
+ */
186
+ readonly staleMcpPins?: { readonly target: string; readonly files: readonly StaleMcpPin[] }
177
187
  /** Static production-readiness evidence for the selected deploy target. */
178
188
  readonly readiness?: DoctorReadiness
189
+ /** The explicit source boundary used by the dependency scan. */
190
+ readonly scanScope?: DoctorScanScope
179
191
  /**
180
192
  * Which bundler this app's `dev`/`build` phases run on, read statically, plus the config hazards
181
193
  * that exist only because there are two. Absent when the directory is not a nifra app.
@@ -191,6 +203,12 @@ export interface DoctorResult {
191
203
  readonly skippedFixes?: readonly DoctorSkippedFix[]
192
204
  }
193
205
 
206
+ export interface DoctorScanScope {
207
+ readonly kind: "standalone" | "workspace-production"
208
+ readonly includeTests: boolean
209
+ readonly excluded: readonly string[]
210
+ }
211
+
194
212
  export type DoctorReadinessStatus = "configured" | "absent" | "not-applicable"
195
213
 
196
214
  export interface DoctorReadinessItem {
@@ -577,7 +595,7 @@ export async function collectStaleWorkspaceDists(
577
595
  if (worst !== undefined) findings.push(worst)
578
596
  }
579
597
  }
580
- return findings.sort((a, b) => a.package.localeCompare(b.package))
598
+ return findings.sort((a, b) => codeUnitOrder(a.package, b.package))
581
599
  }
582
600
 
583
601
  interface ReadinessSource {
@@ -615,7 +633,7 @@ async function collectDoctorReadiness(
615
633
  await walkSource(cwd, (file, source) => {
616
634
  files.push({ file, source, code: codePositionMask(source) })
617
635
  })
618
- const edgeTarget = /^(?:cf-pages|vercel|workers|edge|static)$/.test(resolved?.target ?? "")
636
+ const edgeTarget = /^(?:cloudflare|vercel|workers|edge|static)$/.test(resolved?.target ?? "")
619
637
  const configured = (
620
638
  id: DoctorReadinessItem["id"],
621
639
  label: string,
@@ -705,13 +723,27 @@ async function ancestorDependencySpec(cwd: string, name: string): Promise<string
705
723
  const pkg = await readJson(join(dir, "package.json"))
706
724
  if (pkg !== undefined) {
707
725
  const spec = dependencySpec(pkg, name)
708
- if (spec !== undefined) return spec
726
+ if (spec !== undefined) return rebaseDependencySpec(spec, dir, cwd)
709
727
  }
710
728
  const parent = dirname(dir)
711
729
  if (parent === dir) return undefined
712
730
  }
713
731
  }
714
732
 
733
+ /**
734
+ * A path spec (`file:packages/x`, `link:../x`, `./vendor/x.tgz`) resolves against the package.json
735
+ * that declares it, so a spec copied from an ancestor is re-pointed to the same target from `cwd`.
736
+ */
737
+ function rebaseDependencySpec(spec: string, declaredIn: string, cwd: string): string {
738
+ const protocol = /^(?:file|link|portal):/.exec(spec)?.[0] ?? ""
739
+ const path = spec.slice(protocol.length)
740
+ const relativePath =
741
+ protocol === "" ? /^\.\.?(?:[\\/]|$)/.test(path) : !isAbsolute(path) && !path.startsWith("~")
742
+ if (!relativePath) return spec
743
+ const rebased = relative(cwd, resolve(declaredIn, path)).replaceAll("\\", "/")
744
+ return protocol + (rebased === ".." || rebased.startsWith("../") ? rebased : `./${rebased}`)
745
+ }
746
+
715
747
  async function installedPackageSpec(cwd: string, name: string): Promise<string | undefined> {
716
748
  if (!isNpmPackageName(name)) return undefined
717
749
  const parts = name.split("/")
@@ -751,9 +783,29 @@ export async function collectDoctorResult(
751
783
  const scopes = await doctorPackageScopes(cwd, pkg)
752
784
 
753
785
  const findings: DoctorFinding[] = []
754
- // `includeTests`: tests are part of the typechecked surface, so an import they declare nowhere is a
755
- // real break. Excluding them is what let an undeclared `zod` in a `*.test.ts` pass doctor, pass a
756
- // hoisted local `tsc`, and then fail CI on a clean install with `TS2307: Cannot find module 'zod'`.
786
+ // A standalone app owns its tests, so they remain in the declaration check. A workspace root owns
787
+ // the published package source while its root devDependencies intentionally own shared tests,
788
+ // benchmarks, and scaffold copy-surfaces. Keep that boundary explicit instead of reporting a
789
+ // monorepo-wide false failure or silently dropping files from the result.
790
+ const workspaceProduction = workspacePatterns(pkg).length > 0
791
+ const scanScope: DoctorScanScope = Object.freeze({
792
+ kind: workspaceProduction ? "workspace-production" : "standalone",
793
+ includeTests: !workspaceProduction,
794
+ excluded: workspaceProduction
795
+ ? Object.freeze([
796
+ "**/test/**",
797
+ "**/test-node/**",
798
+ "bench/**",
799
+ "packages/create-nifra/template*/**",
800
+ ])
801
+ : Object.freeze([]),
802
+ })
803
+ const workspaceSurface = (rel: string): boolean =>
804
+ workspaceProduction &&
805
+ (/(^|\/)(?:test|test-node)\//.test(rel) ||
806
+ rel === "bench" ||
807
+ rel.startsWith("bench/") ||
808
+ rel.startsWith("packages/create-nifra/template"))
757
809
  await walkSource(
758
810
  cwd,
759
811
  (rel, content) => {
@@ -762,9 +814,9 @@ export async function collectDoctorResult(
762
814
  findings.push({ file: f.file, line: f.line, package: f.snippet })
763
815
  }
764
816
  },
765
- { includeTests: true },
817
+ { includeTests: scanScope.includeTests, ignore: workspaceSurface },
766
818
  )
767
- findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)
819
+ findings.sort((a, b) => codeUnitOrder(a.file, b.file) || a.line - b.line)
768
820
  const identity = await collectAllDuplicateInstalls(cwd, pkg)
769
821
  const duplicateInstalls = identity.duplicates
770
822
  const staleDists = await collectStaleWorkspaceDists(cwd, pkg)
@@ -776,7 +828,8 @@ export async function collectDoctorResult(
776
828
  // pass its own version (e.g. the MCP server, which already annotates every result with the drift).
777
829
  const toolingDrift =
778
830
  opts.cliVersion !== undefined ? await detectToolingDrift(cwd, opts.cliVersion) : undefined
779
- // `staleDists` and `toolingDrift` are advisory (see DoctorResult): they never fail `ok`.
831
+ const staleMcpPins = await collectStaleMcpPins(cwd).catch(() => undefined)
832
+ // `staleDists`, `toolingDrift` and `staleMcpPins` are advisory (see DoctorResult): they never fail `ok`.
780
833
  return {
781
834
  ok:
782
835
  findings.length === 0 &&
@@ -791,7 +844,9 @@ export async function collectDoctorResult(
791
844
  ...(identity.truncated ? { identityScanTruncated: true } : {}),
792
845
  staleDists,
793
846
  readiness,
847
+ scanScope,
794
848
  ...(toolingDrift !== undefined ? { toolingDrift } : {}),
849
+ ...(staleMcpPins !== undefined && staleMcpPins.files.length > 0 ? { staleMcpPins } : {}),
795
850
  ...(pipeline.ran ? { pipeline } : {}),
796
851
  }
797
852
  }
@@ -817,7 +872,7 @@ export async function applyDoctorAutoFix(
817
872
 
818
873
  const fixed: DoctorAppliedFix[] = []
819
874
  const skippedFixes: DoctorSkippedFix[] = []
820
- for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => a.localeCompare(b))) {
875
+ for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => codeUnitOrder(a, b))) {
821
876
  const pkgPath = join(root, "package.json")
822
877
  const pkg = await readJson(pkgPath)
823
878
  if (pkg === undefined) continue
@@ -922,6 +977,14 @@ export async function runDoctor(
922
977
  console.log(` fix: ${f.fix}\n`)
923
978
  }
924
979
  }
980
+ if (result.scanScope !== undefined) {
981
+ console.log(
982
+ `• dependency scan scope: ${result.scanScope.kind} (${result.scanScope.includeTests ? "includes tests" : "production package source only"})`,
983
+ )
984
+ if (result.scanScope.excluded.length > 0)
985
+ console.log(` excluded: ${result.scanScope.excluded.join(", ")}`)
986
+ console.log("")
987
+ }
925
988
  // Advisory, printed regardless of ok: a stale linked dist doesn't fail doctor, but silently eating
926
989
  // it costs an hour of misdiagnosis when the dev server 500s inside the package.
927
990
  if (result.staleDists.length > 0) {
@@ -963,10 +1026,28 @@ export async function runDoctor(
963
1026
  "its types, checks, and docs may describe a different version than your code builds with.",
964
1027
  )
965
1028
  console.log(
966
- " fix: run the project's own CLI (`bunx --bun nifra doctor` from the project directory, " +
1029
+ ` fix: \`${syncMcpCommand(drift.cli)}\` re-pins the project's MCP launch to the nifra it installs; ` +
1030
+ "for the CLI itself, run the project's own (`bunx --bun nifra doctor` from the project directory, " +
967
1031
  "or ./node_modules/.bin/nifra)\n",
968
1032
  )
969
1033
  }
1034
+ // Advisory: the agent's MCP server is launched from these pins, so a stale one answers for an old
1035
+ // release even when the CLI running doctor matches the project.
1036
+ if (result.staleMcpPins !== undefined) {
1037
+ const { target, files } = result.staleMcpPins
1038
+ console.log(
1039
+ `⚠ the MCP launch pins a nifra other than the project's ${target} - an agent's nifra server answers for that release:`,
1040
+ )
1041
+ for (const file of files)
1042
+ console.log(` ${file.path}: @nifrajs/cli@${file.pinned.join(", ")}`)
1043
+ const command =
1044
+ opts.cliVersion !== undefined
1045
+ ? syncMcpCommand(opts.cliVersion)
1046
+ : "nifra init-agents --sync-mcp"
1047
+ console.log(
1048
+ ` fix: \`${command}\` rewrites only the pinned version, then restart the agent\n`,
1049
+ )
1050
+ }
970
1051
  if (result.ok) {
971
1052
  console.log(
972
1053
  "✓ every imported package is declared and identity-sensitive installs are deduplicated",
@@ -1007,6 +1088,8 @@ export async function runDoctor(
1007
1088
  console.log(` ${copy.version} at ${copy.path} ← ${copy.importers.join(", ")}`)
1008
1089
  }
1009
1090
  console.log(` ${finding.explanation}`)
1091
+ // A planted symlink is the cause no reinstall here can see, so it is named before the fix.
1092
+ if (finding.provenance !== undefined) console.log(` links: ${finding.provenance}`)
1010
1093
  // The shape of the split decides which fix can work, so it is printed above the fix itself.
1011
1094
  if (finding.topology !== undefined) console.log(` topology: ${finding.topology}`)
1012
1095
  // Why a copy this directory never imports still counts. Without it, a workspace-wide answer
package/src/env-file.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * - A missing file is an error, never a silent no-op. `--env-file .env.prod` that quietly did nothing
16
16
  * would make the command look like it verified an environment it never loaded.
17
17
  */
18
- import { readFile } from "node:fs/promises"
18
+ import { readdir, readFile } from "node:fs/promises"
19
19
  import { resolve } from "node:path"
20
20
 
21
21
  const ENV_FILE_FLAG = "--env-file"
@@ -119,3 +119,80 @@ export async function applyEnvFiles(
119
119
  }
120
120
  return applied
121
121
  }
122
+
123
+ /**
124
+ * Remove from `process.env` what Bun loaded from the `.env` files in `cwd` when this process started,
125
+ * keeping every variable the environment itself set. Returns the names removed. `nifra mcp` runs it
126
+ * first and, when anything was removed, serves from a `bun --no-env-file` copy of itself given the
127
+ * result: Bun hands the values it loaded to every subprocess whatever `process.env` says later. The
128
+ * server is long-lived and runs no project code, so it holds none of the project's `.env`; the project
129
+ * subprocesses it starts load those files again themselves.
130
+ *
131
+ * Bun applies the files under the environment (a variable already set wins) and expands `${NAME}`
132
+ * references, so Bun itself says which values came from a file: a subprocess with an empty
133
+ * environment reports what the files add to it, and a variable with that value is removed. A value that
134
+ * differs is asked again with the rest of the environment, for an expansion that read it; one that
135
+ * still differs was set by the environment, and stays.
136
+ */
137
+ export async function forgetAutoLoadedEnv(cwd = process.cwd()): Promise<readonly string[]> {
138
+ const names = await readdir(cwd).catch(() => [])
139
+ if (!names.some((name) => name.startsWith(".env"))) return []
140
+ const mode = process.env.NODE_ENV
141
+ const fromFiles = await envFromFiles(cwd, mode === undefined ? {} : { NODE_ENV: mode })
142
+ const removed: string[] = []
143
+ const differing: string[] = []
144
+ for (const [name, value] of Object.entries(fromFiles)) {
145
+ if (name === "NODE_ENV" || process.env[name] === undefined) continue
146
+ if (process.env[name] === value) removed.push(name)
147
+ else differing.push(name)
148
+ }
149
+ if (differing.length > 0) {
150
+ const rest = { ...process.env }
151
+ for (const name of differing) delete rest[name]
152
+ const expanded = await envFromFiles(cwd, rest)
153
+ for (const name of differing) if (process.env[name] === expanded[name]) removed.push(name)
154
+ }
155
+ for (const name of removed) delete process.env[name]
156
+ return removed
157
+ }
158
+
159
+ /** What the `.env` files in `cwd` add to a Bun process started there with `env`. */
160
+ async function envFromFiles(
161
+ cwd: string,
162
+ env: Record<string, string | undefined>,
163
+ ): Promise<Record<string, string>> {
164
+ // A child holds more than `env`: Windows copies PATH, SYSTEMROOT and a few more from the parent,
165
+ // and a bunfig preload can set others. A `--no-env-file` copy of the child holds those as well.
166
+ const [loaded, bare] = await Promise.all([
167
+ childEnv(cwd, env, []),
168
+ childEnv(cwd, env, ["--no-env-file"]),
169
+ ])
170
+ const added: Record<string, string> = {}
171
+ for (const [name, value] of Object.entries(loaded)) if (bare[name] !== value) added[name] = value
172
+ return added
173
+ }
174
+
175
+ /** What `process.env` holds in a Bun process started in `cwd` with `env` and the runtime `flags`. */
176
+ async function childEnv(
177
+ cwd: string,
178
+ env: Record<string, string | undefined>,
179
+ flags: readonly string[],
180
+ ): Promise<Record<string, string>> {
181
+ const proc = Bun.spawn(
182
+ [process.execPath, ...flags, "-e", "process.stdout.write(JSON.stringify(process.env))"],
183
+ { cwd, env, stdin: "ignore", stdout: "pipe", stderr: "ignore" },
184
+ )
185
+ const [text] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
186
+ let value: unknown
187
+ try {
188
+ value = JSON.parse(text)
189
+ } catch {
190
+ return {}
191
+ }
192
+ if (typeof value !== "object" || value === null) return {}
193
+ return Object.fromEntries(
194
+ Object.entries(value).filter(
195
+ (entry): entry is [string, string] => typeof entry[1] === "string",
196
+ ),
197
+ )
198
+ }