@nifrajs/cli 2.1.0 → 2.2.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 (47) hide show
  1. package/dist/check.d.ts +4 -1
  2. package/dist/check.d.ts.map +1 -1
  3. package/dist/check.js +71 -0
  4. package/dist/check.js.map +1 -1
  5. package/dist/cli.d.ts +9 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +165 -23
  8. package/dist/cli.js.map +1 -1
  9. package/dist/doctor.d.ts +5 -1
  10. package/dist/doctor.d.ts.map +1 -1
  11. package/dist/doctor.js +66 -6
  12. package/dist/doctor.js.map +1 -1
  13. package/dist/load.d.ts +11 -0
  14. package/dist/load.d.ts.map +1 -1
  15. package/dist/load.js +30 -0
  16. package/dist/load.js.map +1 -1
  17. package/dist/mcp-docs-tools.d.ts.map +1 -1
  18. package/dist/mcp-docs-tools.js +7 -3
  19. package/dist/mcp-docs-tools.js.map +1 -1
  20. package/dist/mcp-http.js +1 -1
  21. package/dist/mcp-render.js +9 -1
  22. package/dist/mcp-render.js.map +1 -1
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +44 -2
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/pipeline-guard.d.ts +83 -0
  27. package/dist/pipeline-guard.d.ts.map +1 -0
  28. package/dist/pipeline-guard.js +136 -0
  29. package/dist/pipeline-guard.js.map +1 -0
  30. package/dist/types-search.d.ts +1 -1
  31. package/dist/types-search.d.ts.map +1 -1
  32. package/dist/types-search.js +47 -2
  33. package/dist/types-search.js.map +1 -1
  34. package/docs/examples.json +6 -0
  35. package/docs/llms-full.txt +81 -30
  36. package/docs/types.json +613 -36
  37. package/package.json +9 -9
  38. package/src/check.ts +91 -0
  39. package/src/cli.ts +173 -24
  40. package/src/doctor.ts +71 -6
  41. package/src/load.ts +44 -0
  42. package/src/mcp-docs-tools.ts +13 -3
  43. package/src/mcp-http.ts +1 -1
  44. package/src/mcp-render.ts +9 -1
  45. package/src/mcp.ts +45 -2
  46. package/src/pipeline-guard.ts +186 -0
  47. package/src/types-search.ts +50 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,14 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "2.1.0",
45
- "@nifrajs/core": "2.1.0",
46
- "@nifrajs/mcp": "2.1.0",
47
- "@nifrajs/runner": "2.1.0",
48
- "@nifrajs/schema": "2.1.0",
49
- "@nifrajs/testing": "2.1.0",
50
- "@nifrajs/web": "2.1.0",
51
- "create-nifra": "2.1.0"
44
+ "@nifrajs/client": "2.2.0",
45
+ "@nifrajs/core": "2.2.0",
46
+ "@nifrajs/mcp": "2.2.0",
47
+ "@nifrajs/runner": "2.2.0",
48
+ "@nifrajs/schema": "2.2.0",
49
+ "@nifrajs/testing": "2.2.0",
50
+ "@nifrajs/web": "2.2.0",
51
+ "create-nifra": "2.2.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/check.ts CHANGED
@@ -471,6 +471,69 @@ export function scanUntypedClient(file: string, content: string): SourceFinding[
471
471
  return out
472
472
  }
473
473
 
