@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/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
11
11
  * `nifra` - the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
12
12
  * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
13
13
  *
14
- * nifra dev true-HMR dev server (Vite middleware + nifra SSR) - @nifrajs/web/vite
14
+ * nifra dev true-HMR dev server (Bun native HMR + nifra SSR) - @nifrajs/web/dev
15
15
  * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
16
16
  * nifra start run the default Bun build - dist/server.js
17
17
  *
@@ -20,19 +20,22 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
20
  import { existsSync } from "node:fs";
21
21
  import { resolve } from "node:path";
22
22
  import { inProcessClient } from "@nifrajs/client";
23
- import { createWebApp, DEFAULT_DEV_PORT } from "@nifrajs/web";
23
+ import { createWebApp, DEFAULT_DEV_PORT, } from "@nifrajs/web";
24
24
  import { discoverRoutes } from "@nifrajs/web/fs";
25
25
  import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.js";
26
26
  import { loadApp } from "./load.js";
27
- import { chooseBuildPipeline } from "./pipeline-guard.js";
27
+ import { chooseBuildPipeline, describePipeline } from "./pipeline-guard.js";
28
28
  const HELP = `nifra - zero-config dev/build/start for a nifra app
29
29
 
30
30
  Usage:
31
- nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
32
- [--bun] Run the BUN pipeline instead: Bun.serve native HMR (React Fast
33
- Refresh included, state preserved), no Vite in the process - one
34
- bundler across dev and prod. Plain CSS/Tailwind work; refuses an
35
- app using *.module.css (Bun's dev bundler can't transform it).
31
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Bun). Default port ${DEFAULT_DEV_PORT}.
32
+ An app whose ONLY transforms are \`vitePlugins\` uses Vite (the
33
+ Bun pipeline cannot run them); everything else uses Bun.
34
+ [--vite] Force the Vite middleware/HMR pipeline.
35
+ [--bun] Force Bun.serve native HMR (React Fast Refresh included, state
36
+ preserved), no Vite in the process. CSS Modules, plain CSS,
37
+ Tailwind and the app's own \`clientPlugins\`/\`serverPlugins\` run
38
+ through the same Bun transforms production uses.
36
39
  nifra build [--out <dir>] [--report] Emit a complete deploy directory.
37
40
  [--target <t>] Target a FULL deploy dir for <t>:
38
41
  bun | node | deno | cf-pages | vercel | static. Packages
@@ -67,8 +70,11 @@ Usage:
67
70
  and a "## MCP server" section in AGENTS.md. No-clobber by
68
71
  default (skips a file you've customized); --force overwrites the
69
72
  owned files. AGENTS.md is only appended to, never overwritten.
70
- nifra mcp Start an MCP server (stdio) exposing this project to a coding
71
- agent: nifra_context, nifra_routes (routes+schemas as JSON),
73
+ nifra mcp [dir] Start an MCP server (stdio) exposing this project to a coding
74
+ agent. The project root is [dir] when given, else resolved from
75
+ cwd (marker walk-up + the client's MCP roots); tools refuse
76
+ with a fix when no nifra project is found - never a silent
77
+ wrong-project answer. Tools: nifra_context, nifra_routes,
72
78
  nifra_run (backend), nifra_render (SSR a page), nifra_docs,
73
79
  nifra_example (verified snippets), nifra_scaffold (route→file),
74
80
  nifra_check (drift gate + fixes), nifra_levels (verification
@@ -81,6 +87,8 @@ Usage:
81
87
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
82
88
  server-only imports in routes/). Run as "done"; --json for agents;
83
89
  --lints-only skips tsc for a near-instant inner-loop pass.
90
+ nifra fix [--code <NF-code>] Apply a registered diagnostic recipe, then print the remaining
91
+ structured diagnostics.
84
92
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
85
93
  a full build - clears server-manifest drift after a route add/rename
86
94
  (a new hydrating component still needs a full build).
@@ -96,9 +104,14 @@ Usage:
96
104
  closed. Exits non-zero on any breaking change - run it in CI.
97
105
  nifra sdk --lang <python|go> [--out <file>]
98
106
  Generate a deterministic non-TypeScript SDK from backend.ts.
99
- nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
100
- reflected backend route, and fail when required enforcement
101
- evidence is missing/forbidden or a route is unclassified.
107
+ nifra assure [--config <file>] [--json] [--strict] [--out <file>] [--hydration] [--interact]
108
+ Emit one structured assurance bundle. The bundle records every
109
+ applicable gate as pass, fail, or skip and exits non-zero unless
110
+ its verdict is green. Without these bundle flags, the legacy
111
+ route-assurance report remains available.
112
+ nifra contracts snapshot [--out <file>] Write the deterministic route contract lock.
113
+ nifra contracts check [--json] Check the current routes against contracts.lock.json.
114
+ nifra replay <file> Replay a token-only verification metadata file.
102
115
  nifra capabilities snapshot [--out <file>] [--config <file>]
103
116
  Write the deterministic, token-only capability lockfile, but
104
117
  only after provenance + idempotency assurance passes.
@@ -115,9 +128,15 @@ Usage:
115
128
  Compute the verification ladder (L0 typed contract → L1 route
116
129
  assurance → L2 capability lockfile → L3 route manifest → L4
117
130
  invariant-tested); --min <n> fails the exit code below level n.
131
+ nifra prove [--json] [--file <path>] [--min <n>]
132
+ Build the static verification work graph, plan the cheapest
133
+ proofs for changed files, and report a machine-checkable stop
134
+ condition. Requires a fresh build; never probes a running app.
118
135
  nifra doctor [--json] [--auto-fix] Flag undeclared imports and multiple physical copies of
119
- @nifrajs/core/React identity-sensitive dependencies;
120
- --auto-fix writes safe local-version dependency fixes.
136
+ [--strict] [--target] @nifrajs/core/React identity-sensitive dependencies;
137
+ print production readiness, with --strict making absent
138
+ applicable guarantees fail. --auto-fix writes safe local-
139
+ version dependency fixes.
121
140
  nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
122
141
  [--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
123
142
  [--list] [--json] packages, apply exact imports, then verify with nifra check.
@@ -138,10 +157,11 @@ routes/ from the current directory. Run from your project root.
138
157
  Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with \`--port <n>\` (alias \`-p\`) or the
139
158
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`;
140
159
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
141
- const CLI_VERSION = "2.10.0";
160
+ const CLI_VERSION = "2.12.0";
142
161
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
143
162
  const asAdapter = (v) => v;
