@nifrajs/cli 3.5.0 → 4.0.2

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 (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { join } from "node:path"
14
14
  import { Glob } from "bun"
15
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
15
16
 
16
17
  // The marker `generateServerManifest` writes at the top of the file - so a user file merely named
17
18
  // `server-manifest.ts` is never mistaken for a generated one. Mirrors `nifra check`'s drift scan.
@@ -20,6 +21,14 @@ const GENERATED_MARKER = "GENERATED by @nifrajs/web generateServerManifest"
20
21
  // routes dir relative to the manifest and strips imports to route-relative keys.
21
22
  const ROUTES_PREFIX = /["'](\.{1,2}(?:\/[^"'/]+)*?\/routes\/)[^"']+["']/
22
23
  const IGNORED = /(^|\/)(node_modules|dist(-[a-z0-9]+)?|build|\.nifra|\.git|\.wrangler|coverage)\//
24
+ // The fs codes that mean the routes dir is absent or unreadable. Every other throw is the route table
25
+ // refusing to build, which must fail the command rather than read as "no manifest found".
26
+ const ROUTES_DIR_UNAVAILABLE = new Set(["ENOENT", "ENOTDIR", "EACCES", "EPERM"])
27
+
28
+ function routesDirUnavailable(error: unknown): boolean {
29
+ if (typeof error !== "object" || error === null || !("code" in error)) return false
30
+ return typeof error.code === "string" && ROUTES_DIR_UNAVAILABLE.has(error.code)
31
+ }
23
32
 
24
33
  export interface SyncManifestResult {
25
34
  /** The committed server-manifest file (relative to cwd). */
@@ -33,12 +42,16 @@ export interface SyncManifestResult {
33
42
  /** Set when the sync was REFUSED because discovery found zero routes for a manifest that had some
34
43
  * (`routes/` likely emptied by mistake) - the manifest was left untouched rather than wiped. */
35
44
  readonly refusedEmpty?: boolean
45
+ /** Set when the `routes/` tree could not be built into a route table (a duplicate route, two routes of
46
+ * one shape sharing a path, ...) - the manifest was left untouched and the sync failed. */
47
+ readonly error?: string
36
48
  }
37
49
 
38
50
  /**
39
51
  * Discover every generated `server-manifest.ts` under `cwd` and re-sync its route table from the live
40
52
  * `routes/` tree, preserving baked client assets. Returns one result per generated manifest. Never throws
41
- * on a per-manifest miss (an unreadable/absent routes dir is skipped).
53
+ * on a per-manifest failure: an absent or unreadable routes dir is skipped, and a route table that fails
54
+ * to build is reported in that manifest's `error`.
42
55
  */
43
56
  export async function syncServerManifests(cwd: string): Promise<SyncManifestResult[]> {
44
57
  const { discoverRoutes } = await import("@nifrajs/web/fs")
@@ -59,8 +72,11 @@ export async function syncServerManifests(cwd: string): Promise<SyncManifestResu
59
72
  let next: string
60
73
  try {
61
74
  next = resyncServerManifestSource(source, discoverRoutes(routesDir), routesPrefix)
62
- } catch {
63
- continue // routes dir gone/unreadable - not a manifest we can sync
75
+ } catch (error) {
76
+ if (routesDirUnavailable(error)) continue // not a manifest we can sync
77
+ const message = error instanceof Error ? error.message : String(error)
78
+ results.push({ file: rel, changed: false, added: [], removed: [], error: message })
79
+ continue
64
80
  }
65
81
  const before = parseManifestRouteFiles(source, routesPrefix)
66
82
  const after = parseManifestRouteFiles(next, routesPrefix)
@@ -77,36 +93,51 @@ export async function syncServerManifests(cwd: string): Promise<SyncManifestResu
77
93
  const drift = diffManifestRoutes(before, after)
78
94
  results.push({ file: rel, changed, added: drift.missing, removed: drift.extra })
79
95
  }
80
- return results.sort((a, b) => a.file.localeCompare(b.file))
96
+ return results.sort((a, b) => codeUnitOrder(a.file, b.file))
81
97
  }
82
98
 
83
- /** CLI entry: sync every generated manifest, print a per-file summary + the client-rebuild caveat. */
84
- export async function runSyncManifest(cwd: string): Promise<boolean> {
85
- const results = await syncServerManifests(cwd)
86
- if (results.length === 0) {
87
- console.log("nifra sync-manifest: no generated server-manifest.ts found under this directory.")
88
- return true
89
- }
99
+ /** True when no manifest's route table failed to build. */
100
+ export function syncManifestsOk(results: readonly SyncManifestResult[]): boolean {
101
+ return results.every((r) => r.error === undefined)
102
+ }
103
+
104
+ /** Per-file summary lines + the client-rebuild caveat. */
105
+ export function renderSyncManifestResults(results: readonly SyncManifestResult[]): string[] {
106
+ if (results.length === 0)
107
+ return ["nifra sync-manifest: no generated server-manifest.ts found under this directory."]
108
+ const lines: string[] = []
90
109
  let anyChanged = false
91
110
  for (const r of results) {
92
- if (r.refusedEmpty) {
93
- console.log(
111
+ if (r.error !== undefined) {
112
+ lines.push(
113
+ `✖ ${r.file}: routes/ does not build a route table; left untouched.`,
114
+ ` ${r.error}`,
115
+ )
116
+ } else if (r.refusedEmpty) {
117
+ lines.push(
94
118
  `⚠ ${r.file}: refused - routes/ has zero routes but the manifest had some; left untouched (delete the manifest yourself if this is intentional).`,
95
119
  )
96
120
  } else if (r.changed) {
97
121
  anyChanged = true
98
- console.log(`✓ synced ${r.file}`)
99
- if (r.added.length > 0) console.log(` added: ${r.added.join(", ")}`)
100
- if (r.removed.length > 0) console.log(` removed: ${r.removed.join(", ")}`)
122
+ lines.push(`✓ synced ${r.file}`)
123
+ if (r.added.length > 0) lines.push(` added: ${r.added.join(", ")}`)
124
+ if (r.removed.length > 0) lines.push(` removed: ${r.removed.join(", ")}`)
101
125
  } else {
102
- console.log(`• ${r.file} already in sync`)
126
+ lines.push(`• ${r.file} already in sync`)
103
127
  }
104
128
  }
105
129
  if (anyChanged) {
106
- console.log(
130
+ lines.push(
107
131
  "\nRe-synced the server manifest's route table. If you added or changed a HYDRATING route\n" +
108
132
  "component (not just a loader / SSR-only route), also run a full build so the client bundle matches.",
109
133
  )
110
134
  }
111
- return true
135
+ return lines
136
+ }
137
+
138
+ /** CLI entry: sync every generated manifest and print the summary. False when a route table failed to build. */
139
+ export async function runSyncManifest(cwd: string): Promise<boolean> {
140
+ const results = await syncServerManifests(cwd)
141
+ console.log(renderSyncManifestResults(results).join("\n"))
142
+ return syncManifestsOk(results)
112
143
  }
@@ -24,9 +24,10 @@ export interface SyncRoutesResult {
24
24
  }
25
25
 
26
26
  /**
27
- * Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns `null` when
28
- * there is no `routes/` directory (an API-only project - nothing to type). Never loads `framework.ts`: it
29
- * needs only the route tree, so it works before a build and on a broken framework config.
27
+ * Generate the route-search types `.d.ts` from `<cwd>/routes` (the nifra convention). Returns
28
+ * `null` when there is no `routes/` directory (an API-only project - nothing to type). Never loads
29
+ * `backend/framework.ts`: it needs only the route tree, so it works before a build and on a broken
30
+ * framework config.
30
31
  */
31
32
  export async function syncRouteTypes(cwd: string): Promise<SyncRoutesResult | null> {
32
33
  const routesDir = join(cwd, "routes")
package/src/test-tool.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { type HeadTailOutput, readHeadTail } from "./mcp-io.ts"
2
+ import { mcpProjectPathError, resolveMcpProjectPath } from "./mcp-path.ts"
3
+
1
4
  export interface TestToolArgs {
2
5
  readonly pattern?: unknown
3
6
  readonly timeoutMs?: unknown
@@ -26,9 +29,11 @@ export interface TestToolResult {
26
29
 
27
30
  const DEFAULT_TIMEOUT_MS = 30_000
28
31
  const MAX_TIMEOUT_MS = 300_000
29
- const MAX_OUTPUT_CHARS = 12_000
32
+ const HEAD_CHARS = 4_000
33
+ const TAIL_CHARS = 8_000
34
+ const MAX_OUTPUT_CHARS = HEAD_CHARS + TAIL_CHARS
30
35
 
31
- function normalizePattern(value: unknown): string | undefined {
36
+ function normalizePattern(value: unknown, root: string): string | undefined {
32
37
  if (value === undefined || value === null) return undefined
33
38
  if (typeof value !== "string") throw new Error("pattern must be a string")
34
39
  const pattern = value.trim()
@@ -39,6 +44,9 @@ function normalizePattern(value: unknown): string | undefined {
39
44
  // `pattern` narrows test files; it is not a remote flag injection surface.
40
45
  if (pattern.startsWith("-"))
41
46
  throw new Error("pattern must be a file/path pattern, not a CLI flag")
47
+ // `bun test` runs any `./`, `../` or absolute path it is given, so a pattern stays inside the project.
48
+ if (resolveMcpProjectPath(root, pattern) === null)
49
+ throw new Error(mcpProjectPathError("pattern", pattern))
42
50
  return pattern
43
51
  }
44
52
 
@@ -52,11 +60,18 @@ function normalizeTimeout(value: unknown): number {
52
60
 
53
61
  const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g")
54
62
 
55
- function clean(text: string): string {
56
- const stripped = text.replace(ANSI, "")
63
+ // A UTF-8 character is at most four bytes, so these hold every character the result can show.
64
+ const HEAD_BYTES = HEAD_CHARS * 4
65
+ const TAIL_BYTES = TAIL_CHARS * 4
66
+
67
+ function clean(output: HeadTailOutput): string {
68
+ const strip = (text: string): string => text.replace(ANSI, "")
69
+ if (output.droppedBytes > 0)
70
+ return `${strip(output.head).slice(0, HEAD_CHARS)}\n…(trimmed more than ${output.droppedBytes} bytes)…\n${strip(output.tail).slice(-TAIL_CHARS)}`
71
+ const stripped = strip(output.head + output.tail)
57
72
  return stripped.length <= MAX_OUTPUT_CHARS
58
73
  ? stripped
59
- : `${stripped.slice(0, 4_000)}\n…(trimmed ${stripped.length - MAX_OUTPUT_CHARS} chars)…\n${stripped.slice(-8_000)}`
74
+ : `${stripped.slice(0, HEAD_CHARS)}\n…(trimmed ${stripped.length - MAX_OUTPUT_CHARS} chars)…\n${stripped.slice(-TAIL_CHARS)}`
60
75
  }
61
76
 
62
77
  function firstNumber(pattern: RegExp, text: string): number | undefined {
@@ -89,7 +104,7 @@ export async function collectTestResult(
89
104
  let pattern: string | undefined
90
105
  let timeoutMs: number
91
106
  try {
92
- pattern = normalizePattern(args.pattern)
107
+ pattern = normalizePattern(args.pattern, cwd)
93
108
  timeoutMs = normalizeTimeout(args.timeoutMs)
94
109
  } catch (err) {
95
110
  return {
@@ -124,8 +139,11 @@ export async function collectTestResult(
124
139
  : "cancelled",
125
140
  }
126
141
  }
127
- const proc = Bun.spawn(command, {
142
+ // The running Bun, not the first `bun` on PATH: an MCP client may start the server with no `bun` there.
143
+ const proc = Bun.spawn([process.execPath, ...command.slice(1)], {
128
144
  cwd,
145
+ // Explicit: without `env`, Bun passes the environment it started with, missing `--env-file` values.
146
+ env: process.env,
129
147
  stdout: "pipe",
130
148
  stderr: "pipe",
131
149
  })
@@ -140,13 +158,16 @@ export async function collectTestResult(
140
158
  timedOut = true
141
159
  proc.kill()
142
160
  }, timeoutMs)
143
- let stdoutRaw = ""
144
- let stderrRaw = ""
161
+ const empty: HeadTailOutput = { head: "", tail: "", droppedBytes: 0 }
162
+ let stdoutRaw = empty
163
+ let stderrRaw = empty
145
164
  let exitCode: number | null = null
146
165
  try {
166
+ // Drained to the end but held to what the result shows, so a test run that prints without limit
167
+ // cannot grow the server's memory with it.
147
168
  const result = await Promise.all([
148
- new Response(proc.stdout).text(),
149
- new Response(proc.stderr).text(),
169
+ readHeadTail(proc.stdout, HEAD_BYTES, TAIL_BYTES),
170
+ readHeadTail(proc.stderr, HEAD_BYTES, TAIL_BYTES),
150
171
  proc.exited,
151
172
  ])
152
173
  stdoutRaw = result[0]
@@ -9,7 +9,8 @@
9
9
  * signature/doc body; no args → a per-package index of the available type names.
10
10
  */
11
11
 
12
- import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-terms.ts"
12
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
13
+ import { countBodyHits, queryTermGroups, tokenize, tokenSetScore } from "./search-terms.ts"
13
14
 
14
15
  export interface TypeEntry {
15
16
  readonly name: string
@@ -63,13 +64,19 @@ export function searchTypes(
63
64
  for (const prepared of prepareTypes(types)) {
64
65
  const t = prepared.type
65
66
  let score = 0
67
+ let nameTermMatches = 0
66
68
  for (const term of terms) {
67
- if (tokenSetHas(prepared.nameTokens, term)) score += 8
69
+ const nameScore = tokenSetScore(prepared.nameTokens, term)
70
+ if (nameScore > 0) {
71
+ nameTermMatches += 1
72
+ score += 8 + nameScore * 4
73
+ }
68
74
  score += countBodyHits(prepared.bodyLower, term, 4)
69
75
  }
76
+ score += nameTermMatches * 4
70
77
  if (score > 0) scored.push({ ...t, score })
71
78
  }
72
- return scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit)
79
+ return scored.sort((a, b) => b.score - a.score || codeUnitOrder(a.name, b.name)).slice(0, limit)
73
80
  }
74
81
 
75
82
  function renderEntry(t: TypeEntry): string {
@@ -159,7 +166,7 @@ export function renderTypesResult(
159
166
  byPkg.set(t.package, list)
160
167
  }
161
168
  const index = [...byPkg.entries()]
162
- .sort(([a], [b]) => a.localeCompare(b))
169
+ .sort(([a], [b]) => codeUnitOrder(a, b))
163
170
  .map(([pkg, names]) => `- **${pkg}**: ${names.sort().join(", ")}`)
164
171
  .join("\n")
165
172
  return `# nifra types - index (${types.length} exported symbols; pass \`name\` for the exact declaration, or \`query\` to search)\n\nSignatures are generated from the built \`.d.ts\` - authoritative + complete. Use this instead of reading any \`.d.ts\` file.\n\n${index}`
package/src/upgrade.ts CHANGED
@@ -10,6 +10,11 @@
10
10
  * 3. **import moves** - rewrite exact import specifiers to their updated module paths.
11
11
  * 4. **verify** - reuse the existing `nifra check` gate; no new verification surface.
12
12
  *
13
+ * Upgrading across several releases runs every recipe between the installed version (the lowest
14
+ * fixed-group version the workspace declares) and the target, oldest first, then pins the target. A
15
+ * target newer than this CLI prints the newer CLI's command instead of guessing at recipes it does not
16
+ * carry; nothing is downloaded or run on the user's behalf.
17
+ *
13
18
  * Dry-run by default (prints the plan, writes nothing); `--write` applies. Fail-closed on an unknown
14
19
  * target version or a missing package.json, and deterministic (same repo + target → same edits).
15
20
  *
@@ -35,6 +40,10 @@ export interface UpgradeOptions {
35
40
  readonly verify?: boolean
36
41
  /** Permit a target BELOW the installed version (a rollback). Default false → fail-closed. */
37
42
  readonly allowDowngrade?: boolean
43
+ /** Pin the exact target (`3.6.0`), dropping the `^`/`~` a spec used. Default false → keep it. */
44
+ readonly exact?: boolean
45
+ /** The running CLI's version: the newest target it applies. Default: its newest recipe. */
46
+ readonly cliVersion?: string
38
47
  }
39
48
 
40
49
  export interface PinChange {
@@ -85,17 +94,28 @@ const SEMVER_SPEC =
85
94
  /^([\^~]|>=|<=|>|<|=)?\s*(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/
86
95
 
87
96
  /**
88
- * Rewrite a dependency version spec to `toVersion`, preserving the range operator (`^`, `~`, …).
89
- * Returns null when the spec is not a plain semver spec (→ skip it) or already equals the target.
97
+ * Rewrite a dependency version spec to `toVersion`, preserving the range operator (`^`, `~`, …), or
98
+ * dropping it when `exact`. Returns null when the spec is not a plain semver spec (→ skip it) or
99
+ * already equals the target.
90
100
  */
91
- export function rewriteVersionSpec(spec: string, toVersion: string): string | null {
101
+ export function rewriteVersionSpec(spec: string, toVersion: string, exact = false): string | null {
92
102
  const match = SEMVER_SPEC.exec(spec.trim())
93
103
  if (!match) return null
94
- const operator = match[1] ?? ""
104
+ const operator = exact ? "" : (match[1] ?? "")
95
105
  const next = `${operator}${toVersion}`
96
106
  return next === spec ? null : next
97
107
  }
98
108
 
109
+ /** The version a plain semver spec names (`^2.4.1` → `2.4.1`), or null for any other spec. */
110
+ export function specVersion(spec: string): string | null {
111
+ const match = SEMVER_SPEC.exec(spec.trim())
112
+ if (!match) return null
113
+ return `${match[2]}.${match[3]}.${match[4]}${match[5] === undefined ? "" : `-${match[5]}`}`
114
+ }
115
+
116
+ // A release target: a bare version, never a range, so it can be pinned and printed in a command.
117
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
118
+
99
119
  /** Numeric core (major, minor, patch) of a semver spec, ignoring the range operator + prerelease/build. */
100
120
  export function specVersionTuple(spec: string): readonly [number, number, number] | null {
101
121
  const match = SEMVER_SPEC.exec(spec.trim())
@@ -168,6 +188,7 @@ export function pinSweepText(
168
188
  text: string,
169
189
  rules: readonly { match: string; to: string }[],
170
190
  allowDowngrade = false,
191
+ exact = false,
171
192
  ): {
172
193
  text: string
173
194
  changes: Array<Omit<PinChange, "file">>
@@ -191,7 +212,7 @@ export function pinSweepText(
191
212
  (r) => name === r.match || (r.match.endsWith("/") && name.startsWith(r.match)),
192
213
  )
193
214
  if (!rule) continue
194
- const next = rewriteVersionSpec(rawSpec, rule.to)
215
+ const next = rewriteVersionSpec(rawSpec, rule.to, exact)
195
216
  if (next === null) continue
196
217
  // Refuse a rollback: `upgrade <version>` pins an exact target, so an OLD target on a newer install
197
218
  // walks dependencies backward (e.g. ^2.3.0 → ^2.0.0) and can break a shared-package peer range.
@@ -270,12 +291,12 @@ function rewriteDependencyEntry(
270
291
  let start = match.index
271
292
  let end = match.index + match[0].length
272
293
  let after = end
273
- while (/\\s/.test(body[after] ?? "")) after++
294
+ while (/\s/.test(body[after] ?? "")) after++
274
295
  if (body[after] === ",") {
275
296
  end = after + 1
276
297
  } else {
277
298
  let before = start - 1
278
- while (before >= 0 && /\\s/.test(body[before] ?? "")) before--
299
+ while (before >= 0 && /\s/.test(body[before] ?? "")) before--
279
300
  if (body[before] === ",") start = before
280
301
  }
281
302
  nextBody = body.slice(0, start) + body.slice(end)
@@ -287,6 +308,7 @@ function rewriteDependencyEntry(
287
308
  export function moveDependenciesText(
288
309
  text: string,
289
310
  moves: readonly { from: string; to: string; toVersion: string }[],
311
+ exact = false,
290
312
  ): { text: string; changes: Array<Omit<DependencyMoveChange, "file">> } {
291
313
  let out = text
292
314
  const changes: Array<Omit<DependencyMoveChange, "file">> = []
@@ -304,7 +326,7 @@ export function moveDependenciesText(
304
326
  if (!Object.hasOwn(dependencyMap, move.from)) continue
305
327
  const fromVersion = dependencyMap[move.from]
306
328
  if (typeof fromVersion !== "string") continue
307
- const movedVersion = rewriteVersionSpec(fromVersion, move.toVersion) ?? fromVersion
329
+ const movedVersion = rewriteVersionSpec(fromVersion, move.toVersion, exact) ?? fromVersion
308
330
  // Dependency fields have different install semantics. A devDependency does not satisfy a runtime
309
331
  // dependency, so only deduplicate the successor inside the same field.
310
332
  const targetExists = Object.hasOwn(dependencyMap, move.to)
@@ -329,6 +351,12 @@ export function moveDependenciesText(
329
351
  })
330
352
  }
331
353
  }
354
+ // The edit is textual; a result that no longer parses is never written.
355
+ try {
356
+ JSON.parse(out)
357
+ } catch {
358
+ return { text, changes: [] }
359
+ }
332
360
  return { text: out, changes }
333
361
  }
334
362
 
@@ -358,10 +386,15 @@ export function applyImportMoves(
358
386
  }
359
387
 
360
388
  const SOURCE_GLOB = "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"
361
- const IGNORE_SEGMENTS = ["node_modules/", "/dist/", "/build/", "/.git/", "/coverage/", "/.next/"]
389
+ const IGNORED_DIRECTORIES = new Set(["node_modules", "dist", "build", ".git", "coverage", ".next"])
362
390
 
391
+ // Any directory on the path counts, the top-level one included (`dist/index.js`). Bun's glob yields
392
+ // `dist\index.js` on Windows.
363
393
  const isIgnored = (path: string): boolean =>
364
- path.startsWith("node_modules/") || IGNORE_SEGMENTS.some((seg) => path.includes(seg))
394
+ path
395
+ .split(/[\\/]/)
396
+ .slice(0, -1)
397
+ .some((segment) => IGNORED_DIRECTORIES.has(segment))
365
398
 
366
399
  function scan(cwd: string, pattern: string): string[] {
367
400
  const glob = new Glob(pattern)
@@ -372,12 +405,89 @@ function scan(cwd: string, pattern: string): string[] {
372
405
  return out.sort() // deterministic order
373
406
  }
374
407
 
408
+ // The fixed version group: every package released together at one version.
409
+ const isFixedGroup = (name: string): boolean =>
410
+ name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra"
411
+
412
+ const groupPins = (version: string): UpgradeRecipe["pins"] => [
413
+ { match: "@nifrajs/", to: version },
414
+ { match: "create-nifra", to: version },
415
+ { match: "nifra", to: version },
416
+ ]
417
+
418
+ /**
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.
421
+ */
422
+ export function installedGroupVersion(cwd: string): string | null {
423
+ let lowest: string | null = null
424
+ for (const rel of scan(cwd, "**/package.json")) {
425
+ let parsed: Record<string, unknown>
426
+ try {
427
+ parsed = JSON.parse(readFileSync(join(cwd, rel), "utf8")) as Record<string, unknown>
428
+ } catch {
429
+ continue
430
+ }
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
+ }
441
+ }
442
+ }
443
+ return lowest
444
+ }
445
+
446
+ /** One recipe that applies every release recipe after `installed` up to `target`, oldest first. */
447
+ export interface ChainedRecipe extends UpgradeRecipe {
448
+ /** The release recipes it applies, oldest first. */
449
+ readonly steps: readonly string[]
450
+ }
451
+
452
+ /**
453
+ * Chain the recipes after `installed` (exclusive) up to `target` (inclusive): their dependency and
454
+ * import moves in release order, their notes labeled by release, and the fixed group pinned to
455
+ * `target`. Without a known installed version only the target's own recipe applies.
456
+ */
457
+ export function chainRecipes(target: string, installed: string | null): ChainedRecipe {
458
+ const steps =
459
+ installed === null
460
+ ? getRecipe(target) === undefined
461
+ ? []
462
+ : [target]
463
+ : listRecipeVersions().filter(
464
+ (version) =>
465
+ compareSemverSpec(version, installed) > 0 && compareSemverSpec(version, target) <= 0,
466
+ )
467
+ const recipes = steps.flatMap((version) => getRecipe(version) ?? [])
468
+ // A later rule for the same match replaces an earlier one; the target's pins win last.
469
+ const pins = new Map<string, string>()
470
+ for (const recipe of recipes) for (const rule of recipe.pins) pins.set(rule.match, rule.to)
471
+ for (const rule of getRecipe(target)?.pins ?? groupPins(target)) pins.set(rule.match, rule.to)
472
+ return {
473
+ version: target,
474
+ steps,
475
+ pins: [...pins].map(([match, to]) => ({ match, to })),
476
+ dependencyMoves: recipes.flatMap((recipe) => recipe.dependencyMoves ?? []),
477
+ importMoves: recipes.flatMap((recipe) => recipe.importMoves),
478
+ notes: recipes.flatMap((recipe) =>
479
+ (recipe.notes ?? []).map((note) => `${recipe.version}: ${note}`),
480
+ ),
481
+ }
482
+ }
483
+
375
484
  /** Compute the plan (and, when `write`, apply it) for a target recipe against `cwd`. */
376
485
  export function computeUpgrade(
377
486
  cwd: string,
378
487
  recipe: UpgradeRecipe,
379
488
  write: boolean,
380
489
  allowDowngrade = false,
490
+ exact = false,
381
491
  ): UpgradePlan {
382
492
  const pins: PinChange[] = []
383
493
  const dependencyMoves: DependencyMoveChange[] = []
@@ -388,9 +498,9 @@ export function computeUpgrade(
388
498
  for (const rel of scan(cwd, "**/package.json")) {
389
499
  const abs = join(cwd, rel)
390
500
  const original = readFileSync(abs, "utf8")
391
- const moved = moveDependenciesText(original, recipe.dependencyMoves ?? [])
501
+ const moved = moveDependenciesText(original, recipe.dependencyMoves ?? [], exact)
392
502
  for (const change of moved.changes) dependencyMoves.push({ file: rel, ...change })
393
- const pinned = pinSweepText(moved.text, recipe.pins, allowDowngrade)
503
+ const pinned = pinSweepText(moved.text, recipe.pins, allowDowngrade, exact)
394
504
  for (const change of pinned.changes) pins.push({ file: rel, ...change })
395
505
  for (const d of pinned.downgrades) downgrades.push({ file: rel, ...d })
396
506
  if (write && pinned.text !== original) writeFileSync(abs, pinned.text)
@@ -418,10 +528,22 @@ export function computeUpgrade(
418
528
  }
419
529
  }
420
530
 
421
- function renderPlan(plan: UpgradePlan, write: boolean): string {
531
+ function renderPlan(
532
+ plan: UpgradePlan,
533
+ write: boolean,
534
+ from: string | null,
535
+ steps: readonly string[],
536
+ ): string {
422
537
  const lines: string[] = []
423
538
  const verb = write ? "Applied" : "Planned"
424
539
  lines.push(`nifra upgrade → ${plan.version} (${write ? "write" : "dry-run"})`)
540
+ if (from !== null) {
541
+ lines.push(
542
+ steps.length === 0
543
+ ? `From ${from}; no release recipe in between.`
544
+ : `From ${from}, applying the ${steps.join(", ")} recipe${steps.length === 1 ? "" : "s"}.`,
545
+ )
546
+ }
425
547
  lines.push("")
426
548
  if (
427
549
  plan.pins.length === 0 &&
@@ -471,24 +593,73 @@ function renderDowngradeRefusal(version: string, downgrades: readonly PinChange[
471
593
  ].join("\n")
472
594
  }
473
595
 
596
+ /** The targets this CLI applies: each release recipe up to its own version, and that version. */
597
+ export function upgradeTargets(cliVersion: string): string[] {
598
+ const versions = listRecipeVersions().filter(
599
+ (version) => compareSemverSpec(version, cliVersion) <= 0,
600
+ )
601
+ if (!versions.includes(cliVersion)) versions.push(cliVersion)
602
+ return versions.sort(compareSemverSpec)
603
+ }
604
+
605
+ /** The command that runs `version`'s own CLI with the same flags. `version` is a validated release. */
606
+ function newerCliCommand(version: string, options: UpgradeOptions): string {
607
+ const flags = [
608
+ options.write === true ? "--write" : "",
609
+ options.exact === true ? "--exact" : "",
610
+ options.allowDowngrade === true ? "--allow-downgrade" : "",
611
+ options.verify === false ? "--no-verify" : "",
612
+ options.json === true ? "--json" : "",
613
+ ].filter((flag) => flag !== "")
614
+ return ["bunx", `@nifrajs/cli@${version}`, "upgrade", version, ...flags].join(" ")
615
+ }
616
+
474
617
  /** CLI entry. Returns false (→ non-zero exit) on an unknown version, no project, or a failed verify. */
475
618
  export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<boolean> {
619
+ const cliVersion = options.cliVersion ?? listRecipeVersions().at(-1) ?? "0.0.0"
620
+ const targets = upgradeTargets(cliVersion)
476
621
  if (options.list) {
477
- const versions = listRecipeVersions()
478
- if (options.json) console.log(JSON.stringify({ versions }, null, 2))
479
- else console.log(`available upgrade targets:\n${versions.map((v) => ` ${v}`).join("\n")}`)
622
+ if (options.json) console.log(JSON.stringify({ versions: targets, cliVersion }, null, 2))
623
+ else {
624
+ console.log(
625
+ `available upgrade targets:\n${targets.map((v) => ` ${v}`).join("\n")}\n\n` +
626
+ `A newer release upgrades with its own CLI: bunx @nifrajs/cli@<version> upgrade <version>`,
627
+ )
628
+ }
480
629
  return true
481
630
  }
482
631
 
483
632
  const { version } = options
484
633
  if (version === undefined) {
485
- console.error("[nifra] upgrade needs a target version, e.g. `nifra upgrade 1.8.0` (or --list)")
634
+ console.error(
635
+ `[nifra] upgrade needs a target version, e.g. \`nifra upgrade ${cliVersion}\` (or --list)`,
636
+ )
637
+ return false
638
+ }
639
+ if (!RELEASE_VERSION.test(version)) {
640
+ console.error(`[nifra] ${JSON.stringify(version)} is not a release version, e.g. ${cliVersion}`)
641
+ return false
642
+ }
643
+ if (compareSemverSpec(version, cliVersion) > 0) {
644
+ // Recipes ship with the CLI: only the target release's CLI knows every recipe up to it. Print its
645
+ // command rather than downloading and running code on the user's behalf.
646
+ const command = newerCliCommand(version, options)
647
+ if (options.json) {
648
+ console.log(
649
+ JSON.stringify({ version, cliVersion, refused: "newer-than-cli", command }, null, 2),
650
+ )
651
+ } else {
652
+ console.error(
653
+ `nifra upgrade → ${version}: this CLI is ${cliVersion} and carries the recipes up to it.\n` +
654
+ `Run the ${version} CLI, which carries every recipe up to ${version}:\n\n ${command}`,
655
+ )
656
+ }
486
657
  return false
487
658
  }
488
- const recipe = getRecipe(version)
489
- if (!recipe) {
659
+ if (!targets.includes(version)) {
490
660
  console.error(
491
- `[nifra] no upgrade recipe for ${version}. Available: ${listRecipeVersions().join(", ") || "(none)"}`,
661
+ `[nifra] no upgrade recipe for ${version}. This CLI upgrades to: ${targets.join(", ")}. ` +
662
+ `For ${version} itself, run: ${newerCliCommand(version, options)}`,
492
663
  )
493
664
  return false
494
665
  }
@@ -505,24 +676,33 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions): Promise<
505
676
 
506
677
  const write = options.write === true
507
678
  const allowDowngrade = options.allowDowngrade === true
679
+ const exact = options.exact === true
680
+ const from = installedGroupVersion(cwd)
681
+ const recipe = chainRecipes(version, from)
508
682
 
509
683
  // Fail-closed on rollback: a dry pass writes nothing, so we can refuse before any package.json is touched.
510
- const preview = computeUpgrade(cwd, recipe, false, allowDowngrade)
684
+ const preview = computeUpgrade(cwd, recipe, false, allowDowngrade, exact)
511
685
  if (preview.downgrades.length > 0) {
512
686
  if (options.json) {
513
- console.log(JSON.stringify({ ...preview, write: false, refused: "downgrade" }, null, 2))
687
+ console.log(
688
+ JSON.stringify(
689
+ { ...preview, from, steps: recipe.steps, write: false, refused: "downgrade" },
690
+ null,
691
+ 2,
692
+ ),
693
+ )
514
694
  } else {
515
695
  console.error(renderDowngradeRefusal(recipe.version, preview.downgrades))
516
696
  }
517
697
  return false
518
698
  }
519
699
 
520
- const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade) : preview
700
+ const plan = write ? computeUpgrade(cwd, recipe, true, allowDowngrade, exact) : preview
521
701
 
522
702
  if (options.json) {
523
- console.log(JSON.stringify({ ...plan, write }, null, 2))
703
+ console.log(JSON.stringify({ ...plan, from, steps: recipe.steps, write }, null, 2))
524
704
  } else {
525
- console.log(renderPlan(plan, write))
705
+ console.log(renderPlan(plan, write, from, recipe.steps))
526
706
  }
527
707
 
528
708
  // Verify only makes sense once edits are on disk. Default on after --write; opt out with --no-verify.