@nifrajs/cli 4.0.2 → 5.1.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 (364) hide show
  1. package/README.md +10 -2
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +9 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/assure.d.ts +12 -2
  14. package/dist/assure.d.ts.map +1 -1
  15. package/dist/assure.js +59 -8
  16. package/dist/assure.js.map +1 -1
  17. package/dist/capabilities-tool.d.ts.map +1 -1
  18. package/dist/capabilities-tool.js +36 -9
  19. package/dist/capabilities-tool.js.map +1 -1
  20. package/dist/check-diagnostics.d.ts +22 -2
  21. package/dist/check-diagnostics.d.ts.map +1 -1
  22. package/dist/check-diagnostics.js +54 -7
  23. package/dist/check-diagnostics.js.map +1 -1
  24. package/dist/check-scan.d.ts +83 -24
  25. package/dist/check-scan.d.ts.map +1 -1
  26. package/dist/check-scan.js +1516 -318
  27. package/dist/check-scan.js.map +1 -1
  28. package/dist/check.d.ts +2 -2
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +203 -70
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +9 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +215 -50
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client-module.d.ts +20 -0
  37. package/dist/client-module.d.ts.map +1 -0
  38. package/dist/client-module.js +73 -0
  39. package/dist/client-module.js.map +1 -0
  40. package/dist/command-catalog.d.ts +24 -5
  41. package/dist/command-catalog.d.ts.map +1 -1
  42. package/dist/command-catalog.js +194 -45
  43. package/dist/command-catalog.js.map +1 -1
  44. package/dist/component-check.d.ts +13 -0
  45. package/dist/component-check.d.ts.map +1 -0
  46. package/dist/component-check.js +59 -0
  47. package/dist/component-check.js.map +1 -0
  48. package/dist/contract-proof.d.ts +1 -1
  49. package/dist/contract-proof.d.ts.map +1 -1
  50. package/dist/contract-proof.js +2 -24
  51. package/dist/contract-proof.js.map +1 -1
  52. package/dist/contracts.d.ts +19 -0
  53. package/dist/contracts.d.ts.map +1 -1
  54. package/dist/contracts.js +50 -3
  55. package/dist/contracts.js.map +1 -1
  56. package/dist/db-tool.d.ts.map +1 -1
  57. package/dist/db-tool.js +4 -3
  58. package/dist/db-tool.js.map +1 -1
  59. package/dist/dev-bun-config.d.ts +5 -1
  60. package/dist/dev-bun-config.d.ts.map +1 -1
  61. package/dist/dev-bun-config.js +44 -11
  62. package/dist/dev-bun-config.js.map +1 -1
  63. package/dist/dev-feed-tool.d.ts +18 -1
  64. package/dist/dev-feed-tool.d.ts.map +1 -1
  65. package/dist/dev-feed-tool.js +93 -0
  66. package/dist/dev-feed-tool.js.map +1 -1
  67. package/dist/diagnostics.d.ts +3 -0
  68. package/dist/diagnostics.d.ts.map +1 -1
  69. package/dist/diagnostics.js.map +1 -1
  70. package/dist/diff-tool.d.ts +9 -8
  71. package/dist/diff-tool.d.ts.map +1 -1
  72. package/dist/diff-tool.js +28 -24
  73. package/dist/diff-tool.js.map +1 -1
  74. package/dist/doctor.d.ts +13 -0
  75. package/dist/doctor.d.ts.map +1 -1
  76. package/dist/doctor.js +23 -15
  77. package/dist/doctor.js.map +1 -1
  78. package/dist/env-file.d.ts +6 -0
  79. package/dist/env-file.d.ts.map +1 -1
  80. package/dist/env-file.js +6 -0
  81. package/dist/env-file.js.map +1 -1
  82. package/dist/fix-recipes.d.ts +2 -0
  83. package/dist/fix-recipes.d.ts.map +1 -1
  84. package/dist/fix-recipes.js +102 -2
  85. package/dist/fix-recipes.js.map +1 -1
  86. package/dist/hand-built-manifest.d.ts +27 -0
  87. package/dist/hand-built-manifest.d.ts.map +1 -0
  88. package/dist/hand-built-manifest.js +124 -0
  89. package/dist/hand-built-manifest.js.map +1 -0
  90. package/dist/hooks-report.d.ts +23 -0
  91. package/dist/hooks-report.d.ts.map +1 -0
  92. package/dist/hooks-report.js +104 -0
  93. package/dist/hooks-report.js.map +1 -0
  94. package/dist/init-agents.d.ts +18 -2
  95. package/dist/init-agents.d.ts.map +1 -1
  96. package/dist/init-agents.js +20 -13
  97. package/dist/init-agents.js.map +1 -1
  98. package/dist/internal/config-paths.d.ts +16 -0
  99. package/dist/internal/config-paths.d.ts.map +1 -0
  100. package/dist/internal/config-paths.js +124 -0
  101. package/dist/internal/config-paths.js.map +1 -0
  102. package/dist/internal/installed-package.d.ts +4 -0
  103. package/dist/internal/installed-package.d.ts.map +1 -0
  104. package/dist/internal/installed-package.js +17 -0
  105. package/dist/internal/installed-package.js.map +1 -0
  106. package/dist/internal/nifra-project.d.ts +41 -0
  107. package/dist/internal/nifra-project.d.ts.map +1 -0
  108. package/dist/internal/nifra-project.js +183 -0
  109. package/dist/internal/nifra-project.js.map +1 -0
  110. package/dist/internal/project-specifiers.d.ts +29 -0
  111. package/dist/internal/project-specifiers.d.ts.map +1 -0
  112. package/dist/internal/project-specifiers.js +435 -0
  113. package/dist/internal/project-specifiers.js.map +1 -0
  114. package/dist/internal/script-kind.d.ts.map +1 -1
  115. package/dist/internal/script-kind.js +2 -1
  116. package/dist/internal/script-kind.js.map +1 -1
  117. package/dist/internal/source-facts.d.ts +8 -1
  118. package/dist/internal/source-facts.d.ts.map +1 -1
  119. package/dist/internal/source-facts.js +22 -5
  120. package/dist/internal/source-facts.js.map +1 -1
  121. package/dist/internal/typescript-import.d.ts +1 -1
  122. package/dist/internal/typescript-import.d.ts.map +1 -1
  123. package/dist/internal/typescript-import.js +10 -6
  124. package/dist/internal/typescript-import.js.map +1 -1
  125. package/dist/introspect.d.ts +15 -6
  126. package/dist/introspect.d.ts.map +1 -1
  127. package/dist/introspect.js +87 -25
  128. package/dist/introspect.js.map +1 -1
  129. package/dist/levels-tool.d.ts.map +1 -1
  130. package/dist/levels-tool.js +98 -23
  131. package/dist/levels-tool.js.map +1 -1
  132. package/dist/load.d.ts +33 -10
  133. package/dist/load.d.ts.map +1 -1
  134. package/dist/load.js +60 -10
  135. package/dist/load.js.map +1 -1
  136. package/dist/mcp-context.d.ts +5 -5
  137. package/dist/mcp-context.d.ts.map +1 -1
  138. package/dist/mcp-context.js +8 -23
  139. package/dist/mcp-context.js.map +1 -1
  140. package/dist/mcp-delegate.d.ts +2 -2
  141. package/dist/mcp-delegate.d.ts.map +1 -1
  142. package/dist/mcp-delegate.js +19 -7
  143. package/dist/mcp-delegate.js.map +1 -1
  144. package/dist/mcp-exec.d.ts +13 -4
  145. package/dist/mcp-exec.d.ts.map +1 -1
  146. package/dist/mcp-exec.js +56 -14
  147. package/dist/mcp-exec.js.map +1 -1
  148. package/dist/mcp-http.js +1 -1
  149. package/dist/mcp-io.d.ts +30 -0
  150. package/dist/mcp-io.d.ts.map +1 -1
  151. package/dist/mcp-io.js +63 -0
  152. package/dist/mcp-io.js.map +1 -1
  153. package/dist/mcp-isolate.d.ts.map +1 -1
  154. package/dist/mcp-isolate.js +3 -2
  155. package/dist/mcp-isolate.js.map +1 -1
  156. package/dist/mcp-path.d.ts +2 -1
  157. package/dist/mcp-path.d.ts.map +1 -1
  158. package/dist/mcp-path.js +9 -24
  159. package/dist/mcp-path.js.map +1 -1
  160. package/dist/mcp-project-child.js +1 -1
  161. package/dist/mcp-project-child.js.map +1 -1
  162. package/dist/mcp-root.d.ts.map +1 -1
  163. package/dist/mcp-root.js +50 -10
  164. package/dist/mcp-root.js.map +1 -1
  165. package/dist/mcp-ws.d.ts.map +1 -1
  166. package/dist/mcp-ws.js +3 -1
  167. package/dist/mcp-ws.js.map +1 -1
  168. package/dist/mcp.d.ts.map +1 -1
  169. package/dist/mcp.js +48 -19
  170. package/dist/mcp.js.map +1 -1
  171. package/dist/migrate-layout.d.ts +47 -1
  172. package/dist/migrate-layout.d.ts.map +1 -1
  173. package/dist/migrate-layout.js +1972 -170
  174. package/dist/migrate-layout.js.map +1 -1
  175. package/dist/output-schemas.d.ts +128 -0
  176. package/dist/output-schemas.d.ts.map +1 -0
  177. package/dist/output-schemas.js +975 -0
  178. package/dist/output-schemas.js.map +1 -0
  179. package/dist/port.d.ts.map +1 -1
  180. package/dist/port.js +3 -2
  181. package/dist/port.js.map +1 -1
  182. package/dist/project-facts.d.ts +15 -4
  183. package/dist/project-facts.d.ts.map +1 -1
  184. package/dist/project-facts.js +1 -0
  185. package/dist/project-facts.js.map +1 -1
  186. package/dist/project-path.d.ts +6 -0
  187. package/dist/project-path.d.ts.map +1 -1
  188. package/dist/project-path.js +48 -14
  189. package/dist/project-path.js.map +1 -1
  190. package/dist/recipes/5.0.0.d.ts +11 -0
  191. package/dist/recipes/5.0.0.d.ts.map +1 -0
  192. package/dist/recipes/5.0.0.js +34 -0
  193. package/dist/recipes/5.0.0.js.map +1 -0
  194. package/dist/recipes/index.d.ts +7 -0
  195. package/dist/recipes/index.d.ts.map +1 -1
  196. package/dist/recipes/index.js +2 -0
  197. package/dist/recipes/index.js.map +1 -1
  198. package/dist/release-verification.d.ts +5 -0
  199. package/dist/release-verification.d.ts.map +1 -1
  200. package/dist/release-verification.js +14 -0
  201. package/dist/release-verification.js.map +1 -1
  202. package/dist/replay.d.ts.map +1 -1
  203. package/dist/replay.js +12 -3
  204. package/dist/replay.js.map +1 -1
  205. package/dist/review.d.ts +3 -1
  206. package/dist/review.d.ts.map +1 -1
  207. package/dist/review.js +43 -7
  208. package/dist/review.js.map +1 -1
  209. package/dist/rules/codes.d.ts +12 -0
  210. package/dist/rules/codes.d.ts.map +1 -1
  211. package/dist/rules/codes.js +12 -0
  212. package/dist/rules/codes.js.map +1 -1
  213. package/dist/rules/comment-markers.d.ts +16 -7
  214. package/dist/rules/comment-markers.d.ts.map +1 -1
  215. package/dist/rules/comment-markers.js +58 -19
  216. package/dist/rules/comment-markers.js.map +1 -1
  217. package/dist/rules/contract-visibility.d.ts +3 -0
  218. package/dist/rules/contract-visibility.d.ts.map +1 -0
  219. package/dist/rules/contract-visibility.js +337 -0
  220. package/dist/rules/contract-visibility.js.map +1 -0
  221. package/dist/rules/data-guard.d.ts +13 -0
  222. package/dist/rules/data-guard.d.ts.map +1 -1
  223. package/dist/rules/data-guard.js +356 -37
  224. package/dist/rules/data-guard.js.map +1 -1
  225. package/dist/rules/layout.d.ts +4 -0
  226. package/dist/rules/layout.d.ts.map +1 -0
  227. package/dist/rules/layout.js +19 -0
  228. package/dist/rules/layout.js.map +1 -0
  229. package/dist/rules/legacy.d.ts.map +1 -1
  230. package/dist/rules/legacy.js +178 -44
  231. package/dist/rules/legacy.js.map +1 -1
  232. package/dist/rules/pages.d.ts +50 -0
  233. package/dist/rules/pages.d.ts.map +1 -1
  234. package/dist/rules/pages.js +436 -4
  235. package/dist/rules/pages.js.map +1 -1
  236. package/dist/rules/routes.d.ts +6 -0
  237. package/dist/rules/routes.d.ts.map +1 -1
  238. package/dist/rules/routes.js +111 -16
  239. package/dist/rules/routes.js.map +1 -1
  240. package/dist/rules/secrets.js +2 -2
  241. package/dist/rules/secrets.js.map +1 -1
  242. package/dist/rules/security.d.ts +13 -1
  243. package/dist/rules/security.d.ts.map +1 -1
  244. package/dist/rules/security.js +313 -69
  245. package/dist/rules/security.js.map +1 -1
  246. package/dist/rules/zones.d.ts +13 -0
  247. package/dist/rules/zones.d.ts.map +1 -1
  248. package/dist/rules/zones.js +218 -44
  249. package/dist/rules/zones.js.map +1 -1
  250. package/dist/scaffold.d.ts.map +1 -1
  251. package/dist/scaffold.js +6 -4
  252. package/dist/scaffold.js.map +1 -1
  253. package/dist/sdk.d.ts.map +1 -1
  254. package/dist/sdk.js +14 -5
  255. package/dist/sdk.js.map +1 -1
  256. package/dist/stylex-migrate.d.ts.map +1 -1
  257. package/dist/stylex-migrate.js +290 -24
  258. package/dist/stylex-migrate.js.map +1 -1
  259. package/dist/test-tool.d.ts.map +1 -1
  260. package/dist/test-tool.js +6 -3
  261. package/dist/test-tool.js.map +1 -1
  262. package/dist/typecheck-coverage.d.ts +20 -0
  263. package/dist/typecheck-coverage.d.ts.map +1 -0
  264. package/dist/typecheck-coverage.js +129 -0
  265. package/dist/typecheck-coverage.js.map +1 -0
  266. package/dist/upgrade.d.ts +84 -0
  267. package/dist/upgrade.d.ts.map +1 -1
  268. package/dist/upgrade.js +498 -78
  269. package/dist/upgrade.js.map +1 -1
  270. package/dist/verification-plan.d.ts.map +1 -1
  271. package/dist/verification-plan.js +16 -2
  272. package/dist/verification-plan.js.map +1 -1
  273. package/dist/version-drift.d.ts +22 -0
  274. package/dist/version-drift.d.ts.map +1 -0
  275. package/dist/version-drift.js +94 -0
  276. package/dist/version-drift.js.map +1 -0
  277. package/dist/web-app-options.d.ts +1 -1
  278. package/dist/web-app-options.d.ts.map +1 -1
  279. package/dist/web-app-options.js +1 -1
  280. package/dist/web-app-options.js.map +1 -1
  281. package/dist/work-graph.d.ts.map +1 -1
  282. package/dist/work-graph.js +20 -8
  283. package/dist/work-graph.js.map +1 -1
  284. package/docs/examples.json +55 -7
  285. package/docs/llms-full.txt +2043 -222
  286. package/docs/types.json +1573 -246
  287. package/package.json +11 -12
  288. package/src/agent-files.ts +203 -0
  289. package/src/app-files.ts +34 -4
  290. package/src/assure-hydration.ts +18 -2
  291. package/src/assure.ts +88 -10
  292. package/src/capabilities-tool.ts +47 -9
  293. package/src/check-diagnostics.ts +75 -9
  294. package/src/check-scan.ts +1816 -377
  295. package/src/check.ts +258 -81
  296. package/src/cli.ts +242 -37
  297. package/src/client-module.ts +97 -0
  298. package/src/command-catalog.ts +237 -52
  299. package/src/component-check.ts +79 -0
  300. package/src/contract-proof.ts +3 -25
  301. package/src/contracts.ts +60 -3
  302. package/src/db-tool.ts +5 -2
  303. package/src/dev-bun-config.ts +51 -10
  304. package/src/dev-feed-tool.ts +112 -1
  305. package/src/diagnostics.ts +3 -0
  306. package/src/diff-tool.ts +33 -26
  307. package/src/doctor.ts +43 -21
  308. package/src/env-file.ts +7 -0
  309. package/src/fix-recipes.ts +116 -2
  310. package/src/hand-built-manifest.ts +144 -0
  311. package/src/hooks-report.ts +135 -0
  312. package/src/init-agents.ts +21 -14
  313. package/src/internal/config-paths.ts +154 -0
  314. package/src/internal/installed-package.ts +15 -0
  315. package/src/internal/nifra-project.ts +191 -0
  316. package/src/internal/project-specifiers.ts +477 -0
  317. package/src/internal/script-kind.ts +2 -1
  318. package/src/internal/source-facts.ts +48 -5
  319. package/src/internal/typescript-import.ts +15 -6
  320. package/src/introspect.ts +104 -28
  321. package/src/levels-tool.ts +124 -29
  322. package/src/load.ts +107 -17
  323. package/src/mcp-context.ts +12 -25
  324. package/src/mcp-delegate.ts +22 -10
  325. package/src/mcp-exec.ts +68 -18
  326. package/src/mcp-http.ts +1 -1
  327. package/src/mcp-io.ts +74 -0
  328. package/src/mcp-isolate.ts +9 -2
  329. package/src/mcp-path.ts +8 -22
  330. package/src/mcp-project-child.ts +1 -1
  331. package/src/mcp-root.ts +58 -8
  332. package/src/mcp-ws.ts +4 -2
  333. package/src/mcp.ts +56 -22
  334. package/src/migrate-layout.ts +2257 -176
  335. package/src/output-schemas.ts +1240 -0
  336. package/src/port.ts +3 -2
  337. package/src/project-facts.ts +17 -3
  338. package/src/project-path.ts +44 -13
  339. package/src/recipes/5.0.0.ts +35 -0
  340. package/src/recipes/index.ts +10 -0
  341. package/src/release-verification.ts +20 -0
  342. package/src/replay.ts +14 -3
  343. package/src/review.ts +44 -10
  344. package/src/rules/codes.ts +12 -0
  345. package/src/rules/comment-markers.ts +57 -21
  346. package/src/rules/contract-visibility.ts +383 -0
  347. package/src/rules/data-guard.ts +391 -42
  348. package/src/rules/layout.ts +22 -0
  349. package/src/rules/legacy.ts +206 -52
  350. package/src/rules/pages.ts +481 -4
  351. package/src/rules/routes.ts +124 -16
  352. package/src/rules/secrets.ts +2 -2
  353. package/src/rules/security.ts +391 -72
  354. package/src/rules/zones.ts +232 -42
  355. package/src/scaffold.ts +6 -4
  356. package/src/sdk.ts +14 -5
  357. package/src/stylex-migrate.ts +251 -24
  358. package/src/test-tool.ts +12 -3
  359. package/src/typecheck-coverage.ts +134 -0
  360. package/src/upgrade.ts +576 -72
  361. package/src/verification-plan.ts +33 -2
  362. package/src/version-drift.ts +118 -0
  363. package/src/web-app-options.ts +2 -2
  364. package/src/work-graph.ts +17 -12
