@nifrajs/cli 4.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (323) hide show
  1. package/README.md +3 -0
  2. package/dist/agent-files.d.ts +107 -0
  3. package/dist/agent-files.d.ts.map +1 -0
  4. package/dist/agent-files.js +162 -0
  5. package/dist/agent-files.js.map +1 -0
  6. package/dist/app-files.d.ts +6 -0
  7. package/dist/app-files.d.ts.map +1 -1
  8. package/dist/app-files.js +27 -2
  9. package/dist/app-files.js.map +1 -1
  10. package/dist/assure-hydration.d.ts.map +1 -1
  11. package/dist/assure-hydration.js +7 -2
  12. package/dist/assure-hydration.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts.map +1 -1
  14. package/dist/capabilities-tool.js +13 -3
  15. package/dist/capabilities-tool.js.map +1 -1
  16. package/dist/check-diagnostics.d.ts +20 -2
  17. package/dist/check-diagnostics.d.ts.map +1 -1
  18. package/dist/check-diagnostics.js +54 -7
  19. package/dist/check-diagnostics.js.map +1 -1
  20. package/dist/check-scan.d.ts +75 -22
  21. package/dist/check-scan.d.ts.map +1 -1
  22. package/dist/check-scan.js +1475 -310
  23. package/dist/check-scan.js.map +1 -1
  24. package/dist/check.d.ts +2 -2
  25. package/dist/check.d.ts.map +1 -1
  26. package/dist/check.js +192 -65
  27. package/dist/check.js.map +1 -1
  28. package/dist/cli.d.ts +7 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +112 -5
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client-module.d.ts +18 -0
  33. package/dist/client-module.d.ts.map +1 -0
  34. package/dist/client-module.js +49 -0
  35. package/dist/client-module.js.map +1 -0
  36. package/dist/command-catalog.d.ts +16 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +133 -29
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/component-check.d.ts +13 -0
  41. package/dist/component-check.d.ts.map +1 -0
  42. package/dist/component-check.js +59 -0
  43. package/dist/component-check.js.map +1 -0
  44. package/dist/contract-proof.d.ts +1 -1
  45. package/dist/contract-proof.d.ts.map +1 -1
  46. package/dist/contract-proof.js +2 -24
  47. package/dist/contract-proof.js.map +1 -1
  48. package/dist/contracts.d.ts +19 -0
  49. package/dist/contracts.d.ts.map +1 -1
  50. package/dist/contracts.js +50 -3
  51. package/dist/contracts.js.map +1 -1
  52. package/dist/db-tool.d.ts.map +1 -1
  53. package/dist/db-tool.js +4 -3
  54. package/dist/db-tool.js.map +1 -1
  55. package/dist/dev-bun-config.d.ts +5 -1
  56. package/dist/dev-bun-config.d.ts.map +1 -1
  57. package/dist/dev-bun-config.js +44 -11
  58. package/dist/dev-bun-config.js.map +1 -1
  59. package/dist/diagnostics.d.ts +3 -0
  60. package/dist/diagnostics.d.ts.map +1 -1
  61. package/dist/diagnostics.js.map +1 -1
  62. package/dist/diff-tool.d.ts +9 -8
  63. package/dist/diff-tool.d.ts.map +1 -1
  64. package/dist/diff-tool.js +28 -24
  65. package/dist/diff-tool.js.map +1 -1
  66. package/dist/doctor.d.ts +13 -0
  67. package/dist/doctor.d.ts.map +1 -1
  68. package/dist/doctor.js +23 -15
  69. package/dist/doctor.js.map +1 -1
  70. package/dist/env-file.d.ts +6 -0
  71. package/dist/env-file.d.ts.map +1 -1
  72. package/dist/env-file.js +6 -0
  73. package/dist/env-file.js.map +1 -1
  74. package/dist/fix-recipes.d.ts.map +1 -1
  75. package/dist/fix-recipes.js +21 -2
  76. package/dist/fix-recipes.js.map +1 -1
  77. package/dist/hand-built-manifest.d.ts +27 -0
  78. package/dist/hand-built-manifest.d.ts.map +1 -0
  79. package/dist/hand-built-manifest.js +124 -0
  80. package/dist/hand-built-manifest.js.map +1 -0
  81. package/dist/hooks-report.d.ts +23 -0
  82. package/dist/hooks-report.d.ts.map +1 -0
  83. package/dist/hooks-report.js +104 -0
  84. package/dist/hooks-report.js.map +1 -0
  85. package/dist/init-agents.d.ts +2 -2
  86. package/dist/init-agents.d.ts.map +1 -1
  87. package/dist/init-agents.js +17 -11
  88. package/dist/init-agents.js.map +1 -1
  89. package/dist/internal/nifra-project.d.ts +41 -0
  90. package/dist/internal/nifra-project.d.ts.map +1 -0
  91. package/dist/internal/nifra-project.js +183 -0
  92. package/dist/internal/nifra-project.js.map +1 -0
  93. package/dist/internal/project-specifiers.d.ts +16 -0
  94. package/dist/internal/project-specifiers.d.ts.map +1 -0
  95. package/dist/internal/project-specifiers.js +396 -0
  96. package/dist/internal/project-specifiers.js.map +1 -0
  97. package/dist/internal/script-kind.d.ts.map +1 -1
  98. package/dist/internal/script-kind.js +2 -1
  99. package/dist/internal/script-kind.js.map +1 -1
  100. package/dist/internal/source-facts.d.ts +2 -0
  101. package/dist/internal/source-facts.d.ts.map +1 -1
  102. package/dist/internal/source-facts.js +11 -0
  103. package/dist/internal/source-facts.js.map +1 -1
  104. package/dist/internal/typescript-import.d.ts +1 -1
  105. package/dist/internal/typescript-import.d.ts.map +1 -1
  106. package/dist/internal/typescript-import.js +5 -3
  107. package/dist/internal/typescript-import.js.map +1 -1
  108. package/dist/introspect.d.ts +6 -6
  109. package/dist/introspect.d.ts.map +1 -1
  110. package/dist/introspect.js +55 -24
  111. package/dist/introspect.js.map +1 -1
  112. package/dist/levels-tool.d.ts.map +1 -1
  113. package/dist/levels-tool.js +93 -15
  114. package/dist/levels-tool.js.map +1 -1
  115. package/dist/load.d.ts +31 -8
  116. package/dist/load.d.ts.map +1 -1
  117. package/dist/load.js +37 -7
  118. package/dist/load.js.map +1 -1
  119. package/dist/mcp-context.d.ts +5 -5
  120. package/dist/mcp-context.d.ts.map +1 -1
  121. package/dist/mcp-context.js +2 -2
  122. package/dist/mcp-context.js.map +1 -1
  123. package/dist/mcp-exec.d.ts +12 -4
  124. package/dist/mcp-exec.d.ts.map +1 -1
  125. package/dist/mcp-exec.js +50 -13
  126. package/dist/mcp-exec.js.map +1 -1
  127. package/dist/mcp-http.js +1 -1
  128. package/dist/mcp-io.d.ts +17 -0
  129. package/dist/mcp-io.d.ts.map +1 -1
  130. package/dist/mcp-io.js +40 -0
  131. package/dist/mcp-io.js.map +1 -1
  132. package/dist/mcp-isolate.d.ts.map +1 -1
  133. package/dist/mcp-isolate.js +3 -2
  134. package/dist/mcp-isolate.js.map +1 -1
  135. package/dist/mcp-project-child.js +1 -1
  136. package/dist/mcp-project-child.js.map +1 -1
  137. package/dist/mcp-root.d.ts.map +1 -1
  138. package/dist/mcp-root.js +50 -10
  139. package/dist/mcp-root.js.map +1 -1
  140. package/dist/mcp-ws.d.ts.map +1 -1
  141. package/dist/mcp-ws.js +3 -1
  142. package/dist/mcp-ws.js.map +1 -1
  143. package/dist/mcp.d.ts.map +1 -1
  144. package/dist/mcp.js +8 -2
  145. package/dist/mcp.js.map +1 -1
  146. package/dist/migrate-layout.d.ts +7 -0
  147. package/dist/migrate-layout.d.ts.map +1 -1
  148. package/dist/migrate-layout.js +778 -64
  149. package/dist/migrate-layout.js.map +1 -1
  150. package/dist/output-schemas.d.ts +94 -0
  151. package/dist/output-schemas.d.ts.map +1 -0
  152. package/dist/output-schemas.js +864 -0
  153. package/dist/output-schemas.js.map +1 -0
  154. package/dist/port.d.ts.map +1 -1
  155. package/dist/port.js +3 -2
  156. package/dist/port.js.map +1 -1
  157. package/dist/project-facts.d.ts +15 -4
  158. package/dist/project-facts.d.ts.map +1 -1
  159. package/dist/project-facts.js +1 -0
  160. package/dist/project-facts.js.map +1 -1
  161. package/dist/recipes/5.0.0.d.ts +11 -0
  162. package/dist/recipes/5.0.0.d.ts.map +1 -0
  163. package/dist/recipes/5.0.0.js +34 -0
  164. package/dist/recipes/5.0.0.js.map +1 -0
  165. package/dist/recipes/index.d.ts +7 -0
  166. package/dist/recipes/index.d.ts.map +1 -1
  167. package/dist/recipes/index.js +2 -0
  168. package/dist/recipes/index.js.map +1 -1
  169. package/dist/replay.d.ts.map +1 -1
  170. package/dist/replay.js +12 -3
  171. package/dist/replay.js.map +1 -1
  172. package/dist/review.d.ts +3 -1
  173. package/dist/review.d.ts.map +1 -1
  174. package/dist/review.js +38 -7
  175. package/dist/review.js.map +1 -1
  176. package/dist/rules/codes.d.ts +10 -0
  177. package/dist/rules/codes.d.ts.map +1 -1
  178. package/dist/rules/codes.js +10 -0
  179. package/dist/rules/codes.js.map +1 -1
  180. package/dist/rules/comment-markers.d.ts +16 -7
  181. package/dist/rules/comment-markers.d.ts.map +1 -1
  182. package/dist/rules/comment-markers.js +58 -19
  183. package/dist/rules/comment-markers.js.map +1 -1
  184. package/dist/rules/contract-visibility.d.ts +3 -0
  185. package/dist/rules/contract-visibility.d.ts.map +1 -0
  186. package/dist/rules/contract-visibility.js +337 -0
  187. package/dist/rules/contract-visibility.js.map +1 -0
  188. package/dist/rules/data-guard.d.ts +7 -0
  189. package/dist/rules/data-guard.d.ts.map +1 -1
  190. package/dist/rules/data-guard.js +319 -37
  191. package/dist/rules/data-guard.js.map +1 -1
  192. package/dist/rules/layout.d.ts +4 -0
  193. package/dist/rules/layout.d.ts.map +1 -0
  194. package/dist/rules/layout.js +19 -0
  195. package/dist/rules/layout.js.map +1 -0
  196. package/dist/rules/legacy.d.ts.map +1 -1
  197. package/dist/rules/legacy.js +158 -43
  198. package/dist/rules/legacy.js.map +1 -1
  199. package/dist/rules/pages.d.ts +24 -0
  200. package/dist/rules/pages.d.ts.map +1 -1
  201. package/dist/rules/pages.js +221 -3
  202. package/dist/rules/pages.js.map +1 -1
  203. package/dist/rules/routes.d.ts +6 -0
  204. package/dist/rules/routes.d.ts.map +1 -1
  205. package/dist/rules/routes.js +111 -16
  206. package/dist/rules/routes.js.map +1 -1
  207. package/dist/rules/secrets.js +2 -2
  208. package/dist/rules/secrets.js.map +1 -1
  209. package/dist/rules/security.d.ts +12 -1
  210. package/dist/rules/security.d.ts.map +1 -1
  211. package/dist/rules/security.js +281 -68
  212. package/dist/rules/security.js.map +1 -1
  213. package/dist/rules/zones.d.ts +13 -0
  214. package/dist/rules/zones.d.ts.map +1 -1
  215. package/dist/rules/zones.js +192 -40
  216. package/dist/rules/zones.js.map +1 -1
  217. package/dist/scaffold.d.ts.map +1 -1
  218. package/dist/scaffold.js +6 -4
  219. package/dist/scaffold.js.map +1 -1
  220. package/dist/sdk.d.ts.map +1 -1
  221. package/dist/sdk.js +14 -5
  222. package/dist/sdk.js.map +1 -1
  223. package/dist/stylex-migrate.d.ts.map +1 -1
  224. package/dist/stylex-migrate.js +163 -7
  225. package/dist/stylex-migrate.js.map +1 -1
  226. package/dist/test-tool.d.ts.map +1 -1
  227. package/dist/test-tool.js +12 -3
  228. package/dist/test-tool.js.map +1 -1
  229. package/dist/typecheck-coverage.d.ts +20 -0
  230. package/dist/typecheck-coverage.d.ts.map +1 -0
  231. package/dist/typecheck-coverage.js +129 -0
  232. package/dist/typecheck-coverage.js.map +1 -0
  233. package/dist/upgrade.d.ts +69 -0
  234. package/dist/upgrade.d.ts.map +1 -1
  235. package/dist/upgrade.js +420 -77
  236. package/dist/upgrade.js.map +1 -1
  237. package/dist/verification-plan.d.ts.map +1 -1
  238. package/dist/verification-plan.js +11 -0
  239. package/dist/verification-plan.js.map +1 -1
  240. package/dist/version-drift.d.ts +22 -0
  241. package/dist/version-drift.d.ts.map +1 -0
  242. package/dist/version-drift.js +42 -0
  243. package/dist/version-drift.js.map +1 -0
  244. package/dist/web-app-options.d.ts +1 -1
  245. package/dist/web-app-options.d.ts.map +1 -1
  246. package/dist/web-app-options.js +1 -1
  247. package/dist/web-app-options.js.map +1 -1
  248. package/dist/work-graph.d.ts.map +1 -1
  249. package/dist/work-graph.js +20 -8
  250. package/dist/work-graph.js.map +1 -1
  251. package/docs/examples.json +54 -6
  252. package/docs/llms-full.txt +1539 -138
  253. package/docs/types.json +1456 -206
  254. package/package.json +11 -12
  255. package/src/agent-files.ts +203 -0
  256. package/src/app-files.ts +34 -4
  257. package/src/assure-hydration.ts +15 -2
  258. package/src/capabilities-tool.ts +15 -3
  259. package/src/check-diagnostics.ts +73 -9
  260. package/src/check-scan.ts +1773 -381
  261. package/src/check.ts +237 -76
  262. package/src/cli.ts +141 -5
  263. package/src/client-module.ts +61 -0
  264. package/src/command-catalog.ts +166 -37
  265. package/src/component-check.ts +79 -0
  266. package/src/contract-proof.ts +3 -25
  267. package/src/contracts.ts +60 -3
  268. package/src/db-tool.ts +5 -2
  269. package/src/dev-bun-config.ts +51 -10
  270. package/src/diagnostics.ts +3 -0
  271. package/src/diff-tool.ts +33 -26
  272. package/src/doctor.ts +43 -21
  273. package/src/env-file.ts +7 -0
  274. package/src/fix-recipes.ts +25 -2
  275. package/src/hand-built-manifest.ts +144 -0
  276. package/src/hooks-report.ts +135 -0
  277. package/src/init-agents.ts +17 -11
  278. package/src/internal/nifra-project.ts +191 -0
  279. package/src/internal/project-specifiers.ts +424 -0
  280. package/src/internal/script-kind.ts +2 -1
  281. package/src/internal/source-facts.ts +25 -0
  282. package/src/internal/typescript-import.ts +10 -3
  283. package/src/introspect.ts +65 -27
  284. package/src/levels-tool.ts +117 -20
  285. package/src/load.ts +78 -14
  286. package/src/mcp-context.ts +7 -7
  287. package/src/mcp-exec.ts +60 -17
  288. package/src/mcp-http.ts +1 -1
  289. package/src/mcp-io.ts +41 -0
  290. package/src/mcp-isolate.ts +9 -2
  291. package/src/mcp-project-child.ts +1 -1
  292. package/src/mcp-root.ts +58 -8
  293. package/src/mcp-ws.ts +4 -2
  294. package/src/mcp.ts +8 -3
  295. package/src/migrate-layout.ts +885 -63
  296. package/src/output-schemas.ts +1084 -0
  297. package/src/port.ts +3 -2
  298. package/src/project-facts.ts +17 -3
  299. package/src/recipes/5.0.0.ts +35 -0
  300. package/src/recipes/index.ts +10 -0
  301. package/src/replay.ts +14 -3
  302. package/src/review.ts +39 -10
  303. package/src/rules/codes.ts +10 -0
  304. package/src/rules/comment-markers.ts +57 -21
  305. package/src/rules/contract-visibility.ts +383 -0
  306. package/src/rules/data-guard.ts +351 -42
  307. package/src/rules/layout.ts +22 -0
  308. package/src/rules/legacy.ts +184 -51
  309. package/src/rules/pages.ts +238 -3
  310. package/src/rules/routes.ts +124 -16
  311. package/src/rules/secrets.ts +2 -2
  312. package/src/rules/security.ts +352 -71
  313. package/src/rules/zones.ts +205 -38
  314. package/src/scaffold.ts +6 -4
  315. package/src/sdk.ts +14 -5
  316. package/src/stylex-migrate.ts +134 -7
  317. package/src/test-tool.ts +18 -3
  318. package/src/typecheck-coverage.ts +134 -0
  319. package/src/upgrade.ts +487 -71
  320. package/src/verification-plan.ts +24 -0
  321. package/src/version-drift.ts +67 -0
  322. package/src/web-app-options.ts +2 -2
  323. package/src/work-graph.ts +17 -12
