@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/dist/upgrade.js CHANGED
@@ -5,6 +5,10 @@
5
5
  *
6
6
  * 1. **pin sweep** - set every matching dependency to the target version across the workspace's
7
7
  * package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
8
+ * `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs.
9
+ * The MCP launch pin in `.mcp.json`, `.cursor/mcp.json`, CLAUDE.md and AGENTS.md moves too, the
10
+ * edit `nifra init-agents --sync-mcp` makes; a version pinned anywhere else (a Dockerfile, a CI
11
+ * workflow, another lockfile) is listed only.
8
12
  * 2. **dependency moves** - replace removed packages with their supported successor without
9
13
  * reserializing package.json or leaving duplicate dependency keys.
10
14
  * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
@@ -21,10 +25,14 @@
21
25
  * Deliberately NOT a codemod engine: transforms are string/specifier-level only. Structural (AST)
22
26
  * transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
23
27
  */
24
- import { readFileSync, writeFileSync } from "node:fs";
25
- import { join, relative } from "node:path";
28
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
29
+ import { dirname, join, relative, sep } from "node:path";
30
+ import { fileURLToPath } from "node:url";
26
31
  import { Glob } from "bun";
32
+ import { collectHookReport, diffHookReports, HOOKS_BEFORE_UPGRADE_FILE, parseHookReport, renderHookDiff, } from "./hooks-report.js";
33
+ import { PIN_SITES } from "./init-agents.js";
27
34
  import { getRecipe, listRecipeVersions } from "./recipes/index.js";
35
+ import { markerTarget } from "./rules/comment-markers.js";
28
36
  const DEP_FIELDS = [
29
37
  "dependencies",
30
38
  "devDependencies",
@@ -131,71 +139,155 @@ export function pinSweepText(text, rules, allowDowngrade = false, exact = false)
131
139
  let out = text;
132
140
  const changes = [];
133
141
  const downgrades = [];
142
+ for (const { path, key, target: name, spec: rawSpec } of pinEntries(parsed)) {
143
+ const field = path.join(".");
144
+ const rule = rules.find((r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)));
145
+ if (!rule)
146
+ continue;
147
+ const next = rewriteVersionSpec(rawSpec, rule.to, exact);
148
+ if (next === null)
149
+ continue;
150
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
151
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
152
+ const current = specVersionTuple(rawSpec);
153
+ const target = specVersionTuple(rule.to);
154
+ if (!allowDowngrade &&
155
+ current !== null &&
156
+ target !== null &&
157
+ compareSemverSpec(rawSpec, rule.to) > 0) {
158
+ downgrades.push({ field, name: key, from: rawSpec, to: next });
159
+ continue;
160
+ }
161
+ // Replace exactly `"key"<ws>:<ws>"spec"` inside this field's object, preserving the whitespace. A
162
+ // nested `overrides` entry repeated with the same spec maps the same way, so every one is replaced.
163
+ const range = objectRangeAt(out, path);
164
+ if (range === undefined)
165
+ continue;
166
+ const pattern = new RegExp(`("${escapeRegExp(key)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`, "g");
167
+ const body = out.slice(range.start, range.end);
168
+ const replaced = body.replace(pattern, `$1${next}$2`);
169
+ if (replaced !== body) {
170
+ out = out.slice(0, range.start) + replaced + out.slice(range.end);
171
+ changes.push({ field, name: key, from: rawSpec, to: next });
172
+ }
173
+ }
174
+ return { text: out, changes, downgrades };
175
+ }
176
+ /** Where a package manager forces one version over every range: npm and Bun `overrides` (nested by
177
+ * parent), Yarn and Bun `resolutions`, pnpm `pnpm.overrides`. */
178
+ const OVERRIDE_FIELDS = [
179
+ ["overrides"],
180
+ ["resolutions"],
181
+ ["pnpm", "overrides"],
182
+ ];
183
+ /**
184
+ * The package an override key pins: `@nifrajs/core`, `parent/@nifrajs/core` and the same behind a `**`
185
+ * glob (resolutions), `parent>@nifrajs/core` and `@nifrajs/core@<4` (pnpm).
186
+ */
187
+ function overrideTarget(key) {
188
+ const last = key.slice(key.lastIndexOf(">") + 1);
189
+ return /(?:^|\/)((?:@[^/@]+\/)?[^/@]+)(?:@[^/]*)?$/.exec(last)?.[1] ?? last;
190
+ }
191
+ /** Every string version spec a package.json declares for a package, with the field path it sits in. */
192
+ function pinEntries(parsed) {
193
+ const entries = [];
134
194
  for (const field of DEP_FIELDS) {
135
195
  const deps = parsed[field];
136
196
  if (typeof deps !== "object" || deps === null)
137
197
  continue;
138
- for (const [name, rawSpec] of Object.entries(deps)) {
139
- if (typeof rawSpec !== "string")
140
- continue;
141
- const rule = rules.find((r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)));
142
- if (!rule)
143
- continue;
144
- const next = rewriteVersionSpec(rawSpec, rule.to, exact);
145
- if (next === null)
146
- continue;
147
- // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
148
- // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
149
- const current = specVersionTuple(rawSpec);
150
- const target = specVersionTuple(rule.to);
151
- if (!allowDowngrade &&
152
- current !== null &&
153
- target !== null &&
154
- compareSemverSpec(rawSpec, rule.to) > 0) {
155
- downgrades.push({ field, name, from: rawSpec, to: next });
156
- continue;
198
+ for (const [name, spec] of Object.entries(deps))
199
+ if (typeof spec === "string")
200
+ entries.push({ path: [field], key: name, target: name, spec });
201
+ }
202
+ for (const path of OVERRIDE_FIELDS) {
203
+ let value = parsed;
204
+ for (const key of path)
205
+ value = typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined;
206
+ // npm nests an override under the package whose dependency it pins; `.` pins that package itself.
207
+ const walk = (object, parent) => {
208
+ for (const [key, spec] of Object.entries(object)) {
209
+ const target = key === "." ? parent : overrideTarget(key);
210
+ if (typeof spec === "string" && target !== undefined)
211
+ entries.push({ path, key, target, spec });
212
+ else if (typeof spec === "object" && spec !== null && !Array.isArray(spec))
213
+ walk(spec, target);
157
214
  }
158
- // Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
159
- const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`);
160
- const replaced = out.replace(pattern, `$1${next}$2`);
161
- if (replaced !== out) {
162
- out = replaced;
163
- changes.push({ field, name, from: rawSpec, to: next });
164
- }
165
- }
215
+ };
216
+ if (typeof value === "object" && value !== null && !Array.isArray(value))
217
+ walk(value, undefined);
166
218
  }
167
- return { text: out, changes, downgrades };
219
+ return entries;
168
220
  }
169
- function dependencyObjectRange(text, field) {
170
- const match = new RegExp(`"${field}"\\s*:\\s*\\{`).exec(text);
171
- if (match === null)
221
+ /** The body (between the braces) of the object at `path` in a JSON text: top-level key first, then
222
+ * each nested one, never a same-named key deeper in the document. */
223
+ function objectRangeAt(text, path) {
224
+ const rootOpen = text.indexOf("{");
225
+ if (rootOpen === -1)
172
226
  return undefined;
173
- const open = text.indexOf("{", match.index);
227
+ let range = {
228
+ start: rootOpen + 1,
229
+ end: closingBrace(text, rootOpen),
230
+ };
231
+ for (const key of path) {
232
+ if (range === undefined)
233
+ return undefined;
234
+ range = memberObjectRange(text, range, key);
235
+ }
236
+ return range;
237
+ }
238
+ /** The index of the `"` that closes the string opening at `open`. */
239
+ function stringEnd(text, open) {
240
+ for (let index = open + 1; index < text.length; index++) {
241
+ if (text[index] === "\\")
242
+ index++;
243
+ else if (text[index] === '"')
244
+ return index;
245
+ }
246
+ return text.length;
247
+ }
248
+ /** The index of the brace that closes the one at `open`. */
249
+ function closingBrace(text, open) {
174
250
  let depth = 0;
175
- let quoted = false;
176
- let escaped = false;
177
251
  for (let index = open; index < text.length; index++) {
178
252
  const char = text[index];
179
- if (quoted) {
180
- if (escaped)
181
- escaped = false;
182
- else if (char === "\\")
183
- escaped = true;
184
- else if (char === '"')
185
- quoted = false;
186
- continue;
187
- }
188
253
  if (char === '"')
189
- quoted = true;
190
- else if (char === "{")
254
+ index = stringEnd(text, index);
255
+ else if (char === "{" || char === "[")
256
+ depth++;
257
+ else if ((char === "}" || char === "]") && --depth === 0)
258
+ return index;
259
+ }
260
+ return text.length;
261
+ }
262
+ /** The body of `key`'s object value among the direct members of the object body `within`. */
263
+ function memberObjectRange(text, within, key) {
264
+ let depth = 0;
265
+ for (let index = within.start; index < within.end; index++) {
266
+ const char = text[index];
267
+ if (char === "{" || char === "[")
191
268
  depth++;
192
- else if (char === "}" && --depth === 0)
193
- return { start: open + 1, end: index };
269
+ else if (char === "}" || char === "]")
270
+ depth--;
271
+ else if (char === '"') {
272
+ const close = stringEnd(text, index);
273
+ let after = close + 1;
274
+ while (/\s/.test(text[after] ?? ""))
275
+ after++;
276
+ if (depth === 0 && text[after] === ":" && text.slice(index + 1, close) === key) {
277
+ after++;
278
+ while (/\s/.test(text[after] ?? ""))
279
+ after++;
280
+ return text[after] === "{"
281
+ ? { start: after + 1, end: closingBrace(text, after) }
282
+ : undefined;
283
+ }
284
+ index = close;
285
+ }
194
286
  }
195
287
  return undefined;
196
288
  }
197
289
  function rewriteDependencyEntry(text, field, from, fromVersion, to, toVersion) {
198
- const range = dependencyObjectRange(text, field);
290
+ const range = objectRangeAt(text, [field]);
199
291
  if (range === undefined)
200
292
  return text;
201
293
  const body = text.slice(range.start, range.end);
@@ -308,28 +400,47 @@ const isIgnored = (path) => path
308
400
  .split(/[\\/]/)
309
401
  .slice(0, -1)
310
402
  .some((segment) => IGNORED_DIRECTORIES.has(segment));
311
- function scan(cwd, pattern) {
403
+ function scan(cwd, pattern, dot = false) {
312
404
  const glob = new Glob(pattern);
313
405
  const out = [];
314
- for (const path of glob.scanSync({ cwd, dot: false })) {
406
+ // The write boundary: a symlinked file or directory (a linked repository) is never yielded, so the
407
+ // upgrade can only edit files that live under `cwd`.
408
+ for (const path of glob.scanSync({ cwd, dot, followSymlinks: false })) {
315
409
  if (!isIgnored(path))
316
- out.push(path);
410
+ out.push(path.replaceAll("\\", "/"));
317
411
  }
318
412
  return out.sort(); // deterministic order
319
413
  }
320
414
  // The fixed version group: every package released together at one version.
321
- const isFixedGroup = (name) => name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra";
415
+ export const isFixedGroup = (name) => name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra";
322
416
  const groupPins = (version) => [
323
417
  { match: "@nifrajs/", to: version },
324
418
  { match: "create-nifra", to: version },
325
419
  { match: "nifra", to: version },
326
420
  ];
421
+ /** Every fixed-group package a package.json under `cwd` names, whatever its spec. */
422
+ export function groupDeclaredNames(cwd) {
423
+ const names = new Set();
424
+ for (const rel of scan(cwd, "**/package.json")) {
425
+ try {
426
+ const parsed = JSON.parse(readFileSync(join(cwd, rel), "utf8"));
427
+ for (const { target } of pinEntries(parsed))
428
+ if (isFixedGroup(target))
429
+ names.add(target);
430
+ }
431
+ catch {
432
+ // Not JSON: it declares nothing.
433
+ }
434
+ }
435
+ return names;
436
+ }
327
437
  /**
328
- * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
329
- * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
438
+ * Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
439
+ * with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
440
+ * and other non-semver specs are not versions.
330
441
  */
331
- export function installedGroupVersion(cwd) {
332
- let lowest = null;
442
+ export function groupPinVersions(cwd) {
443
+ const versions = new Map();
333
444
  for (const rel of scan(cwd, "**/package.json")) {
334
445
  let parsed;
335
446
  try {
@@ -338,19 +449,171 @@ export function installedGroupVersion(cwd) {
338
449
  catch {
339
450
  continue;
340
451
  }
341
- for (const field of DEP_FIELDS) {
342
- const deps = parsed[field];
343
- if (typeof deps !== "object" || deps === null)
452
+ for (const { target, spec } of pinEntries(parsed)) {
453
+ if (!isFixedGroup(target))
344
454
  continue;
345
- for (const [name, spec] of Object.entries(deps)) {
346
- if (!isFixedGroup(name) || typeof spec !== "string")
347
- continue;
348
- const version = specVersion(spec);
349
- if (version !== null && (lowest === null || compareSemverSpec(version, lowest) < 0)) {
350
- lowest = version;
351
- }
352
- }
455
+ const version = specVersion(spec);
456
+ if (version === null)
457
+ continue;
458
+ const files = versions.get(version) ?? [];
459
+ if (!files.includes(rel))
460
+ files.push(rel);
461
+ versions.set(version, files);
462
+ }
463
+ }
464
+ return versions;
465
+ }
466
+ const STRAY_PIN_FILES = [
467
+ "**/{.mcp.json,mcp.json}",
468
+ "**/{AGENTS.md,CLAUDE.md}",
469
+ "**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
470
+ "**/{docker-compose,compose}*.{yml,yaml}",
471
+ ".github/workflows/*.{yml,yaml}",
472
+ ".gitlab-ci.yml",
473
+ "**/*.lock",
474
+ "**/{package-lock.json,pnpm-lock.yaml}",
475
+ ];
476
+ const LOCKFILE = /(?:\.lock|package-lock\.json|pnpm-lock\.yaml)$/;
477
+ const GROUP_PIN = /(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g;
478
+ /**
479
+ * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs and
480
+ * agent guidance beyond the launch pin it moves, container files, CI workflows, and lockfiles besides
481
+ * the workspace's own (which install rewrites). `planned` holds a file's text after the upgrade's own
482
+ * edits, so a dry run lists what `--write` would leave.
483
+ */
484
+ export function strayPins(cwd, version, planned = new Map()) {
485
+ const found = [];
486
+ const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)));
487
+ for (const rel of [...files].sort()) {
488
+ const lockfile = LOCKFILE.test(rel);
489
+ if (lockfile && !/[\\/]/.test(rel))
490
+ continue;
491
+ let text;
492
+ try {
493
+ if (statSync(join(cwd, rel)).size > 8_000_000)
494
+ continue;
495
+ text = planned.get(rel) ?? readFileSync(join(cwd, rel), "utf8");
496
+ }
497
+ catch {
498
+ continue;
499
+ }
500
+ const stale = [...text.matchAll(GROUP_PIN)].filter((match) => compareSemverSpec(match[2] ?? "", version) !== 0);
501
+ const site = (match, more) => ({
502
+ file: rel,
503
+ line: text.slice(0, match.index ?? 0).split("\n").length,
504
+ pin: match[0],
505
+ lockfile,
506
+ more,
507
+ });
508
+ const first = stale[0];
509
+ if (first === undefined)
510
+ continue;
511
+ if (lockfile)
512
+ found.push(site(first, stale.length - 1));
513
+ else
514
+ for (const match of stale)
515
+ found.push(site(match, 0));
516
+ }
517
+ return found;
518
+ }
519
+ /** Whether `rel` is a regular file under `cwd` reached through no symlink: the upgrade's write boundary. */
520
+ function plainFileAt(cwd, rel) {
521
+ let path = cwd;
522
+ const parts = rel.split("/");
523
+ for (const [index, part] of parts.entries()) {
524
+ path = join(path, part);
525
+ let stat;
526
+ try {
527
+ stat = lstatSync(path);
528
+ }
529
+ catch {
530
+ return false;
531
+ }
532
+ if (stat.isSymbolicLink())
533
+ return false;
534
+ if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory())
535
+ return false;
536
+ }
537
+ return true;
538
+ }
539
+ /**
540
+ * The MCP launch pin in each agent file at `cwd`, moved to `version`: the files and the edit of
541
+ * `nifra init-agents --sync-mcp`, so an agent launches the nifra the project installs once it does.
542
+ */
543
+ function agentPinEdits(cwd, version) {
544
+ const edits = [];
545
+ for (const site of PIN_SITES) {
546
+ if (!plainFileAt(cwd, site.path))
547
+ continue;
548
+ // latin1 is one char per byte, so every byte outside a pin is written back as it was.
549
+ const scan = site.repin(readFileSync(join(cwd, site.path), "latin1"), version);
550
+ if (scan.stale.length === 0)
551
+ continue;
552
+ edits.push({ change: { file: site.path, from: scan.stale, to: version }, text: scan.text });
553
+ }
554
+ return edits;
555
+ }
556
+ const packageVersionAt = (dir) => {
557
+ try {
558
+ const meta = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
559
+ const version = Reflect.get(Object(meta), "version");
560
+ return typeof version === "string" && version.length > 0 ? version : undefined;
561
+ }
562
+ catch {
563
+ return undefined;
564
+ }
565
+ };
566
+ /**
567
+ * Every fixed-group package installed in `cwd/node_modules`, realpath-resolved so a linked repository
568
+ * reports its own version. `inside` is false for one that resolves outside the workspace root.
569
+ */
570
+ export function installedGroupPackages(cwd) {
571
+ const modules = join(cwd, "node_modules");
572
+ const names = ["nifra", "create-nifra"].filter((name) => existsSync(join(modules, name)));
573
+ try {
574
+ for (const name of readdirSync(join(modules, "@nifrajs")))
575
+ names.push(`@nifrajs/${name}`);
576
+ }
577
+ catch {
578
+ // No scoped installs.
579
+ }
580
+ let root;
581
+ try {
582
+ root = realpathSync(cwd);
583
+ }
584
+ catch {
585
+ root = cwd;
586
+ }
587
+ const found = [];
588
+ for (const name of names) {
589
+ let path;
590
+ try {
591
+ path = realpathSync(join(modules, name));
592
+ }
593
+ catch {
594
+ continue;
353
595
  }
596
+ const version = packageVersionAt(path);
597
+ if (version === undefined)
598
+ continue;
599
+ found.push({
600
+ package: name,
601
+ path,
602
+ version,
603
+ inside: path === root || path.startsWith(root + sep),
604
+ });
605
+ }
606
+ return found;
607
+ }
608
+ /**
609
+ * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
610
+ * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
611
+ */
612
+ export function installedGroupVersion(cwd) {
613
+ let lowest = null;
614
+ for (const version of groupPinVersions(cwd).keys()) {
615
+ if (lowest === null || compareSemverSpec(version, lowest) < 0)
616
+ lowest = version;
354
617
  }
355
618
  return lowest;
356
619
  }
@@ -379,15 +642,22 @@ export function chainRecipes(target, installed) {
379
642
  pins: [...pins].map(([match, to]) => ({ match, to })),
380
643
  dependencyMoves: recipes.flatMap((recipe) => recipe.dependencyMoves ?? []),
381
644
  importMoves: recipes.flatMap((recipe) => recipe.importMoves),
645
+ retiredMarkers: recipes.flatMap((recipe) => recipe.retiredMarkers ?? []),
382
646
  notes: recipes.flatMap((recipe) => (recipe.notes ?? []).map((note) => `${recipe.version}: ${note}`)),
383
647
  };
384
648
  }
385
649
  /** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
386
650
  export function computeUpgrade(cwd, recipe, write, allowDowngrade = false, exact = false) {
387
651
  const pins = [];
652
+ const agentPins = [];
653
+ const planned = new Map();
388
654
  const dependencyMoves = [];
389
655
  const importMoves = [];
390
656
  const downgrades = [];
657
+ const retiredMarkers = [];
658
+ const linkedOutside = installedGroupPackages(cwd)
659
+ .filter((entry) => !entry.inside && entry.version !== recipe.version)
660
+ .map(({ package: name, path, version }) => ({ package: name, path, version }));
391
661
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
392
662
  for (const rel of scan(cwd, "**/package.json")) {
393
663
  const abs = join(cwd, rel);
@@ -404,6 +674,12 @@ export function computeUpgrade(cwd, recipe, write, allowDowngrade = false, exact
404
674
  writeFileSync(abs, pinned.text);
405
675
  }
406
676
  }
677
+ for (const { change, text } of agentPinEdits(cwd, recipe.version)) {
678
+ agentPins.push(change);
679
+ planned.set(change.file, text);
680
+ if (write)
681
+ writeFileSync(join(cwd, change.file), Buffer.from(text, "latin1"));
682
+ }
407
683
  if (recipe.importMoves.length > 0) {
408
684
  for (const rel of scan(cwd, SOURCE_GLOB)) {
409
685
  const abs = join(cwd, rel);
@@ -417,12 +693,37 @@ export function computeUpgrade(cwd, recipe, write, allowDowngrade = false, exact
417
693
  writeFileSync(abs, result.text);
418
694
  }
419
695
  }
696
+ // Read after the import moves, so a listed line number is the one the file has after `--write`.
697
+ const retired = recipe.retiredMarkers ?? [];
698
+ if (retired.length > 0) {
699
+ for (const rel of scan(cwd, SOURCE_GLOB)) {
700
+ const text = readFileSync(join(cwd, rel), "utf8");
701
+ if (!retired.some(({ marker }) => text.includes(marker)))
702
+ continue;
703
+ const lines = text.split("\n");
704
+ for (let line = 1; line <= lines.length; line++) {
705
+ for (const { marker, instead } of retired) {
706
+ if (!(lines[line - 1] ?? "").includes(marker))
707
+ continue;
708
+ const target = markerTarget(lines, line);
709
+ const covers = target === undefined
710
+ ? undefined
711
+ : { line: target, text: (lines[target - 1] ?? "").trim() };
712
+ retiredMarkers.push({ file: rel, line, marker, covers, instead });
713
+ }
714
+ }
715
+ }
716
+ }
420
717
  return {
421
718
  version: recipe.version,
422
719
  pins,
720
+ agentPins,
423
721
  dependencyMoves,
424
722
  importMoves,
425
723
  downgrades,
724
+ retiredMarkers,
725
+ linkedOutside,
726
+ strayPins: strayPins(cwd, recipe.version, planned),
426
727
  notes: recipe.notes ?? [],
427
728
  };
428
729
  }
@@ -437,8 +738,12 @@ function renderPlan(plan, write, from, steps) {
437
738
  }
438
739
  lines.push("");
439
740
  if (plan.pins.length === 0 &&
741
+ plan.agentPins.length === 0 &&
440
742
  plan.dependencyMoves.length === 0 &&
441
- plan.importMoves.length === 0) {
743
+ plan.importMoves.length === 0 &&
744
+ plan.retiredMarkers.length === 0 &&
745
+ plan.linkedOutside.length === 0 &&
746
+ plan.strayPins.length === 0) {
442
747
  lines.push("Already up to date - no changes.");
443
748
  return lines.join("\n");
444
749
  }
@@ -458,6 +763,14 @@ function renderPlan(plan, write, from, steps) {
458
763
  lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`);
459
764
  lines.push("");
460
765
  }
766
+ if (plan.agentPins.length > 0) {
767
+ lines.push(`${verb} ${plan.agentPins.length} MCP launch pin(s):`);
768
+ for (const p of plan.agentPins)
769
+ lines.push(` ${p.file} @nifrajs/cli: ${p.from.join(", ")} → ${p.to}`);
770
+ if (write)
771
+ lines.push(" Restart your agent so it launches the new nifra MCP server.");
772
+ lines.push("");
773
+ }
461
774
  if (plan.importMoves.length > 0) {
462
775
  const total = plan.importMoves.reduce((n, m) => n + m.count, 0);
463
776
  lines.push(`${verb} ${total} import move(s) across ${plan.importMoves.length} file(s):`);
@@ -465,6 +778,39 @@ function renderPlan(plan, write, from, steps) {
465
778
  lines.push(` ${m.file} ${m.from} → ${m.to} (${m.count})`);
466
779
  lines.push("");
467
780
  }
781
+ if (plan.retiredMarkers.length > 0) {
782
+ lines.push(`${plan.retiredMarkers.length} retired comment(s) that silence nothing now; nifra check reports what each covered:`);
783
+ for (const site of plan.retiredMarkers) {
784
+ const covers = site.covers === undefined
785
+ ? "covers no line"
786
+ : `covers line ${site.covers.line}: ${site.covers.text}`;
787
+ lines.push(` ${site.file}:${site.line} ${site.marker} ${covers}`);
788
+ lines.push(` write instead: ${site.instead}`);
789
+ }
790
+ lines.push("");
791
+ }
792
+ if (plan.linkedOutside.length > 0) {
793
+ lines.push("Left alone, linked from outside the workspace root (upgrade that repository on its own):");
794
+ for (const entry of plan.linkedOutside)
795
+ lines.push(` ${entry.package} ${entry.version} at ${entry.path}`);
796
+ lines.push("");
797
+ }
798
+ const pinned = plan.strayPins.filter((site) => !site.lockfile);
799
+ if (pinned.length > 0) {
800
+ lines.push(`Left alone, another version pinned outside package.json (update by hand):`);
801
+ for (const site of pinned)
802
+ lines.push(` ${site.file}:${site.line} ${site.pin}`);
803
+ lines.push("");
804
+ }
805
+ const locks = plan.strayPins.filter((site) => site.lockfile);
806
+ if (locks.length > 0) {
807
+ lines.push(`Left alone, a lockfile besides the workspace's own (regenerate it after installing):`);
808
+ for (const site of locks) {
809
+ const more = site.more === 0 ? "" : ` and ${site.more} more`;
810
+ lines.push(` ${site.file}:${site.line} ${site.pin}${more}`);
811
+ }
812
+ lines.push("");
813
+ }
468
814
  for (const note of plan.notes)
469
815
  lines.push(`note: ${note}`);
470
816
  if (!write)
@@ -483,6 +829,63 @@ function renderDowngradeRefusal(version, downgrades) {
483
829
  "installed one, or pass --allow-downgrade if the rollback is intended.",
484
830
  ].join("\n");
485
831
  }
832
+ async function hookReportBefore(cwd) {
833
+ try {
834
+ const { loadBackendApp } = await import("./load.js");
835
+ const { backend } = await loadBackendApp(cwd);
836
+ return await collectHookReport(backend);
837
+ }
838
+ catch (error) {
839
+ return { error: error instanceof Error ? error.message : String(error) };
840
+ }
841
+ }
842
+ /** The report after the edits, from a fresh process: the edited sources must not come from this one's
843
+ * module cache. Saves the pre-upgrade report for `nifra routes --hooks --since` after an install. */
844
+ async function hookScopeAcross(cwd, before) {
845
+ if ("error" in before)
846
+ return { error: before.error };
847
+ const saved = join(cwd, HOOKS_BEFORE_UPGRADE_FILE);
848
+ mkdirSync(dirname(saved), { recursive: true });
849
+ writeFileSync(saved, `${JSON.stringify(before, null, 2)}\n`);
850
+ const entry = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
851
+ const child = Bun.spawn([process.execPath, entry, "routes", "--hooks", "--json"], {
852
+ cwd,
853
+ env: process.env,
854
+ stdout: "pipe",
855
+ stderr: "pipe",
856
+ });
857
+ const [text, , code] = await Promise.all([
858
+ new Response(child.stdout).text(),
859
+ new Response(child.stderr).text(),
860
+ child.exited,
861
+ ]);
862
+ const after = parseHookReport(text);
863
+ if (after === undefined) {
864
+ return {
865
+ error: `\`nifra routes --hooks\` exited ${code} after the edits`,
866
+ saved: HOOKS_BEFORE_UPGRADE_FILE,
867
+ };
868
+ }
869
+ return { saved: HOOKS_BEFORE_UPGRADE_FILE, changes: diffHookReports(before, after) };
870
+ }
871
+ function renderHookScope(scope) {
872
+ const since = (saved) => [
873
+ "The new release's hooks run once its packages are installed. After installing, compare against the report saved before the edits:",
874
+ ` nifra routes --hooks --since ${saved}`,
875
+ ];
876
+ if ("error" in scope) {
877
+ const head = `Hook scope: not captured (${scope.error}).`;
878
+ return scope.saved === undefined ? head : [head, ...since(scope.saved)].join("\n");
879
+ }
880
+ return [
881
+ "Hook scope across the edits (nifra routes --hooks):",
882
+ renderHookDiff(scope.changes)
883
+ .split("\n")
884
+ .map((line) => ` ${line}`)
885
+ .join("\n"),
886
+ ...since(scope.saved),
887
+ ].join("\n");
888
+ }
486
889
  /** The targets this CLI applies: each release recipe up to its own version, and that version. */
487
890
  export function upgradeTargets(cliVersion) {
488
891
  const versions = listRecipeVersions().filter((version) => compareSemverSpec(version, cliVersion) <= 0);
@@ -497,6 +900,7 @@ function newerCliCommand(version, options) {
497
900
  options.exact === true ? "--exact" : "",
498
901
  options.allowDowngrade === true ? "--allow-downgrade" : "",
499
902
  options.verify === false ? "--no-verify" : "",
903
+ options.hooks === false ? "--no-hooks" : "",
500
904
  options.json === true ? "--json" : "",
501
905
  ].filter((flag) => flag !== "");
502
906
  return ["bunx", `@nifrajs/cli@${version}`, "upgrade", version, ...flags].join(" ");
@@ -565,20 +969,36 @@ export async function runUpgrade(cwd, options) {
565
969
  }
566
970
  return false;
567
971
  }
972
+ const captureHooks = write && options.hooks !== false;
973
+ const before = captureHooks ? await hookReportBefore(cwd) : undefined;
568
974
  const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade, exact) : preview;
975
+ const hooks = before === undefined ? undefined : await hookScopeAcross(cwd, before);
976
+ // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
977
+ const verify = write && options.verify !== false;
569
978
  if (options.json) {
570
- console.log(JSON.stringify({ ...plan, from, steps: recipe.steps, write }, null, 2));
571
- }
572
- else {
573
- console.log(renderPlan(plan, write, from, recipe.steps));
979
+ // The check result rides inside the one JSON document; its human report would break the stream.
980
+ const report = { ...plan, from, steps: recipe.steps, write };
981
+ if (hooks !== undefined)
982
+ report.hooks = hooks;
983
+ let ok = true;
984
+ if (verify) {
985
+ const { collectProjectVerification } = await import("./verification.js");
986
+ const verification = await collectProjectVerification(cwd, { lintsOnly: false });
987
+ const check = await verification.check();
988
+ report.check = check;
989
+ ok = check.ok;
990
+ }
991
+ console.log(JSON.stringify(report, null, 2));
992
+ return ok;
574
993
  }
575
- // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
576
- if (write && options.verify !== false) {
994
+ console.log(renderPlan(plan, write, from, recipe.steps));
995
+ if (hooks !== undefined)
996
+ console.log(`\n${renderHookScope(hooks)}`);
997
+ if (verify) {
577
998
  const { runCheck } = await import("./check.js");
578
999
  const ok = await runCheck(cwd, { json: false });
579
1000
  if (!ok) {
580
- if (!options.json)
581
- console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.");
1001
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.");
582
1002
  return false;
583
1003
  }
584
1004
  }