@nifrajs/cli 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +21 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +138 -38
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +28 -3
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +958 -71
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/4.0.0.d.ts.map +1 -1
  162. package/dist/recipes/4.0.0.js +2 -0
  163. package/dist/recipes/4.0.0.js.map +1 -1
  164. package/dist/recipes/5.0.0.d.ts +11 -0
  165. package/dist/recipes/5.0.0.d.ts.map +1 -0
  166. package/dist/recipes/5.0.0.js +34 -0
  167. package/dist/recipes/5.0.0.js.map +1 -0
  168. package/dist/recipes/index.d.ts +7 -0
  169. package/dist/recipes/index.d.ts.map +1 -1
  170. package/dist/recipes/index.js +2 -0
  171. package/dist/recipes/index.js.map +1 -1
  172. package/dist/replay.d.ts.map +1 -1
  173. package/dist/replay.js +12 -3
  174. package/dist/replay.js.map +1 -1
  175. package/dist/review.d.ts +3 -1
  176. package/dist/review.d.ts.map +1 -1
  177. package/dist/review.js +38 -7
  178. package/dist/review.js.map +1 -1
  179. package/dist/rules/codes.d.ts +10 -0
  180. package/dist/rules/codes.d.ts.map +1 -1
  181. package/dist/rules/codes.js +10 -0
  182. package/dist/rules/codes.js.map +1 -1
  183. package/dist/rules/comment-markers.d.ts +16 -7
  184. package/dist/rules/comment-markers.d.ts.map +1 -1
  185. package/dist/rules/comment-markers.js +58 -19
  186. package/dist/rules/comment-markers.js.map +1 -1
  187. package/dist/rules/contract-visibility.d.ts +3 -0
  188. package/dist/rules/contract-visibility.d.ts.map +1 -0
  189. package/dist/rules/contract-visibility.js +337 -0
  190. package/dist/rules/contract-visibility.js.map +1 -0
  191. package/dist/rules/data-guard.d.ts +7 -0
  192. package/dist/rules/data-guard.d.ts.map +1 -1
  193. package/dist/rules/data-guard.js +319 -37
  194. package/dist/rules/data-guard.js.map +1 -1
  195. package/dist/rules/layout.d.ts +4 -0
  196. package/dist/rules/layout.d.ts.map +1 -0
  197. package/dist/rules/layout.js +19 -0
  198. package/dist/rules/layout.js.map +1 -0
  199. package/dist/rules/legacy.d.ts.map +1 -1
  200. package/dist/rules/legacy.js +158 -43
  201. package/dist/rules/legacy.js.map +1 -1
  202. package/dist/rules/pages.d.ts +24 -0
  203. package/dist/rules/pages.d.ts.map +1 -1
  204. package/dist/rules/pages.js +221 -3
  205. package/dist/rules/pages.js.map +1 -1
  206. package/dist/rules/routes.d.ts +6 -0
  207. package/dist/rules/routes.d.ts.map +1 -1
  208. package/dist/rules/routes.js +111 -16
  209. package/dist/rules/routes.js.map +1 -1
  210. package/dist/rules/secrets.js +2 -2
  211. package/dist/rules/secrets.js.map +1 -1
  212. package/dist/rules/security.d.ts +12 -1
  213. package/dist/rules/security.d.ts.map +1 -1
  214. package/dist/rules/security.js +281 -68
  215. package/dist/rules/security.js.map +1 -1
  216. package/dist/rules/zones.d.ts +13 -0
  217. package/dist/rules/zones.d.ts.map +1 -1
  218. package/dist/rules/zones.js +192 -40
  219. package/dist/rules/zones.js.map +1 -1
  220. package/dist/scaffold.d.ts.map +1 -1
  221. package/dist/scaffold.js +6 -4
  222. package/dist/scaffold.js.map +1 -1
  223. package/dist/sdk.d.ts.map +1 -1
  224. package/dist/sdk.js +14 -5
  225. package/dist/sdk.js.map +1 -1
  226. package/dist/stylex-migrate.d.ts.map +1 -1
  227. package/dist/stylex-migrate.js +163 -7
  228. package/dist/stylex-migrate.js.map +1 -1
  229. package/dist/sync-manifest.d.ts +10 -2
  230. package/dist/sync-manifest.d.ts.map +1 -1
  231. package/dist/sync-manifest.js +42 -18
  232. package/dist/sync-manifest.js.map +1 -1
  233. package/dist/test-tool.d.ts.map +1 -1
  234. package/dist/test-tool.js +12 -3
  235. package/dist/test-tool.js.map +1 -1
  236. package/dist/typecheck-coverage.d.ts +20 -0
  237. package/dist/typecheck-coverage.d.ts.map +1 -0
  238. package/dist/typecheck-coverage.js +129 -0
  239. package/dist/typecheck-coverage.js.map +1 -0
  240. package/dist/upgrade.d.ts +69 -0
  241. package/dist/upgrade.d.ts.map +1 -1
  242. package/dist/upgrade.js +420 -77
  243. package/dist/upgrade.js.map +1 -1
  244. package/dist/verification-plan.d.ts.map +1 -1
  245. package/dist/verification-plan.js +11 -0
  246. package/dist/verification-plan.js.map +1 -1
  247. package/dist/version-drift.d.ts +22 -0
  248. package/dist/version-drift.d.ts.map +1 -0
  249. package/dist/version-drift.js +42 -0
  250. package/dist/version-drift.js.map +1 -0
  251. package/dist/web-app-options.d.ts +1 -1
  252. package/dist/web-app-options.d.ts.map +1 -1
  253. package/dist/web-app-options.js +1 -1
  254. package/dist/web-app-options.js.map +1 -1
  255. package/dist/work-graph.d.ts.map +1 -1
  256. package/dist/work-graph.js +20 -8
  257. package/dist/work-graph.js.map +1 -1
  258. package/docs/examples.json +54 -6
  259. package/docs/llms-full.txt +1539 -138
  260. package/docs/types.json +1456 -206
  261. package/package.json +11 -12
  262. package/src/agent-files.ts +203 -0
  263. package/src/app-files.ts +34 -4
  264. package/src/assure-hydration.ts +15 -2
  265. package/src/capabilities-tool.ts +15 -3
  266. package/src/check-diagnostics.ts +73 -9
  267. package/src/check-scan.ts +1773 -381
  268. package/src/check.ts +237 -76
  269. package/src/cli.ts +141 -5
  270. package/src/client-module.ts +61 -0
  271. package/src/command-catalog.ts +181 -47
  272. package/src/component-check.ts +79 -0
  273. package/src/contract-proof.ts +3 -25
  274. package/src/contracts.ts +60 -3
  275. package/src/db-tool.ts +5 -2
  276. package/src/dev-bun-config.ts +51 -10
  277. package/src/diagnostics.ts +3 -0
  278. package/src/diff-tool.ts +33 -26
  279. package/src/doctor.ts +43 -21
  280. package/src/env-file.ts +7 -0
  281. package/src/fix-recipes.ts +34 -3
  282. package/src/hand-built-manifest.ts +144 -0
  283. package/src/hooks-report.ts +135 -0
  284. package/src/init-agents.ts +17 -11
  285. package/src/internal/nifra-project.ts +191 -0
  286. package/src/internal/project-specifiers.ts +424 -0
  287. package/src/internal/script-kind.ts +2 -1
  288. package/src/internal/source-facts.ts +25 -0
  289. package/src/internal/typescript-import.ts +10 -3
  290. package/src/introspect.ts +65 -27
  291. package/src/levels-tool.ts +117 -20
  292. package/src/load.ts +78 -14
  293. package/src/mcp-context.ts +7 -7
  294. package/src/mcp-exec.ts +60 -17
  295. package/src/mcp-http.ts +1 -1
  296. package/src/mcp-io.ts +41 -0
  297. package/src/mcp-isolate.ts +9 -2
  298. package/src/mcp-project-child.ts +1 -1
  299. package/src/mcp-root.ts +58 -8
  300. package/src/mcp-ws.ts +4 -2
  301. package/src/mcp.ts +8 -3
  302. package/src/migrate-layout.ts +1109 -70
  303. package/src/output-schemas.ts +1084 -0
  304. package/src/port.ts +3 -2
  305. package/src/project-facts.ts +17 -3
  306. package/src/recipes/4.0.0.ts +2 -0
  307. package/src/recipes/5.0.0.ts +35 -0
  308. package/src/recipes/index.ts +10 -0
  309. package/src/replay.ts +14 -3
  310. package/src/review.ts +39 -10
  311. package/src/rules/codes.ts +10 -0
  312. package/src/rules/comment-markers.ts +57 -21
  313. package/src/rules/contract-visibility.ts +383 -0
  314. package/src/rules/data-guard.ts +351 -42
  315. package/src/rules/layout.ts +22 -0
  316. package/src/rules/legacy.ts +184 -51
  317. package/src/rules/pages.ts +238 -3
  318. package/src/rules/routes.ts +124 -16
  319. package/src/rules/secrets.ts +2 -2
  320. package/src/rules/security.ts +352 -71
  321. package/src/rules/zones.ts +205 -38
  322. package/src/scaffold.ts +6 -4
  323. package/src/sdk.ts +14 -5
  324. package/src/stylex-migrate.ts +134 -7
  325. package/src/sync-manifest.ts +48 -18
  326. package/src/test-tool.ts +18 -3
  327. package/src/typecheck-coverage.ts +134 -0
  328. package/src/upgrade.ts +487 -71
  329. package/src/verification-plan.ts +24 -0
  330. package/src/version-drift.ts +67 -0
  331. package/src/web-app-options.ts +2 -2
  332. package/src/work-graph.ts +17 -12