package/src/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { formatShadowedPages, shadowedPages } from "@nifrajs/web/route-manifest"
24
24
  import type { BunPlugin } from "bun"
25
25
  import { adapterFile, BACKEND_APP_FILE } from "./app-files.ts"
26
26
  import { bindCommandArgv, findCommandSpec, renderCommandCatalogHelp } from "./command-catalog.ts"
27
- import { applyEnvFiles, forgetAutoLoadedEnv, takeEnvFileFlags } from "./env-file.ts"
27
+ import { applyEnvFiles, forgetAutoLoadedEnv, REFLECT_ENV, takeEnvFileFlags } from "./env-file.ts"
28
28
  import { FRAMEWORK_WEB_OPTIONS, type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
29
29
  import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
30
30
  import {
@@ -165,6 +165,10 @@ Usage:
165
165
  against it: a route the target can't honour (an ssr route on a
166
166
  static build, ISR where there's no revalidation) exits nonzero
167
167
  with the consequence - run in CI so it fails the build, not prod.
168
+ nifra routes --hooks [--json] List the hooks each backend route and each prefix's unmatched
169
+ [--since <file>] paths actually run: kind, plugin name, and where it was
170
+ registered (app, group, merge, mount). --since diffs against a
171
+ saved --json report.
168
172
  nifra init-agents [--force] [--json] Retrofit an EXISTING app with the agent files a new app ships:
169
173
  .mcp.json + .cursor/mcp.json (register this project's nifra MCP),
170
174
  each agent's pointer to AGENTS.md (CLAUDE.md, GEMINI.md,
@@ -201,6 +205,8 @@ Usage:
201
205
  backend code in browser code). Run as "done"; --json for agents;
202
206
  --sarif emits the same stable diagnostics as SARIF 2.1.0 for external
203
207
  review tools; --lints-only skips tsc for a near-instant inner-loop pass.
208
+ With a contracts.lock.json, both modes import backend/app.ts
209
+ (running its top-level code) to compare the routes it serves.
204
210
  nifra verify [--release] [--json] Run the shared repository verification gate. --release runs the
205
211
  full build, test, coverage, corpus, consumer, and cross-runtime gate.
206
212
  nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
@@ -262,7 +268,8 @@ Usage:
262
268
  [--list] [--json] apply exact imports, pin every matching dependency (keeping
263
269
  [--exact] ^/~, or exact with --exact), then verify with nifra check.
264
270
  [--allow-downgrade] Dry-run by default; --write applies then verifies (--no-verify
265
- to skip). --list shows available targets. A version newer than
271
+ [--no-hooks] to skip), then prints how hook scope changed (--no-hooks to
272
+ skip). --list shows available targets. A version newer than
266
273
  this CLI prints the command for that release's CLI. Fail-closed
267
274
  on an unknown version or a rollback (--allow-downgrade
268
275
  overrides). Deterministic + idempotent.
@@ -276,6 +283,10 @@ Usage:
276
283
  params, the data its output schemas let through, and api typed
277
284
  by backend/app.ts. dev, build and check refresh them too;
278
285
  --check fails when one is stale.
286
+ nifra schemas [--write] [--json] Draft loaderOutput/actionOutput from each loader's and
287
+ action's return type for review; --write adds the missing
288
+ ones (marked nifra:generated) and refreshes generated ones.
289
+ Lists the returned fields a hand-written schema drops.
279
290
  nifra port [--target <t>] [--json] Portability linter: print a feature × deploy-target capability
280
291
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
281
292
  globals, node: builtins) with file:line evidence. --target auto-
