@nifrajs/cli 2.10.0 → 2.12.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 (193) hide show
  1. package/README.md +7 -1
  2. package/dist/assure-hydration.d.ts +14 -0
  3. package/dist/assure-hydration.d.ts.map +1 -0
  4. package/dist/assure-hydration.js +483 -0
  5. package/dist/assure-hydration.js.map +1 -0
  6. package/dist/assure.d.ts +35 -0
  7. package/dist/assure.d.ts.map +1 -1
  8. package/dist/assure.js +222 -1
  9. package/dist/assure.js.map +1 -1
  10. package/dist/capabilities-tool.d.ts +13 -0
  11. package/dist/capabilities-tool.d.ts.map +1 -1
  12. package/dist/capabilities-tool.js +64 -1
  13. package/dist/capabilities-tool.js.map +1 -1
  14. package/dist/check.d.ts +133 -10
  15. package/dist/check.d.ts.map +1 -1
  16. package/dist/check.js +741 -148
  17. package/dist/check.js.map +1 -1
  18. package/dist/cli.d.ts +21 -14
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +288 -73
  21. package/dist/cli.js.map +1 -1
  22. package/dist/contract-proof.d.ts +49 -0
  23. package/dist/contract-proof.d.ts.map +1 -0
  24. package/dist/contract-proof.js +128 -0
  25. package/dist/contract-proof.js.map +1 -0
  26. package/dist/contracts.d.ts +22 -0
  27. package/dist/contracts.d.ts.map +1 -0
  28. package/dist/contracts.js +134 -0
  29. package/dist/contracts.js.map +1 -0
  30. package/dist/dev-bun-config.d.ts +17 -6
  31. package/dist/dev-bun-config.d.ts.map +1 -1
  32. package/dist/dev-bun-config.js +51 -14
  33. package/dist/dev-bun-config.js.map +1 -1
  34. package/dist/diagnostics.d.ts +23 -0
  35. package/dist/diagnostics.d.ts.map +1 -0
  36. package/dist/diagnostics.js +9 -0
  37. package/dist/diagnostics.js.map +1 -0
  38. package/dist/diff-tool.d.ts +2 -0
  39. package/dist/diff-tool.d.ts.map +1 -1
  40. package/dist/diff-tool.js +1 -1
  41. package/dist/diff-tool.js.map +1 -1
  42. package/dist/docs-search.d.ts +8 -3
  43. package/dist/docs-search.d.ts.map +1 -1
  44. package/dist/docs-search.js +36 -15
  45. package/dist/docs-search.js.map +1 -1
  46. package/dist/doctor.d.ts +38 -2
  47. package/dist/doctor.d.ts.map +1 -1
  48. package/dist/doctor.js +189 -7
  49. package/dist/doctor.js.map +1 -1
  50. package/dist/examples.d.ts +1 -2
  51. package/dist/examples.d.ts.map +1 -1
  52. package/dist/examples.js +37 -16
  53. package/dist/examples.js.map +1 -1
  54. package/dist/fix-recipes.d.ts +12 -0
  55. package/dist/fix-recipes.d.ts.map +1 -0
  56. package/dist/fix-recipes.js +106 -0
  57. package/dist/fix-recipes.js.map +1 -0
  58. package/dist/init-agents.d.ts.map +1 -1
  59. package/dist/init-agents.js +60 -4
  60. package/dist/init-agents.js.map +1 -1
  61. package/dist/internal/source-facts.d.ts +15 -0
  62. package/dist/internal/source-facts.d.ts.map +1 -0
  63. package/dist/internal/source-facts.js +82 -0
  64. package/dist/internal/source-facts.js.map +1 -0
  65. package/dist/internal/typescript-import.d.ts +9 -0
  66. package/dist/internal/typescript-import.d.ts.map +1 -1
  67. package/dist/internal/typescript-import.js +38 -0
  68. package/dist/internal/typescript-import.js.map +1 -1
  69. package/dist/introspect.d.ts +6 -2
  70. package/dist/introspect.d.ts.map +1 -1
  71. package/dist/introspect.js +60 -5
  72. package/dist/introspect.js.map +1 -1
  73. package/dist/load.d.ts +10 -0
  74. package/dist/load.d.ts.map +1 -1
  75. package/dist/load.js +1 -0
  76. package/dist/load.js.map +1 -1
  77. package/dist/manifest-tool.d.ts.map +1 -1
  78. package/dist/manifest-tool.js +14 -9
  79. package/dist/manifest-tool.js.map +1 -1
  80. package/dist/mcp-docs-tools.d.ts.map +1 -1
  81. package/dist/mcp-docs-tools.js +3 -0
  82. package/dist/mcp-docs-tools.js.map +1 -1
  83. package/dist/mcp-http.js +1 -1
  84. package/dist/mcp-root.d.ts +83 -0
  85. package/dist/mcp-root.d.ts.map +1 -0
  86. package/dist/mcp-root.js +221 -0
  87. package/dist/mcp-root.js.map +1 -0
  88. package/dist/mcp-run.d.ts.map +1 -1
  89. package/dist/mcp-run.js +18 -4
  90. package/dist/mcp-run.js.map +1 -1
  91. package/dist/mcp.d.ts +27 -4
  92. package/dist/mcp.d.ts.map +1 -1
  93. package/dist/mcp.js +360 -27
  94. package/dist/mcp.js.map +1 -1
  95. package/dist/pipeline-guard.d.ts +25 -10
  96. package/dist/pipeline-guard.d.ts.map +1 -1
  97. package/dist/pipeline-guard.js +35 -15
  98. package/dist/pipeline-guard.js.map +1 -1
  99. package/dist/pipeline-report.d.ts +34 -0
  100. package/dist/pipeline-report.d.ts.map +1 -0
  101. package/dist/pipeline-report.js +282 -0
  102. package/dist/pipeline-report.js.map +1 -0
  103. package/dist/project-path.d.ts +3 -0
  104. package/dist/project-path.d.ts.map +1 -0
  105. package/dist/project-path.js +36 -0
  106. package/dist/project-path.js.map +1 -0
  107. package/dist/replay.d.ts +12 -0
  108. package/dist/replay.d.ts.map +1 -0
  109. package/dist/replay.js +36 -0
  110. package/dist/replay.js.map +1 -0
  111. package/dist/rules/codes.d.ts +42 -0
  112. package/dist/rules/codes.d.ts.map +1 -0
  113. package/dist/rules/codes.js +52 -0
  114. package/dist/rules/codes.js.map +1 -0
  115. package/dist/rules/comment-markers.d.ts +10 -0
  116. package/dist/rules/comment-markers.d.ts.map +1 -0
  117. package/dist/rules/comment-markers.js +24 -0
  118. package/dist/rules/comment-markers.js.map +1 -0
  119. package/dist/rules/index.d.ts +31 -0
  120. package/dist/rules/index.d.ts.map +1 -0
  121. package/dist/rules/index.js +133 -0
  122. package/dist/rules/index.js.map +1 -0
  123. package/dist/rules/legacy.d.ts +7 -0
  124. package/dist/rules/legacy.d.ts.map +1 -0
  125. package/dist/rules/legacy.js +85 -0
  126. package/dist/rules/legacy.js.map +1 -0
  127. package/dist/rules/routes.d.ts +12 -0
  128. package/dist/rules/routes.d.ts.map +1 -0
  129. package/dist/rules/routes.js +142 -0
  130. package/dist/rules/routes.js.map +1 -0
  131. package/dist/rules/security.d.ts +10 -0
  132. package/dist/rules/security.d.ts.map +1 -0
  133. package/dist/rules/security.js +471 -0
  134. package/dist/rules/security.js.map +1 -0
  135. package/dist/search-terms.d.ts +2 -0
  136. package/dist/search-terms.d.ts.map +1 -1
  137. package/dist/search-terms.js +5 -1
  138. package/dist/search-terms.js.map +1 -1
  139. package/dist/types-search.d.ts +1 -2
  140. package/dist/types-search.d.ts.map +1 -1
  141. package/dist/types-search.js +33 -16
  142. package/dist/types-search.js.map +1 -1
  143. package/dist/verification.d.ts +9 -0
  144. package/dist/verification.d.ts.map +1 -1
  145. package/dist/verification.js +17 -1
  146. package/dist/verification.js.map +1 -1
  147. package/dist/work-graph.d.ts +123 -0
  148. package/dist/work-graph.d.ts.map +1 -0
  149. package/dist/work-graph.js +465 -0
  150. package/dist/work-graph.js.map +1 -0
  151. package/docs/examples.json +32 -2
  152. package/docs/llms-full.txt +1105 -27
  153. package/docs/types.json +1772 -137
  154. package/package.json +34 -9
  155. package/src/assure-hydration.ts +606 -0
  156. package/src/assure.ts +316 -2
  157. package/src/capabilities-tool.ts +80 -1
  158. package/src/check.ts +923 -169
  159. package/src/cli.ts +297 -76
  160. package/src/contract-proof.ts +194 -0
  161. package/src/contracts.ts +157 -0
  162. package/src/dev-bun-config.ts +57 -14
  163. package/src/diagnostics.ts +32 -0
  164. package/src/diff-tool.ts +1 -1
  165. package/src/docs-search.ts +45 -15
  166. package/src/doctor.ts +286 -8
  167. package/src/examples.ts +43 -15
  168. package/src/fix-recipes.ts +126 -0
  169. package/src/init-agents.ts +61 -4
  170. package/src/internal/source-facts.ts +133 -0
  171. package/src/internal/typescript-import.ts +29 -0
  172. package/src/introspect.ts +81 -6
  173. package/src/load.ts +11 -0
  174. package/src/manifest-tool.ts +16 -10
  175. package/src/mcp-docs-tools.ts +3 -0
  176. package/src/mcp-http.ts +1 -1
  177. package/src/mcp-root.ts +271 -0
  178. package/src/mcp-run.ts +19 -4
  179. package/src/mcp.ts +440 -34
  180. package/src/pipeline-guard.ts +44 -14
  181. package/src/pipeline-report.ts +341 -0
  182. package/src/project-path.ts +36 -0
  183. package/src/replay.ts +44 -0
  184. package/src/rules/codes.ts +57 -0
  185. package/src/rules/comment-markers.ts +26 -0
  186. package/src/rules/index.ts +175 -0
  187. package/src/rules/legacy.ts +93 -0
  188. package/src/rules/routes.ts +157 -0
  189. package/src/rules/security.ts +505 -0
  190. package/src/search-terms.ts +9 -4
  191. package/src/types-search.ts +39 -15
  192. package/src/verification.ts +18 -1
  193. package/src/work-graph.ts +613 -0
