@nifrajs/cli 4.0.2 → 5.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 (323) hide show
  1. package/README.md +3 -0
  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 +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
package/src/mcp-exec.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { type Dirent, realpathSync } from "node:fs"
10
10
  import { readdir, stat } from "node:fs/promises"
11
- import { relative, resolve } from "node:path"
11
+ import { isAbsolute, relative, resolve } from "node:path"
12
12
  import { fileURLToPath } from "node:url"
13
13
  import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
14
14
  import {
@@ -24,7 +24,7 @@ import {
24
24
  import { collectContractProof } from "./contract-proof.ts"
25
25
  import { loadDocsCorpus } from "./docs-search.ts"
26
26
  import { loadExamplesCorpus } from "./examples.ts"
27
- import type { LoadAppOptions, LoadedApp } from "./load.ts"
27
+ import type { AppLoader, IntrospectedApp, LoadAppOptions, LoadedApp } from "./load.ts"
28
28
  import {
29
29
  type CommandMcpToolOptions,
30
30
  dirError,
@@ -36,7 +36,9 @@ import {
36
36
  CHILD_INPUT_MAX_BYTES,
37
37
  CHILD_OUTPUT_MAX_BYTES,
38
38
  CHILD_TIMEOUT_MS,
39
+ OWN_PROCESS_GROUP,
39
40
  readBoundedStream,
41
+ signalProcessGroup,
40
42
  timeoutMessage,
41
43
  validateLocalPort,
42
44
  } from "./mcp-io.ts"
@@ -46,20 +48,29 @@ import { loadTypesCorpus } from "./types-search.ts"
46
48
 
47
49
  const CODEFRAME_SOURCE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro|mdx)$/i
48
50
 
51
+ type GatePaths = Pick<typeof import("node:path"), "isAbsolute" | "relative" | "resolve">
52
+
49
53
  /** The files a stack the caller supplies may show a codeframe from: project source, never a dotfile
50
- * or anything under a dot directory (`.env`, `.git/`, `.nifra/`). */
51
- function explainSourceGate(root: string): (file: string) => boolean {
54
+ * or anything under a dot directory (`.env`, `.git/`, `.nifra/`), and never a file outside the root -
55
+ * which on Windows includes one on another drive, where `relative` answers with an absolute path. */
56
+ export function explainSourceGate(
57
+ root: string,
58
+ paths: GatePaths = { isAbsolute, relative, resolve },
59
+ ): (file: string) => boolean {
52
60
  // The codeframe reads a native realpath, which expands a Windows short name (`RUNNER~1`); the root
53
61
  // has to be spelled the same way or every file looks like it sits outside it.
54
- let base = resolve(root)
62
+ let base = paths.resolve(root)
55
63
  try {
56
64
  base = realpathSync.native(base)
57
65
  } catch {}
58
- return (file) =>
59
- CODEFRAME_SOURCE.test(file) &&
60
- !relative(base, file)
61
- .split(/[\\/]/)
62
- .some((segment) => segment.startsWith("."))
66
+ return (file) => {
67
+ const inside = paths.relative(base, file)
68
+ return (
69
+ CODEFRAME_SOURCE.test(file) &&
70
+ !paths.isAbsolute(inside) &&
71
+ !inside.split(/[\\/]/).some((segment) => segment.startsWith("."))
72
+ )
73
+ }
63
74
  }
64
75
 
65
76
  /** Path to a sibling child entry (`mcp-run` / `mcp-render` / `mcp-ws`), resolved next to this module (`.ts` in
@@ -74,7 +85,16 @@ export function toMcpTool(
74
85
  const entry = "run" in source ? undefined : source
75
86
  const { cwd } = options
76
87
  const loadAppCached = options.loadAppCached ?? createCachedAppLoader(cwd)
77
- const pathFields = ["config", "out", "lockfile", "before", "after", "baseline", "file"] as const
88
+ const pathFields = [
89
+ "config",
90
+ "out",
91
+ "lockfile",
92
+ "before",
93
+ "after",
94
+ "baseline",
95
+ "file",
96
+ "since",
97
+ ] as const
78
98
  const catalogEntry = entry ?? toCommandCatalogEntry(spec)
79
99
  return {
80
100
  name: commandMcpName(catalogEntry.name),
@@ -143,7 +163,7 @@ export function toMcpTool(
143
163
 
144
164
  export function catalogProjectTools(
145
165
  cwd: string,
146
- loadAppCached: () => Promise<LoadedApp> = createCachedAppLoader(cwd),
166
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
147
167
  ): McpTool[] {
148
168
  return commandCatalog
149
169
  .filter((entry) => entry.transports.includes("mcp"))
@@ -156,11 +176,11 @@ export function catalogProjectTools(
156
176
 
157
177
  export function projectTools(
158
178
  cwd: string,
159
- loadAppCached: (outDirName?: string) => Promise<LoadedApp> = createCachedAppLoader(cwd),
179
+ loadAppCached: AppLoader = createCachedAppLoader(cwd),
160
180
  ): McpTool[] {
161
181
  const warmRun = createWarmHandler("mcp-run", cwd, "run")
162
182
  const warmRender = createWarmHandler("mcp-render", cwd, "render")
163
- const catalogTools = catalogProjectTools(cwd, () => loadAppCached())
183
+ const catalogTools = catalogProjectTools(cwd, loadAppCached)
164
184
  const legacyTools: McpTool[] = [
165
185
  {
166
186
  name: "nifra_verify",
@@ -692,12 +712,13 @@ export async function spawnChild(
692
712
  stdin: "pipe",
693
713
  stdout: "pipe",
694
714
  stderr: "pipe",
715
+ detached: OWN_PROCESS_GROUP,
695
716
  })
696
717
  let killed = false
697
718
  const kill = (): void => {
698
719
  if (killed) return
699
720
  killed = true
700
- proc.kill()
721
+ signalProcessGroup(proc, "SIGTERM")
701
722
  }
702
723
  const abort = (): void => kill()
703
724
  signal?.addEventListener("abort", abort, { once: true })
@@ -791,6 +812,16 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
791
812
  readonly stderr: ReadableStream<Uint8Array>
792
813
  }
793
814
 
815
+ const liveWarmWorkers = new Set<WarmWorker>()
816
+ let warmWorkersClosed = false
817
+
818
+ /** Stop every warm worker and start no more, once the MCP client has closed stdin. A live worker
819
+ * holds the process open, so the server would otherwise never exit. */
820
+ export function closeWarmWorkers(): void {
821
+ warmWorkersClosed = true
822
+ for (const worker of liveWarmWorkers) worker.stop()
823
+ }
824
+
794
825
  /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
795
826
  * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
796
827
  * powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
@@ -827,10 +858,12 @@ export class WarmWorker {
827
858
  stdout: "pipe",
828
859
  stderr: "pipe",
829
860
  }) as PipeSubprocess
861
+ liveWarmWorkers.add(this)
830
862
  void this.readStdout()
831
863
  void this.readStderr()
832
864
  void this.proc.exited.then((code) => {
833
865
  this.closed = true
866
+ liveWarmWorkers.delete(this)
834
867
  const err = new Error(
835
868
  `warm ${this.label} worker exited (${code})${this.stderrBuffer ? `:\n${this.stderrBuffer}` : ""}`,
836
869
  )
@@ -1025,8 +1058,10 @@ export function createWarmHandler(
1025
1058
  label: string,
1026
1059
  ): (input: unknown, signal?: AbortSignal) => Promise<string> {
1027
1060
  let worker: WarmWorker | undefined
1061
+ const shuttingDown = `${label} cancelled: the MCP server is shutting down.`
1028
1062
  return async (input, signal) => {
1029
1063
  const fingerprint = await warmRunFingerprint(cwd)
1064
+ if (warmWorkersClosed) return shuttingDown
1030
1065
  let current = worker
1031
1066
  if (current === undefined || current.fingerprint !== fingerprint) {
1032
1067
  current?.stop()
@@ -1044,6 +1079,7 @@ export function createWarmHandler(
1044
1079
  const reason = cancellationSuffix(signal)
1045
1080
  return `${label} cancelled${reason}.`
1046
1081
  }
1082
+ if (warmWorkersClosed) return shuttingDown
1047
1083
  return spawnChild(child, cwd, input, label, signal)
1048
1084
  }
1049
1085
  }
@@ -1146,7 +1182,7 @@ export async function appFingerprint(cwd: string): Promise<string> {
1146
1182
  export function createCachedAppLoader(
1147
1183
  cwd: string,
1148
1184
  options: CachedAppLoaderOptions = {},
1149
- ): (outDirName?: string) => Promise<LoadedApp> {
1185
+ ): AppLoader {
1150
1186
  const loadAppCached =
1151
1187
  options.loadApp ??
1152
1188
  (async (root: string, outDirName?: string, loadOptions?: LoadAppOptions) => {
@@ -1162,7 +1198,7 @@ export function createCachedAppLoader(
1162
1198
  }
1163
1199
  | undefined
1164
1200
 
1165
- return async (outDirName = "dist") => {
1201
+ const whole = async (outDirName = "dist"): Promise<LoadedApp> => {
1166
1202
  const nextFingerprint = await fingerprint(cwd)
1167
1203
  if (
1168
1204
  cached !== undefined &&
@@ -1177,4 +1213,11 @@ export function createCachedAppLoader(
1177
1213
  cached = { outDirName, fingerprint: nextFingerprint, app }
1178
1214
  return app
1179
1215
  }
1216
+ const backend = async (): Promise<IntrospectedApp> => {
1217
+ const { hasAppConfig, loadBackendApp } = await import("./load.ts")
1218
+ if (hasAppConfig(cwd)) return whole()
1219
+ // The fingerprint names backend/app.ts, so an edit loads a fresh module and no edit reuses it.
1220
+ return loadBackendApp(cwd, { importQuery: cacheToken(await fingerprint(cwd)) })
1221
+ }
1222
+ return Object.assign(whole, { backend })
1180
1223
  }
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "4.0.2"
32
+ const VERSION = "5.0.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>
package/src/mcp-io.ts CHANGED
@@ -16,6 +16,47 @@ 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
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const
40
+
41
+ /**
42
+ * A child in its own group no longer gets the Ctrl+C the terminal sends its parent: on SIGINT,
43
+ * SIGTERM or SIGHUP run `stop`, then let this process take the signal as it would have. Returns the
44
+ * function that removes the listeners.
45
+ */
46
+ export function forwardTerminalSignals(stop: () => void): () => void {
47
+ if (!OWN_PROCESS_GROUP) return () => {}
48
+ const remove = (): void => {
49
+ for (const name of FORWARDED_SIGNALS) process.off(name, forward)
50
+ }
51
+ const forward = (signal: NodeJS.Signals): void => {
52
+ stop()
53
+ remove()
54
+ process.kill(process.pid, signal)
55
+ }
56
+ for (const name of FORWARDED_SIGNALS) process.on(name, forward)
57
+ return remove
58
+ }
59
+
19
60
  /** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
20
61
  export function validateLocalPort(port: unknown): number | undefined {
21
62
  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")
@@ -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
 
@@ -308,6 +309,9 @@ export async function runMcpServer(
308
309
  if (message.method === "initialize") {
309
310
  rootsSupported = clientSupportsRoots(message.params)
310
311
  }
312
+ // An install during the session changes the nifra the project builds with, and this process keeps
313
+ // the code it started with: re-read the installed version per call so no answer is for a stale one.
314
+ if (message.method === "tools/call") drift = await detectToolingDrift(rootState.root, version)
311
315
 
312
316
  let activeTools: McpTool[]
313
317
  if (ctx.apps) {
@@ -418,6 +422,7 @@ export async function runMcpServer(
418
422
  send(rpcError(null, -32600, "MCP message exceeded the size limit"))
419
423
  }
420
424
  }
425
+ closeWarmWorkers()
421
426
  }
422
427
 
423
428
  function responseIdOf(message: JsonRpcResponse | JsonRpcNotification): string | number | null {