package/src/mcp-io.ts CHANGED
@@ -16,6 +16,80 @@ export const CHILD_INPUT_MAX_BYTES = 4 * 1024 * 1024
16
16
  export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
17
17
  export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
18
18
 
19
+ /**
20
+ * On POSIX a bounded child is spawned `detached`, leading its own process group, so stopping it
21
+ * reaches every process it started. Otherwise a grandchild still holding the pipes keeps the read,
22
+ * and the tool call, open past its timeout.
23
+ */
24
+ export const OWN_PROCESS_GROUP = process.platform !== "win32"
25
+
26
+ /** Signal a child spawned with `detached: OWN_PROCESS_GROUP`: its whole group on POSIX. */
27
+ export function signalProcessGroup(
28
+ proc: { readonly pid: number; kill(signal?: NodeJS.Signals): void },
29
+ signal: NodeJS.Signals,
30
+ ): void {
31
+ try {
32
+ if (OWN_PROCESS_GROUP) process.kill(-proc.pid, signal)
33
+ else proc.kill(signal)
34
+ } catch {
35
+ // The group has already exited.
36
+ }
37
+ }
38
+
39
+ /** How long a stopped child gets to exit after SIGTERM before it is sent SIGKILL. */
40
+ export const KILL_GRACE_MS = 2_000
41
+
42
+ /** Stopping a child: `stop` sends SIGTERM, `dispose` drops the SIGKILL still pending once it is done. */
43
+ export interface EscalatingStop {
44
+ readonly stop: () => void
45
+ readonly dispose: () => void
46
+ }
47
+
48
+ /**
49
+ * Stop a child with SIGTERM, then SIGKILL once `graceMs` passes. Project code can catch SIGTERM
50
+ * (a graceful-shutdown hook waiting on a hung request), and a child that never exits keeps its
51
+ * caller waiting and outlives it. `send` delivers one signal to the child or its group.
52
+ */
53
+ export function escalatingStop(
54
+ send: (signal: NodeJS.Signals) => void,
55
+ graceMs: number = KILL_GRACE_MS,
56
+ ): EscalatingStop {
57
+ let escalation: ReturnType<typeof setTimeout> | undefined
58
+ let disposed = false
59
+ return {
60
+ stop: () => {
61
+ if (disposed) return
62
+ send("SIGTERM")
63
+ escalation ??= setTimeout(() => send("SIGKILL"), graceMs)
64
+ },
65
+ dispose: () => {
66
+ disposed = true
67
+ clearTimeout(escalation)
68
+ },
69
+ }
70
+ }
71
+
72
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
73
+
74
+ /**
75
+ * A child in its own group no longer gets the Ctrl+C the terminal sends its parent: on SIGINT,
76
+ * SIGTERM or SIGHUP run `stop`, then let this process take the signal as it would have. Returns the
77
+ * function that removes the listeners.
78
+ */
79
+ export function forwardTerminalSignals(stop: () => void): () => void {
80
+ if (!OWN_PROCESS_GROUP) return () => {}
81
+ const remove = (): void => {
82
+ for (const name of FORWARDED_SIGNALS) process.off(name, forward)
83
+ }
84
+ const forward = (signal: NodeJS.Signals): void => {
85
+ stop()
86
+ remove()
87
+ process.kill(process.pid, signal)
88
+ }
89
+ for (const name of FORWARDED_SIGNALS) process.on(name, forward)
90
+ return remove
91
+ }
92
+
19
93
  /** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
20
94
  export function validateLocalPort(port: unknown): number | undefined {
21
95
  return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
@@ -13,7 +13,13 @@ import { fileURLToPath } from "node:url"
13
13
  import { CONFIG_FILE } from "./app-files.ts"
14
14
  import type { NifraMonorepoConfig } from "./load.ts"
15
15
  import { appFingerprint } from "./mcp-exec.ts"
16
- import { CHILD_TIMEOUT_MS, readBoundedLines, readBoundedStream } from "./mcp-io.ts"
16
+ import {
17
+ CHILD_TIMEOUT_MS,
18
+ OWN_PROCESS_GROUP,
19
+ readBoundedLines,
20
+ readBoundedStream,
21
+ signalProcessGroup,
22
+ } from "./mcp-io.ts"
17
23
  import type {
18
24
  AppSurface,
19
25
  ProjectChildAnswer,
@@ -121,6 +127,7 @@ export async function askProjectChild(
121
127
  stdin: "pipe",
122
128
  stdout: "pipe",
123
129
  stderr: "pipe",
130
+ detached: OWN_PROCESS_GROUP,
124
131
  })
125
132
  } catch (error) {
126
133
  return failed(
@@ -131,7 +138,7 @@ export async function askProjectChild(
131
138
  const stop = (reason: "timeout" | "cancel"): void => {
132
139
  if (stopped !== undefined) return
133
140
  stopped = reason
134
- proc.kill("SIGKILL")
141
+ signalProcessGroup(proc, "SIGKILL")
135
142
  }
136
143
  const timer = timeoutMs === undefined ? undefined : setTimeout(() => stop("timeout"), timeoutMs)
137
144
  const onAbort = (): void => stop("cancel")
package/src/mcp-path.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { realpathSync } from "node:fs"
2
- import { basename, isAbsolute, relative, resolve } from "node:path"
2
+ import { isAbsolute, relative, resolve } from "node:path"
3
+ import { writeTargetOf } from "./project-path.ts"
3
4
 
4
5
  /**
5
6
  * Resolve an MCP path without allowing lexical or symlink escapes from the selected project.
6
7
  *
7
8
  * MCP callers are not equivalent to a local CLI user: the selected checkout is their filesystem
8
9
  * capability boundary. The nearest-existing-ancestor walk also supports output files that do not
9
- * exist yet while still canonicalizing every existing path component.
10
+ * exist yet while still canonicalizing every existing path component, and a dangling symlink is
11
+ * judged by where it points.
10
12
  */