package/src/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `nifra` - the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
4
4
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
5
5
  *
6
- * nifra dev true-HMR dev server (Vite middleware + nifra SSR) - @nifrajs/web/vite
6
+ * nifra dev true-HMR dev server (Bun native HMR + nifra SSR) - @nifrajs/web/dev
7
7
  * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
8
8
  * nifra start run the default Bun build - dist/server.js
9
9
  *
@@ -12,12 +12,17 @@
12
12
  import { existsSync } from "node:fs"
13
13
  import { resolve } from "node:path"
14
14
  import { inProcessClient } from "@nifrajs/client"
15
- import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
15
+ import {
16
+ type CreateWebAppOptions,
17
+ createWebApp,
18
+ DEFAULT_DEV_PORT,
19
+ type RenderAdapter,
20
+ } from "@nifrajs/web"
16
21
  import { discoverRoutes } from "@nifrajs/web/fs"
17
22
  import type { BunPlugin } from "bun"
18
23
  import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
19
- import { type LoadedApp, loadApp } from "./load.ts"
20
- import { chooseBuildPipeline } from "./pipeline-guard.ts"
24
+ import { type LoadedApp, loadApp, type NifraFramework } from "./load.ts"
25
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.ts"
21
26
 
22
27
  export interface Flags {
23
28
  readonly port: number
@@ -30,9 +35,8 @@ export interface Flags {
30
35
  /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
31
36
  * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
32
37
  readonly vite: boolean
33
- /** `nifra dev --bun`: run the Bun-pipeline dev server (Bun.serve native HMR - including React Fast
34
- * Refresh with state preserved - and no Vite in the process). Default stays Vite for its plugin
35
- * ecosystem; `--bun` trades that for one bundler across dev and prod. Refuses CSS-Modules apps.
38
+ /** `nifra dev --bun`: force the Bun-pipeline dev server. Rarely needed - `dev` and `build` share one
39
+ * rule, so Bun is already the default unless `vitePlugins` are the app's ONLY transforms.
36
40
  *
37
41
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
38
42
  readonly bun: boolean
@@ -41,11 +45,14 @@ export interface Flags {
41
45
  const HELP = `nifra - zero-config dev/build/start for a nifra app
42
46
 
43
47
  Usage:
44
- nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
45
- [--bun] Run the BUN pipeline instead: Bun.serve native HMR (React Fast
46
- Refresh included, state preserved), no Vite in the process - one
47
- bundler across dev and prod. Plain CSS/Tailwind work; refuses an
48
- app using *.module.css (Bun's dev bundler can't transform it).
48
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Bun). Default port ${DEFAULT_DEV_PORT}.
49
+ An app whose ONLY transforms are \`vitePlugins\` uses Vite (the
50
+ Bun pipeline cannot run them); everything else uses Bun.
51
+ [--vite] Force the Vite middleware/HMR pipeline.
52
+ [--bun] Force Bun.serve native HMR (React Fast Refresh included, state
53
+ preserved), no Vite in the process. CSS Modules, plain CSS,
54
+ Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
55
+ through the same Bun transforms production uses.
49
56
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
50
57
  [--target <t>] Target a FULL deploy dir for <t>:
51
58
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -80,8 +87,11 @@ Usage:
80
87
  and a "## MCP server" section in AGENTS.md. No-clobber by
81
88
  default (skips a file you've customized); --force overwrites the
82
89
  owned files. AGENTS.md is only appended to, never overwritten.
83
- nifra mcp Start an MCP server (stdio) exposing this project to a coding
84
- agent: nifra_context, nifra_routes (routes+schemas as JSON),
90
+ nifra mcp [dir] Start an MCP server (stdio) exposing this project to a coding
91
+ agent. The project root is [dir] when given, else resolved from
92
+ cwd (marker walk-up + the client's MCP roots); tools refuse
93
+ with a fix when no nifra project is found - never a silent
94
+ wrong-project answer. Tools: nifra_context, nifra_routes,
85
95
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
86
96
  nifra_example (verified snippets), nifra_scaffold (route→file),
87
97
  nifra_check (drift gate + fixes), nifra_levels (verification
@@ -94,6 +104,8 @@ Usage:
94
104
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
95
105
  server-only imports in routes/). Run as "done"; --json for agents;
96
106
  --lints-only skips tsc for a near-instant inner-loop pass.
107
+ nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
108
+ structured diagnostics.
97
109
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
98
110
  a full build - clears server-manifest drift after a route add/rename
99
111
  (a new hydrating component still needs a full build).
@@ -109,9 +121,14 @@ Usage:
109
121
  closed. Exits non-zero on any breaking change - run it in CI.
110
122
  nifra sdk --lang <python|go> [--out <file>]
111
123
  Generate a deterministic non-TypeScript SDK from backend.ts.
112
- nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
113
- reflected backend route, and fail when required enforcement
114
- evidence is missing/forbidden or a route is unclassified.
124
+ nifra assure [--config <file>] [--json] [--strict] [--out <file>] [--hydration] [--interact]
125
+ Emit one structured assurance bundle. The bundle records every
126
+ applicable gate as pass, fail, or skip and exits non-zero unless
127
+ its verdict is green. Without these bundle flags, the legacy
128
+ route-assurance report remains available.
129
+ nifra contracts snapshot [--out <file>] Write the deterministic route contract lock.
130
+ nifra contracts check [--json] Check the current routes against contracts.lock.json.
131
+ nifra replay <file> Replay a token-only verification metadata file.
115
132
  nifra capabilities snapshot [--out <file>] [--config <file>]
116
133
  Write the deterministic, token-only capability lockfile, but
117
134
  only after provenance + idempotency assurance passes.
@@ -128,9 +145,15 @@ Usage:
128
145
  Compute the verification ladder (L0 typed contract → L1 route
129
146
  assurance → L2 capability lockfile → L3 route manifest → L4
130
147
  invariant-tested); --min <n> fails the exit code below level n.
148
+ nifra prove [--json] [--file <path>] [--min <n>]
149
+ Build the static verification work graph, plan the cheapest
150
+ proofs for changed files, and report a machine-checkable stop
151
+ condition. Requires a fresh build; never probes a running app.
131
152
  nifra doctor [--json] [--auto-fix] Flag undeclared imports and multiple physical copies of
132
- @nifrajs/core/React identity-sensitive dependencies;
133
- --auto-fix writes safe local-version dependency fixes.
153
+ [--strict] [--target] @nifrajs/core/React identity-sensitive dependencies;
154
+ print production readiness, with --strict making absent
155
+ applicable guarantees fail. --auto-fix writes safe local-
156
+ version dependency fixes.
134
157
  nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
135
158
  [--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
136
159
  [--list] [--json] packages, apply exact imports, then verify with nifra check.
@@ -152,11 +175,13 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
152
175
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
153
176
 
154
177
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
155
- const CLI_VERSION = "2.10.0"
178
+ const CLI_VERSION = "2.12.0"
156
179
 
157
180
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
158
181
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
159
182
  const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
183
+ const asUse = (v: (app: never) => void): NonNullable<CreateWebAppOptions["use"]> =>
184
+ v as NonNullable<CreateWebAppOptions["use"]>
160
185
  const apiOf = (backend: unknown): { api?: unknown } =>
161
186
  backend === undefined ? {} : { api: inProcessClient(backend as never) }
162
187
 
@@ -194,52 +219,26 @@ export function formatCliError(err: unknown): string {
194
219
  return String(err)
195
220
  }
196
221
 
197
- /**
198
- * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
199
- *
200
- * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
201
- * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
202
- * bundler does not: the import compiles to a dangling reference and the browser throws
203
- * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
204
- * neither CSS Modules nor the dev server, so the cause is invisible.
205
- *
206
- * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
207
- * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
208
- * an app without CSS Modules still gets the Bun dev loop.
209
- */
210
- export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
211
- // Server functions and `*.server` modules are handled: the generated bunfig (dev-bun-config.ts)
212
- // delivers the SAME serverFnStubPlugin/serverOnlyEmptyPlugin the production build uses to Bun's
213
- // dev-server bundler via `[serve.static] plugins` - the one channel that bundler accepts plugins
214
- // through (a runtime `Bun.plugin` never reaches it; programmatic ask: oven-sh/bun#36830). The old
215
- // fail-closed refusal for those modules is gone because the leak it guarded is gone.
216
- //
217
- // CSS Modules remain gated: the `*.module.css` plugin rests on an `onResolve` virtual-module
218
- // stylesheet emitter that is not yet verified under `[serve.static]`, and its failure mode is a
219
- // broken client rather than a leak - refuse loudly until it is proven there.
220
- const { Glob } = await import("bun")
221
- const offenders: string[] = []
222
- for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
223
- if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
224
- offenders.push(file)
225
- if (offenders.length >= 5) break
226
- }
227
- if (offenders.length === 0) return
228
- throw new Error(
229
- "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
230
- "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
231
- "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
232
- ` CSS Modules found: ${offenders.join(", ")}\n` +
233
- " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
234
- )
235
- }
236
-
237
222
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
238
- // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
239
- // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
240
- // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
241
- if (flags.bun) {
242
- await assertBunDevSupportsApp(app)
223
+ if (flags.vite && flags.bun) {
224
+ throw new Error("[nifra] `nifra dev` takes `--vite` or `--bun`, not both.")
225
+ }
226
+ // Default to Bun for the dev phase too. An app whose only transforms are Vite plugins stays on Vite so
227
+ // its transform is never silently dropped; `--vite`/`--bun` make that choice explicit.
228
+ // ONE rule for both phases - `chooseBuildPipeline` decides `dev` exactly as it decides `build`, so a
229
+ // project cannot end up bundled by one toolchain in dev and the other in production. An earlier
230
+ // version of this kept apps declaring `vitePlugins` on Vite for dev even when they also shipped Bun
231
+ // equivalents, on the theory that the framework plugins were the safer HMR path. Measurement killed
232
+ // it: Vue, Solid and Svelte all render correctly on the Bun pipeline, so the hedge bought nothing
233
+ // and cost an app the guarantee that dev and production bundle the same way. (Svelte used to render
234
+ // ONLY on Bun, which made the hedge actively harmful; it now renders on either - see the note at the
235
+ // Vite branch for what the adapter had to be given to make that true.)
236
+ const decision = chooseBuildPipeline(
237
+ app.resolvedPlugins,
238
+ flags.vite ? "vite" : flags.bun ? "bun" : undefined,
239
+ "dev",
240
+ )
241
+ if (decision.pipeline === "bun") {
243
242
  // Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
244
243
  // start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
245
244
  // generating a config and re-execing this same command once with `--config=`. The child proves
@@ -259,15 +258,36 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
259
258
  "launches; refusing to serve without the client-boundary plugins.",
260
259
  )
261
260
  }