package/src/upgrade.ts CHANGED
@@ -5,6 +5,8 @@
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
+ * a version pinned anywhere else (an MCP config, a Dockerfile, another lockfile) is listed only.
8
10
  * 2. **dependency moves** - replace removed packages with their supported successor without
9
11
  * reserializing package.json or leaving duplicate dependency keys.
10
12
  * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
@@ -22,10 +24,29 @@
22
24
  * transforms are a future addition - a recipe that needs one is the signal to add the engine, not before.
23
25
  */
24
26
 
25
- import { readFileSync, writeFileSync } from "node:fs"
26
- import { join, relative } from "node:path"
27
+ import {
28
+ existsSync,
29
+ mkdirSync,
30
+ readdirSync,
31
+ readFileSync,
32
+ realpathSync,
33
+ statSync,
34
+ writeFileSync,
35
+ } from "node:fs"
36
+ import { dirname, join, relative, sep } from "node:path"
37
+ import { fileURLToPath } from "node:url"
38
+ import type { ReflectedHookReport } from "@nifrajs/core/reflection"
27
39
  import { Glob } from "bun"
40
+ import {
41
+ collectHookReport,
42
+ diffHookReports,
43
+ HOOKS_BEFORE_UPGRADE_FILE,
44
+ type HookReportChange,
45
+ parseHookReport,
46
+ renderHookDiff,
47
+ } from "./hooks-report.ts"
28
48
  import { getRecipe, listRecipeVersions, type UpgradeRecipe } from "./recipes/index.ts"