474
+ /**
475
+ * Specifiers that no longer exist, and what replaced them.
476
+ *
477
+ * These are the breaks a typecheck does not catch and a test suite does not either. `@nifrajs/core/ws`
478
+ * was a side-effect import removed in 2.0 in favour of `.use(websocket())`; a consuming package kept
479
+ * the import and a 533-test suite stayed green while the app could not boot. `@nifrajs/budget` folded
480
+ * into core with no npm deprecation, so `bun install` fails workspace-wide with a resolution error
481
+ * that names neither the cause nor the replacement.
482
+ *
483
+ * A lint is the right shape for this: it runs before boot, names the file and line, and states the
484
+ * replacement - which is what neither the resolver error nor the runtime failure does.
485
+ */
486
+ const REMOVED_IMPORTS: ReadonlyArray<{
487
+ readonly specifier: string
488
+ readonly since: string
489
+ readonly replacement: string
490
+ /** Flag ONLY the bare side-effect form (`import "x"`). A module that still exports values is not
491
+ * removed - only its install-by-import behaviour is - so flagging every import of it would be
492
+ * wrong and would train people to ignore the rule. */
493
+ readonly sideEffectOnly?: boolean
494
+ }> = [
495
+ {
496
+ specifier: "@nifrajs/budget",
497
+ since: "2.0",
498
+ replacement:
499
+ 'import from "@nifrajs/core/budget" — the package folded into core and npm `latest` is still 1.13.0, so a `^2` range resolves to nothing',
500
+ },
501
+ {
502
+ specifier: "@nifrajs/core/ws",
503
+ since: "2.0",
504
+ sideEffectOnly: true,
505
+ replacement:
506
+ 'a bare `import "@nifrajs/core/ws"` no longer installs the runtime — register it explicitly with `.use(websocket())`, importing `websocket` from the same module',
507
+ },
508
+ ]
509
+
510
+ /** Whether the import statement at `index` is the bare side-effect form (`import "x"`), rather than
511
+ * one with bindings (`import { y } from "x"`). */
512
+ function isSideEffectImport(content: string, index: number): boolean {
513
+ // `staticImportEdges` reports the index of the STATEMENT, not of the specifier, so read forward:
514
+ // a side-effect import is `import` followed directly by the quoted specifier, with no bindings and
515
+ // no `from` in between.
516
+ return /^import\s*["'`]/.test(content.slice(index, index + 32))
517
+ }
518
+
519
+ /** Flag an import of a specifier that no longer exists. Pure; matches the exact specifier or a subpath
520
+ * of it, so `@nifrajs/budget/x` is caught alongside `@nifrajs/budget`. */
521
+ export function scanRemovedImports(file: string, content: string): SourceFinding[] {
522
+ const out: SourceFinding[] = []
523
+ const lines = content.split("\n")
524
+ for (const edge of staticImportEdges(content)) {
525
+ const removed = REMOVED_IMPORTS.find(
526
+ (entry) =>
527
+ edge.specifier === entry.specifier || edge.specifier.startsWith(`${entry.specifier}/`),
528
+ )
529
+ if (removed === undefined) continue
530
+ if (removed.sideEffectOnly === true && !isSideEffectImport(content, edge.index)) continue
531
+ const line = lineAt(content, edge.index)
532
+ out.push({ file, line, snippet: (lines[line - 1] ?? "").trim() })
533
+ }
534
+ return out
535
+ }
536
+
474
537
  /** Scan a route module for top-level server-only imports. Returns `[]` for non-route files (only
475
538
  * `routes/` modules are browser-bundled, so a server-only import elsewhere is fine). Each finding carries
476
539
  * the offending `specifier` so the diagnostic can render the `routeFile → specifier` chain. Pure. */
@@ -939,6 +1002,7 @@ export interface CheckDiagnostic {
939
1002
  | "typed-client"
940
1003
  | "untyped-client"
941
1004
  | "server-only-import"
1005
+ | "removed-import"
942
1006
  | "response-route"
943
1007
  | "undeclared-dependency"
944
1008
  | "duplicate-install"
@@ -1189,6 +1253,7 @@ export async function collectCheckResult(
1189
1253
  const fetches: SourceFinding[] = []
1190
1254
  const staticRoutes: StaticRouteFinding[] = []
1191
1255
  const untypedClients: SourceFinding[] = []
1256
+ const removedImports: SourceFinding[] = []
1192
1257
  const serverImports: TransitiveServerImportFinding[] = []
1193
1258
  const responseRoutes: SourceFinding[] = []
1194
1259
  // Route modules (rel + content) collected during the walk, so the TRANSITIVE server-only resolution
@@ -1207,6 +1272,7 @@ export async function collectCheckResult(
1207
1272
  fetches.push(...scanFetchText(rel, content, checkConfig.externalMounts))
1208
1273
  staticRoutes.push(...scanStaticRouteText(rel, content))
1209
1274
  untypedClients.push(...scanUntypedClient(rel, content))
1275
+ removedImports.push(...scanRemovedImports(rel, content))
1210
1276
  if (ROUTE_FILE.test(rel)) routeModules.push({ rel, content })
1211
1277
  responseRoutes.push(...scanResponseRoutes(rel, content))
1212
1278
  }),
@@ -1299,6 +1365,21 @@ export async function collectCheckResult(
1299
1365
  suggestion: ownFetchSuggestion(f, routes),
1300
1366
  })
1301
1367
  }