11
13
  export function resolveMcpProjectPath(root: string, value: string): string | null {
12
14
  if (value.length === 0 || isAbsolute(value)) return null
@@ -23,26 +25,10 @@ export function resolveMcpProjectPath(root: string, value: string): string | nul
23
25
  return null
24
26
  }
25
27
 
26
- let probe = target
27
- const missing: string[] = []
28
- while (true) {
29
- try {
30
- const canonicalProbe = realpathSync(probe)
31
- const canonicalRelative = relative(canonicalRoot, canonicalProbe)
32
- if (canonicalRelative.startsWith("..") || isAbsolute(canonicalRelative)) return null
33
-
34
- const candidate = resolve(canonicalProbe, ...missing.reverse())
35
- const candidateRelative = relative(canonicalRoot, candidate)
36
- return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
37
- } catch (error) {
38
- if (!(error && typeof error === "object" && (error as { code?: string }).code === "ENOENT"))
39
- return null
40
- const parent = resolve(probe, "..")
41
- if (parent === probe) return null
42
- missing.push(basename(probe))
43
- probe = parent
44
- }
45
- }
28
+ const candidate = writeTargetOf(target)
29
+ if (candidate === undefined) return null
30
+ const candidateRelative = relative(canonicalRoot, candidate)
31
+ return candidateRelative.startsWith("..") || isAbsolute(candidateRelative) ? null : candidate
46
32
  }