49
+ import { markerTarget } from "./rules/comment-markers.ts"
29
50
 
30
51
  export interface UpgradeOptions {
31
52
  /** Target version, e.g. "1.8.0". Required unless `list` is set. */
@@ -44,6 +65,11 @@ export interface UpgradeOptions {
44
65
  readonly exact?: boolean
45
66
  /** The running CLI's version: the newest target it applies. Default: its newest recipe. */
46
67
  readonly cliVersion?: string
68
+ /**
69
+ * With `--write`, capture `nifra routes --hooks` before and after the edits and print the diff, so a
70
+ * change in which hooks run where is visible. Loads the backend. Default true; `--no-hooks` skips it.
71
+ */
72
+ readonly hooks?: boolean
47
73
  }
48
74
 
49
75
  export interface PinChange {
@@ -71,6 +97,16 @@ export interface DependencyMoveChange {
71
97
  readonly action: "renamed" | "removed"
72
98
  }
73
99
 
100
+ /** A retired comment in the project, and the line it applied to before the release retired it. */
101
+ export interface RetiredMarkerSite {
102
+ readonly file: string
103
+ readonly line: number
104
+ readonly marker: string
105
+ /** The line the comment covered; `undefined` when a blank line follows its comment block. */
106
+ readonly covers: { readonly line: number; readonly text: string } | undefined
107
+ readonly instead: string
108
+ }
109
+
74
110
  export interface UpgradePlan {
75
111
  readonly version: string
76
112
  readonly pins: readonly PinChange[]
@@ -78,6 +114,12 @@ export interface UpgradePlan {
78
114
  readonly importMoves: readonly ImportChange[]
79
115
  /** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
80
116
  readonly downgrades: readonly PinChange[]
117
+ /** Comments a release in the chain stopped honoring. Listed only, never rewritten. */
118
+ readonly retiredMarkers: readonly RetiredMarkerSite[]
119
+ /** Installed group packages linked from outside the workspace root, at another version. Never edited. */
120
+ readonly linkedOutside: readonly LinkedGroupInstall[]
121
+ /** Other versions pinned outside package.json. Never edited. */
122
+ readonly strayPins: readonly StrayPin[]
81
123
  readonly notes: readonly string[]
82
124
  }
83
125
 
@@ -203,63 +245,153 @@ export function pinSweepText(
203
245
  let out = text
204
246
  const changes: Array<Omit<PinChange, "file">> = []
205
247
  const downgrades: Array<Omit<PinChange, "file">> = []
248
+ for (const { path, key, target: name, spec: rawSpec } of pinEntries(parsed)) {
249
+ const field = path.join(".")
250
+ const rule = rules.find(
251
+ (r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)),
252
+ )
253
+ if (!rule) continue
254
+ const next = rewriteVersionSpec(rawSpec, rule.to, exact)
255
+ if (next === null) continue
256
+ // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
257
+ // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
258
+ const current = specVersionTuple(rawSpec)
259
+ const target = specVersionTuple(rule.to)
260
+ if (
261
+ !allowDowngrade &&
262
+ current !== null &&
263
+ target !== null &&
264
+ compareSemverSpec(rawSpec, rule.to) > 0
265
+ ) {
266
+ downgrades.push({ field, name: key, from: rawSpec, to: next })
267
+ continue
268
+ }
269
+ // Replace exactly `"key"<ws>:<ws>"spec"` inside this field's object, preserving the whitespace. A
270
+ // nested `overrides` entry repeated with the same spec maps the same way, so every one is replaced.
271
+ const range = objectRangeAt(out, path)
272
+ if (range === undefined) continue
273
+ const pattern = new RegExp(`("${escapeRegExp(key)}"\\s*:\\s*")${escapeRegExp(rawSpec)}(")`, "g")
274
+ const body = out.slice(range.start, range.end)
275
+ const replaced = body.replace(pattern, `$1${next}$2`)
276
+ if (replaced !== body) {
277
+ out = out.slice(0, range.start) + replaced + out.slice(range.end)
278
+ changes.push({ field, name: key, from: rawSpec, to: next })
279
+ }
280
+ }
281
+ return { text: out, changes, downgrades }
282
+ }
283
+
284
+ /** Where a package manager forces one version over every range: npm and Bun `overrides` (nested by
285
+ * parent), Yarn and Bun `resolutions`, pnpm `pnpm.overrides`. */
286
+ const OVERRIDE_FIELDS: readonly (readonly string[])[] = [
287
+ ["overrides"],
288
+ ["resolutions"],
289
+ ["pnpm", "overrides"],
290
+ ]
291
+
292
+ /**
293
+ * The package an override key pins: `@nifrajs/core`, `parent/@nifrajs/core` and the same behind a `**`
294
+ * glob (resolutions), `parent>@nifrajs/core` and `@nifrajs/core@<4` (pnpm).
295
+ */
296
+ function overrideTarget(key: string): string {
297
+ const last = key.slice(key.lastIndexOf(">") + 1)
298
+ return /(?:^|\/)((?:@[^/@]+\/)?[^/@]+)(?:@[^/]*)?$/.exec(last)?.[1] ?? last
299
+ }
300
+
301
+ /** Every string version spec a package.json declares for a package, with the field path it sits in. */
302
+ function pinEntries(
303
+ parsed: Record<string, unknown>,
304
+ ): Array<{ path: readonly string[]; key: string; target: string; spec: string }> {
305
+ const entries: Array<{ path: readonly string[]; key: string; target: string; spec: string }> = []
206
306
  for (const field of DEP_FIELDS) {
207
307
  const deps = parsed[field]
208
308
  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 })
309
+ for (const [name, spec] of Object.entries(deps))
310
+ if (typeof spec === "string") entries.push({ path: [field], key: name, target: name, spec })
311
+ }
312
+ for (const path of OVERRIDE_FIELDS) {
313
+ let value: unknown = parsed
314
+ for (const key of path)
315
+ value = typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined
316
+ // npm nests an override under the package whose dependency it pins; `.` pins that package itself.
317
+ const walk = (object: object, parent: string | undefined): void => {
318
+ for (const [key, spec] of Object.entries(object)) {
319
+ const target = key === "." ? parent : overrideTarget(key)
320
+ if (typeof spec === "string" && target !== undefined)
321
+ entries.push({ path, key, target, spec })
322
+ else if (typeof spec === "object" && spec !== null && !Array.isArray(spec))
323
+ walk(spec, target)
236
324
  }
237
325
  }
326
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) walk(value, undefined)
238
327
  }