262
- const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd)
261
+ const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath)
263
262
  const child = Bun.spawn(
264
- [process.execPath, `--config=${bunfigPath}`, ...process.argv.slice(1)],
263
+ [
264
+ process.execPath,
265
+ `--config=${bunfigPath}`,
266
+ // The app's `conditions` reach SSR here or nowhere: bunfig has no key for them (a top-level,
267
+ // `[run]`, `[serve.static]` or `[bundle]` `conditions` is parsed and ignored), and the re-exec
268
+ // is the only point where this process's resolver is still configurable. Without it a package
269
+ // with an `exports` map resolves to a DIFFERENT file on the server than in the client bundle,
270
+ // which is the two-toolchains-one-module failure the pipeline guard exists to prevent.
271
+ //
272
+ // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
273
+ // whole string as a single condition name, so the comma form fails silently.
274
+ ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
275
+ ...process.argv.slice(1),
276
+ ],
265
277
  {
266
278
  stdio: ["inherit", "inherit", "inherit"],
267
279
  env: {
268
280
  ...process.env,
269
281
  NIFRA_BUN_DEV_TOKEN: launchToken,
270
282
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
283
+ // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
284
+ // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
285
+ // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
286
+ // so on the server that fallback is the only signal there is. It has to be in place before
287
+ // the app's config is read, because reading it imports the adapter and through it the
288
+ // framework runtime; setting it inside the dev server would already be a step too late.
289
+ // Preserved if the caller pinned one.
290
+ NODE_ENV: process.env.NODE_ENV ?? "development",
271
291
  },
272
292
  },
273
293
  )