47
33
 
48
34
  export function mcpProjectPathError(label: string, value: string): string {
@@ -67,7 +67,7 @@ export async function answerProjectRequest(
67
67
  }
68
68
  const loadAppCached = createCachedAppLoader(cwd)
69
69
  const backend = (): Promise<unknown> =>
70
- loadAppCached().then(
70
+ loadAppCached.backend().then(
71
71
  (app) => app.backend,
72
72
  () => undefined,
73
73
  )
package/src/mcp-root.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * Everything here is pure state + fs probes; the I/O wiring lives in `./mcp.ts`.
18
18
  */
19
19
 
20
- import { readFile } from "node:fs/promises"
20
+ import { readdir, readFile, stat } from "node:fs/promises"
21
21
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
22
22
  import { fileURLToPath } from "node:url"
23
23
 
@@ -46,6 +46,8 @@ const MAX_ROOT_PATH_LENGTH = 4_096
46
46
  /** Workspace members probed per workspace root. A monorepo larger than this is pointed at explicitly
47
47
  * (`nifra mcp <dir>`) rather than scanned on every server start. */
48
48
  const MAX_WORKSPACE_MEMBERS = 256
49
+ /** Directories one workspace probe may read while matching member patterns. */
50
+ const MAX_WORKSPACE_DIRECTORIES = 4_096
49
51
 
50
52
  /** A directory is a nifra project when its `package.json` depends on any `@nifrajs/*` package, or it
51
53
  * is a `nifra.config.ts` monorepo root. Backend-only projects (no web config, no `routes/`) count -
@@ -102,6 +104,57 @@ async function workspacePatterns(dir: string): Promise<readonly string[] | null>
102
104
  )
103
105
  }
104
106
 
107
+ /**
108
+ * The directories under `dir` that `pattern` matches and that hold a `package.json`, in name order. A
109
+ * walk rather than a glob scan: a `**` pattern would otherwise descend every `node_modules` below it,
110
+ * where a member never lives. It starts at the pattern's literal prefix, stops at the pattern's depth
111
+ * unless it has a `**`, skips dot directories, and draws on one directory budget for the whole probe.
112
+ */
113
+ async function workspaceMatches(
114
+ dir: string,
115
+ pattern: string,
116
+ budget: { directories: number },
117
+ ): Promise<string[]> {
118
+ const segments = pattern
119
+ .replace(/^\.\//, "")
120
+ .replace(/\/+$/, "")
121
+ .split("/")
122
+ .filter((segment) => segment !== "" && segment !== ".")
123
+ const firstGlob = segments.findIndex((segment) => /[*?[{]/.test(segment))
124
+ const prefix = firstGlob === -1 ? segments : segments.slice(0, firstGlob)
125
+ const depth = segments.includes("**") ? Number.POSITIVE_INFINITY : segments.length
126
+ const glob = new Bun.Glob(segments.join("/"))
127
+ const found: string[] = []
128
+ const queue: { readonly path: string; readonly depth: number }[] = [
129
+ { path: prefix.join("/"), depth: prefix.length },
130
+ ]
131
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
132
+ if (budget.directories <= 0) break
133
+ budget.directories -= 1
134
+ const absolute = join(dir, next.path)
135
+ if (next.path !== "" && glob.match(next.path) && (await isFile(join(absolute, "package.json"))))
136
+ found.push(next.path)
137
+ if (next.depth >= depth) continue
138
+ const entries = await readdir(absolute, { withFileTypes: true }).catch(() => [])
139
+ const names = entries
140
+ .filter(
141
+ (entry) =>
142
+ entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith("."),
143
+ )
144
+ .map((entry) => entry.name)
145
+ .sort()
146
+ for (const name of names) {
147
+ queue.push({ path: next.path === "" ? name : `${next.path}/${name}`, depth: next.depth + 1 })
148
+ }
149
+ }
150
+ return found
151
+ }
152
+
153
+ const isFile = (path: string): Promise<boolean> =>
154
+ stat(path)
155
+ .then((info) => info.isFile())
156
+ .catch(() => false)
157
+
105
158
  /**
106
159
  * The nifra projects among `dir`'s package-manager workspace members, in declaration order. Empty when
107
160
  * `dir` declares no `workspaces`. A member is a directory a pattern matches that holds a `package.json`;
@@ -111,15 +164,12 @@ export async function workspaceProjects(dir: string): Promise<string[]> {
111
164
  const patterns = await workspacePatterns(dir)
112
165
  if (patterns === null) return []
113
166
  const members = new Set<string>()
114
- let probed = 0
167
+ const budget = { directories: MAX_WORKSPACE_DIRECTORIES }
115
168
  for (const pattern of patterns) {
116
- const glob = new Bun.Glob(`${pattern.replace(/\/+$/, "")}/package.json`)
117
- for await (const match of glob.scan({ cwd: dir, onlyFiles: true })) {
118
- if (match.split(/[\\/]/).includes("node_modules")) continue
119
- if (++probed > MAX_WORKSPACE_MEMBERS) break
120
- members.add(dirname(join(dir, match)))
169
+ for (const member of await workspaceMatches(dir, pattern, budget)) {
170
+ if (members.size === MAX_WORKSPACE_MEMBERS) break
171
+ members.add(join(dir, member))
121
172
  }
122
- if (probed > MAX_WORKSPACE_MEMBERS) break
123
173
  }
124
174
  const projects: string[] = []
125
175
  for (const member of members) if (await isNifraProjectDir(member)) projects.push(member)
package/src/mcp-ws.ts CHANGED
@@ -21,7 +21,7 @@ interface RunningServerLike {
21
21
  }
22
22
 
23
23
  interface ListenableApp {
24
- listen(port: number): RunningServerLike
24
+ listen(port: number, options: { readonly hostname: string }): RunningServerLike
25
25
  }
26
26
 
27
27
  interface NormalizedInput {
@@ -206,7 +206,9 @@ export async function runWebSocket(
206
206
  let running: RunningServerLike | undefined
207
207
  let socket: WebSocket | undefined
208
208
  try {
209
- running = app.listen(0)
209
+ // Loopback only: the project's backend, with its environment loaded, must not be reachable
210
+ // from the network for the length of a verification.
211
+ running = app.listen(0, { hostname: "127.0.0.1" })
210
212
  const url = `ws://127.0.0.1:${running.port}${normalized.path}`
211
213
  socket = new WebSocket(url)
212
214
  socket.binaryType = "arraybuffer"
package/src/mcp.ts CHANGED
@@ -52,7 +52,7 @@ import { stat } from "node:fs/promises"
52
52
  import { resolve } from "node:path"
53
53
  import { loadDocsCorpus } from "./docs-search.ts"
54
54
  import { loadExamplesCorpus } from "./examples.ts"
55
- import type { LoadedApp } from "./load.ts"
55
+ import type { AppLoader } from "./load.ts"
56
56
  import { loadMonorepoApps } from "./load.ts"
57
57
  import { delegateToProjectCli, refuseVersionSensitive } from "./mcp-delegate.ts"
58
58
  import { docsTools } from "./mcp-docs-tools.ts"
@@ -108,7 +108,7 @@ export {
108
108
  } from "./mcp-io.ts"
109
109
  export { clientSupportsRoots, guardTools } from "./mcp-root.ts"
110
110
 
111
- import { projectTools } from "./mcp-exec.ts"
111
+ import { closeWarmWorkers, projectTools } from "./mcp-exec.ts"
112
112
  import {
113
113
  createAppSurface,
114
114
  detectMonorepoIsolated,
@@ -147,10 +147,11 @@ export {
147
147
  * The loader the server builds its tool and resource lists with. It never loads: every handler that
148
148
  * reaches the app runs in a project subprocess (`./mcp-isolate.ts`), so one that tried here fails.
149
149
  */
150
- const noAppInServer = (): Promise<LoadedApp> =>
150
+ const refuseApp = (): Promise<never> =>
151
151
  Promise.reject(
152
152
  new Error("[nifra] the MCP server does not load the app; project code runs in a subprocess"),
153
153
  )
154
+ const noAppInServer: AppLoader = Object.assign(refuseApp, { backend: refuseApp })
154
155
 
155
156
  const isolatedToolsByDir = new Map<string, McpTool[]>()
156
157
 
@@ -180,6 +181,13 @@ function appFeatures(cwd: string, surface: IsolatedSurface): McpServerFeatures {
180
181
  }
181
182
 
182
183
  const ROOTS_REQUEST_ID = "nifra:roots/list"
184
+ /** Messages whose answer reads nothing the app declares: the handshake is never held up by its import. */
185
+ const ANSWERED_WITHOUT_THE_APP: ReadonlySet<string> = new Set([
186
+ "initialize",
187
+ "ping",
188
+ "notifications/initialized",
189
+ "notifications/roots/list_changed",
190
+ ])
183
191
  const MAX_STDIO_MESSAGE_BYTES = 8 * 1024 * 1024
184
192
  const STDIO_ENCODER = new TextEncoder()
185
193
 
@@ -206,8 +214,12 @@ async function createProjectContext(root: string): Promise<ProjectContext> {
206
214
  )
207
215
  const allResources: McpResource[] = []
208
216
  const allPrompts: McpPrompt[] = []
209
- for (const { name, cwd: appCwd, surface } of apps) {
210
- const ns = namespaceForApp(name, [], appFeatures(appCwd, await surface()))
217
+ // Each app loads in its own subprocess, so they load side by side.
218
+ const loaded = await Promise.all(
219
+ apps.map(async (app) => ({ app, surface: await app.surface() })),
220
+ )
221
+ for (const { app, surface } of loaded) {
222
+ const ns = namespaceForApp(app.name, [], appFeatures(app.cwd, surface))
211
223
  allResources.push(...(ns.features.resources ?? []))
212
224
  allPrompts.push(...(ns.features.prompts ?? []))
213
225
  }
@@ -250,7 +262,9 @@ export async function runMcpServer(
250
262
  return
251
263
  }
252
264
  }
253
- let ctx = await createProjectContext(rootState.root)
265
+ // Loaded in the background: the app's import can take seconds, and the handshake needs none of it.
266
+ let ctx = createProjectContext(rootState.root)
267
+ ctx.catch(() => {})
254
268
  let drift: ToolingDrift | undefined = await detectToolingDrift(rootState.root, version)
255
269
  const serverInfo = { name: "nifra", version }
256
270
  const state = createMcpProtocolState()
@@ -285,7 +299,8 @@ export async function runMcpServer(
285
299
  if (result === undefined) return
286
300
  const next = await applyClientRoots(rootState, pathsFromRootsResult(result))
287
301
  if (next.root !== rootState.root) {
288
- ctx = await createProjectContext(next.root)
302
+ ctx = createProjectContext(next.root)
303
+ ctx.catch(() => {})
289
304
  drift = await detectToolingDrift(next.root, version)
290
305
  }
291
306
  rootState = next
@@ -308,16 +323,42 @@ export async function runMcpServer(
308
323
  if (message.method === "initialize") {
309
324
  rootsSupported = clientSupportsRoots(message.params)
310
325
  }
326
+ // An install during the session changes the nifra the project builds with, and this process keeps
327
+ // the code it started with: re-read the installed version per call so no answer is for a stale one.
328
+ if (message.method === "tools/call") drift = await detectToolingDrift(rootState.root, version)
311
329
 
330
+ const method = message.method ?? ""
331
+ if (ANSWERED_WITHOUT_THE_APP.has(method)) {
332
+ const response = await handleRpc(
333
+ message,
334
+ [],
335
+ serverInfo,
336
+ {
337
+ resources: projectResources(rootState.root, noAppInServer),
338
+ prompts: projectPrompts(),
339
+ instructions: rootInstructions(rootState, drift),
340
+ },
341
+ { state, sendNotification: send, exposeToolErrors: true },
342
+ )
343
+ if (response) send(response)
344
+ // Ask for the client's workspace roots once the handshake completes, and again whenever the
345
+ // client says they changed. The answer comes back through the interception above.
346
+ if (method.startsWith("notifications/")) requestRoots()
347
+ return
348
+ }
349
+ const project = await ctx
312
350
  let activeTools: McpTool[]
313
- if (ctx.apps) {
351
+ if (project.apps) {
352
+ const loaded = await Promise.all(
353
+ project.apps.map(async (app) => ({ app, surface: await app.surface() })),
354
+ )
314
355
  const allTools: McpTool[] = []
315
- for (const { name, cwd: appCwd, surface } of ctx.apps) {
356
+ for (const { app, surface } of loaded) {
316
357
  const tools = [
317
- ...refuseVersionSensitive(isolatedProjectTools(appCwd), drift),
318
- ...(await surface()).tools,
358
+ ...refuseVersionSensitive(isolatedProjectTools(app.cwd), drift),
359
+ ...surface.tools,
319
360
  ]
320
- const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
361
+ const ns = namespaceForApp(app.name, tools, { resources: [], prompts: [] })
321
362
  allTools.push(...ns.tools)
322
363
  }
323
364
  activeTools = [
@@ -330,7 +371,7 @@ export async function runMcpServer(
330
371
  } else {
331
372
  activeTools = [
332
373
  ...refuseVersionSensitive(isolatedProjectTools(rootState.root), drift),
333
- ...(await ctx.surface()).tools,
374
+ ...(await project.surface()).tools,
334
375
  ]
335
376
  }
336
377
  const verdict = await rootVerdict(rootState)
@@ -342,7 +383,7 @@ export async function runMcpServer(
342
383
  )
343
384
 
344
385
  const features: McpServerFeatures = {
345
- ...ctx.features,
386
+ ...project.features,
346
387
  instructions: rootInstructions(rootState, drift),
347
388
  }
348
389
  const response = await handleRpc(message, activeTools, serverInfo, features, {
@@ -352,14 +393,6 @@ export async function runMcpServer(
352
393
  exposeToolErrors: true,
353
394
  })
354
395
  if (response) send(response)
355
- // Ask for the client's workspace roots once the handshake completes, and again whenever the
356
- // client says they changed. The answer comes back through the interception above.
357
- if (
358
- message.method === "notifications/initialized" ||
359
- message.method === "notifications/roots/list_changed"
360
- ) {
361
- requestRoots()
362
- }
363
396
  }
364
397
 
365
398
  const decoder = new TextDecoder()
@@ -418,6 +451,7 @@ export async function runMcpServer(
418
451
  send(rpcError(null, -32600, "MCP message exceeded the size limit"))
419
452
  }
420
453
  }
454
+ closeWarmWorkers()
421
455
  }
422
456
 
423
457
  function responseIdOf(message: JsonRpcResponse | JsonRpcNotification): string | number | null {