@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/upgrade.ts 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.
@@ -22,10 +26,31 @@
22
26
  * transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
23
27
  */
24
28
 
25
- import { readFileSync, writeFileSync } from "node:fs"
26
- import { join, relative } from "node:path"
29
+ import {
30
+ existsSync,
31
+ lstatSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ realpathSync,
36
+ statSync,
37
+ writeFileSync,
38
+ } from "node:fs"
39
+ import { dirname, join, relative, sep } from "node:path"
40
+ import { fileURLToPath } from "node:url"
41
+ import type { ReflectedHookReport } from "@nifrajs/core/reflection"
27
42
  import { Glob } from "bun"
43
+ import {
44
+ collectHookReport,
45
+ diffHookReports,
46
+ HOOKS_BEFORE_UPGRADE_FILE,
47
+ type HookReportChange,
48
+ parseHookReport,
49
+ renderHookDiff,
50
+ } from "./hooks-report.ts"
51
+ import { PIN_SITES } from "./init-agents.ts"
28
52
  import { getRecipe, listRecipeVersions, type UpgradeRecipe } from "./recipes/index.ts"
53
+ import { markerTarget } from "./rules/comment-markers.ts"
29
54
 
30
55
  export interface UpgradeOptions {
31
56
  /** Target version, e.g. "1.8.0". Required unless `list` is set. */
@@ -44,6 +69,11 @@ export interface UpgradeOptions {
44
69
  readonly exact?: boolean
45
70
  /** The running CLI's version: the newest target it applies. Default: its newest recipe. */
46
71
  readonly cliVersion?: string
72
+ /**
73
+ * With `--write`, capture `nifra routes --hooks` before and after the edits and print the diff, so a
74
+ * change in which hooks run where is visible. Loads the backend. Default true; `--no-hooks` skips it.
75
+ */
76
+ readonly hooks?: boolean
47
77
  }
48
78
 
49
79
  export interface PinChange {
@@ -71,13 +101,38 @@ export interface DependencyMoveChange {
71
101
  readonly action: "renamed" | "removed"
72
102
  }
73
103
 
104
+ /** An agent file whose MCP launch pins another `@nifrajs/cli`, re-pinned to the target. */
105
+ export interface AgentPinChange {
106
+ readonly file: string
107
+ /** The versions it pinned, in order of appearance. */
108
+ readonly from: readonly string[]
109
+ readonly to: string
110
+ }
111
+
112
+ /** A retired comment in the project, and the line it applied to before the release retired it. */
113
+ export interface RetiredMarkerSite {
114
+ readonly file: string
115
+ readonly line: number
116
+ readonly marker: string
117
+ /** The line the comment covered; `undefined` when a blank line follows its comment block. */
118
+ readonly covers: { readonly line: number; readonly text: string } | undefined
119
+ readonly instead: string
120
+ }
121
+
74
122
  export interface UpgradePlan {
75
123
  readonly version: string
76
124
  readonly pins: readonly PinChange[]
125
+ readonly agentPins: readonly AgentPinChange[]
77
126
  readonly dependencyMoves: readonly DependencyMoveChange[]
78
127
  readonly importMoves: readonly ImportChange[]
79
128
  /** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
80
129
  readonly downgrades: readonly PinChange[]
130
+ /** Comments a release in the chain stopped honoring. Listed only, never rewritten. */
131
+ readonly retiredMarkers: readonly RetiredMarkerSite[]
132
+ /** Installed group packages linked from outside the workspace root, at another version. Never edited. */
133
+ readonly linkedOutside: readonly LinkedGroupInstall[]
134
+ /** Other versions pinned outside package.json. Never edited. */
135
+ readonly strayPins: readonly StrayPin[]
81
136
  readonly notes: readonly string[]
82
137
  }
83
138
 
@@ -203,63 +258,153 @@ export function pinSweepText(
203
258
  let out = text
204
259
  const changes: Array<Omit<PinChange, "file">> = []
205
260
  const downgrades: Array<Omit<PinChange, "file">> = []
261
+ for (const { path, key, target: name, spec: rawSpec } of pinEntries(parsed)) {
262
+ const field = path.join(".")
263
+ const rule = rules.find(
264
+ (r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)),
265
+ )
266
+ if (!rule) continue
267
+ const next = rewriteVersionSpec(rawSpec, rule.to, exact)
268
+ if (next === null) continue
269
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
270
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
271
+ const current = specVersionTuple(rawSpec)
272
+ const target = specVersionTuple(rule.to)
273
+ if (
274
+ !allowDowngrade &&
275
+ current !== null &&
276
+ target !== null &&
277
+ compareSemverSpec(rawSpec, rule.to) > 0
278
+ ) {
279
+ downgrades.push({ field, name: key, from: rawSpec, to: next })
280
+ continue
281
+ }
282
+ // Replace exactly `"key"<ws>:<ws>"spec"` inside this field's object, preserving the whitespace. A
283
+ // nested `overrides` entry repeated with the same spec maps the same way, so every one is replaced.
284
+ const range = objectRangeAt(out, path)
285
+ if (range === undefined) continue
286
+ const pattern = new RegExp(`("${escapeRegExp(key)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`, "g")
287
+ const body = out.slice(range.start, range.end)
288
+ const replaced = body.replace(pattern, `$1${next}$2`)
289
+ if (replaced !== body) {
290
+ out = out.slice(0, range.start) + replaced + out.slice(range.end)
291
+ changes.push({ field, name: key, from: rawSpec, to: next })
292
+ }
293
+ }
294
+ return { text: out, changes, downgrades }
295
+ }
296
+
297
+ /** Where a package manager forces one version over every range: npm and Bun `overrides` (nested by
298
+ * parent), Yarn and Bun `resolutions`, pnpm `pnpm.overrides`. */
299
+ const OVERRIDE_FIELDS: readonly (readonly string[])[] = [
300
+ ["overrides"],
301
+ ["resolutions"],
302
+ ["pnpm", "overrides"],
303
+ ]
304
+
305
+ /**
306
+ * The package an override key pins: `@nifrajs/core`, `parent/@nifrajs/core` and the same behind a `**`
307
+ * glob (resolutions), `parent>@nifrajs/core` and `@nifrajs/core@<4` (pnpm).
308
+ */
309
+ function overrideTarget(key: string): string {
310
+ const last = key.slice(key.lastIndexOf(">") + 1)
311
+ return /(?:^|\/)((?:@[^/@]+\/)?[^/@]+)(?:@[^/]*)?$/.exec(last)?.[1] ?? last
312
+ }
313
+
314
+ /** Every string version spec a package.json declares for a package, with the field path it sits in. */
315
+ function pinEntries(
316
+ parsed: Record<string, unknown>,
317
+ ): Array<{ path: readonly string[]; key: string; target: string; spec: string }> {
318
+ const entries: Array<{ path: readonly string[]; key: string; target: string; spec: string }> = []
206
319
  for (const field of DEP_FIELDS) {
207
320
  const deps = parsed[field]
208
321
  if (typeof deps !== "object" || deps === null) continue
209
- for (const [name, rawSpec] of Object.entries(deps as Record<string, unknown>)) {
210
- if (typeof rawSpec !== "string") continue
211
- const rule = rules.find(
212
- (r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)),
213
- )
214
- if (!rule) continue
215
- const next = rewriteVersionSpec(rawSpec, rule.to, exact)
216
- if (next === null) continue
217
- // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
218
- // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
219
- const current = specVersionTuple(rawSpec)
220
- const target = specVersionTuple(rule.to)
221
- if (
222
- !allowDowngrade &&
223
- current !== null &&
224
- target !== null &&
225
- compareSemverSpec(rawSpec, rule.to) > 0
226
- ) {
227
- downgrades.push({ field, name, from: rawSpec, to: next })
228
- continue
229
- }
230
- // Replace exactly `"name"<ws>:<ws>"spec"`, preserving the surrounding whitespace.
231
- const pattern = new RegExp(`("${escapeRegExp(name)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`)
232
- const replaced = out.replace(pattern, `$1${next}$2`)
233
- if (replaced !== out) {
234
- out = replaced
235
- changes.push({ field, name, from: rawSpec, to: next })
322
+ for (const [name, spec] of Object.entries(deps))
323
+ if (typeof spec === "string") entries.push({ path: [field], key: name, target: name, spec })
324
+ }
325
+ for (const path of OVERRIDE_FIELDS) {
326
+ let value: unknown = parsed
327
+ for (const key of path)
328
+ value = typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined
329
+ // npm nests an override under the package whose dependency it pins; `.` pins that package itself.
330
+ const walk = (object: object, parent: string | undefined): void => {
331
+ for (const [key, spec] of Object.entries(object)) {
332
+ const target = key === "." ? parent : overrideTarget(key)
333
+ if (typeof spec === "string" && target !== undefined)
334
+ entries.push({ path, key, target, spec })
335
+ else if (typeof spec === "object" && spec !== null && !Array.isArray(spec))
336
+ walk(spec, target)
236
337
  }
237
338
  }
339
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) walk(value, undefined)
238
340
  }
239
- return { text: out, changes, downgrades }
341
+ return entries
240
342
  }
241
343
 
242
- function dependencyObjectRange(
344
+ /** The body (between the braces) of the object at `path` in a JSON text: top-level key first, then
345
+ * each nested one, never a same-named key deeper in the document. */
346
+ function objectRangeAt(
243
347
  text: string,
244
- field: (typeof DEP_FIELDS)[number],
348
+ path: readonly string[],
245
349
  ): { start: number; end: number } | undefined {
246
- const match = new RegExp(`"${field}"\\s*:\\s*\\{`).exec(text)
247
- if (match === null) return undefined
248
- const open = text.indexOf("{", match.index)
350
+ const rootOpen = text.indexOf("{")
351
+ if (rootOpen === -1) return undefined
352
+ let range: { start: number; end: number } | undefined = {
353
+ start: rootOpen + 1,
354
+ end: closingBrace(text, rootOpen),
355
+ }
356
+ for (const key of path) {
357
+ if (range === undefined) return undefined
358
+ range = memberObjectRange(text, range, key)
359
+ }
360
+ return range
361
+ }
362
+
363
+ /** The index of the `"` that closes the string opening at `open`. */
364
+ function stringEnd(text: string, open: number): number {
365
+ for (let index = open + 1; index < text.length; index++) {
366
+ if (text[index] === "\\") index++
367
+ else if (text[index] === '"') return index
368
+ }
369
+ return text.length
370
+ }
371
+
372
+ /** The index of the brace that closes the one at `open`. */
373
+ function closingBrace(text: string, open: number): number {
249
374
  let depth = 0
250
- let quoted = false
251
- let escaped = false
252
375
  for (let index = open; index < text.length; index++) {
253
376
  const char = text[index]
254
- if (quoted) {
255
- if (escaped) escaped = false
256
- else if (char === "\\") escaped = true
257
- else if (char === '"') quoted = false
258
- continue
377
+ if (char === '"') index = stringEnd(text, index)
378
+ else if (char === "{" || char === "[") depth++
379
+ else if ((char === "}" || char === "]") && --depth === 0) return index
380
+ }
381
+ return text.length
382
+ }
383
+
384
+ /** The body of `key`'s object value among the direct members of the object body `within`. */
385
+ function memberObjectRange(
386
+ text: string,
387
+ within: { start: number; end: number },
388
+ key: string,
389
+ ): { start: number; end: number } | undefined {
390
+ let depth = 0
391
+ for (let index = within.start; index < within.end; index++) {
392
+ const char = text[index]
393
+ if (char === "{" || char === "[") depth++
394
+ else if (char === "}" || char === "]") depth--
395
+ else if (char === '"') {
396
+ const close = stringEnd(text, index)
397
+ let after = close + 1
398
+ while (/\s/.test(text[after] ?? "")) after++
399
+ if (depth === 0 && text[after] === ":" && text.slice(index + 1, close) === key) {
400
+ after++
401
+ while (/\s/.test(text[after] ?? "")) after++
402
+ return text[after] === "{"
403
+ ? { start: after + 1, end: closingBrace(text, after) }
404
+ : undefined
405
+ }
406
+ index = close
259
407
  }
260
- if (char === '"') quoted = true
261
- else if (char === "{") depth++
262
- else if (char === "}" && --depth === 0) return { start: open + 1, end: index }
263
408
  }
264
409
  return undefined
265
410
  }
@@ -272,7 +417,7 @@ function rewriteDependencyEntry(
272
417
  to: string | undefined,
273
418
  toVersion: string | undefined,
274
419
  ): string {
275
- const range = dependencyObjectRange(text, field)
420
+ const range = objectRangeAt(text, [field])
276
421
  if (range === undefined) return text
277
422
  const body = text.slice(range.start, range.end)
278
423
  const key = escapeRegExp(from)
@@ -396,17 +541,19 @@ const isIgnored = (path: string): boolean =>
396
541
  .slice(0, -1)
397
542
  .some((segment) => IGNORED_DIRECTORIES.has(segment))
398
543
 
399
- function scan(cwd: string, pattern: string): string[] {
544
+ function scan(cwd: string, pattern: string, dot = false): string[] {
400
545
  const glob = new Glob(pattern)
401
546
  const out: string[] = []
402
- for (const path of glob.scanSync({ cwd, dot: false })) {
403
- if (!isIgnored(path)) out.push(path)
547
+ // The write boundary: a symlinked file or directory (a linked repository) is never yielded, so the
548
+ // upgrade can only edit files that live under `cwd`.
549
+ for (const path of glob.scanSync({ cwd, dot, followSymlinks: false })) {
550
+ if (!isIgnored(path)) out.push(path.replaceAll("\\", "/"))
404
551
  }
405
552
  return out.sort() // deterministic order
406
553
  }
407
554
 
408
555
  // The fixed version group: every package released together at one version.
409
- const isFixedGroup = (name: string): boolean =>
556
+ export const isFixedGroup = (name: string): boolean =>
410
557
  name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra"
411
558
 
412
559
  const groupPins = (version: string): UpgradeRecipe["pins"] => [
@@ -415,12 +562,27 @@ const groupPins = (version: string): UpgradeRecipe["pins"] => [
415
562
  { match: "nifra", to: version },
416
563
  ]
417
564
 
565
+ /** Every fixed-group package a package.json under `cwd` names, whatever its spec. */
566
+ export function groupDeclaredNames(cwd: string): ReadonlySet<string> {
567
+ const names = new Set<string>()
568
+ for (const rel of scan(cwd, "**/package.json")) {
569
+ try {
570
+ const parsed = JSON.parse(readFileSync(join(cwd, rel), "utf8")) as Record<string, unknown>
571
+ for (const { target } of pinEntries(parsed)) if (isFixedGroup(target)) names.add(target)
572
+ } catch {
573
+ // Not JSON: it declares nothing.
574
+ }
575
+ }
576
+ return names
577
+ }
578
+
418
579
  /**
419
- * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
420
- * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
580
+ * Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
581
+ * with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
582
+ * and other non-semver specs are not versions.
421
583
  */
422
- export function installedGroupVersion(cwd: string): string | null {
423
- let lowest: string | null = null
584
+ export function groupPinVersions(cwd: string): ReadonlyMap<string, readonly string[]> {
585
+ const versions = new Map<string, string[]>()
424
586
  for (const rel of scan(cwd, "**/package.json")) {
425
587
  let parsed: Record<string, unknown>
426
588
  try {
@@ -428,17 +590,187 @@ export function installedGroupVersion(cwd: string): string | null {
428
590
  } catch {
429
591
  continue
430
592
  }
431
- for (const field of DEP_FIELDS) {
432
- const deps = parsed[field]
433
- if (typeof deps !== "object" || deps === null) continue
434
- for (const [name, spec] of Object.entries(deps as Record<string, unknown>)) {
435
- if (!isFixedGroup(name) || typeof spec !== "string") continue
436
- const version = specVersion(spec)
437
- if (version !== null && (lowest === null || compareSemverSpec(version, lowest) < 0)) {
438
- lowest = version
439
- }
440
- }
593
+ for (const { target, spec } of pinEntries(parsed)) {
594
+ if (!isFixedGroup(target)) continue
595
+ const version = specVersion(spec)
596
+ if (version === null) continue
597
+ const files = versions.get(version) ?? []
598
+ if (!files.includes(rel)) files.push(rel)
599
+ versions.set(version, files)
600
+ }
601
+ }
602
+ return versions
603
+ }
604
+
605
+ /** A fixed-group version spelled outside package.json, which the upgrade lists and never edits. */
606
+ export interface StrayPin {
607
+ readonly file: string
608
+ readonly line: number
609
+ /** As written: `@nifrajs/cli@3.1.0`. */
610
+ readonly pin: string
611
+ /** A lockfile other than the workspace's own: regenerate it. Listed once, with its other stale pins
612
+ * counted in `more`. */
613
+ readonly lockfile: boolean
614
+ readonly more: number
615
+ }
616
+
617
+ const STRAY_PIN_FILES = [
618
+ "**/{.mcp.json,mcp.json}",
619
+ "**/{AGENTS.md,CLAUDE.md}",
620
+ "**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
621
+ "**/{docker-compose,compose}*.{yml,yaml}",
622
+ ".github/workflows/*.{yml,yaml}",
623
+ ".gitlab-ci.yml",
624
+ "**/*.lock",
625
+ "**/{package-lock.json,pnpm-lock.yaml}",
626
+ ]
627
+ const LOCKFILE = /(?:\.lock|package-lock\.json|pnpm-lock\.yaml)$/
628
+ const GROUP_PIN =
629
+ /(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g
630
+
631
+ /**
632
+ * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs and
633
+ * agent guidance beyond the launch pin it moves, container files, CI workflows, and lockfiles besides
634
+ * the workspace's own (which install rewrites). `planned` holds a file's text after the upgrade's own
635
+ * edits, so a dry run lists what `--write` would leave.
636
+ */
637
+ export function strayPins(
638
+ cwd: string,
639
+ version: string,
640
+ planned: ReadonlyMap<string, string> = new Map(),
641
+ ): StrayPin[] {
642
+ const found: StrayPin[] = []
643
+ const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)))
644
+ for (const rel of [...files].sort()) {
645
+ const lockfile = LOCKFILE.test(rel)
646
+ if (lockfile && !/[\\/]/.test(rel)) continue
647
+ let text: string
648
+ try {
649
+ if (statSync(join(cwd, rel)).size > 8_000_000) continue
650
+ text = planned.get(rel) ?? readFileSync(join(cwd, rel), "utf8")
651
+ } catch {
652
+ continue
441
653
  }
654
+ const stale = [...text.matchAll(GROUP_PIN)].filter(
655
+ (match) => compareSemverSpec(match[2] ?? "", version) !== 0,
656
+ )
657
+ const site = (match: RegExpMatchArray, more: number): StrayPin => ({
658
+ file: rel,
659
+ line: text.slice(0, match.index ?? 0).split("\n").length,
660
+ pin: match[0],
661
+ lockfile,
662
+ more,
663
+ })
664
+ const first = stale[0]
665
+ if (first === undefined) continue
666
+ if (lockfile) found.push(site(first, stale.length - 1))
667
+ else for (const match of stale) found.push(site(match, 0))
668
+ }
669
+ return found
670
+ }
671
+
672
+ /** Whether `rel` is a regular file under `cwd` reached through no symlink: the upgrade's write boundary. */
673
+ function plainFileAt(cwd: string, rel: string): boolean {
674
+ let path = cwd
675
+ const parts = rel.split("/")
676
+ for (const [index, part] of parts.entries()) {
677
+ path = join(path, part)
678
+ let stat: ReturnType<typeof lstatSync>
679
+ try {
680
+ stat = lstatSync(path)
681
+ } catch {
682
+ return false
683
+ }
684
+ if (stat.isSymbolicLink()) return false
685
+ if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory()) return false
686
+ }
687
+ return true
688
+ }
689
+
690
+ /**
691
+ * The MCP launch pin in each agent file at `cwd`, moved to `version`: the files and the edit of
692
+ * `nifra init-agents --sync-mcp`, so an agent launches the nifra the project installs once it does.
693
+ */
694
+ function agentPinEdits(
695
+ cwd: string,
696
+ version: string,
697
+ ): Array<{ readonly change: AgentPinChange; readonly text: string }> {
698
+ const edits: Array<{ change: AgentPinChange; text: string }> = []
699
+ for (const site of PIN_SITES) {
700
+ if (!plainFileAt(cwd, site.path)) continue
701
+ // latin1 is one char per byte, so every byte outside a pin is written back as it was.
702
+ const scan = site.repin(readFileSync(join(cwd, site.path), "latin1"), version)
703
+ if (scan.stale.length === 0) continue
704
+ edits.push({ change: { file: site.path, from: scan.stale, to: version }, text: scan.text })
705
+ }
706
+ return edits
707
+ }
708
+
709
+ /** An installed fixed-group package, followed to its real location. */
710
+ export interface LinkedGroupInstall {
711
+ readonly package: string
712
+ readonly path: string
713
+ readonly version: string
714
+ }
715
+
716
+ const packageVersionAt = (dir: string): string | undefined => {
717
+ try {
718
+ const meta: unknown = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
719
+ const version = Reflect.get(Object(meta), "version")
720
+ return typeof version === "string" && version.length > 0 ? version : undefined
721
+ } catch {
722
+ return undefined
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Every fixed-group package installed in `cwd/node_modules`, realpath-resolved so a linked repository
728
+ * reports its own version. `inside` is false for one that resolves outside the workspace root.
729
+ */
730
+ export function installedGroupPackages(
731
+ cwd: string,
732
+ ): readonly (LinkedGroupInstall & { readonly inside: boolean })[] {
733
+ const modules = join(cwd, "node_modules")
734
+ const names = ["nifra", "create-nifra"].filter((name) => existsSync(join(modules, name)))
735
+ try {
736
+ for (const name of readdirSync(join(modules, "@nifrajs"))) names.push(`@nifrajs/${name}`)
737
+ } catch {
738
+ // No scoped installs.
739
+ }
740
+ let root: string
741
+ try {
742
+ root = realpathSync(cwd)
743
+ } catch {
744
+ root = cwd
745
+ }
746
+ const found: (LinkedGroupInstall & { inside: boolean })[] = []
747
+ for (const name of names) {
748
+ let path: string
749
+ try {
750
+ path = realpathSync(join(modules, name))
751
+ } catch {
752
+ continue
753
+ }
754
+ const version = packageVersionAt(path)
755
+ if (version === undefined) continue
756
+ found.push({
757
+ package: name,
758
+ path,
759
+ version,
760
+ inside: path === root || path.startsWith(root + sep),
761
+ })
762
+ }
763
+ return found
764
+ }
765
+
766
+ /**
767
+ * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
768
+ * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
769
+ */
770
+ export function installedGroupVersion(cwd: string): string | null {
771
+ let lowest: string | null = null
772
+ for (const version of groupPinVersions(cwd).keys()) {
773
+ if (lowest === null || compareSemverSpec(version, lowest) < 0) lowest = version
442
774
  }
443
775
  return lowest
444
776
  }
@@ -475,6 +807,7 @@ export function chainRecipes(target: string, installed: string | null): ChainedR
475
807
  pins: [...pins].map(([match, to]) => ({ match, to })),
476
808
  dependencyMoves: recipes.flatMap((recipe) => recipe.dependencyMoves ?? []),
477
809
  importMoves: recipes.flatMap((recipe) => recipe.importMoves),
810
+ retiredMarkers: recipes.flatMap((recipe) => recipe.retiredMarkers ?? []),
478
811
  notes: recipes.flatMap((recipe) =>
479
812
  (recipe.notes ?? []).map((note) => `${recipe.version}: ${note}`),
480
813
  ),
@@ -490,9 +823,15 @@ export function computeUpgrade(
490
823
  exact = false,
491
824
  ): UpgradePlan {
492
825
  const pins: PinChange[] = []
826
+ const agentPins: AgentPinChange[] = []
827
+ const planned = new Map<string, string>()
493
828
  const dependencyMoves: DependencyMoveChange[] = []
494
829
  const importMoves: ImportChange[] = []
495
830
  const downgrades: PinChange[] = []
831
+ const retiredMarkers: RetiredMarkerSite[] = []
832
+ const linkedOutside = installedGroupPackages(cwd)
833
+ .filter((entry) => !entry.inside && entry.version !== recipe.version)
834
+ .map(({ package: name, path, version }) => ({ package: name, path, version }))
496
835
 
497
836
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
498
837
  for (const rel of scan(cwd, "**/package.json")) {
@@ -507,6 +846,12 @@ export function computeUpgrade(
507
846
  }
508
847
  }
509
848
 
849
+ for (const { change, text } of agentPinEdits(cwd, recipe.version)) {
850
+ agentPins.push(change)
851
+ planned.set(change.file, text)
852
+ if (write) writeFileSync(join(cwd, change.file), Buffer.from(text, "latin1"))
853
+ }
854
+
510
855
  if (recipe.importMoves.length > 0) {
511
856
  for (const rel of scan(cwd, SOURCE_GLOB)) {
512
857
  const abs = join(cwd, rel)
@@ -518,12 +863,37 @@ export function computeUpgrade(
518
863
  }
519
864
  }
520
865
 
866
+ // Read after the import moves, so a listed line number is the one the file has after `--write`.
867
+ const retired = recipe.retiredMarkers ?? []
868
+ if (retired.length > 0) {
869
+ for (const rel of scan(cwd, SOURCE_GLOB)) {
870
+ const text = readFileSync(join(cwd, rel), "utf8")
871
+ if (!retired.some(({ marker }) => text.includes(marker))) continue
872
+ const lines = text.split("\n")
873
+ for (let line = 1; line <= lines.length; line++) {
874
+ for (const { marker, instead } of retired) {
875
+ if (!(lines[line - 1] ?? "").includes(marker)) continue
876
+ const target = markerTarget(lines, line)
877
+ const covers =
878
+ target === undefined
879
+ ? undefined
880
+ : { line: target, text: (lines[target - 1] ?? "").trim() }
881
+ retiredMarkers.push({ file: rel, line, marker, covers, instead })
882
+ }
883
+ }
884
+ }
885
+ }
886
+
521
887
  return {
522
888
  version: recipe.version,
523
889
  pins,
890
+ agentPins,
524
891
  dependencyMoves,
525
892
  importMoves,
526
893
  downgrades,
894
+ retiredMarkers,
895
+ linkedOutside,
896
+ strayPins: strayPins(cwd, recipe.version, planned),
527
897
  notes: recipe.notes ?? [],
528
898
  }
529
899
  }
@@ -547,8 +917,12 @@ function renderPlan(
547
917
  lines.push("")
548
918
  if (
549
919
  plan.pins.length === 0 &&
920
+ plan.agentPins.length === 0 &&
550
921
  plan.dependencyMoves.length === 0 &&
551
- plan.importMoves.length === 0
922
+ plan.importMoves.length === 0 &&
923
+ plan.retiredMarkers.length === 0 &&
924
+ plan.linkedOutside.length === 0 &&
925
+ plan.strayPins.length === 0
552
926
  ) {
553
927
  lines.push("Already up to date - no changes.")
554
928
  return lines.join("\n")
@@ -569,12 +943,58 @@ function renderPlan(
569
943
  for (const p of plan.pins) lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`)
570
944
  lines.push("")
571
945
  }
946
+ if (plan.agentPins.length > 0) {
947
+ lines.push(`${verb} ${plan.agentPins.length} MCP launch pin(s):`)
948
+ for (const p of plan.agentPins)
949
+ lines.push(` ${p.file} @nifrajs/cli: ${p.from.join(", ")} → ${p.to}`)
950
+ if (write) lines.push(" Restart your agent so it launches the new nifra MCP server.")
951
+ lines.push("")
952
+ }
572
953
  if (plan.importMoves.length > 0) {
573
954
  const total = plan.importMoves.reduce((n, m) => n + m.count, 0)
574
955
  lines.push(`${verb} ${total} import move(s) across ${plan.importMoves.length} file(s):`)
575
956
  for (const m of plan.importMoves) lines.push(` ${m.file} ${m.from} → ${m.to} (${m.count})`)
576
957
  lines.push("")
577
958
  }
959
+ if (plan.retiredMarkers.length > 0) {
960
+ lines.push(
961
+ `${plan.retiredMarkers.length} retired comment(s) that silence nothing now; nifra check reports what each covered:`,
962
+ )
963
+ for (const site of plan.retiredMarkers) {
964
+ const covers =
965
+ site.covers === undefined
966
+ ? "covers no line"
967
+ : `covers line ${site.covers.line}: ${site.covers.text}`
968
+ lines.push(` ${site.file}:${site.line} ${site.marker} ${covers}`)
969
+ lines.push(` write instead: ${site.instead}`)
970
+ }
971
+ lines.push("")
972
+ }
973
+ if (plan.linkedOutside.length > 0) {
974
+ lines.push(
975
+ "Left alone, linked from outside the workspace root (upgrade that repository on its own):",
976
+ )
977
+ for (const entry of plan.linkedOutside)
978
+ lines.push(` ${entry.package} ${entry.version} at ${entry.path}`)
979
+ lines.push("")
980
+ }
981
+ const pinned = plan.strayPins.filter((site) => !site.lockfile)
982
+ if (pinned.length > 0) {
983
+ lines.push(`Left alone, another version pinned outside package.json (update by hand):`)
984
+ for (const site of pinned) lines.push(` ${site.file}:${site.line} ${site.pin}`)
985
+ lines.push("")
986
+ }
987
+ const locks = plan.strayPins.filter((site) => site.lockfile)
988
+ if (locks.length > 0) {
989
+ lines.push(
990
+ `Left alone, a lockfile besides the workspace's own (regenerate it after installing):`,
991
+ )
992
+ for (const site of locks) {
993
+ const more = site.more === 0 ? "" : ` and ${site.more} more`
994
+ lines.push(` ${site.file}:${site.line} ${site.pin}${more}`)
995
+ }
996
+ lines.push("")
997
+ }
578
998
  for (const note of plan.notes) lines.push(`note: ${note}`)
579
999
  if (!write) lines.push("\nRe-run with --write to apply, then nifra check verifies.")
580
1000
  return lines.join("\n").trimEnd()
@@ -593,6 +1013,74 @@ function renderDowngradeRefusal(version: string, downgrades: readonly PinChange[
593
1013
  ].join("\n")
594
1014
  }
595
1015
 
1016
+ /** The hook report around `--write`: the diff and where the pre-upgrade report was saved, or why not. */
1017
+ export type UpgradeHookScope =
1018
+ | { readonly saved: string; readonly changes: readonly HookReportChange[] }
1019
+ | { readonly error: string; readonly saved?: string }
1020
+
1021
+ async function hookReportBefore(cwd: string): Promise<ReflectedHookReport | { error: string }> {
1022
+ try {
1023
+ const { loadBackendApp } = await import("./load.ts")
1024
+ const { backend } = await loadBackendApp(cwd)
1025
+ return await collectHookReport(backend)
1026
+ } catch (error) {
1027
+ return { error: error instanceof Error ? error.message : String(error) }
1028
+ }
1029
+ }
1030
+
1031
+ /** The report after the edits, from a fresh process: the edited sources must not come from this one's
1032
+ * module cache. Saves the pre-upgrade report for `nifra routes --hooks --since` after an install. */
1033
+ async function hookScopeAcross(
1034
+ cwd: string,
1035
+ before: ReflectedHookReport | { error: string },
1036
+ ): Promise<UpgradeHookScope> {
1037
+ if ("error" in before) return { error: before.error }
1038
+ const saved = join(cwd, HOOKS_BEFORE_UPGRADE_FILE)
1039
+ mkdirSync(dirname(saved), { recursive: true })
1040
+ writeFileSync(saved, `${JSON.stringify(before, null, 2)}\n`)
1041
+ const entry = fileURLToPath(
1042
+ new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url),
1043
+ )
1044
+ const child = Bun.spawn([process.execPath, entry, "routes", "--hooks", "--json"], {
1045
+ cwd,
1046
+ env: process.env,
1047
+ stdout: "pipe",
1048
+ stderr: "pipe",
1049
+ })
1050
+ const [text, , code] = await Promise.all([
1051
+ new Response(child.stdout).text(),
1052
+ new Response(child.stderr).text(),
1053
+ child.exited,
1054
+ ])
1055
+ const after = parseHookReport(text)
1056
+ if (after === undefined) {
1057
+ return {
1058
+ error: `\`nifra routes --hooks\` exited ${code} after the edits`,
1059
+ saved: HOOKS_BEFORE_UPGRADE_FILE,
1060
+ }
1061
+ }
1062
+ return { saved: HOOKS_BEFORE_UPGRADE_FILE, changes: diffHookReports(before, after) }
1063
+ }
1064
+
1065
+ function renderHookScope(scope: UpgradeHookScope): string {
1066
+ const since = (saved: string): string[] => [
1067
+ "The new release's hooks run once its packages are installed. After installing, compare against the report saved before the edits:",
1068
+ ` nifra routes --hooks --since ${saved}`,
1069
+ ]
1070
+ if ("error" in scope) {
1071
+ const head = `Hook scope: not captured (${scope.error}).`
1072
+ return scope.saved === undefined ? head : [head, ...since(scope.saved)].join("\n")
1073
+ }
1074
+ return [
1075
+ "Hook scope across the edits (nifra routes --hooks):",
1076
+ renderHookDiff(scope.changes)
1077
+ .split("\n")
1078
+ .map((line) => ` ${line}`)
1079
+ .join("\n"),
1080
+ ...since(scope.saved),
1081
+ ].join("\n")
1082
+ }
1083
+
596
1084
  /** The targets this CLI applies: each release recipe up to its own version, and that version. */
597
1085
  export function upgradeTargets(cliVersion: string): string[] {
598
1086
  const versions = listRecipeVersions().filter(
@@ -609,6 +1097,7 @@ function newerCliCommand(version: string, options: UpgradeOptions): string {
609
1097
  options.exact === true ? "--exact" : "",
610
1098
  options.allowDowngrade === true ? "--allow-downgrade" : "",
611
1099
  options.verify === false ? "--no-verify" : "",
1100
+ options.hooks === false ? "--no-hooks" : "",
612
1101
  options.json === true ? "--json" : "",
613
1102
  ].filter((flag) => flag !== "")
614
1103
  return ["bunx", `@nifrajs/cli@${version}`, "upgrade", version, ...flags].join(" ")
@@ -697,21 +1186,36 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
697
1186
  return false
698
1187
  }
699
1188
 
1189
+ const captureHooks = write && options.hooks !== false
1190
+ const before = captureHooks ? await hookReportBefore(cwd) : undefined
700
1191
  const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade, exact) : preview
1192
+ const hooks = before === undefined ? undefined : await hookScopeAcross(cwd, before)
701
1193
 
1194
+ // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
1195
+ const verify = write && options.verify !== false
702
1196
  if (options.json) {
703
- console.log(JSON.stringify({ ...plan, from, steps: recipe.steps, write }, null, 2))
704
- } else {
705
- console.log(renderPlan(plan, write, from, recipe.steps))
1197
+ // The check result rides inside the one JSON document; its human report would break the stream.
1198
+ const report: Record<string, unknown> = { ...plan, from, steps: recipe.steps, write }
1199
+ if (hooks !== undefined) report.hooks = hooks
1200
+ let ok = true
1201
+ if (verify) {
1202
+ const { collectProjectVerification } = await import("./verification.ts")
1203
+ const verification = await collectProjectVerification(cwd, { lintsOnly: false })
1204
+ const check = await verification.check()
1205
+ report.check = check
1206
+ ok = check.ok
1207
+ }
1208
+ console.log(JSON.stringify(report, null, 2))
1209
+ return ok
706
1210
  }
1211
+ console.log(renderPlan(plan, write, from, recipe.steps))
1212
+ if (hooks !== undefined) console.log(`\n${renderHookScope(hooks)}`)
707
1213
 
708
- // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
709
- if (write && options.verify !== false) {
1214
+ if (verify) {
710
1215
  const { runCheck } = await import("./check.ts")
711
1216
  const ok = await runCheck(cwd, { json: false })
712
1217
  if (!ok) {
713
- if (!options.json)
714
- console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
1218
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
715
1219
  return false
716
1220
  }
717
1221
  }