@@ -276,6 +296,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
276
296
  process.on("SIGTERM", forward)
277
297
  process.exit(await child.exited)
278
298
  }
299
+ // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
300
+ // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
301
+ //
302
+ // - CSS Modules. The scoped name is a pure function of file path + class name, so the `"ssr"` form
303
+ // produces the identical map the client bundle emits (and no stylesheet - that ships from the
304
+ // client build). Without it `styles.box` is `undefined` on the server: the SSR markup carries no
305
+ // class, the page paints unstyled, and hydration then reports a className mismatch.
306
+ // - the app's `serverPlugins` - the SSR counterpart of the `clientPlugins` the generated bunfig
307
+ // delivers, exactly as `nifra build`'s prerender pass registers them.
308
+ //
309
+ // Registered BEFORE `createDevServer`, because a Bun runtime plugin only affects modules loaded
310
+ // after it - and `createApp` imports the route modules.
311
+ const { plugin } = await import("bun")
312
+ const { cssModulesBunPlugin } = await import("@nifrajs/web/plugins/css-modules")
313
+ plugin(cssModulesBunPlugin("ssr"))
314
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
315
+ // "After" has one consequence an adapter has to respect. Reading the app's config is what PRODUCES
316
+ // these plugins, so the config's import graph - which includes the adapter - is already loaded when
317
+ // they register. An adapter must therefore not import an asset its own plugin compiles at module
318
+ // scope; it has to defer to first render, which happens after this point. `@nifrajs/web-svelte`
319
+ // loads its `Chain.svelte` that way for exactly this reason, and says so at the import.
279
320
  const { createDevServer } = await import("@nifrajs/web/dev")