1368
+ for (const f of removedImports.sort(bySite)) {
1369
+ const entry = REMOVED_IMPORTS.find(
1370
+ (candidate) =>
1371
+ f.snippet.includes(`"${candidate.specifier}`) ||
1372
+ f.snippet.includes(`'${candidate.specifier}`),
1373
+ )
1374
+ diagnostics.push({
1375
+ rule: "removed-import",
1376
+ severity: "error",
1377
+ file: f.file,
1378
+ line: f.line,
1379
+ message: `${f.snippet} — removed in nifra ${entry?.since ?? "2.0"}: ${entry?.replacement ?? "see the changelog"}`,
1380
+ fix: entry?.replacement ?? "see the changelog",
1381
+ })
1382
+ }
1302
1383
  for (const f of untypedClients.sort(bySite)) {
1303
1384
  diagnostics.push({
1304
1385
  rule: "untyped-client",
@@ -1602,5 +1683,15 @@ export async function runCheck(
1602
1683
  "\ntip: no .mcp.json here - run `nifra init-agents` to wire this project's MCP + agent files (no-clobber).",
1603
1684
  )
1604
1685
  }
1686
+ // `public/` used to be served in dev (inherited from Vite) and not in production, so every app
1687
+ // hand-rolled static serving in its own server entry - or shipped a file that 404'd only once
1688
+ // deployed. `@nifrajs/web` owns it now, so an app carrying the workaround can delete it. A tip
1689
+ // rather than a finding: a hand-rolled handler still works (it simply runs first), so this is
1690
+ // not a failure, and telling an app it can delete code is the entire point.
1691
+ if (existsSync(join(cwd, "public"))) {
1692
+ console.log(
1693
+ '\ntip: `public/` is now served by @nifrajs/web in dev AND production (publicDir, default "public"). If your server entry hand-rolls static serving, you can delete it.',
1694
+ )
1695
+ }
1605
1696
  return result.ok
1606
1697
  }
package/src/cli.ts CHANGED
@@ -16,7 +16,8 @@ import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
17
  import type { BunPlugin } from "bun"
18
18
  import { describeProject, describeRoutes } from "./introspect.ts"
19
- import { type LoadedApp, loadApp, resolvePlugins } from "./load.ts"
19
+ import { type LoadedApp, loadApp } from "./load.ts"
20
+ import { chooseBuildPipeline } from "./pipeline-guard.ts"
20
21
 
21
22
  export interface Flags {
22
23
  readonly port: number
@@ -26,13 +27,26 @@ export interface Flags {
26
27
  readonly target: string
27
28
  /** `nifra build --report`: print a per-chunk size + gzip table after the build. */
28
29
  readonly report: boolean
30
+ /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
31
+ * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
32
+ 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.
36
+ *
37
+ * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
38
+ readonly bun: boolean
29
39
  }
30
40
 
31
41
  const HELP = `nifra — zero-config dev/build/start for a nifra app
32
42
 
33
43
  Usage:
34
44
  nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
35
- nifra build [--out <dir>] [--report] Emit a complete Bun deploy directory by default.
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).
49
+ nifra build [--out <dir>] [--report] Emit a complete deploy directory.
36
50
  [--target <t>] Target a FULL deploy dir for <t>:
37
51
  bun | node | deno | cf-pages | vercel | static. Packages
38
52
  buildClient + buildServer (+ prerender for static) so an app
@@ -40,6 +54,11 @@ Usage:
40
54
  _routes.json. The server entry is generated from your
41
55
  framework.ts (adapter) + backend.ts + routes/. --report prints
42
56
  a per-chunk size + gzip table (biggest first).
57
+ [--vite | --bun] Force the bundler. Without a flag it follows your config: Bun
58
+ (faster, Bun-native) unless your ONLY transforms are
59
+ \`vitePlugins\`, which the Bun build cannot run - then Vite,
60
+ and it says so. --bun is refused in exactly that case rather
61
+ than dropping the transforms silently.
43
62
  nifra start [--port <n>] [--out <dir>] Run the Bun build at <out>/server.js. Default port ${DEFAULT_DEV_PORT}.
44
63
  nifra context Print this project's route INDEX (API + page routes) + conventions
45
64
  for an AI agent's prompt. Per-route schemas: nifra mcp's
@@ -48,6 +67,11 @@ Usage:
48
67
  routes (routes/) + the in-process backend's API routes,
49
68
  marking which API routes are auto-mounted under apiPrefix.
50
69
  --json for agents (see POST /api/x is/isn't served, not via 405).
70
+ nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
71
+ hydration, and cache policy in one table. With --target, gate
72
+ against it: a route the target can't honour (an ssr route on a
73
+ static build, ISR where there's no revalidation) exits nonzero
74
+ with the consequence - run in CI so it fails the build, not prod.
51
75
  nifra init-agents [--force] [--json] Retrofit an EXISTING app with the agent-discovery files a new
52
76
  app ships: .mcp.json + .cursor/mcp.json (register this project's
53
77
  nifra MCP), CLAUDE.md (MCP-first preamble + @AGENTS.md import),
@@ -107,7 +131,7 @@ Usage:
107
131
  [--ci] [--strict] matrix (in-memory stores, in-process cron/WebSocket, Bun/Deno
108
132
  globals, node: builtins) with file:line evidence. --target auto-
109
133
  detected from build/deploy scripts, wrangler.toml, or vercel config;
110
- --ci (or any --target) exits non-zero when a used feature is
134
+ --ci (or any --target) exits nonzero when a used feature is
111
135
  unsupported on the target; --strict also fails on caveats.
112
136
 
113
137
  Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backend.ts (optional), and
@@ -117,7 +141,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
117
141
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
118
142
 
119
143
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
120
- const CLI_VERSION = "2.1.0"
144
+ const CLI_VERSION = "2.2.0"
121
145
 
122
146
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
123
147
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -125,7 +149,67 @@ const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
125
149
  const apiOf = (backend: unknown): { api?: unknown } =>
126
150
  backend === undefined ? {} : { api: inProcessClient(backend as never) }
127
151
 
152
+ /**
153
+ * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
154
+ *
155
+ * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
156
+ * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
157
+ * bundler does not: the import compiles to a dangling reference and the browser throws
158
+ * `ReferenceError: import_X_module is not defined` from inside the component — a message that names
159
+ * neither CSS Modules nor the dev server, so the cause is invisible.
160
+ *
161
+ * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
162
+ * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
163
+ * an app without CSS Modules still gets the Bun dev loop.
164
+ */
165
+ async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
166
+ const { Glob } = await import("bun")
167
+ const offenders: string[] = []
168
+ for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
169
+ if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
170
+ offenders.push(file)
171
+ if (offenders.length >= 5) break
172
+ }
173
+ if (offenders.length === 0) return
174
+ throw new Error(
175
+ "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
176
+ "transform (its production `Bun.build` does, so `nifra build` is unaffected). The client would load " +
177
+ "with a bare `ReferenceError: import_… is not defined` instead of your component.\n" +
178
+ ` CSS Modules found: ${offenders.join(", ")}\n` +
179
+ " Use `nifra dev` (the Vite pipeline handles them), or drop the CSS Modules to use the Bun dev loop.",
180
+ )
181
+ }
182
+
128
183
  async function dev(app: LoadedApp, flags: Flags): Promise<void> {
184
+ // `--bun`: the Bun-pipeline dev server — Bun.serve's native HMR bundles + hot-reloads the client while
185
+ // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
186
+ // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
187
+ if (flags.bun) {
188
+ await assertBunDevSupportsApp(app)
189
+ const { createDevServer } = await import("@nifrajs/web/dev")
190
+ const { framework: fw, routesDir, outDir, backend } = app
191
+ const server = await createDevServer({
192
+ routesDir,
193
+ outDir,
194
+ clientModule: fw.clientModule,
195
+ port: flags.port,
196
+ // The Bun pipeline's own plugins (SFC compilers etc.) — never `vitePlugins`, which belong to the
197
+ // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
198
+ plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
199
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
200
+ ...(fw.conditions ? { conditions: fw.conditions } : {}),
201
+ ...(fw.define ? { define: fw.define } : {}),
202
+ createApp: (clientEntry, importQuery) =>
203
+ createWebApp({
204
+ adapter: asAdapter(fw.adapter),
205
+ manifest: discoverRoutes(routesDir, { importQuery }),
206
+ clientEntry,
207
+ ...apiOf(backend),
208
+ }),
209
+ })
210
+ console.log(`nifra dev (bun) → http://localhost:${server.port}`)
211
+ return
212
+ }
129
213
  // Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
130
214
  // sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
131
215
  // with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
@@ -137,26 +221,33 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
137
221
  "script (`bun run dev`), not `bunx @nifrajs/cli dev` (which can't resolve the project's peers).",
138
222
  )