@@ -304,12 +315,23 @@ set in the process environment is never overwritten by a file.
304
315
  ${renderCommandCatalogHelp()}`
305
316
 
306
317
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
307
- const CLI_VERSION = "4.0.2"
318
+ const CLI_VERSION = "5.0.0"
308
319
 
309
320
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
310
321
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
311
322
  const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
312
323
 
324
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Trusted application configuration supplies native Vite plugins; the pinned pipeline validates their hooks.
325
+ const asComponentPlugins = (
326
+ value: readonly unknown[],
327
+ ): NonNullable<
328
+ import("@nifrajs/web/server-components/build").ComponentPipelineOptions["plugins"]
329
+ > =>
330
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Trusted config forwards the pinned Vite plugin ABI; this does not accept request data.
331
+ value as NonNullable<
332
+ import("@nifrajs/web/server-components/build").ComponentPipelineOptions["plugins"]
333
+ >
334
+
313
335
  /**
314
336
  * Render an error for the CLI, unwrapping the detail a bare `.message` drops.
315
337
  *
@@ -345,6 +367,27 @@ export function formatCliError(err: unknown): string {
345
367
  }
346
368
 
347
369
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
370
+ if (app.framework.componentPipeline !== undefined) {
371
+ if (flags.bun)
372
+ throw new Error(
373
+ "[nifra/components] Components require the Vite 8 pipeline; Bun remains a server runtime",
374
+ )
375
+ const { devServerComponents } = await import("@nifrajs/web/server-components/build")
376
+ if (
377
+ app.resolvedPlugins.clientPlugins.length > 0 ||
378
+ app.resolvedPlugins.serverPlugins.length > 0
379
+ )
380
+ throw new Error(
381
+ "[nifra/components] Use Vite equivalents for client/server Bun plugins in the component pipeline",
382
+ )
383
+ await devServerComponents({
384
+ ...app.framework.componentPipeline,
385
+ root: app.cwd,
386
+ port: flags.port,
387
+ plugins: asComponentPlugins(app.resolvedPlugins.vitePlugins),
388
+ })
389
+ return
390
+ }
348
391
  if (flags.vite && flags.bun) {
349
392
  throw new Error("[nifra] `nifra dev` takes `--vite` or `--bun`, not both.")
350
393
  }
@@ -511,6 +554,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
511
554
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
512
555
  ...(fw.define ? { define: fw.define } : {}),
513
556
  indicator: showsIndicator(flags, fw),
557
+ allowedHosts: fw.dev?.allowedHosts ?? [],
514
558
  createApp: (clientEntry, importQuery, dev) =>
515
559
  createWebApp({
516
560
  adapter: asAdapter(fw.adapter),
@@ -693,7 +737,7 @@ async function assertFreshWorkspaceDists(cwd: string): Promise<void> {
693
737
  let rootPackage: Record<string, unknown>
694
738
  try {
695
739
  rootPackage = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>
696
- // @nifra-gate-reviewed: not a security gate. An unreadable/non-JSON package.json is a separate
740
+ // nifra-expect NF-S001: not a security gate. An unreadable/non-JSON package.json is a separate
697
741
  // build problem; returning here skips the freshness check rather than masking a denial.
698
742
  } catch {
699
743
  return // an unreadable/!JSON package.json is a separate problem; don't mask it as a stale-dist failure
@@ -727,6 +771,40 @@ async function buildForTarget(
727
771
  throw new Error("[nifra] `nifra build` takes `--vite` or `--bun`, not both.")
728
772
  }
729
773
  const { framework: fw } = app
774
+ if (fw.componentPipeline !== undefined) {
775
+ if (flags.bun)
776
+ throw new Error(
777
+ "[nifra/components] Components require the Vite 8 pipeline; Bun remains a server runtime",
778
+ )
779
+ if (target === "static")
780
+ throw new Error(
781
+ "[nifra/components] Component owners require private per-request execution; choose bun, node, deno, cloudflare, or vercel",
782
+ )
783
+ if (
784
+ app.resolvedPlugins.clientPlugins.length > 0 ||
785
+ app.resolvedPlugins.serverPlugins.length > 0
786
+ )
787
+ throw new Error(
788
+ "[nifra/components] Use Vite equivalents for client/server Bun plugins in the component pipeline",
789
+ )
790
+ const { buildServerComponents } = await import("@nifrajs/web/server-components/build")
791
+ const outDir =
792
+ target === "vercel" && flags.out === undefined
793
+ ? resolve(app.cwd, ".vercel/output")
794
+ : app.outDir
795
+ const result = await buildServerComponents({
796
+ ...fw.componentPipeline,
797
+ target,
798
+ root: app.cwd,
799
+ outDir,
800
+ plugins: asComponentPlugins(app.resolvedPlugins.vitePlugins),
801
+ })
802
+ await assertFreshWorkspaceDists(app.cwd)
803
+ console.log(
804
+ `[nifra/components] Built ${fw.componentPipeline.adapter} for ${target} (${result.build}) in ${outDir}`,
805
+ )
806
+ return
807
+ }
730
808
  const cssCodeSplitRequested = fw.cssCodeSplit === false
731
809
  const hasBunTransforms =
732
810
  app.resolvedPlugins.clientPlugins.length > 0 || app.resolvedPlugins.serverPlugins.length > 0
@@ -945,6 +1023,42 @@ function installReflectionExitHint(): (command: string | undefined) => void {
945
1023
  }
946
1024
  }
947
1025
 
1026
+ /** Commands that import the app only to read what it declares, never sending it a request, so the
1027
+ * app may skip connecting to its services. `assure` qualifies without its bundle lane, which renders. */
1028
+ const READ_ONLY_COMMANDS: ReadonlySet<string> = new Set([
1029
+ "capabilities",
1030
+ "check",
1031
+ "context",
1032
+ "contracts",
1033
+ "diff",
1034
+ "doctor",
1035
+ "manifest",
1036
+ "openapi",
1037
+ "routes",
1038
+ "sync-manifest",
1039
+ "sync-routes",
1040
+ ])
1041
+
1042
+ const HELD_OPEN_GRACE_MS = 2_000
1043
+
1044
+ /**
1045
+ * A command that imports the app for its answer has already answered when the app's own work - a
1046
+ * database or Redis client reconnecting, an interval - would keep the process alive indefinitely.
1047
+ * A process that exits on its own never reaches this unref'd timer; a held one is ended with a note.
1048
+ */
1049
+ function exitIfHeldOpen(command: string): void {
1050
+ setTimeout(() => {
1051
+ process.stderr.write(
1052
+ `[nifra] \`nifra ${command}\` has finished, but something the app started when it was imported ` +
1053
+ `(an open connection or a timer) is keeping the process alive, so nifra is exiting.` +
1054
+ (process.env[REFLECT_ENV] === command
1055
+ ? ` To skip that work while nifra only reads the app, check process.env.${REFLECT_ENV}, which is set to "${command}".\n`
1056
+ : "\n"),
1057
+ )
1058
+ process.exit(typeof process.exitCode === "number" ? process.exitCode : 0)
1059
+ }, HELD_OPEN_GRACE_MS).unref()
1060
+ }
1061
+
948
1062
  /** Set on the copy of `nifra mcp` that serves without `.env` files, so it does not restart again. */