144
163
  const asBunPlugins = (v) => v;
164
+ const asUse = (v) => v;
145
165
  const apiOf = (backend) => backend === undefined ? {} : { api: inProcessClient(backend) };
146
166
  /**
147
167
  * Render an error for the CLI, unwrapping the detail a bare `.message` drops.
@@ -174,52 +194,22 @@ export function formatCliError(err) {
174
194
  return err.message;
175
195
  return String(err);
176
196
  }
177
- /**
178
- * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
179
- *
180
- * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
181
- * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
182
- * bundler does not: the import compiles to a dangling reference and the browser throws
183
- * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
184
- * neither CSS Modules nor the dev server, so the cause is invisible.
185
- *
186
- * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
187
- * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
188
- * an app without CSS Modules still gets the Bun dev loop.
189
- */
190
- export async function assertBunDevSupportsApp(app) {
191
- // Server functions and `*.server` modules are handled: the generated bunfig (dev-bun-config.ts)
192
- // delivers the SAME serverFnStubPlugin/serverOnlyEmptyPlugin the production build uses to Bun's
193
- // dev-server bundler via `[serve.static] plugins` - the one channel that bundler accepts plugins
194
- // through (a runtime `Bun.plugin` never reaches it; programmatic ask: oven-sh/bun#36830). The old
195
- // fail-closed refusal for those modules is gone because the leak it guarded is gone.
196
- //
197
- // CSS Modules remain gated: the `*.module.css` plugin rests on an `onResolve` virtual-module
198
- // stylesheet emitter that is not yet verified under `[serve.static]`, and its failure mode is a
199
- // broken client rather than a leak - refuse loudly until it is proven there.
200
- const { Glob } = await import("bun");
201
- const offenders = [];
202
- for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
203
- if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file))
204
- continue;
205
- offenders.push(file);
206
- if (offenders.length >= 5)
207
- break;
208
- }
209
- if (offenders.length === 0)
210
- return;
211
- throw new Error("[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
212
- "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
213
- "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
214
- ` CSS Modules found: ${offenders.join(", ")}\n` +
215
- " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.");
216
- }
217
197
  async function dev(app, flags) {
218
- // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
219
- // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
220
- // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
221
- if (flags.bun) {
222
- await assertBunDevSupportsApp(app);
198
+ if (flags.vite && flags.bun) {
199
+ throw new Error("[nifra] `nifra dev` takes `--vite` or `--bun`, not both.");
200
+ }
201
+ // Default to Bun for the dev phase too. An app whose only transforms are Vite plugins stays on Vite so
202
+ // its transform is never silently dropped; `--vite`/`--bun` make that choice explicit.
203
+ // ONE rule for both phases - `chooseBuildPipeline` decides `dev` exactly as it decides `build`, so a
204
+ // project cannot end up bundled by one toolchain in dev and the other in production. An earlier
205
+ // version of this kept apps declaring `vitePlugins` on Vite for dev even when they also shipped Bun
206
+ // equivalents, on the theory that the framework plugins were the safer HMR path. Measurement killed
207
+ // it: Vue, Solid and Svelte all render correctly on the Bun pipeline, so the hedge bought nothing
208
+ // and cost an app the guarantee that dev and production bundle the same way. (Svelte used to render
209
+ // ONLY on Bun, which made the hedge actively harmful; it now renders on either - see the note at the
210
+ // Vite branch for what the adapter had to be given to make that true.)
211
+ const decision = chooseBuildPipeline(app.resolvedPlugins, flags.vite ? "vite" : flags.bun ? "bun" : undefined, "dev");
212
+ if (decision.pipeline === "bun") {
223
213
  // Bun's dev-server bundler takes plugins only via bunfig `[serve.static]`, read at process
224
214
  // start - so the boundary plugins (server-fn stubs, server-only emptying) are delivered by
225
215
  // generating a config and re-execing this same command once with `--config=`. The child proves
@@ -237,13 +227,34 @@ async function dev(app, flags) {
237
227
  "relaunches. Something is rewriting `.nifra/dev-bun/` or the environment between " +
238
228
  "launches; refusing to serve without the client-boundary plugins.");
239
229
  }
240
- const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd);
241
- const child = Bun.spawn([process.execPath, `--config=${bunfigPath}`, ...process.argv.slice(1)], {
230
+ const { bunfigPath, launchToken } = await writeBunDevConfig(app.cwd, app.configPath);
231
+ const child = Bun.spawn([
232
+ process.execPath,
233
+ `--config=${bunfigPath}`,
234
+ // The app's `conditions` reach SSR here or nowhere: bunfig has no key for them (a top-level,
235
+ // `[run]`, `[serve.static]` or `[bundle]` `conditions` is parsed and ignored), and the re-exec
236
+ // is the only point where this process's resolver is still configurable. Without it a package
237
+ // with an `exports` map resolves to a DIFFERENT file on the server than in the client bundle,
238
+ // which is the two-toolchains-one-module failure the pipeline guard exists to prevent.
239
+ //
240
+ // One flag per condition. `--conditions=a,b` is accepted and matches nothing: Bun takes the
241
+ // whole string as a single condition name, so the comma form fails silently.
242
+ ...(app.framework.conditions ?? []).map((condition) => `--conditions=${condition}`),
243
+ ...process.argv.slice(1),
244
+ ], {
242
245
  stdio: ["inherit", "inherit", "inherit"],
243
246
  env: {
244
247
  ...process.env,
245
248
  NIFRA_BUN_DEV_TOKEN: launchToken,
246
249
  NIFRA_BUN_DEV_DEPTH: String(depth + 1),
250
+ // The child is the process that renders, so it is the one that needs dev-shaped runtimes.
251
+ // A library ships its dev build behind the `development` export condition with a `NODE_ENV`
252
+ // fallback for resolvers that set no conditions - which is Bun's runtime, where SSR runs,
253
+ // so on the server that fallback is the only signal there is. It has to be in place before
254
+ // the app's config is read, because reading it imports the adapter and through it the
255
+ // framework runtime; setting it inside the dev server would already be a step too late.
256
+ // Preserved if the caller pinned one.
257
+ NODE_ENV: process.env.NODE_ENV ?? "development",
247
258
  },
248
259
  });
249
260
  const forward = () => child.kill("SIGINT");
@@ -251,6 +262,28 @@ async function dev(app, flags) {
251
262
  process.on("SIGTERM", forward);
252
263
  process.exit(await child.exited);
253
264
  }
265
+ // SSR runs in THIS process, on Bun's runtime - a different loader from the dev-server bundler the
266
+ // bunfig above configures. Both halves have to agree, so the runtime gets the same transforms:
267
+ //
268
+ // - CSS Modules. The scoped name is a pure function of file path + class name, so the `"ssr"` form
269
+ // produces the identical map the client bundle emits (and no stylesheet - that ships from the
270
+ // client build). Without it `styles.box` is `undefined` on the server: the SSR markup carries no
271
+ // class, the page paints unstyled, and hydration then reports a className mismatch.
272
+ // - the app's `serverPlugins` - the SSR counterpart of the `clientPlugins` the generated bunfig
273
+ // delivers, exactly as `nifra build`'s prerender pass registers them.
274
+ //
275
+ // Registered BEFORE `createDevServer`, because a Bun runtime plugin only affects modules loaded
276
+ // after it - and `createApp` imports the route modules.
277
+ const { plugin } = await import("bun");
278
+ const { cssModulesBunPlugin } = await import("@nifrajs/web/plugins/css-modules");
279
+ plugin(cssModulesBunPlugin("ssr"));
280
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins))
281
+ plugin(p);
282
+ // "After" has one consequence an adapter has to respect. Reading the app's config is what PRODUCES
283
+ // these plugins, so the config's import graph - which includes the adapter - is already loaded when
284
+ // they register. An adapter must therefore not import an asset its own plugin compiles at module
285
+ // scope; it has to defer to first render, which happens after this point. `@nifrajs/web-svelte`
286
+ // loads its `Chain.svelte` that way for exactly this reason, and says so at the import.
254
287
  const { createDevServer } = await import("@nifrajs/web/dev");
255
288
  const { framework: fw, routesDir, outDir, backend } = app;
256
289
  const server = await createDevServer({
@@ -258,8 +291,10 @@ async function dev(app, flags) {
258
291
  outDir,
259
292
  clientModule: fw.clientModule,
260
293
  port: flags.port,
261
- // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
262
- // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
294
+ // For the background client-leak guard, which re-runs `buildClient` on change - the SERVED client
295
+ // bundle gets these same plugins through the generated bunfig, since Bun's dev-server bundler takes
296
+ // plugins only as module paths. Never `vitePlugins`, which belong to the other pipeline;
297
+ // `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
263
298
  plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
264
299
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
265
300
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -268,13 +303,15 @@ async function dev(app, flags) {
268
303
  adapter: asAdapter(fw.adapter),
269
304
  manifest: discoverRoutes(routesDir, { importQuery }),
270
305
  clientEntry,
306
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
271
307
  ...apiOf(backend),
272
308
  }),
273
309
  });
274
310
  console.log(`nifra dev (bun) → http://localhost:${server.port}`);
311
+ console.log(` ${describePipeline(decision)}`);
275
312
  return;
276
313
  }