280
321
  const { framework: fw, routesDir, outDir, backend } = app
281
322
  const server = await createDevServer({
@@ -283,8 +324,10 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
283
324
  outDir,
284
325
  clientModule: fw.clientModule,
285
326
  port: flags.port,
286
- // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
287
- // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
327
+ // For the background client-leak guard, which re-runs `buildClient` on change - the SERVED client
328
+ // bundle gets these same plugins through the generated bunfig, since Bun's dev-server bundler takes
329
+ // plugins only as module paths. Never `vitePlugins`, which belong to the other pipeline;
330
+ // `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
288
331
  plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
289
332
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
290
333
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -294,13 +337,15 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
294
337
  adapter: asAdapter(fw.adapter),
295
338
  manifest: discoverRoutes(routesDir, { importQuery }),
296
339
  clientEntry,
340
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
297
341
  ...apiOf(backend),
298
342
  }),
299
343
  })
300
344
  console.log(`nifra dev (bun) → http://localhost:${server.port}`)
345
+ console.log(` ${describePipeline(decision)}`)
301
346
  return
302
347
  }
303
- // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
348
+ // Preflight: the Vite fallback needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
304
349
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
305
350
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
306
351
  try {
@@ -321,6 +366,18 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
321
366
  // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
322
367
  // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
323
368
  // not a redundancy worth keeping "just in case".
369
+ //
370
+ // Svelte is the case that proves the rule, and the discarded attempts are recorded so they are not
371
+ // retried. The adapter package stays `ssr.external` (its context must be the ONE instance the
372
+ // Bun-imported adapter provides), so BUN loads it - and `@nifrajs/web-svelte` renders through its own
373
+ // `Chain.svelte`, which needs a compiler that this pipeline does not put in the runtime. Registering
374
+ // the app's `serverPlugins` here, scoped to that package's directory, compiles it against a SECOND
375
+ // Svelte runtime and the first `setContext` dies (`context.function` null); externalizing the
376
+ // adapter's framework peer as well gets further and then dies on `context.function[FILENAME]`,
377
+ // because `svelteBunPlugin` and `vite-plugin-svelte` do not emit the same dev-mode component shape.
378
+ // Both attempts add a compiler. The fix removes one instead: `createViteDevServer` publishes its
379
+ // `ssrLoadModule` via `setSsrModuleLoader`, and the adapter loads `Chain.svelte` AND `svelte/server`
380
+ // through it - one toolchain, one runtime, context intact across the whole tree.
324
381
  const server = await createViteDevServer({
325
382
  root: cwd,
326
383
  routesDir,
@@ -340,10 +397,12 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
340
397
  adapter: asAdapter(fw.adapter),
341
398
  manifest: discoverRoutes(routesDir, { load }),
342
399
  clientEntry,
400
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
343
401
  ...apiOf(backend),
344
402
  }),
345
403
  })