949
1063
  const MCP_WITHOUT_ENV_FILES = "NIFRA_MCP_WITHOUT_ENV_FILES"
950
1064
 
@@ -1051,6 +1165,9 @@ async function main(): Promise<void> {
1051
1165
  const catalogSpec = findCommandSpec(dbSub === undefined ? command : `db-${dbSub}`)
1052
1166
  if (catalogSpec?.transports.includes("cli")) {
1053
1167
  const markReflecting = installReflectionExitHint()
1168
+ if (READ_ONLY_COMMANDS.has(command) || (command === "assure" && !assureBundleRequested(argv))) {
1169
+ process.env[REFLECT_ENV] = command
1170
+ }
1054
1171
  try {
1055
1172
  const input = bindCommandArgv(catalogSpec, argv.slice(dbSub === undefined ? 1 : 2))
1056
1173
  markReflecting(command)
@@ -1086,6 +1203,7 @@ async function main(): Promise<void> {
1086
1203
  ? candidate
1087
1204
  : 1
1088
1205
  }
1206
+ exitIfHeldOpen(command)
1089
1207
  return
1090
1208
  }
1091
1209
  if (command === "verify") {
@@ -1150,6 +1268,7 @@ async function main(): Promise<void> {
1150
1268
  json: argv.includes("--json"),
1151
1269
  list: argv.includes("--list"),
1152
1270
  verify: !argv.includes("--no-verify"),
1271
+ hooks: !argv.includes("--no-hooks"),
1153
1272
  allowDowngrade: argv.includes("--allow-downgrade"),
1154
1273
  exact: argv.includes("--exact"),
1155
1274
  cliVersion: CLI_VERSION,
@@ -1278,12 +1397,29 @@ function findCliCommand(values: readonly string[]): number {
1278
1397
  return values.findIndex((value) => isCliCommandToken(value))
1279
1398
  }
1280
1399
 
1400
+ /**
1401
+ * The command vector from one runtime argv: the command, then its arguments. Arguments typed before
1402
+ * the command (`nifra --json check`) follow the command's own, ahead of any `--`, rather than being
1403
+ * dropped. Only when index 1 is the script: a runtime flag there means the leading entries are the
1404
+ * runtime's own, not the caller's.
1405
+ */
1406
+ export function commandArgv(values: readonly string[], commandIndex: number): readonly string[] {
1407
+ const command = values.slice(commandIndex)
1408
+ const script = values[1]
1409
+ if (commandIndex <= 2 || script === undefined || script.startsWith("-")) return command
1410
+ const leading = values.slice(2, commandIndex)
1411
+ const separator = command.indexOf("--")
1412
+ return separator === -1
1413
+ ? [...command, ...leading]
1414
+ : [...command.slice(0, separator), ...leading, ...command.slice(separator)]
1415
+ }
1416
+
1281
1417
  function cliArgv(): readonly string[] {
1282
1418
  const reexec = reexecCliArgv()
1283
1419
  if (reexec !== undefined) return reexec
1284
1420
  for (const values of [Bun.argv, process.argv]) {
1285
1421
  const commandIndex = findCliCommand(values)
1286
- if (commandIndex >= 0) return values.slice(commandIndex)
1422
+ if (commandIndex >= 0) return commandArgv(values, commandIndex)
1287
1423
  }
1288
1424
  return Bun.argv.slice(2)
1289
1425
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * An app's `clientModule` when its config names a first-party file. The browser bundle's generated
3
+ * entry imports it, so the build classifies that file, and everything it imports, as browser code.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { CONFIG_FILE, FRAMEWORK_FILE } from "./app-files.ts"
8
+ import { stripComments } from "./check-scan.ts"
9
+ import type { SpecifierResolver } from "./internal/project-specifiers.ts"
10
+
11
+ const CLIENT_MODULE_EXPORT =
12
+ /\bexport\s+const\s+clientModule\s*(?::\s*string\s*)?=\s*(?:"([^"\\\r\n]*)"|'([^'\\\r\n]*)')/
13
+
14
+ export interface ClientModuleRoot {
15
+ /** The project-relative config file that names it. */
16
+ readonly config: string
17
+ readonly line: number
18
+ readonly specifier: string
19
+ /** The real path of the file it names. */
20
+ readonly file: string
21
+ }
22
+
23
+ /** The project-relative directory of every app among `files`: one holding a nifra config. */
24
+ export function appDirs(files: readonly string[]): string[] {
25
+ const apps = new Set<string>()
26
+ for (const file of files) {
27
+ for (const config of [CONFIG_FILE, FRAMEWORK_FILE]) {
28
+ if (file === config) apps.add("")
29
+ else if (file.endsWith(`/${config}`)) apps.add(file.slice(0, -config.length - 1))
30
+ }
31
+ }
32
+ return [...apps]
33
+ }
34
+
35
+ /**
36
+ * The file `clientModule` names in `app`, read the way the loader reads it: from `nifra.config.ts`
37
+ * when it exists, else `backend/framework.ts`, and a relative path against the app root. `undefined`
38
+ * for a package specifier, a value that is not a string literal, or a path to no file.
39
+ */
40
+ export function clientModuleRoot(
41
+ root: string,
42
+ app: string,
43
+ resolve: SpecifierResolver,
44
+ ): ClientModuleRoot | undefined {
45
+ const config = existsSync(join(root, app, CONFIG_FILE)) ? CONFIG_FILE : FRAMEWORK_FILE
46
+ const path = join(root, app, config)
47
+ if (!existsSync(path)) return undefined
48
+ const text = readFileSync(path, "utf8")
49
+ // `stripComments` keeps offsets, so the match index is a position in `text`.
50
+ const match = CLIENT_MODULE_EXPORT.exec(stripComments(text))
51
+ if (match === null) return undefined
52
+ const specifier = match[1] ?? match[2] ?? ""
53
+ const file = resolve(join(root, app, CONFIG_FILE), specifier)
54
+ if (file === undefined) return undefined
55
+ return {
56
+ config: app === "" ? config : `${app}/${config}`,
57
+ line: text.slice(0, match.index).split("\n").length,
58
+ specifier,
59
+ file,
60
+ }
61
+ }
@@ -22,9 +22,10 @@ import { type Diagnostic, diagnostic, normalizeSeverity, toSarifLog } from "./di
22
22
  import type { DoctorResult } from "./doctor.ts"
23
23
  import { type I18nCheckOutput, i18nCheckPassed, renderI18nCheck } from "./i18n-check.ts"
24
24
  import type { VerificationLevelsResult } from "./levels-tool.ts"
25
- import type { LoadedApp } from "./load.ts"
25
+ import type { AppLoader, IntrospectedApp } from "./load.ts"
26
26
  import type { ManifestEmitCommandResult } from "./manifest-tool.ts"
27
27
  import type { LayoutMigrationResult } from "./migrate-layout.ts"
28
+ import type { OutputSchemaStatus, OutputSchemasReport } from "./output-schemas.ts"
28
29
  import { collectPortResult, type PortResult, renderReport } from "./port.ts"
29
30
  import type { ReplayResult } from "./replay.ts"
30
31
  import { reviewSpec } from "./review.ts"
@@ -80,8 +81,8 @@ export interface CommandCtx {
80
81
  readonly signal?: AbortSignal
81
82
  readonly progress?: (message: string) => void
82
83
  readonly cliVersion?: string
83
- /** Adapter-provided cache for commands that need the loaded web app. */
84
- readonly loadApp?: () => Promise<LoadedApp>
84
+ /** Adapter-provided cache for commands that need the loaded app. */
85
+ readonly loadApp?: AppLoader
85
86
  }
86
87
 
87
88
  export interface CommandSpec<Input, Output> {
@@ -328,6 +329,8 @@ interface RoutesInput {
328
329
  readonly json?: boolean | undefined
329
330
  readonly graph?: boolean | undefined
330
331
  readonly modes?: boolean | undefined
332
+ readonly hooks?: boolean | undefined
333
+ readonly since?: string | undefined
331
334
  readonly target?: string | undefined
332
335
  readonly dir?: string | undefined
333
336
  }
@@ -390,6 +393,12 @@ interface TypesInput {
390
393
  readonly dir?: string | undefined
391
394
  }
392
395
 
396
+ interface SchemasInput {
397
+ readonly write?: boolean | undefined
398
+ readonly json?: boolean | undefined
399
+ readonly dir?: string | undefined
400
+ }
401
+
393
402
  interface SnapshotInput {
394
403
  readonly out?: string | undefined
395
404
  readonly json?: boolean | undefined
@@ -458,7 +467,7 @@ interface AssureCommandOutput {
458
467
  interface LevelsCommandOutput extends VerificationLevelsResult {}
459
468
  interface RoutesCommandOutput {
460
469
  readonly ok: boolean
461
- readonly mode: "default" | "graph" | "modes"
470
+ readonly mode: "default" | "graph" | "modes" | "hooks"
462
471
  readonly data: unknown
463
472
  readonly text: string
464
473
  }
@@ -493,6 +502,9 @@ interface DiffCommandOutput {
493
502
  readonly hasBreaking: boolean
494
503
  readonly changes: readonly unknown[]
495
504
  }
505
+ interface RouteDiffCommandOutput extends DiffCommandOutput {
506
+ readonly text: string
507
+ }
496
508
  interface ContractsCommandOutput {
497
509
  readonly ok: boolean
498
510
  readonly lock?: ContractsLock
@@ -647,15 +659,20 @@ const ROUTES_SCHEMA = input<RoutesInput>(
647
659
  json: { type: "boolean" },
648
660
  graph: { type: "boolean" },
649
661
  modes: { type: "boolean" },
662
+ hooks: { type: "boolean" },
663
+ since: { type: "string" },
650
664
  target: { type: "string" },
651
665
  dir: { type: "string" },
652
666
  }),
653
667
  (value) => {
654
668
  const raw = withDir(record(value))
655
- if (raw.graph === true && raw.modes === true)
656
- throw new TypeError("graph and modes cannot be combined")
669
+ if ([raw.graph, raw.modes, raw.hooks].filter((flag) => flag === true).length > 1)
670
+ throw new TypeError("graph, modes and hooks cannot be combined")
671
+ if (raw.since !== undefined && raw.hooks !== true)
672
+ throw new TypeError("since compares hook reports and needs hooks")
657
673
  return {
658
- ...parseBooleanFlags(raw, ["json", "graph", "modes"]),
674
+ ...parseBooleanFlags(raw, ["json", "graph", "modes", "hooks"]),
675
+ since: optionalString(raw.since, "since"),
659
676
  target: optionalString(raw.target, "target"),
660
677
  ...(raw.dir === undefined ? {} : { dir: raw.dir }),
661
678
  }
@@ -781,6 +798,17 @@ const TYPES_SCHEMA = input<TypesInput>(
781
798
  },
782
799
  )
783
800
 
801
+ const SCHEMAS_SCHEMA = input<SchemasInput>(
802
+ objectSchema({ write: { type: "boolean" }, json: { type: "boolean" }, dir: { type: "string" } }),
803
+ (value) => {
804
+ const raw = withDir(record(value))
805
+ return {
806
+ ...parseBooleanFlags(raw, ["write", "json"]),
807
+ ...(raw.dir === undefined ? {} : { dir: raw.dir }),
808
+ }
809
+ },
810
+ )
811
+
784
812
  const SNAPSHOT_SCHEMA = input<SnapshotInput>(
785
813
  objectSchema({ out: { type: "string" }, json: { type: "boolean" }, dir: { type: "string" } }),
786
814
  (value) => {
@@ -944,9 +972,10 @@ const I18N_SCHEMA = input<I18nInput>(
944
972
  },
945
973
  )
946
974
 
947
- function loadAppFor(ctx: CommandCtx): Promise<LoadedApp> {
948
- if (ctx.loadApp !== undefined) return ctx.loadApp()
949
- return import("./load.ts").then(({ loadApp }) => loadApp(ctx.cwd))
975
+ /** The app as the backend readers see it: an API-only app is its `backend/app.ts` alone. */
976
+ function backendAppFor(ctx: CommandCtx): Promise<IntrospectedApp> {
977
+ if (ctx.loadApp !== undefined) return ctx.loadApp.backend()
978
+ return import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
950
979
  }
951
980
 
952
981
  function structuredDiagnostics(out: CheckCommandOutput): readonly Diagnostic[] {
@@ -1195,7 +1224,8 @@ const manifestSpec: CommandSpec<ManifestInput, ManifestEmitCommandResult | DiffC
1195
1224
 
1196
1225
  const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
1197
1226
  name: "routes",
1198
- summary: "List, graph, or target-check every page and API route the app serves.",
1227
+ summary:
1228
+ "List, graph, or target-check every page and API route the app serves, or the hooks each runs.",
1199
1229
  input: ROUTES_SCHEMA,
1200
1230
  output: output({
1201
1231
  type: "object",
@@ -1209,12 +1239,47 @@ const routesSpec: CommandSpec<RoutesInput, RoutesCommandOutput> = {
1209
1239
  { name: "json", field: "json", type: "boolean" },
1210
1240
  { name: "graph", field: "graph", type: "boolean" },
1211
1241
  { name: "modes", field: "modes", type: "boolean" },
1242
+ { name: "hooks", field: "hooks", type: "boolean" },
1243
+ { name: "since", field: "since", type: "string" },
1212
1244
  { name: "target", field: "target", type: "string" },
1213
1245
  ],
1214
1246
  },
1215
1247
  async run(value, ctx) {
1216
- const app = await loadAppFor(ctx)
1248
+ if (value.hooks === true) {
1249
+ const app = await backendAppFor(ctx)
1250
+ const hooks = await import("./hooks-report.ts")
1251
+ const report = await hooks.collectHookReport(app.backend)
1252
+ if (value.since === undefined) {
1253
+ return { ok: true, mode: "hooks", data: report, text: hooks.renderHookReport(report) }
1254
+ }
1255
+ const saved = hooks.parseHookReport(
1256
+ await Bun.file(resolve(ctx.cwd, value.since))
1257
+ .text()
1258
+ .catch(() => ""),
1259
+ )
1260
+ if (saved === undefined) {
1261
+ return {
1262
+ ok: false,
1263
+ mode: "hooks",
1264
+ data: { report, error: `${value.since} is not a saved hook report` },
1265
+ text: `[nifra] ${value.since} is not a saved hook report (write one with \`nifra routes --hooks --json\`)`,
1266
+ }
1267
+ }
1268
+ const changes = hooks.diffHookReports(saved, report)
1269
+ return {
1270
+ ok: true,
1271
+ mode: "hooks",
1272
+ data: { report, changes },
1273
+ text: hooks.renderHookDiff(changes),
1274
+ }
1275
+ }
1276
+ const app = await backendAppFor(ctx)
1217
1277
  if (value.modes === true) {
1278
+ if (app.framework === undefined) {
1279
+ throw new Error(
1280
+ `[nifra] routes --modes reads page routes, and ${ctx.cwd} is an API-only app (backend/app.ts, no web config)`,
1281
+ )
1282
+ }
1218
1283
  const { buildRouteManifest, renderRouteManifest } = await import(
1219
1284
  "@nifrajs/web/route-manifest"
1220
1285
  )
@@ -1264,7 +1329,7 @@ const contextSpec: CommandSpec<ContextInput, ContextCommandOutput> = {
1264
1329
  async run(value, ctx) {
1265
1330
  return {
1266
1331
  ok: true,
1267
- text: (await import("./introspect.ts")).describeProject(await loadAppFor(ctx), {
1332
+ text: (await import("./introspect.ts")).describeProject(await backendAppFor(ctx), {
1268
1333
  ...(value.path === undefined ? {} : { path: value.path }),
1269
1334
  ...(value.kind === undefined ? {} : { kind: value.kind }),
1270
1335
  }),
@@ -1292,10 +1357,7 @@ const openApiSpec: CommandSpec<OpenApiInput, OpenApiCommandOutput> = {
1292
1357
  ],
1293
1358
  },
1294
1359
  async run(value, ctx) {
1295
- const app =
1296
- ctx.loadApp !== undefined
1297
- ? await ctx.loadApp()
1298
- : await import("./load.ts").then(({ loadBackendApp }) => loadBackendApp(ctx.cwd))
1360
+ const app = await backendAppFor(ctx)
1299
1361
  const { renderOpenApiWithTypes } = await import("./openapi-tool.ts")
1300
1362
  const format = value.format ?? "json"
1301
1363
  return {
@@ -1430,6 +1492,8 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1430
1492
  splits: { type: "array" },
1431
1493
  moves: { type: "array" },
1432
1494
  rewritten: { type: "array" },
1495
+ tooling: { type: "array" },
1496
+ mode: { type: "string" },
1433
1497
  },
1434
1498
  required: ["ok", "write", "issues"],
1435
1499
  }),
@@ -1459,13 +1523,14 @@ const migrateSpec: CommandSpec<MigrateInput, MigrateCommandOutput> = {
1459
1523
  if (out.kind === "layout") {
1460
1524
  return [
1461
1525
  out.ok
1462
- ? `✓ ${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
1526
+ ? `✓ ${out.mode === "api" ? "API-only app" : `${out.splits.length} route file${out.splits.length === 1 ? "" : "s"} split`}, ${out.moves.length} file${out.moves.length === 1 ? "" : "s"} moved`
1463
1527
  : `⚠ ${out.issues.length} issue${out.issues.length === 1 ? "" : "s"} need a decision; everything else is planned`,
1464
1528
  ...out.splits.map(
1465
1529
  (split) => ` split ${split.file} → ${split.backend} (${split.moved.join(", ")})`,
1466
1530
  ),
1467
1531
  ...out.moves.map((move) => ` move ${move.from} → ${move.to}`),
1468
1532
  ...out.rewritten.map((file) => ` edit ${file} (imports)`),
1533
+ ...out.tooling.map((edit) => ` edit ${edit.file} (${edit.change})`),
1469
1534
  ...out.issues.map((issue) => ` ⚠ ${issue.file}: ${issue.reason}`),
1470
1535
  ...(out.write ? [] : [" dry run: pass --write to apply"]),
1471
1536
  ]
@@ -1586,6 +1651,75 @@ const typesSpec: CommandSpec<TypesInput, RouteTypesReport> = {
1586
1651
  success: (out) => out.ok,
1587
1652
  }
1588
1653
 
1654
+ const SCHEMA_STATUS_LINE: Readonly<Record<OutputSchemaStatus, string>> = {
1655
+ missing: "+ would write",
1656
+ generated: "+ wrote",
1657
+ stale: "↻ stale",
1658
+ regenerated: "↻ rewrote",
1659
+ current: "✓ current",
1660
+ declared: "· hand-written",
1661
+ "no-data": "· no data",
1662
+ unreadable: "⚠ unreadable",
1663
+ }
1664
+
1665
+ const schemasSpec: CommandSpec<SchemasInput, OutputSchemasReport> = {
1666
+ name: "schemas",
1667
+ summary:
1668
+ "Write a reviewable `loaderOutput`/`actionOutput` from the loader's or action's return type where a route has none (`--write`), keep generated ones current, and list returned fields a hand-written schema drops.",
1669
+ input: SCHEMAS_SCHEMA,
1670
+ output: output({
1671
+ type: "object",
1672
+ properties: {
1673
+ ok: { type: "boolean" },
1674
+ write: { type: "boolean" },
1675
+ entries: { type: "array" },
1676
+ written: { type: "array" },
1677
+ warnings: { type: "array" },
1678
+ },
1679
+ required: ["ok", "write", "entries", "written", "warnings"],
1680
+ }),
1681
+ transports: ["cli"],
1682
+ stability: "stable",
1683
+ argv: {
1684
+ flags: [
1685
+ { name: "write", field: "write", type: "boolean" },
1686
+ { name: "json", field: "json", type: "boolean" },
1687
+ { name: "dir", field: "dir", type: "string" },
1688
+ ],
1689
+ },
1690
+ async run(value, ctx) {
1691
+ const { outputSchemas } = await import("./output-schemas.ts")
1692
+ return outputSchemas(resolve(ctx.cwd, value.dir ?? "."), { write: value.write === true })
1693
+ },
1694
+ render: (out) => {
1695
+ const shown = out.entries.filter(
1696
+ (entry) =>
1697
+ entry.status !== "current" &&
1698
+ entry.status !== "no-data" &&
1699
+ (entry.status !== "declared" || entry.dropped.length > 0),
1700
+ )
1701
+ const current = out.entries.filter((entry) => entry.status === "current").length
1702
+ return [
1703
+ ...out.warnings.map((warning) => `⚠ ${warning}`),
1704
+ `${out.ok ? "✓" : "✖"} ${out.entries.length} output schema${out.entries.length === 1 ? "" : "s"} read, ${current} generated and current`,
1705
+ ...shown.map((entry) => {
1706
+ const detail =
1707
+ entry.reason ??
1708
+ (entry.edited === true
1709
+ ? "edited by hand under its nifra:generated line"
1710
+ : entry.dropped.length > 0
1711
+ ? `drops ${entry.dropped.join(", ")}`
1712
+ : undefined)
1713
+ return ` ${SCHEMA_STATUS_LINE[entry.status]} ${entry.file}:${entry.line} ${entry.schema}${detail === undefined ? "" : ` - ${detail}`}`
1714
+ }),
1715
+ ...(out.write || !out.entries.some((e) => e.status === "missing" || e.status === "stale")
1716
+ ? []
1717
+ : [" dry run: pass --write to apply"]),
1718
+ ]
1719
+ },
1720
+ success: (out) => out.ok,
1721
+ }
1722
+
1589
1723
  const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1590
1724
  name: "snapshot",
1591
1725
  summary: "Write the backend API contract as a versioned JSON baseline.",
@@ -1610,18 +1744,16 @@ const snapshotSpec: CommandSpec<SnapshotInput, SnapshotCommandOutput> = {
1610
1744
  },
1611
1745
  async run(value, ctx) {
1612
1746
  const mod = await import("./diff-tool.ts")
1613
- const routes = await mod.snapshotBackend(ctx.cwd)
1614
1747
  const file = value.out ?? mod.DEFAULT_SNAPSHOT_FILE
1615
- const snapshot = { nifraSnapshot: 1 as const, routes }
1616
- await Bun.write(resolve(ctx.cwd, file), `${JSON.stringify(snapshot, null, 2)}\n`)
1617
- return { ok: true, file, routes: routes.length, snapshot }
1748
+ const snapshot = await mod.writeSnapshot(ctx.cwd, file)
1749
+ return { ok: true, file, routes: snapshot.routes.length, snapshot }
1618
1750
  },
1619
1751
  render: (out) => [
1620
1752
  `[nifra] wrote ${out.routes} route${out.routes === 1 ? "" : "s"} to ${out.file}`,
1621
1753
  ],
1622
1754
  }
1623
1755
 
1624
- const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
1756
+ const diffSpec: CommandSpec<DiffInput, RouteDiffCommandOutput> = {
1625
1757
  name: "diff",
1626
1758
  summary: "Compare the current backend contract with a baseline and fail on breaking changes.",
1627
1759
  input: DIFF_SCHEMA,
@@ -1638,21 +1770,17 @@ const diffSpec: CommandSpec<DiffInput, DiffCommandOutput> = {
1638
1770
  stability: "stable",
1639
1771
  argv: { positionals: ["baseline"], flags: [{ name: "json", field: "json", type: "boolean" }] },
1640
1772
  async run(value, ctx) {
1641
- const mod = await import("./diff-tool.ts")
1642
- const baseline = value.baseline ?? mod.DEFAULT_SNAPSHOT_FILE
1643
- const file = await Bun.file(resolve(ctx.cwd, baseline)).text()
1644
- const before = mod.parseSnapshotFile(file, baseline)
1645
- const current = await mod.snapshotBackend(ctx.cwd)
1646
- const { diffRouteSnapshots } = await import("@nifrajs/core/diff")
1647
- const diff = diffRouteSnapshots(before.routes, current)
1648
- return { ok: !diff.hasBreaking, hasBreaking: diff.hasBreaking, changes: diff.changes }
1773
+ const { diffAgainstBaseline, formatDiff } = await import("./diff-tool.ts")
1774
+ const diff = await diffAgainstBaseline(ctx.cwd, value.baseline)
1775
+ return {
1776
+ ok: !diff.hasBreaking,
1777
+ hasBreaking: diff.hasBreaking,
1778
+ changes: diff.changes,
1779
+ text: formatDiff(diff),
1780
+ }
1649
1781
  },
1650
- render: (out) => [
1651
- out.ok ? "✓ no breaking changes" : "✖ breaking changes",
1652
- ...(out.changes as readonly { method: string; path: string; message: string }[]).map(
1653
- (c) => `${c.method} ${c.path}: ${c.message}`,
1654
- ),
1655
- ],
1782
+ render: (out) => [out.text],
1783
+ json: ({ ok, hasBreaking, changes }) => ({ ok, hasBreaking, changes }),
1656
1784
  success: (out) => out.ok,
1657
1785
  }
1658
1786
 
@@ -1880,6 +2008,7 @@ export const commandSpecs = Object.freeze([
1880
2008
  migrateSpec,
1881
2009
  targetSpec,
1882
2010
  typesSpec,
2011
+ schemasSpec,
1883
2012
  snapshotSpec,
1884
2013
  diffSpec,
1885
2014
  contractsSpec,