277
- // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
314
+ // Preflight: the Vite fallback needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
278
315
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
279
316
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
280
317
  try {
@@ -294,6 +331,18 @@ async function dev(app, flags) {
294
331
  // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
295
332
  // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
296
333
  // not a redundancy worth keeping "just in case".
334
+ //
335
+ // Svelte is the case that proves the rule, and the discarded attempts are recorded so they are not
336
+ // retried. The adapter package stays `ssr.external` (its context must be the ONE instance the
337
+ // Bun-imported adapter provides), so BUN loads it - and `@nifrajs/web-svelte` renders through its own
338
+ // `Chain.svelte`, which needs a compiler that this pipeline does not put in the runtime. Registering
339
+ // the app's `serverPlugins` here, scoped to that package's directory, compiles it against a SECOND
340
+ // Svelte runtime and the first `setContext` dies (`context.function` null); externalizing the
341
+ // adapter's framework peer as well gets further and then dies on `context.function[FILENAME]`,
342
+ // because `svelteBunPlugin` and `vite-plugin-svelte` do not emit the same dev-mode component shape.
343
+ // Both attempts add a compiler. The fix removes one instead: `createViteDevServer` publishes its
344
+ // `ssrLoadModule` via `setSsrModuleLoader`, and the adapter loads `Chain.svelte` AND `svelte/server`
345
+ // through it - one toolchain, one runtime, context intact across the whole tree.
297
346
  const server = await createViteDevServer({
298
347
  root: cwd,
299
348
  routesDir,
@@ -312,10 +361,12 @@ async function dev(app, flags) {
312
361
  adapter: asAdapter(fw.adapter),
313
362
  manifest: discoverRoutes(routesDir, { load }),
314
363
  clientEntry,
364
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
315
365
  ...apiOf(backend),
316
366
  }),
317
367
  });
318
- console.log(`nifra dev → http://localhost:${server.port}`);
368
+ console.log(`nifra dev (vite) → http://localhost:${server.port}`);
369
+ console.log(` ${describePipeline(decision)}`);
319
370
  }