346
- console.log(`nifra dev → http://localhost:${server.port}`)
404
+ console.log(`nifra dev (vite) → http://localhost:${server.port}`)
405
+ console.log(` ${describePipeline(decision)}`)
347
406
  }
348
407
 
349
408
  /**
@@ -353,6 +412,37 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
353
412
  * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
354
413
  * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
355
414
  */
415
+ /**
416
+ * Refuse a `use` the generated server entry cannot import.
417
+ *
418
+ * `nifra build` emits `import { use } from <frameworkFile>` (framework.ts, the edge-bundlable file),
419
+ * but `loadApp` prefers `nifra.config.ts` - so an app with BOTH files whose `use` lives only in
420
+ * `nifra.config.ts` makes `fw.use` defined while `framework.ts` exports nothing of that name, and the
421
+ * build dies later with an opaque bundler error pointing at generated code. Refuse it here with the
422
+ * exact move instead, in the spirit of `assertPipelineSeparation` (load.ts).
423
+ *
424
+ * Detected by importing `frameworkFile` and checking the named export, not by comparing paths alone:
425
+ * a split app legitimately DEFINES `use` in framework.ts and re-exports it from nifra.config.ts
426
+ * (exactly how `adapter` reaches both readers), and a path compare would refuse that correct layout.
427
+ * The import is cheap - framework.ts is already in the loaded config's module graph in that layout.
428
+ */
429
+ export async function assertUseIsEdgeExported(
430
+ use: NifraFramework["use"],
431
+ configPath: string,
432
+ frameworkFile: string,
433
+ ): Promise<void> {
434
+ if (use === undefined || configPath === frameworkFile) return
435
+ const mod = (await import(frameworkFile).catch(() => ({}))) as { use?: unknown }
436
+ if (typeof mod.use === "function") return
437
+ throw new Error(
438
+ `[nifra] \`use\` is exported from ${configPath} but not from ${frameworkFile}. ` +
439
+ "`nifra build` generates a server entry that imports `use` from framework.ts (the edge-bundled " +
440
+ "file), so this build would fail later with an opaque bundler error inside generated code.\n\n" +
441
+ " - Define `use` in framework.ts and re-export it from nifra.config.ts " +
442
+ '(`export { use } from "./framework.ts"`) so `nifra dev` sees it too.',
443
+ )
444
+ }
445
+
356
446
  async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