139
223
  }
140
- const { plugin } = await import("bun")
141
224
  const { createViteDevServer } = await import("@nifrajs/web/vite")
142
225
  const { framework: fw, routesDir, cwd, backend } = app
143
- // nifra SSRs each request by importing the route modules through Bun (not Vite's ssrLoadModule), so
144
- // `.vue`/`.svelte`/Solid routes need their SSR compile plugin registered on Bun's runtime first —
145
- // the dev analog of `nifra start`. React/Preact JSX is Bun-native → no server plugins → no-op.
146
- for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
226
+ // No Bun SSR plugins here. `nifra dev` runs the VITE pipeline, and Vite now resolves and compiles
227
+ // route modules for SSR too (via `ssrLoadModule`), so `.vue`/`.svelte`/Solid are handled by the
228
+ // app's `vitePlugins` on both halves.
229
+ //
230
+ // Registering Bun's SFC plugins alongside was the structural intermix: two toolchains compiling the
231
+ // same file in one process, with only one of them governed by Vite's `resolve.dedupe`. That is what
232
+ // made an app's hand-written React alias fail to reach SSR, and it is the condition being removed -
233
+ // not a redundancy worth keeping "just in case".
147
234
  const server = await createViteDevServer({
148
235
  root: cwd,
149
236
  routesDir,
150
237
  clientModule: fw.clientModule,
151
- plugins: await resolvePlugins(fw.vitePlugins),
238
+ plugins: app.resolvedPlugins.vitePlugins,
239
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
152
240
  poll: flags.poll,
153
241
  port: flags.port,
154
242
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
155
243
  ...(fw.define ? { define: fw.define } : {}),
156
- createApp: (clientEntry, importQuery) =>
244
+ // `load` resolves route modules through VITE, not through Bun. That is what makes the Vite
245
+ // pipeline own the whole phase: the client and the server now agree on every specifier, so
246
+ // `resolve.dedupe` finally governs SSR and the dual-React crash cannot occur.
247
+ createApp: (clientEntry, load) =>
157
248
  createWebApp({
158
249
  adapter: asAdapter(fw.adapter),
159
- manifest: discoverRoutes(routesDir, { importQuery }),
250
+ manifest: discoverRoutes(routesDir, { load }),
160
251
  clientEntry,
161
252
  ...apiOf(backend),
162
253
  }),
@@ -171,13 +262,27 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
171
262
  * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
172
263
  * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
173
264
  */
174
- async function buildForTarget(app: LoadedApp, target: string, report: boolean): Promise<void> {
175
- const { buildTarget, isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import(
176
- "@nifrajs/web/build"
177
- )
265
+ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Promise<void> {
266
+ const { isBuildTarget, renderSizeReport, BUILD_TARGETS } = await import("@nifrajs/web/build")
178
267
  if (!isBuildTarget(target)) {
179
268
  throw new Error(`[nifra] unknown --target "${target}". Valid: ${BUILD_TARGETS.join(", ")}.`)
180
269
  }
270
+ if (flags.vite && flags.bun) {
271
+ throw new Error("[nifra] `nifra build` takes `--vite` or `--bun`, not both.")
272
+ }
273
+ // Which bundler builds this app. Default is Bun; an app whose ONLY transforms are `vitePlugins` gets
274
+ // Vite instead, because the Bun build cannot run them and would silently omit the work. `--vite`/`--bun`
275
+ // force it, and the one forced combination that would drop transforms throws.
276
+ const decision = chooseBuildPipeline(
277
+ app.resolvedPlugins,
278
+ flags.vite ? "vite" : flags.bun ? "bun" : undefined,
279
+ )
280
+ const useVite = decision.pipeline === "vite"
281
+ // Same deploy-dir output — buildTargetVite delegates to the same orchestrator as buildTarget — so only
282
+ // the bundler and the plugin FORMAT (Vite plugins, not Bun) differ.
283
+ const buildTarget = useVite
284
+ ? (await import("@nifrajs/web/build-vite")).buildTargetVite
285
+ : (await import("@nifrajs/web/build")).buildTarget
181
286
  const { framework: fw, routesDir, outDir, cwd, backend } = app
182
287
  // The server entry must import the adapter from `framework.ts` (edge-safe), not the loaded config.
183
288
  // `loadApp` guarantees one of them exists; prefer framework.ts so a multi-target app's Vite-plugin
@@ -188,6 +293,18 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
188
293
  ? resolve(cwd, "nifra.config.ts")
189
294
  : resolve(cwd, "framework.ts")
190
295
  const backendFile = resolve(cwd, "backend.ts")
296
+ // Plugin FORMAT differs by pipeline: the Bun build takes Bun plugins (clientPlugins/serverPlugins); the
297
+ // Vite build takes the app's Vite plugins (fw.vitePlugins) for BOTH halves. buildTargetWith forwards
298
+ // whatever it's given straight to the chosen bundler, which casts to its own plugin type.
299
+ const plugins = useVite
300
+ ? {
301
+ clientPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
302
+ serverPlugins: app.resolvedPlugins.vitePlugins as unknown as BunPlugin[],
303
+ }
304
+ : {
305
+ clientPlugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
306
+ serverPlugins: asBunPlugins(app.resolvedPlugins.serverPlugins),
307
+ }
191
308
  const result = await buildTarget(target, {
192
309
  routesDir,
193
310
  outDir,
@@ -195,15 +312,19 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
195
312
  clientModule: fw.clientModule,
196
313
  adapterImport: frameworkFile,
197
314
  ...(backend !== undefined && existsSync(backendFile) ? { backendImport: backendFile } : {}),
198
- clientPlugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
199
- serverPlugins: asBunPlugins(await resolvePlugins(fw.serverPlugins)),
315
+ ...plugins,
200
316
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
201
317
  ...(fw.define ? { define: fw.define } : {}),
318
+ ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
319
+ ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
202
320
  // The static target needs a built app to drive prerendering — only build it when targeting static.
203
321
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
204
322
  })
205
- console.log(`nifra build (${target}) → ${result.run}`)
206
- if (report) console.log(`\n${renderSizeReport(result.size)}`)
323
+ console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
324
+ // An auto-selected pipeline is stated, never silent: the user asked for the default and got something
325
+ // else, and the reason is the thing that makes that legible instead of surprising.
326
+ if (decision.reason !== undefined) console.log(` ${decision.reason}`)
327
+ if (flags.report) console.log(`\n${renderSizeReport(result.size)}`)
207
328
  }
208
329
 
209
330
  /** Build the app FACTORY for the `static` target's prerender pass. Registers the
@@ -217,7 +338,7 @@ async function buildPrerenderApp(
217
338
  ): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
218
339
  const { plugin } = await import("bun")
219
340
  const { framework: fw, routesDir, backend } = app
220
- for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
341
+ for (const p of asBunPlugins(app.resolvedPlugins.serverPlugins)) plugin(p)
221
342
  return (client) =>
222
343
  createWebApp({
223
344
  adapter: asAdapter(fw.adapter),
@@ -266,6 +387,8 @@ export function parseFlags(args: readonly string[]): Flags {
266
387
  let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
267
388
  let target = "bun"
268
389
  let report = false
390
+ let vite = false
391
+ let bun = false
269
392
  for (let i = 0; i < args.length; i++) {
270
393
  const a = args[i]
271
394
  if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
@@ -273,11 +396,13 @@ export function parseFlags(args: readonly string[]): Flags {
273
396
  else if (a === "--poll") poll = true
274
397
  else if (a === "--target" && args[i + 1]) target = args[++i] as string
275
398
  else if (a === "--report") report = true
399
+ else if (a === "--vite") vite = true
400
+ else if (a === "--bun") bun = true
276
401
  }
277
402
  if (!Number.isFinite(port) || port < 0 || port > 65535) {
278
403
  throw new Error(`[nifra] invalid --port: ${port}`)
279
404
  }
280
- return { port, out, poll, target, report }
405
+ return { port, out, poll, target, report, vite, bun }
281
406
  }
282
407
 
283
408
  async function main(): Promise<void> {
@@ -588,10 +713,34 @@ async function main(): Promise<void> {
588
713
  const flags = parseFlags(argv.slice(1))
589
714
  const app = await loadApp(process.cwd(), flags.out)
590
715
  if (command === "dev") await dev(app, flags)
591
- else if (command === "build") await buildForTarget(app, flags.target, flags.report)
716
+ else if (command === "build") await buildForTarget(app, flags.target, flags)
592
717
  else if (command === "context") console.log(describeProject(app))
593
718
  else if (command === "routes") {
594
- console.log(await describeRoutes(app, { json: argv.includes("--json") }))
719
+ if (argv.includes("--modes")) {
720
+ // `--modes` answers a different question than the default table: not "what URLs exist" but "how
721
+ // does each render, and can the target I'm about to deploy to actually honour that". Resolves
722
+ // against `--target` when given, so a `static` build's server-needing route fails here, in one
723
+ // place, instead of 404ing in production.
724
+ const { buildRouteManifest, renderRouteManifest } = await import(
725
+ "@nifrajs/web/route-manifest"
726
+ )
727
+ const manifest = discoverRoutes(app.routesDir)
728
+ // `flags.target` defaults to "bun" for `build`; here an EXPLICIT `--target` is what opts into
729
+ // gating, so read it from argv rather than inheriting the build default (which would silently gate
730
+ // every `nifra routes --modes` against bun).
731
+ const targetIdx = argv.indexOf("--target")
732
+ const target = targetIdx !== -1 ? argv[targetIdx + 1] : undefined
733
+ const routeManifest = await buildRouteManifest(manifest, {
734
+ ...(target !== undefined ? { target } : {}),
735
+ })
736
+ if (argv.includes("--json")) console.log(JSON.stringify(routeManifest, null, 2))
737
+ else console.log(renderRouteManifest(routeManifest))
738
+ // A target that cannot honour a route is a build-breaking fact, not a note: exit non-zero so the
739
+ // same command in CI gates the deploy it is warning about.
740
+ if (routeManifest.conflicts.length > 0) process.exitCode = 1
741
+ } else {
742
+ console.log(await describeRoutes(app, { json: argv.includes("--json") }))
743
+ }
595
744
  } else await start(app, flags)
596
745
  }
597
746
 
package/src/doctor.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
11
  * the package's own name, and tsconfig `paths` aliases are excluded — none of them are npm deps.
12
12
  */
13
- import { realpath } from "node:fs/promises"
13
+ import { realpath, stat } from "node:fs/promises"
14
14
  import { builtinModules } from "node:module"
15
15
  import { dirname, join, relative } from "node:path"
16
16
  import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
@@ -336,13 +336,75 @@ const displayPath = (cwd: string, path: string): string => {
336
336
  return rel === "" ? "." : rel
337
337
  }
338
338
 
339
+ /**
340
+ * Resolve the workspace root that actually governs `cwd`, or `undefined` when there is none.
341
+ *
342
+ * Everything below is anchored to a package that declares `workspaces`. In a monorepo you run
343
+ * `nifra check` from the app - `apps/web` - and THAT manifest declares nothing, so the scan collapses
344
+ * to the app itself and the sibling package holding the second copy is never probed. Running from an
345
+ * app subdirectory is the normal case, which made this the configuration the check was blind in.
346
+ *
347
+ * An ancestor is adopted only when its `workspaces` patterns genuinely match `cwd`. A parent
348
+ * directory that happens to contain a manifest is not automatically this project's root, and adopting
349
+ * one would silently widen the scan to an unrelated tree. The walk also stops at a `.git` boundary
350
+ * (checked after the manifest, so a repo root that IS the workspace root still counts) and at the
351
+ * filesystem root.
352
+ */
353
+ /** Whether `path` exists at all. `stat`, not a file read: a `.git` is a directory in a normal
354
+ * checkout and a FILE in a worktree or submodule, and both mark the same boundary. */
355
+ const pathExists = (path: string): Promise<boolean> =>
356
+ stat(path).then(
357
+ () => true,
358
+ () => false,
359
+ )
360
+
361
+ async function findWorkspaceRoot(
362
+ cwd: string,
363
+ ): Promise<{ root: string; package: Record<string, unknown> } | undefined> {
364
+ let dir = dirname(cwd)
365
+ for (;;) {
366
+ const manifestPath = join(dir, "package.json")
367
+ const manifest = await readJson(manifestPath)
368
+ if (manifest !== undefined) {
369
+ const patterns = workspacePatterns(manifest)
370
+ if (patterns.length > 0 && (await patternsCover(dir, patterns, cwd))) {
371
+ return { root: dir, package: manifest }
372
+ }
373
+ }
374
+ // A `.git` here means we have reached this project's boundary; anything above belongs to a
375
+ // different repo (or a parent checkout) and must not be adopted.
376
+ if (await pathExists(join(dir, ".git"))) return undefined
377
+ const parent = dirname(dir)
378
+ if (parent === dir) return undefined
379
+ dir = parent
380
+ }
381
+ }
382
+
383
+ /** Whether any of `root`'s workspace patterns resolves to a manifest inside `target`'s package. */
384
+ async function patternsCover(root: string, patterns: string[], target: string): Promise<boolean> {
385
+ for (const pattern of patterns) {
386
+ const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
387
+ for await (const rel of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
388
+ if (rel.split(/[\\/]/).includes("node_modules")) continue
389
+ if (dirname(join(root, rel)) === target) return true
390
+ }
391
+ }
392
+ return false
393
+ }
394
+
339
395
  /** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
340
- * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based. */
396
+ * the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
397
+ *
398
+ * `cwd` is where the user ran the check; the scan is anchored at the workspace root above it (see
399
+ * {@link findWorkspaceRoot}) so sibling packages are probed, while paths are still REPORTED relative
400
+ * to `cwd` so the findings read the way the user's terminal does. */
341
401
  export async function collectDuplicateInstalls(
342
402
  cwd: string,
343
403
  rootPackage: Record<string, unknown>,
344
404
  ): Promise<DuplicateInstallFinding[]> {
345
- const importers = await workspaceImporters(cwd, rootPackage)
405
+ const workspace = await findWorkspaceRoot(cwd)
406
+ const scanRoot = workspace?.root ?? cwd
407
+ const importers = await workspaceImporters(scanRoot, workspace?.package ?? rootPackage)
346
408
  const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
347
409
  const record = (
348
410
  name: string,
@@ -364,7 +426,10 @@ export async function collectDuplicateInstalls(
364
426
  for (const name of duplicateTargets(importer.package)) {
365
427
  if (importer.package.name === name) continue
366
428
  targets.add(name)
367
- const copy = await resolvedInstalledCopy(importer.root, cwd, name)
429
+ // Boundary is the SCAN root, not `cwd`: an importer can now sit beside or above the directory
430
+ // the user ran from, and stopping the upward walk at `cwd` would abandon it before reaching the
431
+ // hoisted copy it actually resolves.
432
+ const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
368
433
  if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
369
434
  }
370
435
  }
@@ -374,8 +439,8 @@ export async function collectDuplicateInstalls(
374
439
  // out hides the very split it should catch: every declaring package nested onto one copy while the
375
440
  // root resolved a different one, which reads as a single copy when only declarers are consulted.
376
441
  for (const name of targets) {
377
- const copy = await resolvedInstalledCopy(cwd, cwd, name)
378
- if (copy !== undefined) record(name, copy, ".")
442
+ const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
443
+ if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
379
444
  }
380
445
 
381
446
  const findings: DuplicateInstallFinding[] = []