320
371
  /**
321
372
  * `nifra build --target <t>` - package the engine (buildClient + buildServer + prerender) into one
@@ -324,6 +375,32 @@ async function dev(app, flags) {
324
375
  * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
325
376
  * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
326
377
  */
378
+ /**
379
+ * Refuse a `use` the generated server entry cannot import.
380
+ *
381
+ * `nifra build` emits `import { use } from <frameworkFile>` (framework.ts, the edge-bundlable file),
382
+ * but `loadApp` prefers `nifra.config.ts` - so an app with BOTH files whose `use` lives only in
383
+ * `nifra.config.ts` makes `fw.use` defined while `framework.ts` exports nothing of that name, and the
384
+ * build dies later with an opaque bundler error pointing at generated code. Refuse it here with the
385
+ * exact move instead, in the spirit of `assertPipelineSeparation` (load.ts).
386
+ *
387
+ * Detected by importing `frameworkFile` and checking the named export, not by comparing paths alone:
388
+ * a split app legitimately DEFINES `use` in framework.ts and re-exports it from nifra.config.ts
389
+ * (exactly how `adapter` reaches both readers), and a path compare would refuse that correct layout.
390
+ * The import is cheap - framework.ts is already in the loaded config's module graph in that layout.
391
+ */
392
+ export async function assertUseIsEdgeExported(use, configPath, frameworkFile) {
393
+ if (use === undefined || configPath === frameworkFile)
394
+ return;
395
+ const mod = (await import(__rewriteRelativeImportExtension(frameworkFile)).catch(() => ({})));
396
+ if (typeof mod.use === "function")
397
+ return;
398
+ throw new Error(`[nifra] \`use\` is exported from ${configPath} but not from ${frameworkFile}. ` +
399
+ "`nifra build` generates a server entry that imports `use` from framework.ts (the edge-bundled " +
400
+ "file), so this build would fail later with an opaque bundler error inside generated code.\n\n" +
401
+ " - Define `use` in framework.ts and re-export it from nifra.config.ts " +
402
+ '(`export { use } from "./framework.ts"`) so `nifra dev` sees it too.');
403
+ }
327
404
  async function buildForTarget(app, target, flags) {
328
405
  const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build");
329
406
  if (!isBuildTarget(target)) {
@@ -352,6 +429,7 @@ async function buildForTarget(app, target, flags) {
352
429
  ? resolve(cwd, "nifra.config.ts")
353
430
  : resolve(cwd, "framework.ts");
354
431
  const backendFile = resolve(cwd, "backend.ts");
432
+ await assertUseIsEdgeExported(fw.use, app.configPath, frameworkFile);
355
433
  // Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
356
434
  // Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
357
435
  // whatever it's given straight to the chosen bundler, which casts to its own plugin type.
@@ -370,6 +448,7 @@ async function buildForTarget(app, target, flags) {
370
448
  workDir: resolve(cwd, ".nifra-build"),
371
449
  clientModule: fw.clientModule,
372
450
  adapterImport: frameworkFile,
451
+ ...(fw.use ? { useImport: frameworkFile } : {}),
373
452
  ...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
374
453
  ...plugins,
375
454
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -379,11 +458,10 @@ async function buildForTarget(app, target, flags) {
379
458
  // The static target needs a built app to drive prerendering - only build it when targeting static.
380
459
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
381
460
  });
382
- console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`);
383
- // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
384
- // else, and the reason is the thing that makes that legible instead of surprising.
385
- if (decision.reason !== undefined)
386
- console.log(` ${decision.reason}`);
461
+ console.log(`nifra build (${target}, ${decision.pipeline}) → ${result.run}`);
462
+ // The pipeline is stated on every build, not only the surprising ones - an auto-selected Vite build
463
+ // must never look like the default, and a default Bun build must not leave the question open either.
464
+ console.log(` ${describePipeline(decision)}`);
387
465
  if (flags.report)
388
466
  console.log(`\n${renderSizeReport(result.size)}`);
389
467
  }
@@ -402,6 +480,7 @@ async function buildPrerenderApp(app) {
402
480
  adapter: asAdapter(fw.adapter),
403
481
  manifest: discoverRoutes(routesDir),
404
482
  clientEntry: client.entry,
483
+ ...(fw.use ? { use: asUse(fw.use) } : {}),
405
484
  ...(client.routes ? { routePreload: client.routes } : {}),
406
485
  ...(client.css ? { styles: client.css } : {}),
407
486
  ...(client.routeStyles ? { routeStyles: client.routeStyles } : {}),
@@ -471,7 +550,10 @@ async function main() {
471
550
  // the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
472
551
  if (command === "mcp") {
473
552
  const { runMcpServer } = await import("./mcp.js");
474
- await runMcpServer(process.cwd(), CLI_VERSION);
553
+ // `nifra mcp [dir]` - an explicit project directory pins the root (for clients configured outside
554
+ // the project); otherwise the server resolves it from cwd + the client's MCP roots.
555
+ const dirArg = argv[1] !== undefined && !argv[1].startsWith("-") ? argv[1] : undefined;
556
+ await runMcpServer(process.cwd(), CLI_VERSION, dirArg);
475
557
  return;
476
558
  }
477
559
  // `docs-mcp` runs the PUBLIC docs MCP over HTTP - project-independent (serves the bundled corpus), so
@@ -501,11 +583,36 @@ async function main() {
501
583
  const { runCheck } = await import("./check.js");
502
584
  if (!(await runCheck(process.cwd(), {
503
585
  json: argv.includes("--json"),
586
+ structured: argv.includes("--json"),
504
587
  lintsOnly: argv.includes("--lints-only"),
505
588
  })))
506
589
  process.exitCode = 1;
507
590
  return;
508
591
  }
592
+ if (command === "fix") {
593
+ const codeIndex = argv.indexOf("--code");
594
+ const code = codeIndex === -1 ? undefined : argv[codeIndex + 1];
595
+ try {
596
+ const { collectCheckResult } = await import("./check.js");
597
+ const { applyDiagnosticRecipe } = await import("./fix-recipes.js");
598
+ const result = await collectCheckResult(process.cwd(), { lintsOnly: true });
599
+ const changed = [];
600
+ for (const diagnostic of result.structuredDiagnostics ?? []) {
601
+ if (code !== undefined && diagnostic.code !== code)
602
+ continue;
603
+ changed.push(...(await applyDiagnosticRecipe(process.cwd(), diagnostic)));
604
+ }
605
+ const final = await collectCheckResult(process.cwd(), { lintsOnly: true });
606
+ console.log(JSON.stringify({ changed, diagnostics: final.structuredDiagnostics ?? [] }, null, 2));
607
+ if (final.ok === false)
608
+ process.exitCode = 1;
609
+ }
610
+ catch (err) {
611
+ console.error(formatCliError(err));
612
+ process.exitCode = 1;
613
+ }
614
+ return;
615
+ }
509
616
  // `init-agents` retrofits the agent-discovery files (.mcp.json, CLAUDE.md, …) into the cwd. It's a
510
617
  // pure file-writing command independent of the app loading, so dispatch it before the eager `loadApp`
511
618
  // (an existing app might be API-only or not yet built). It always succeeds unless a write throws.
@@ -538,9 +645,18 @@ async function main() {
538
645
  // so it runs on an API-only / not-yet-built project.
539
646
  if (command === "doctor") {
540
647
  const { runDoctor } = await import("./doctor.js");
648
+ const targetIdx = argv.indexOf("--target");
649
+ const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined;
650
+ if (targetIdx !== -1 && (target === undefined || target.startsWith("-"))) {
651
+ console.error("[nifra] --target needs a value: bun | node | deno | cf-pages | vercel");
652
+ process.exitCode = 1;
653
+ return;
654
+ }
541
655
  if (!(await runDoctor(process.cwd(), {
542
656
  json: argv.includes("--json"),
543
657
  autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
658
+ strict: argv.includes("--strict"),
659
+ ...(target === undefined ? {} : { target }),
544
660
  })))
545
661
  process.exitCode = 1;
546
662
  return;
@@ -618,6 +734,15 @@ async function main() {
618
734
  try {
619
735
  if (!(await runAssurance(process.cwd(), {
620
736
  json: argv.includes("--json"),
737
+ bundle: argv.includes("--json") ||
738
+ argv.includes("--strict") ||
739
+ argv.includes("--hydration") ||
740
+ argv.includes("--interact") ||
741
+ argv.includes("--out"),
742
+ strict: argv.includes("--strict"),
743
+ hydration: argv.includes("--hydration"),
744
+ interact: argv.includes("--interact"),
745
+ ...(argv.includes("--out") ? { out: argv[argv.indexOf("--out") + 1] } : {}),
621
746
  ...(config !== undefined ? { config } : {}),
622
747
  })))
623
748
  process.exitCode = 1;
@@ -628,6 +753,57 @@ async function main() {
628
753
  }
629
754
  return;
630
755
  }
756
+ if (command === "contracts") {
757
+ const action = argv[1];
758
+ const outIndex = argv.indexOf("--out");
759
+ const out = outIndex === -1 ? undefined : argv[outIndex + 1];
760
+ try {
761
+ const { checkContractsLock, snapshotContracts } = await import("./contracts.js");
762
+ if (action === "snapshot") {
763
+ const lock = await snapshotContracts(process.cwd(), out);
764
+ if (argv.includes("--json"))
765
+ console.log(JSON.stringify(lock, null, 2));
766
+ else
767
+ console.log(`[nifra] wrote ${Object.keys(lock.routes).length} contract digests`);
768
+ }
769
+ else if (action === "check") {
770
+ const result = await checkContractsLock(process.cwd());
771
+ const output = { ok: result.present && result.diagnostics.length === 0, ...result };
772
+ console.log(JSON.stringify(output, null, 2));
773
+ if (!output.ok)
774
+ process.exitCode = 1;
775
+ }
776
+ else {
777
+ console.error("[nifra] contracts needs snapshot or check");
778
+ process.exitCode = 1;
779
+ }
780
+ }
781
+ catch (err) {
782
+ console.error(formatCliError(err));
783
+ process.exitCode = 1;
784
+ }
785
+ return;
786
+ }
787
+ if (command === "replay") {
788
+ const file = argv[1];
789
+ if (file === undefined || file.startsWith("-")) {
790
+ console.error("[nifra] replay needs a file path");
791
+ process.exitCode = 1;
792
+ return;
793
+ }
794
+ try {
795
+ const { runReplay } = await import("./replay.js");
796
+ const result = await runReplay(process.cwd(), file);
797
+ console.log(JSON.stringify(result, null, 2));
798
+ if (!result.ok)
799
+ process.exitCode = 1;
800
+ }
801
+ catch (err) {
802
+ console.error(formatCliError(err));
803
+ process.exitCode = 1;
804
+ }
805
+ return;
806
+ }
631
807
  if (command === "capabilities") {
632
808
  const action = argv[1];
633
809
  const valueAfter = (flag) => {
@@ -740,6 +916,45 @@ async function main() {
740
916
  }
741
917
  return;
742
918
  }
919
+ if (command === "prove") {
920
+ const valueAfter = (flag) => {
921
+ const index = argv.indexOf(flag);
922
+ const value = index === -1 ? undefined : argv[index + 1];
923
+ if (index !== -1 && (value === undefined || value.startsWith("-"))) {
924
+ throw new Error(`[nifra] ${flag} needs a value`);
925
+ }
926
+ return value;
927
+ };
928
+ try {
929
+ const minRaw = valueAfter("--min");
930
+ const min = minRaw === undefined ? undefined : Number(minRaw);
931
+ if (min !== undefined && (!Number.isInteger(min) || min < 0 || min > 4)) {
932
+ throw new Error("[nifra] --min must be an integer level between 0 and 4");
933
+ }
934
+ const changedFiles = [];
935
+ for (let index = 0; index < argv.length; index++) {
936
+ if (argv[index] !== "--file")
937
+ continue;
938
+ const file = argv[index + 1];
939
+ if (file === undefined || file.startsWith("-"))
940
+ throw new Error("[nifra] --file needs a path");
941
+ changedFiles.push(file);
942
+ index++;
943
+ }
944
+ const { runWorkGraph } = await import("./work-graph.js");
945
+ if (!(await runWorkGraph(process.cwd(), {
946
+ json: argv.includes("--json"),
947
+ ...(changedFiles.length === 0 ? {} : { changedFiles }),
948
+ ...(min === undefined ? {} : { minLevel: min }),
949
+ })))
950
+ process.exitCode = 1;
951
+ }
952
+ catch (err) {
953
+ console.error(formatCliError(err));
954
+ process.exitCode = 1;
955
+ }
956
+ return;
957
+ }
743
958
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
744
959
  // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
745
960
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).