357
447
  const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
358
448
  if (!isBuildTarget(target)) {
@@ -384,6 +474,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
384
474
  ? resolve(cwd, "nifra.config.ts")
385
475
  : resolve(cwd, "framework.ts")
386
476
  const backendFile = resolve(cwd, "backend.ts")
477
+ await assertUseIsEdgeExported(fw.use, app.configPath, frameworkFile)
387
478
  // Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
388
479
  // Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
389
480
  // whatever it's given straight to the chosen bundler, which casts to its own plugin type.
@@ -402,6 +493,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
402
493
  workDir: resolve(cwd, ".nifra-build"),
403
494
  clientModule: fw.clientModule,
404
495
  adapterImport: frameworkFile,
496
+ ...(fw.use ? { useImport: frameworkFile } : {}),
405
497
  ...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
406
498
  ...plugins,
407
499
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -411,10 +503,10 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
411
503
  // The static target needs a built app to drive prerendering - only build it when targeting static.
412
504
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
413
505
  })
414
- console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
415
- // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
416
- // else, and the reason is the thing that makes that legible instead of surprising.
417
- if (decision.reason !== undefined) console.log(` ${decision.reason}`)
506
+ console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`)
507
+ // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
508
+ // must never look like the default, and a default Bun build must not leave the question open either.
509
+ console.log(` ${describePipeline(decision)}`)
418
510
  if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
419
511
  }
420
512
 
@@ -435,6 +527,7 @@ async function buildPrerenderApp(
435
527
  adapter: asAdapter(fw.adapter),
436
528
  manifest: discoverRoutes(routesDir),
437
529
  clientEntry: client.entry,
530
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
438
531
  ...(client.routes ? { routePreload: client.routes } : {}),
439
532
  ...(client.css ? { styles: client.css } : {}),
440
533
  ...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
@@ -511,7 +604,10 @@ async function main(): Promise<void> {
511
604
  // the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
512
605
  if (command === "mcp") {
513
606
  const { runMcpServer } = await import("./mcp.ts")
514
- await runMcpServer(process.cwd(), CLI_VERSION)
607
+ // `nifra mcp [dir]` - an explicit project directory pins the root (for clients configured outside
608
+ // the project); otherwise the server resolves it from cwd + the client's MCP roots.
609
+ const dirArg = argv[1] !== undefined && !argv[1].startsWith("-") ? argv[1] : undefined
610
+ await runMcpServer(process.cwd(), CLI_VERSION, dirArg)
515
611
  return
516
612
  }
517
613
  // `docs-mcp` runs the PUBLIC docs MCP over HTTP - project-independent (serves the bundled corpus), so
@@ -546,12 +642,36 @@ async function main(): Promise<void> {
546
642
  if (
547
643
  !(await runCheck(process.cwd(), {
548
644
  json: argv.includes("--json"),
645
+ structured: argv.includes("--json"),
549
646
  lintsOnly: argv.includes("--lints-only"),
550
647
  }))
551
648
  )
552
649
  process.exitCode = 1
553
650
  return
554
651
  }
652
+ if (command === "fix") {
653
+ const codeIndex = argv.indexOf("--code")
654
+ const code = codeIndex === -1 ? undefined : argv[codeIndex + 1]
655
+ try {
656
+ const { collectCheckResult } = await import("./check.ts")
657
+ const { applyDiagnosticRecipe } = await import("./fix-recipes.ts")
658
+ const result = await collectCheckResult(process.cwd(), { lintsOnly: true })
659
+ const changed: string[] = []
660
+ for (const diagnostic of result.structuredDiagnostics ?? []) {
661
+ if (code !== undefined && diagnostic.code !== code) continue
662
+ changed.push(...(await applyDiagnosticRecipe(process.cwd(), diagnostic)))
663
+ }
664
+ const final = await collectCheckResult(process.cwd(), { lintsOnly: true })
665
+ console.log(
666
+ JSON.stringify({ changed, diagnostics: final.structuredDiagnostics ?? [] }, null, 2),
667
+ )
668
+ if (final.ok === false) process.exitCode = 1
669
+ } catch (err) {
670
+ console.error(formatCliError(err))
671
+ process.exitCode = 1
672
+ }
673
+ return
674
+ }
555
675
  // `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
556
676
  // pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
557
677
  // (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