239
- return { text: out, changes, downgrades }
328
+ return entries
240
329
  }
241
330
 
242
- function dependencyObjectRange(
331
+ /** The body (between the braces) of the object at `path` in a JSON text: top-level key first, then
332
+ * each nested one, never a same-named key deeper in the document. */
333
+ function objectRangeAt(
243
334
  text: string,
244
- field: (typeof DEP_FIELDS)[number],
335
+ path: readonly string[],
245
336
  ): { 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)
337
+ const rootOpen = text.indexOf("{")
338
+ if (rootOpen === -1) return undefined
339
+ let range: { start: number; end: number } | undefined = {
340
+ start: rootOpen + 1,
341
+ end: closingBrace(text, rootOpen),
342
+ }
343
+ for (const key of path) {
344
+ if (range === undefined) return undefined
345
+ range = memberObjectRange(text, range, key)
346
+ }
347
+ return range
348
+ }
349
+
350
+ /** The index of the `"` that closes the string opening at `open`. */
351
+ function stringEnd(text: string, open: number): number {
352
+ for (let index = open + 1; index < text.length; index++) {
353
+ if (text[index] === "\\") index++
354
+ else if (text[index] === '"') return index
355
+ }
356
+ return text.length
357
+ }
358
+
359
+ /** The index of the brace that closes the one at `open`. */
360
+ function closingBrace(text: string, open: number): number {
249
361
  let depth = 0
250
- let quoted = false
251
- let escaped = false
252
362
  for (let index = open; index < text.length; index++) {
253
363
  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
364
+ if (char === '"') index = stringEnd(text, index)
365
+ else if (char === "{" || char === "[") depth++
366
+ else if ((char === "}" || char === "]") && --depth === 0) return index
367
+ }
368
+ return text.length
369
+ }
370
+
371
+ /** The body of `key`'s object value among the direct members of the object body `within`. */
372
+ function memberObjectRange(
373
+ text: string,
374
+ within: { start: number; end: number },
375
+ key: string,
376
+ ): { start: number; end: number } | undefined {
377
+ let depth = 0
378
+ for (let index = within.start; index < within.end; index++) {
379
+ const char = text[index]
380
+ if (char === "{" || char === "[") depth++
381
+ else if (char === "}" || char === "]") depth--
382
+ else if (char === '"') {
383
+ const close = stringEnd(text, index)
384
+ let after = close + 1
385
+ while (/\s/.test(text[after] ?? "")) after++
386
+ if (depth === 0 && text[after] === ":" && text.slice(index + 1, close) === key) {
387
+ after++
388
+ while (/\s/.test(text[after] ?? "")) after++
389
+ return text[after] === "{"
390
+ ? { start: after + 1, end: closingBrace(text, after) }
391
+ : undefined
392
+ }
393
+ index = close
259
394
  }
260
- if (char === '"') quoted = true
261
- else if (char === "{") depth++
262
- else if (char === "}" && --depth === 0) return { start: open + 1, end: index }
263
395
  }
264
396
  return undefined
265
397
  }
@@ -272,7 +404,7 @@ function rewriteDependencyEntry(
272
404
  to: string | undefined,
273
405
  toVersion: string | undefined,
274
406
  ): string {
275
- const range = dependencyObjectRange(text, field)
407
+ const range = objectRangeAt(text, [field])
276
408
  if (range === undefined) return text
277
409
  const body = text.slice(range.start, range.end)
278
410
  const key = escapeRegExp(from)
@@ -396,11 +528,13 @@ const isIgnored = (path: string): boolean =>
396
528
  .slice(0, -1)
397
529
  .some((segment) => IGNORED_DIRECTORIES.has(segment))
398
530
 
399
- function scan(cwd: string, pattern: string): string[] {
531
+ function scan(cwd: string, pattern: string, dot = false): string[] {
400
532
  const glob = new Glob(pattern)
401
533
  const out: string[] = []
402
- for (const path of glob.scanSync({ cwd, dot: false })) {
403
- if (!isIgnored(path)) out.push(path)
534
+ // The write boundary: a symlinked file or directory (a linked repository) is never yielded, so the
535
+ // upgrade can only edit files that live under `cwd`.
536
+ for (const path of glob.scanSync({ cwd, dot, followSymlinks: false })) {
537
+ if (!isIgnored(path)) out.push(path.replaceAll("\\", "/"))
404
538
  }
405
539
  return out.sort() // deterministic order
406
540
  }
@@ -416,11 +550,12 @@ const groupPins = (version: string): UpgradeRecipe["pins"] => [
416
550
  ]
417
551
 
418
552
  /**
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.
553
+ * Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
554
+ * with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
555
+ * and other non-semver specs are not versions.
421
556
  */
422
- export function installedGroupVersion(cwd: string): string | null {
423
- let lowest: string | null = null
557
+ export function groupPinVersions(cwd: string): ReadonlyMap<string, readonly string[]> {
558
+ const versions = new Map<string, string[]>()
424
559
  for (const rel of scan(cwd, "**/package.json")) {
425
560
  let parsed: Record<string, unknown>
426
561
  try {
@@ -428,18 +563,144 @@ export function installedGroupVersion(cwd: string): string | null {
428
563
  } catch {
429
564
  continue
430
565
  }
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
- }
566
+ for (const { target, spec } of pinEntries(parsed)) {
567
+ if (!isFixedGroup(target)) continue
568
+ const version = specVersion(spec)
569
+ if (version === null) continue
570
+ const files = versions.get(version) ?? []
571
+ if (!files.includes(rel)) files.push(rel)
572
+ versions.set(version, files)
441
573
  }
442
574
  }
575
+ return versions
576
+ }
577
+
578
+ /** A fixed-group version spelled outside package.json, which the upgrade lists and never edits. */
579
+ export interface StrayPin {
580
+ readonly file: string
581
+ readonly line: number
582
+ /** As written: `@nifrajs/cli@3.1.0`. */
583
+ readonly pin: string
584
+ /** A lockfile other than the workspace's own: regenerate it. Listed once, with its other stale pins
585
+ * counted in `more`. */
586
+ readonly lockfile: boolean
587
+ readonly more: number
588
+ }
589
+
590
+ const STRAY_PIN_FILES = [
591
+ "**/{.mcp.json,mcp.json}",
592
+ "**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
593
+ "**/{docker-compose,compose}*.{yml,yaml}",
594
+ ".github/workflows/*.{yml,yaml}",
595
+ ".gitlab-ci.yml",
596
+ "**/*.lock",
597
+ "**/{package-lock.json,pnpm-lock.yaml}",
598
+ ]
599
+ const LOCKFILE = /(?:\.lock|package-lock\.json|pnpm-lock\.yaml)$/
600
+ const GROUP_PIN =
601
+ /(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g
602
+
603
+ /**
604
+ * Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs,
605
+ * container files, CI workflows, and lockfiles besides the workspace's own (which install rewrites).
606
+ */
607
+ export function strayPins(cwd: string, version: string): StrayPin[] {
608
+ const found: StrayPin[] = []
609
+ const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)))
610
+ for (const rel of [...files].sort()) {
611
+ const lockfile = LOCKFILE.test(rel)
612
+ if (lockfile && !/[\\/]/.test(rel)) continue
613
+ let text: string
614
+ try {
615
+ if (statSync(join(cwd, rel)).size > 8_000_000) continue
616
+ text = readFileSync(join(cwd, rel), "utf8")
617
+ } catch {
618
+ continue
619
+ }
620
+ const stale = [...text.matchAll(GROUP_PIN)].filter(
621
+ (match) => compareSemverSpec(match[2] ?? "", version) !== 0,
622
+ )
623
+ const site = (match: RegExpMatchArray, more: number): StrayPin => ({
624
+ file: rel,
625
+ line: text.slice(0, match.index ?? 0).split("\n").length,
626
+ pin: match[0],
627
+ lockfile,
628
+ more,
629
+ })
630
+ const first = stale[0]
631
+ if (first === undefined) continue
632
+ if (lockfile) found.push(site(first, stale.length - 1))
633
+ else for (const match of stale) found.push(site(match, 0))
634
+ }
635
+ return found
636
+ }
637
+
638
+ /** An installed fixed-group package, followed to its real location. */
639
+ export interface LinkedGroupInstall {
640
+ readonly package: string
641
+ readonly path: string
642
+ readonly version: string
643
+ }
644
+
645
+ const packageVersionAt = (dir: string): string | undefined => {
646
+ try {
647
+ const meta: unknown = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
648
+ const version = Reflect.get(Object(meta), "version")
649
+ return typeof version === "string" && version.length > 0 ? version : undefined
650
+ } catch {
651
+ return undefined
652
+ }
653
+ }
654
+
655
+ /**
656
+ * Every fixed-group package installed in `cwd/node_modules`, realpath-resolved so a linked repository
657
+ * reports its own version. `inside` is false for one that resolves outside the workspace root.
658
+ */
659
+ export function installedGroupPackages(
660
+ cwd: string,
661
+ ): readonly (LinkedGroupInstall & { readonly inside: boolean })[] {
662
+ const modules = join(cwd, "node_modules")
663
+ const names = ["nifra", "create-nifra"].filter((name) => existsSync(join(modules, name)))
664
+ try {
665
+ for (const name of readdirSync(join(modules, "@nifrajs"))) names.push(`@nifrajs/${name}`)
666
+ } catch {
667
+ // No scoped installs.
668
+ }
669
+ let root: string
670
+ try {
671
+ root = realpathSync(cwd)
672
+ } catch {
673
+ root = cwd
674
+ }
675
+ const found: (LinkedGroupInstall & { inside: boolean })[] = []
676
+ for (const name of names) {
677
+ let path: string
678
+ try {
679
+ path = realpathSync(join(modules, name))
680
+ } catch {
681
+ continue
682
+ }
683
+ const version = packageVersionAt(path)
684
+ if (version === undefined) continue
685
+ found.push({
686
+ package: name,
687
+ path,
688
+ version,
689
+ inside: path === root || path.startsWith(root + sep),
690
+ })
691
+ }
692
+ return found
693
+ }
694
+
695
+ /**
696
+ * The lowest fixed-group version any package.json under `cwd` declares with a plain semver spec, or
697
+ * null when none does. Recipes newer than it still have edits to make somewhere in the workspace.
698
+ */
699
+ export function installedGroupVersion(cwd: string): string | null {
700
+ let lowest: string | null = null
701
+ for (const version of groupPinVersions(cwd).keys()) {
702
+ if (lowest === null || compareSemverSpec(version, lowest) < 0) lowest = version
703
+ }
443
704
  return lowest
444
705
  }
445
706
 
@@ -475,6 +736,7 @@ export function chainRecipes(target: string, installed: string | null): ChainedR
475
736
  pins: [...pins].map(([match, to]) => ({ match, to })),
476
737
  dependencyMoves: recipes.flatMap((recipe) => recipe.dependencyMoves ?? []),
477
738
  importMoves: recipes.flatMap((recipe) => recipe.importMoves),
739
+ retiredMarkers: recipes.flatMap((recipe) => recipe.retiredMarkers ?? []),
478
740
  notes: recipes.flatMap((recipe) =>
479
741
  (recipe.notes ?? []).map((note) => `${recipe.version}: ${note}`),
480
742
  ),
@@ -493,6 +755,10 @@ export function computeUpgrade(
493
755
  const dependencyMoves: DependencyMoveChange[] = []
494
756
  const importMoves: ImportChange[] = []
495
757
  const downgrades: PinChange[] = []
758
+ const retiredMarkers: RetiredMarkerSite[] = []
759
+ const linkedOutside = installedGroupPackages(cwd)
760
+ .filter((entry) => !entry.inside && entry.version !== recipe.version)
761
+ .map(({ package: name, path, version }) => ({ package: name, path, version }))
496
762
 
497
763
  if ((recipe.dependencyMoves?.length ?? 0) > 0 || recipe.pins.length > 0) {
498
764
  for (const rel of scan(cwd, "**/package.json")) {
@@ -518,12 +784,36 @@ export function computeUpgrade(
518
784
  }
519
785
  }
520
786
 
787
+ // Read after the import moves, so a listed line number is the one the file has after `--write`.
788
+ const retired = recipe.retiredMarkers ?? []
789
+ if (retired.length > 0) {
790
+ for (const rel of scan(cwd, SOURCE_GLOB)) {
791
+ const text = readFileSync(join(cwd, rel), "utf8")
792
+ if (!retired.some(({ marker }) => text.includes(marker))) continue
793
+ const lines = text.split("\n")
794
+ for (let line = 1; line <= lines.length; line++) {
795
+ for (const { marker, instead } of retired) {
796
+ if (!(lines[line - 1] ?? "").includes(marker)) continue
797
+ const target = markerTarget(lines, line)
798
+ const covers =
799
+ target === undefined
800
+ ? undefined
801
+ : { line: target, text: (lines[target - 1] ?? "").trim() }
802
+ retiredMarkers.push({ file: rel, line, marker, covers, instead })
803
+ }
804
+ }
805
+ }
806
+ }
807
+
521
808
  return {
522
809
  version: recipe.version,
523
810
  pins,
524
811
  dependencyMoves,
525
812
  importMoves,
526
813
  downgrades,
814
+ retiredMarkers,
815
+ linkedOutside,
816
+ strayPins: strayPins(cwd, recipe.version),
527
817
  notes: recipe.notes ?? [],
528
818
  }
529
819
  }
@@ -548,7 +838,10 @@ function renderPlan(
548
838
  if (
549
839
  plan.pins.length === 0 &&
550
840
  plan.dependencyMoves.length === 0 &&
551
- plan.importMoves.length === 0
841
+ plan.importMoves.length === 0 &&
842
+ plan.retiredMarkers.length === 0 &&
843
+ plan.linkedOutside.length === 0 &&
844
+ plan.strayPins.length === 0
552
845
  ) {
553
846
  lines.push("Already up to date - no changes.")
554
847
  return lines.join("\n")
@@ -575,6 +868,45 @@ function renderPlan(
575
868
  for (const m of plan.importMoves) lines.push(` ${m.file} ${m.from} → ${m.to} (${m.count})`)
576
869
  lines.push("")
577
870
  }
871
+ if (plan.retiredMarkers.length > 0) {
872
+ lines.push(
873
+ `${plan.retiredMarkers.length} retired comment(s) that silence nothing now; nifra check reports what each covered:`,
874
+ )
875
+ for (const site of plan.retiredMarkers) {
876
+ const covers =
877
+ site.covers === undefined
878
+ ? "covers no line"
879
+ : `covers line ${site.covers.line}: ${site.covers.text}`
880
+ lines.push(` ${site.file}:${site.line} ${site.marker} ${covers}`)
881
+ lines.push(` write instead: ${site.instead}`)
882
+ }
883
+ lines.push("")
884
+ }
885
+ if (plan.linkedOutside.length > 0) {
886
+ lines.push(
887
+ "Left alone, linked from outside the workspace root (upgrade that repository on its own):",
888
+ )
889
+ for (const entry of plan.linkedOutside)
890
+ lines.push(` ${entry.package} ${entry.version} at ${entry.path}`)
891
+ lines.push("")
892
+ }
893
+ const pinned = plan.strayPins.filter((site) => !site.lockfile)
894
+ if (pinned.length > 0) {
895
+ lines.push(`Left alone, another version pinned outside package.json (update by hand):`)
896
+ for (const site of pinned) lines.push(` ${site.file}:${site.line} ${site.pin}`)
897
+ lines.push("")
898
+ }
899
+ const locks = plan.strayPins.filter((site) => site.lockfile)
900
+ if (locks.length > 0) {
901
+ lines.push(
902
+ `Left alone, a lockfile besides the workspace's own (regenerate it after installing):`,
903
+ )
904
+ for (const site of locks) {
905
+ const more = site.more === 0 ? "" : ` and ${site.more} more`
906
+ lines.push(` ${site.file}:${site.line} ${site.pin}${more}`)
907
+ }
908
+ lines.push("")
909
+ }
578
910
  for (const note of plan.notes) lines.push(`note: ${note}`)
579
911
  if (!write) lines.push("\nRe-run with --write to apply, then nifra check verifies.")
580
912
  return lines.join("\n").trimEnd()
@@ -593,6 +925,74 @@ function renderDowngradeRefusal(version: string, downgrades: readonly PinChange[
593
925
  ].join("\n")
594
926
  }
595
927
 
928
+ /** The hook report around `--write`: the diff and where the pre-upgrade report was saved, or why not. */
929
+ export type UpgradeHookScope =
930
+ | { readonly saved: string; readonly changes: readonly HookReportChange[] }
931
+ | { readonly error: string; readonly saved?: string }
932
+
933
+ async function hookReportBefore(cwd: string): Promise<ReflectedHookReport | { error: string }> {
934
+ try {
935
+ const { loadBackendApp } = await import("./load.ts")
936
+ const { backend } = await loadBackendApp(cwd)
937
+ return await collectHookReport(backend)
938
+ } catch (error) {
939
+ return { error: error instanceof Error ? error.message : String(error) }
940
+ }
941
+ }
942
+
943
+ /** The report after the edits, from a fresh process: the edited sources must not come from this one's
944
+ * module cache. Saves the pre-upgrade report for `nifra routes --hooks --since` after an install. */
945
+ async function hookScopeAcross(
946
+ cwd: string,
947
+ before: ReflectedHookReport | { error: string },
948
+ ): Promise<UpgradeHookScope> {
949
+ if ("error" in before) return { error: before.error }
950
+ const saved = join(cwd, HOOKS_BEFORE_UPGRADE_FILE)
951
+ mkdirSync(dirname(saved), { recursive: true })
952
+ writeFileSync(saved, `${JSON.stringify(before, null, 2)}\n`)
953
+ const entry = fileURLToPath(
954
+ new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url),
955
+ )
956
+ const child = Bun.spawn([process.execPath, entry, "routes", "--hooks", "--json"], {
957
+ cwd,
958
+ env: process.env,
959
+ stdout: "pipe",
960
+ stderr: "pipe",
961
+ })
962
+ const [text, , code] = await Promise.all([
963
+ new Response(child.stdout).text(),
964
+ new Response(child.stderr).text(),
965
+ child.exited,
966
+ ])
967
+ const after = parseHookReport(text)
968
+ if (after === undefined) {
969
+ return {
970
+ error: `\`nifra routes --hooks\` exited ${code} after the edits`,
971
+ saved: HOOKS_BEFORE_UPGRADE_FILE,
972
+ }
973
+ }
974
+ return { saved: HOOKS_BEFORE_UPGRADE_FILE, changes: diffHookReports(before, after) }
975
+ }
976
+
977
+ function renderHookScope(scope: UpgradeHookScope): string {
978
+ const since = (saved: string): string[] => [
979
+ "The new release's hooks run once its packages are installed. After installing, compare against the report saved before the edits:",
980
+ ` nifra routes --hooks --since ${saved}`,
981
+ ]
982
+ if ("error" in scope) {
983
+ const head = `Hook scope: not captured (${scope.error}).`
984
+ return scope.saved === undefined ? head : [head, ...since(scope.saved)].join("\n")
985
+ }
986
+ return [
987
+ "Hook scope across the edits (nifra routes --hooks):",
988
+ renderHookDiff(scope.changes)
989
+ .split("\n")
990
+ .map((line) => ` ${line}`)
991
+ .join("\n"),
992
+ ...since(scope.saved),
993
+ ].join("\n")
994
+ }
995
+
596
996
  /** The targets this CLI applies: each release recipe up to its own version, and that version. */
597
997
  export function upgradeTargets(cliVersion: string): string[] {
598
998
  const versions = listRecipeVersions().filter(
@@ -609,6 +1009,7 @@ function newerCliCommand(version: string, options: UpgradeOptions): string {
609
1009
  options.exact === true ? "--exact" : "",
610
1010
  options.allowDowngrade === true ? "--allow-downgrade" : "",
611
1011
  options.verify === false ? "--no-verify" : "",
1012
+ options.hooks === false ? "--no-hooks" : "",
612
1013
  options.json === true ? "--json" : "",
613
1014
  ].filter((flag) => flag !== "")
614
1015
  return ["bunx", `@nifrajs/cli@${version}`, "upgrade", version, ...flags].join(" ")
@@ -697,21 +1098,36 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
697
1098
  return false
698
1099
  }
699
1100
 
1101
+ const captureHooks = write && options.hooks !== false
1102
+ const before = captureHooks ? await hookReportBefore(cwd) : undefined
700
1103
  const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade, exact) : preview
1104
+ const hooks = before === undefined ? undefined : await hookScopeAcross(cwd, before)
701
1105
 
1106
+ // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.
1107
+ const verify = write && options.verify !== false
702
1108
  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))
1109
+ // The check result rides inside the one JSON document; its human report would break the stream.
1110
+ const report: Record<string, unknown> = { ...plan, from, steps: recipe.steps, write }
1111
+ if (hooks !== undefined) report.hooks = hooks
1112
+ let ok = true
1113
+ if (verify) {
1114
+ const { collectProjectVerification } = await import("./verification.ts")
1115
+ const verification = await collectProjectVerification(cwd, { lintsOnly: false })
1116
+ const check = await verification.check()
1117
+ report.check = check
1118
+ ok = check.ok
1119
+ }
1120
+ console.log(JSON.stringify(report, null, 2))
1121
+ return ok
706
1122
  }
1123
+ console.log(renderPlan(plan, write, from, recipe.steps))
1124
+ if (hooks !== undefined) console.log(`\n${renderHookScope(hooks)}`)
707
1125
 
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) {
1126
+ if (verify) {
710
1127
  const { runCheck } = await import("./check.ts")
711
1128
  const ok = await runCheck(cwd, { json: false })
712
1129
  if (!ok) {
713
- if (!options.json)
714
- console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
1130
+ console.error("\n[nifra] upgrade applied but `nifra check` failed - review above.")
715
1131
  return false
716
1132
  }
717
1133
  }