@@ -582,10 +702,19 @@ async function main(): Promise<void> {
582
702
  // so it runs on an API-only / not-yet-built project.
583
703
  if (command === "doctor") {
584
704
  const { runDoctor } = await import("./doctor.ts")
705
+ const targetIdx = argv.indexOf("--target")
706
+ const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
707
+ if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
708
+ console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel")
709
+ process.exitCode = 1
710
+ return
711
+ }
585
712
  if (
586
713
  !(await runDoctor(process.cwd(), {
587
714
  json: argv.includes("--json"),
588
715
  autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
716
+ strict: argv.includes("--strict"),
717
+ ...(target === undefined ? {} : { target }),
589
718
  }))
590
719
  )
591
720
  process.exitCode = 1
@@ -662,6 +791,16 @@ async function main(): Promise<void> {
662
791
  if (
663
792
  !(await runAssurance(process.cwd(), {
664
793
  json: argv.includes("--json"),
794
+ bundle:
795
+ argv.includes("--json") ||
796
+ argv.includes("--strict") ||
797
+ argv.includes("--hydration") ||
798
+ argv.includes("--interact") ||
799
+ argv.includes("--out"),
800
+ strict: argv.includes("--strict"),
801
+ hydration: argv.includes("--hydration"),
802
+ interact: argv.includes("--interact"),
803
+ ...(argv.includes("--out") ? { out: argv[argv.indexOf("--out") + 1] } : {}),
665
804
  ...(config !== undefined ? { config } : {}),
666
805
  }))
667
806
  )
@@ -672,6 +811,49 @@ async function main(): Promise<void> {
672
811
  }
673
812
  return
674
813
  }
814
+ if (command === "contracts") {
815
+ const action = argv[1]
816
+ const outIndex = argv.indexOf("--out")
817
+ const out = outIndex === -1 ? undefined : argv[outIndex + 1]
818
+ try {
819
+ const { checkContractsLock, snapshotContracts } = await import("./contracts.ts")
820
+ if (action === "snapshot") {
821
+ const lock = await snapshotContracts(process.cwd(), out)
822
+ if (argv.includes("--json")) console.log(JSON.stringify(lock, null, 2))
823
+ else console.log(`[nifra] wrote ${Object.keys(lock.routes).length} contract digests`)
824
+ } else if (action === "check") {
825
+ const result = await checkContractsLock(process.cwd())
826
+ const output = { ok: result.present && result.diagnostics.length === 0, ...result }
827
+ console.log(JSON.stringify(output, null, 2))
828
+ if (!output.ok) process.exitCode = 1
829
+ } else {
830
+ console.error("[nifra] contracts needs snapshot or check")
831
+ process.exitCode = 1
832
+ }
833
+ } catch (err) {
834
+ console.error(formatCliError(err))
835
+ process.exitCode = 1
836
+ }
837
+ return
838
+ }
839
+ if (command === "replay") {
840
+ const file = argv[1]
841
+ if (file === undefined || file.startsWith("-")) {
842
+ console.error("[nifra] replay needs a file path")
843
+ process.exitCode = 1
844
+ return
845
+ }
846
+ try {
847
+ const { runReplay } = await import("./replay.ts")
848
+ const result = await runReplay(process.cwd(), file)
849
+ console.log(JSON.stringify(result, null, 2))
850
+ if (!result.ok) process.exitCode = 1
851
+ } catch (err) {
852
+ console.error(formatCliError(err))
853
+ process.exitCode = 1
854
+ }
855
+ return
856
+ }
675
857
  if (command === "capabilities") {
676
858
  const action = argv[1]
677
859
  const valueAfter = (flag: string): string | undefined => {
@@ -786,6 +968,45 @@ async function main(): Promise<void> {
786
968
  }
787
969
  return
788
970
  }
971
+ if (command === "prove") {
972
+ const valueAfter = (flag: string): string | undefined => {
973
+ const index = argv.indexOf(flag)
974
+ const value = index === -1 ? undefined : argv[index + 1]
975
+ if (index !== -1 && (value === undefined || value.startsWith("-"))) {
976
+ throw new Error(`[nifra] ${flag} needs a value`)
977
+ }
978
+ return value
979
+ }
980
+ try {
981
+ const minRaw = valueAfter("--min")
982
+ const min = minRaw === undefined ? undefined : Number(minRaw)
983
+ if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
984
+ throw new Error("[nifra] --min must be an integer level between 0 and 4")
985
+ }
986
+ const changedFiles: string[] = []
987
+ for (let index = 0; index < argv.length; index++) {
988
+ if (argv[index] !== "--file") continue
989
+ const file = argv[index + 1]
990
+ if (file === undefined || file.startsWith("-"))
991
+ throw new Error("[nifra] --file needs a path")
992
+ changedFiles.push(file)
993
+ index++
994
+ }
995
+ const { runWorkGraph } = await import("./work-graph.ts")
996
+ if (
997
+ !(await runWorkGraph(process.cwd(), {
998
+ json: argv.includes("--json"),
999
+ ...(changedFiles.length === 0 ? {} : { changedFiles }),
1000
+ ...(min === undefined ? {} : { minLevel: min }),
1001
+ }))
1002
+ )
1003
+ process.exitCode = 1
1004
+ } catch (err) {
1005
+ console.error(formatCliError(err))
1006
+ process.exitCode = 1
1007
+ }
1008
+ return
1009
+ }
789
1010
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
790
1011
  // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
791
1012
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).