@nifrajs/cli 2.2.0 → 2.3.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 (117) hide show
  1. package/README.md +25 -25
  2. package/dist/assure.d.ts +2 -2
  3. package/dist/assure.d.ts.map +1 -1
  4. package/dist/assure.js +17 -10
  5. package/dist/assure.js.map +1 -1
  6. package/dist/capabilities-tool.d.ts +7 -0
  7. package/dist/capabilities-tool.d.ts.map +1 -1
  8. package/dist/capabilities-tool.js +68 -16
  9. package/dist/capabilities-tool.js.map +1 -1
  10. package/dist/check.d.ts +61 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +332 -86
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +30 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +132 -36
  17. package/dist/cli.js.map +1 -1
  18. package/dist/diff-tool.d.ts +3 -3
  19. package/dist/diff-tool.js +7 -7
  20. package/dist/docs-search.d.ts +3 -3
  21. package/dist/docs-search.js +6 -6
  22. package/dist/doctor.d.ts +3 -3
  23. package/dist/doctor.js +8 -8
  24. package/dist/examples.d.ts +5 -5
  25. package/dist/examples.js +5 -5
  26. package/dist/init-agents.d.ts +14 -14
  27. package/dist/init-agents.js +14 -14
  28. package/dist/internal/typescript-import.d.ts +24 -0
  29. package/dist/internal/typescript-import.d.ts.map +1 -0
  30. package/dist/internal/typescript-import.js +32 -0
  31. package/dist/internal/typescript-import.js.map +1 -0
  32. package/dist/introspect.d.ts +12 -12
  33. package/dist/introspect.js +24 -24
  34. package/dist/levels-tool.d.ts +8 -8
  35. package/dist/levels-tool.d.ts.map +1 -1
  36. package/dist/levels-tool.js +33 -30
  37. package/dist/levels-tool.js.map +1 -1
  38. package/dist/load.d.ts +6 -6
  39. package/dist/load.d.ts.map +1 -1
  40. package/dist/load.js +15 -7
  41. package/dist/load.js.map +1 -1
  42. package/dist/manifest-tool.d.ts +1 -1
  43. package/dist/manifest-tool.d.ts.map +1 -1
  44. package/dist/manifest-tool.js +4 -2
  45. package/dist/manifest-tool.js.map +1 -1
  46. package/dist/mcp-docs-tools.d.ts +1 -1
  47. package/dist/mcp-docs-tools.js +9 -9
  48. package/dist/mcp-http.d.ts +5 -5
  49. package/dist/mcp-http.js +7 -7
  50. package/dist/mcp-protocol.d.ts +2 -2
  51. package/dist/mcp-protocol.js +2 -2
  52. package/dist/mcp-render.d.ts +3 -3
  53. package/dist/mcp-render.js +6 -6
  54. package/dist/mcp-run.d.ts +2 -2
  55. package/dist/mcp-run.js +2 -2
  56. package/dist/mcp.d.ts +14 -14
  57. package/dist/mcp.d.ts.map +1 -1
  58. package/dist/mcp.js +75 -45
  59. package/dist/mcp.js.map +1 -1
  60. package/dist/openapi-tool.d.ts +1 -1
  61. package/dist/openapi-tool.js +2 -2
  62. package/dist/pipeline-guard.d.ts +1 -1
  63. package/dist/pipeline-guard.js +1 -1
  64. package/dist/port.d.ts +9 -9
  65. package/dist/port.js +27 -27
  66. package/dist/recipes/1.8.0.d.ts +2 -2
  67. package/dist/recipes/1.8.0.js +5 -5
  68. package/dist/recipes/index.d.ts +2 -2
  69. package/dist/recipes/index.js +1 -1
  70. package/dist/scaffold.d.ts +3 -3
  71. package/dist/scaffold.js +8 -8
  72. package/dist/sync-routes.d.ts +28 -0
  73. package/dist/sync-routes.d.ts.map +1 -0
  74. package/dist/sync-routes.js +55 -0
  75. package/dist/sync-routes.js.map +1 -0
  76. package/dist/types-search.d.ts +5 -5
  77. package/dist/types-search.js +7 -7
  78. package/dist/upgrade.d.ts +7 -7
  79. package/dist/upgrade.js +12 -12
  80. package/dist/verification.d.ts +51 -0
  81. package/dist/verification.d.ts.map +1 -0
  82. package/dist/verification.js +80 -0
  83. package/dist/verification.js.map +1 -0
  84. package/docs/examples.json +174 -90
  85. package/docs/llms-full.txt +1696 -747
  86. package/docs/types.json +1892 -3105
  87. package/package.json +22 -11
  88. package/src/assure.ts +17 -18
  89. package/src/capabilities-tool.ts +82 -16
  90. package/src/check.ts +405 -103
  91. package/src/cli.ts +141 -38
  92. package/src/diff-tool.ts +8 -8
  93. package/src/docs-search.ts +6 -6
  94. package/src/doctor.ts +10 -10
  95. package/src/examples.ts +7 -7
  96. package/src/init-agents.ts +17 -17
  97. package/src/internal/typescript-import.ts +33 -0
  98. package/src/introspect.ts +28 -28
  99. package/src/levels-tool.ts +37 -31
  100. package/src/load.ts +21 -12
  101. package/src/manifest-tool.ts +4 -2
  102. package/src/mcp-docs-tools.ts +9 -9
  103. package/src/mcp-http.ts +7 -7
  104. package/src/mcp-protocol.ts +2 -2
  105. package/src/mcp-render.ts +6 -6
  106. package/src/mcp-run.ts +2 -2
  107. package/src/mcp.ts +82 -45
  108. package/src/openapi-tool.ts +2 -2
  109. package/src/pipeline-guard.ts +1 -1
  110. package/src/port.ts +31 -31
  111. package/src/recipes/1.8.0.ts +5 -5
  112. package/src/recipes/index.ts +2 -2
  113. package/src/scaffold.ts +8 -8
  114. package/src/sync-routes.ts +69 -0
  115. package/src/types-search.ts +8 -8
  116. package/src/upgrade.ts +12 -12
  117. package/src/verification.ts +123 -0
package/src/cli.ts CHANGED
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * `nifra` — the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
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
7
- * nifra build emit a complete target-specific deploy directory — @nifrajs/web/build
8
- * nifra start run the default Bun build — dist/server.js
6
+ * nifra dev true-HMR dev server (Vite middleware + nifra SSR) - @nifrajs/web/vite
7
+ * nifra build emit a complete target-specific deploy directory - @nifrajs/web/build
8
+ * nifra start run the default Bun build - dist/server.js
9
9
  *
10
10
  * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
11
11
  */
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
+ createWebApp,
17
+ DEFAULT_DEV_PORT,
18
+ type RenderAdapter,
19
+ SERVER_FN_MODULE,
20
+ SERVER_ONLY_MODULE,
21
+ } from "@nifrajs/web"
16
22
  import { discoverRoutes } from "@nifrajs/web/fs"
17
23
  import type { BunPlugin } from "bun"
18
24
  import { describeProject, describeRoutes } from "./introspect.ts"
@@ -30,15 +36,15 @@ export interface Flags {
30
36
  /** `nifra build --vite`: force the client + server through Vite/Rollup. Without a flag the pipeline is
31
37
  * chosen per app (`chooseBuildPipeline`) - Bun unless the app's only transforms are `vitePlugins`. */
32
38
  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
39
+ /** `nifra dev --bun`: run the Bun-pipeline dev server (Bun.serve native HMR - including React Fast
40
+ * Refresh with state preserved - and no Vite in the process). Default stays Vite for its plugin
35
41
  * ecosystem; `--bun` trades that for one bundler across dev and prod. Refuses CSS-Modules apps.
36
42
  *
37
43
  * `nifra build --bun`: force the Bun build. Refuses when that would drop the app's `vitePlugins`. */
38
44
  readonly bun: boolean
39
45
  }
40
46
 
41
- const HELP = `nifra — zero-config dev/build/start for a nifra app
47
+ const HELP = `nifra - zero-config dev/build/start for a nifra app
42
48
 
43
49
  Usage:
44
50
  nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
@@ -84,7 +90,7 @@ Usage:
84
90
  nifra_example (verified snippets), nifra_scaffold (route→file),
85
91
  nifra_check (drift gate + fixes), nifra_levels (verification
86
92
  ladder), nifra_doctor (deps + duplicate installs).
87
- nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
93
+ nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) -
88
94
  self-host on a VPS so any remote agent can learn nifra. Default :8787.
89
95
  nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
90
96
  server-only imports in routes/). Run as "done"; --json for agents;
@@ -92,13 +98,16 @@ Usage:
92
98
  nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
93
99
  a full build - clears server-manifest drift after a route add/rename
94
100
  (a new hydrating component still needs a full build).
101
+ nifra sync-routes Regenerate nifra-routes.d.ts from routes/ so navigate({ to, search })
102
+ is typed against each static route's searchSchema (a stale shape is a
103
+ tsc error). Include the file in your tsconfig. Pure file write.
95
104
  nifra snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
96
- JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
105
+ JSON - the baseline for \`nifra diff\`. Default api-snapshot.json.
97
106
  nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
98
107
  against the committed baseline. Direction-aware (a new required
99
108
  request field or a removed response field breaks; widening a
100
109
  request enum or adding a response field doesn't) and fails
101
- closed. Exits non-zero on any breaking change — run it in CI.
110
+ closed. Exits non-zero on any breaking change - run it in CI.
102
111
  nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
103
112
  reflected backend route, and fail when required enforcement
104
113
  evidence is missing/forbidden or a route is unclassified.
@@ -141,7 +150,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
141
150
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
142
151
 
143
152
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
144
- const CLI_VERSION = "2.2.0"
153
+ const CLI_VERSION = "2.3.0"
145
154
 
146
155
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
147
156
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -149,20 +158,54 @@ const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
149
158
  const apiOf = (backend: unknown): { api?: unknown } =>
150
159
  backend === undefined ? {} : { api: inProcessClient(backend as never) }
151
160
 
161
+ /**
162
+ * Render an error for the CLI, unwrapping the detail a bare `.message` drops.
163
+ *
164
+ * `Bun.build` reports a bundling failure as an `AggregateError` whose `.message` is a generic
165
+ * "Bundle failed" and whose `.errors` hold the real causes - the unresolved import, the plugin that
166
+ * threw, each with a file and line. `buildClient`/`buildServer` catch a RETURNED `{ success: false }`
167
+ * and print `result.logs`, but a `Bun.build` that THROWS (a plugin `onLoad`/`onResolve` that throws, a
168
+ * resolution failure) rejects before that check runs, so the AggregateError reaches the CLI whole and a
169
+ * catch printing only `.message` throws the causes away. That is the difference between "Bundle failed"
170
+ * and "Could not resolve ./db from routes/x.tsx". So unwrap an AggregateError - directly, or one carried
171
+ * as a `.cause` - into a line per underlying error.
172
+ */
173
+ export function formatCliError(err: unknown): string {
174
+ const aggregate =
175
+ err instanceof AggregateError
176
+ ? err
177
+ : err instanceof Error && err.cause instanceof AggregateError
178
+ ? err.cause
179
+ : undefined
180
+ if (aggregate !== undefined && aggregate.errors.length > 0) {
181
+ const outer = err instanceof Error ? err.message : ""
182
+ const head = outer !== "" && outer !== "Bundle failed" ? outer : "build failed"
183
+ // `String(e)` (not `e.message`) so a Bun BuildMessage/ResolveMessage renders with its file + line,
184
+ // not just the bare text. Multi-line entries are indented to stay under the head. Deduplicated
185
+ // because Bun can repeat one cause across `.errors`.
186
+ const details = [...new Set(aggregate.errors.map((e) => String(e).trim()))].map(
187
+ (line) => ` - ${line.replace(/\n/g, "\n ")}`,
188
+ )
189
+ return [head, ...details].join("\n")
190
+ }
191
+ if (err instanceof Error) return err.message
192
+ return String(err)
193
+ }
194
+
152
195
  /**
153
196
  * Refuse `nifra dev --bun` for an app Bun's DEV-server bundler cannot compile.
154
197
  *
155
198
  * Bun's dev server and `Bun.build` are not the same bundler. `Bun.build` transforms `*.module.css` into a
156
199
  * scoped class map (the Bun production build of a CSS-Modules app works, verified), but the dev server's
157
200
  * 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
201
+ * `ReferenceError: import_X_module is not defined` from inside the component - a message that names
159
202
  * neither CSS Modules nor the dev server, so the cause is invisible.
160
203
  *
161
204
  * A silently-broken client is the one outcome worth failing the command over, so this checks up front and
162
205
  * names the trade plus both ways out. Narrow by design: only the transform proven missing is refused, so
163
206
  * an app without CSS Modules still gets the Bun dev loop.
164
207
  */
165
- async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
208
+ export async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
166
209
  const { Glob } = await import("bun")
167
210
  const offenders: string[] = []
168
211
  for await (const file of new Glob("**/*.module.css").scan({ cwd: app.cwd, dot: false })) {
@@ -170,6 +213,58 @@ async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
170
213
  offenders.push(file)
171
214
  if (offenders.length >= 5) break
172
215
  }
216
+ // Server functions are the same trade with a worse consequence. The client build replaces a `*.fn`
217
+ // module with stubs so its body never reaches a browser; Bun's dev-server bundler takes no plugins, so
218
+ // there it would be bundled WHOLE - the function bodies and every secret they import. Measured, not
219
+ // assumed: a runtime `Bun.plugin` onLoad does not reach `HTMLBundle`, so the module ships raw.
220
+ //
221
+ // `*.server` modules are the same trade again: the client build EMPTIES them, and with no plugins
222
+ // here they ship whole.
223
+ //
224
+ // Both are matched with the SAME regexes the transforms use, imported rather than re-encoded. A
225
+ // hand-written glob here is how these two gates drifted from the pipelines they guard: the old
226
+ // `**/*.fn.{ts,tsx,js,jsx}` missed `.fn.mts`, `.fn.cts`, `.fn.mjs` and `.fn.cjs`, every one of which
227
+ // both build pipelines stub - so a module the framework calls a server function was waved straight
228
+ // into the browser by the check that exists to stop exactly that. A gate written in a second dialect
229
+ // is a gate that eventually disagrees with the thing it guards.
230
+ const leaking: string[] = []
231
+ const serverOnly: string[] = []
232
+ for await (const file of new Glob("**/*.{server,fn,ts,tsx,js,jsx,mts,cts,mjs,cjs}").scan({
233
+ cwd: app.cwd,
234
+ dot: false,
235
+ })) {
236
+ if (/(^|\/)(node_modules|dist|\.nifra|\.git)\//.test(file)) continue
237
+ // Strip the source extension once so the conventions' regexes see `src/todos.fn` / `src/db.server`,
238
+ // which is the shape they match. The glob also includes the extensionless `.fn` / `.server` forms;
239
+ // those already have the matcher-ready shape and pass through unchanged.
240
+ const stem = file.replace(/\.[cm]?[jt]sx?$/, "")
241
+ if (SERVER_FN_MODULE.test(stem)) {
242
+ if (leaking.length < 5) leaking.push(file)
243
+ } else if (SERVER_ONLY_MODULE.test(stem)) {
244
+ if (serverOnly.length < 5) serverOnly.push(file)
245
+ }
246
+ if (leaking.length >= 5 && serverOnly.length >= 5) break
247
+ }
248
+ if (serverOnly.length > 0) {
249
+ throw new Error(
250
+ "[nifra] `nifra dev --bun` can't empty `*.server` modules: Bun's DEV-server bundler takes no " +
251
+ "plugins, so a server-only module would be bundled WHOLE into the browser - along with whatever " +
252
+ "it imports and any secret it holds. `nifra build` and `nifra dev` (Vite) both replace it with " +
253
+ "an empty module, so only this pipeline is affected.\n" +
254
+ ` Server-only modules found: ${serverOnly.join(", ")}\n` +
255
+ " Use `nifra dev` (the Vite pipeline), or reach these from a route/loader for the Bun dev loop.",
256
+ )
257
+ }
258
+ if (leaking.length > 0) {
259
+ throw new Error(
260
+ "[nifra] `nifra dev --bun` can't transform server functions: Bun's DEV-server bundler takes no " +
261
+ "plugins, so a `*.fn` module would be bundled WHOLE into the browser - its function bodies and " +
262
+ "anything they import, secrets included. `nifra build` and `nifra dev` (Vite) both replace it " +
263
+ "with client stubs, so only this pipeline is affected.\n" +
264
+ ` Server-function modules found: ${leaking.join(", ")}\n` +
265
+ " Use `nifra dev` (the Vite pipeline), or move these calls behind a route for the Bun dev loop.",
266
+ )
267
+ }
173
268
  if (offenders.length === 0) return
174
269
  throw new Error(
175
270
  "[nifra] `nifra dev --bun` can't compile CSS Modules: Bun's DEV-server bundler has no `*.module.css` " +
@@ -181,7 +276,7 @@ async function assertBunDevSupportsApp(app: LoadedApp): Promise<void> {
181
276
  }
182
277
 
183
278
  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
279
+ // `--bun`: the Bun-pipeline dev server - Bun.serve's native HMR bundles + hot-reloads the client while
185
280
  // Bun's runtime resolves SSR, with no Vite in the process at all. The mirror of `nifra build --vite`:
186
281
  // each pipeline is selectable in BOTH phases, and neither ever runs inside the other.
187
282
  if (flags.bun) {
@@ -193,7 +288,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
193
288
  outDir,
194
289
  clientModule: fw.clientModule,
195
290
  port: flags.port,
196
- // The Bun pipeline's own plugins (SFC compilers etc.) — never `vitePlugins`, which belong to the
291
+ // The Bun pipeline's own plugins (SFC compilers etc.) - never `vitePlugins`, which belong to the
197
292
  // other pipeline; `assertPipelineSeparation` already refuses a plugin sitting in the wrong slot.
198
293
  plugins: asBunPlugins(app.resolvedPlugins.clientPlugins),
199
294
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
@@ -237,6 +332,7 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
237
332
  clientModule: fw.clientModule,
238
333
  plugins: app.resolvedPlugins.vitePlugins,
239
334
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
335
+ ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
240
336
  poll: flags.poll,
241
337
  port: flags.port,
242
338
  ...(fw.conditions ? { conditions: fw.conditions } : {}),
@@ -256,9 +352,9 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
256
352
  }
257
353
 
258
354
  /**
259
- * `nifra build --target <t>` — package the engine (buildClient + buildServer + prerender) into one
355
+ * `nifra build --target <t>` - package the engine (buildClient + buildServer + prerender) into one
260
356
  * command that emits a full deploy dir, so an app no longer hand-writes build-bun.ts + _worker.ts +
261
- * _routes.json per target. The adapter is imported from `framework.ts` (the edge-bundlable file — never
357
+ * _routes.json per target. The adapter is imported from `framework.ts` (the edge-bundlable file - never
262
358
  * `nifra.config.ts`, which pulls in Vite plugins), and the backend from `backend.ts` when present;
263
359
  * `buildTarget` generates the per-target server entry from those + the app's `routes/`.
264
360
  */
@@ -278,7 +374,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
278
374
  flags.vite ? "vite" : flags.bun ? "bun" : undefined,
279
375
  )
280
376
  const useVite = decision.pipeline === "vite"
281
- // Same deploy-dir output — buildTargetVite delegates to the same orchestrator as buildTarget — so only
377
+ // Same deploy-dir output - buildTargetVite delegates to the same orchestrator as buildTarget - so only
282
378
  // the bundler and the plugin FORMAT (Vite plugins, not Bun) differ.
283
379
  const buildTarget = useVite
284
380
  ? (await import("@nifrajs/web/build-vite")).buildTargetVite
@@ -317,7 +413,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
317
413
  ...(fw.define ? { define: fw.define } : {}),
318
414
  ...(fw.publicDir !== undefined ? { publicDir: fw.publicDir } : {}),
319
415
  ...(fw.publicEnvPrefix !== undefined ? { publicEnvPrefix: fw.publicEnvPrefix } : {}),
320
- // The static target needs a built app to drive prerendering — only build it when targeting static.
416
+ // The static target needs a built app to drive prerendering - only build it when targeting static.
321
417
  ...(target === "static" ? { prerenderApp: await buildPrerenderApp(app) } : {}),
322
418
  })
323
419
  console.log(`nifra build (${target}${useVite ? ", vite" : ""}) → ${result.run}`)
@@ -330,7 +426,7 @@ async function buildForTarget(app: LoadedApp, target: string, flags: Flags): Pro
330
426
  /** Build the app FACTORY for the `static` target's prerender pass. Registers the
331
427
  * framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
332
428
  * `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
333
- * bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
429
+ * bundle (`client.entry`) - it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
334
430
  * would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
335
431
  * from the same manifest so the static HTML matches the deployed app. */
336
432
  async function buildPrerenderApp(
@@ -371,7 +467,7 @@ async function start(app: LoadedApp, flags: Flags): Promise<void> {
371
467
  )
372
468
  }
373
469
  throw new Error(
374
- `[nifra] no ${serverFile} — run \`nifra build\` or \`nifra build --target bun\` first.`,
470
+ `[nifra] no ${serverFile} - run \`nifra build\` or \`nifra build --target bun\` first.`,
375
471
  )
376
472
  }
377
473
  Bun.env.PORT = String(flags.port)
@@ -416,14 +512,14 @@ async function main(): Promise<void> {
416
512
  console.log(`nifra (@nifrajs/cli) ${CLI_VERSION}`)
417
513
  return
418
514
  }
419
- // `mcp` runs a long-lived stdio server and loads the project lazily per-tool — it must not go through
515
+ // `mcp` runs a long-lived stdio server and loads the project lazily per-tool - it must not go through
420
516
  // the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
421
517
  if (command === "mcp") {
422
518
  const { runMcpServer } = await import("./mcp.ts")
423
519
  await runMcpServer(process.cwd(), CLI_VERSION)
424
520
  return
425
521
  }
426
- // `docs-mcp` runs the PUBLIC docs MCP over HTTP — project-independent (serves the bundled corpus), so
522
+ // `docs-mcp` runs the PUBLIC docs MCP over HTTP - project-independent (serves the bundled corpus), so
427
523
  // it self-hosts anywhere Bun runs (a VPS behind a reverse proxy, a container). Long-lived; no loadApp.
428
524
  if (command === "docs-mcp") {
429
525
  const { handleMcpHttp } = await import("./mcp-http.ts")
@@ -436,7 +532,7 @@ async function main(): Promise<void> {
436
532
  console.log(`nifra docs MCP (HTTP) → ${server.url}`)
437
533
  return
438
534
  }
439
- // `check` is a pure cwd-based gate (typecheck + lint) — it must run even when the project doesn't
535
+ // `check` is a pure cwd-based gate (typecheck + lint) - it must run even when the project doesn't
440
536
  // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
441
537
  if (command === "check") {
442
538
  const { runCheck } = await import("./check.ts")
@@ -468,7 +564,14 @@ async function main(): Promise<void> {
468
564
  if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
469
565
  return
470
566
  }
471
- // `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
567
+ // `sync-routes` regenerates the route-search types `.d.ts` (typed `navigate({ to, search })`) from
568
+ // `routes/`. Pure fs/text (needs only the route tree, not `framework.ts`), so dispatch before `loadApp`.
569
+ if (command === "sync-routes") {
570
+ const { runSyncRoutes } = await import("./sync-routes.ts")
571
+ if (!(await runSyncRoutes(process.cwd()))) process.exitCode = 1
572
+ return
573
+ }
574
+ // `doctor` is a pure cwd check (imports vs declared deps) - like `check`, dispatch before `loadApp`
472
575
  // so it runs on an API-only / not-yet-built project.
473
576
  if (command === "doctor") {
474
577
  const { runDoctor } = await import("./doctor.ts")
@@ -481,7 +584,7 @@ async function main(): Promise<void> {
481
584
  process.exitCode = 1
482
585
  return
483
586
  }
484
- // `snapshot` / `diff` load ONLY backend.ts (the API contract) — like `check`, they must run on an
587
+ // `snapshot` / `diff` load ONLY backend.ts (the API contract) - like `check`, they must run on an
485
588
  // API-only project, so they dispatch before the eager `loadApp`.
486
589
  if (command === "snapshot") {
487
590
  const { runSnapshot } = await import("./diff-tool.ts")
@@ -495,7 +598,7 @@ async function main(): Promise<void> {
495
598
  try {
496
599
  await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
497
600
  } catch (err) {
498
- console.error(err instanceof Error ? err.message : String(err))
601
+ console.error(formatCliError(err))
499
602
  process.exitCode = 1
500
603
  }
501
604
  return
@@ -508,7 +611,7 @@ async function main(): Promise<void> {
508
611
  process.exitCode = 1
509
612
  }
510
613
  } catch (err) {
511
- console.error(err instanceof Error ? err.message : String(err))
614
+ console.error(formatCliError(err))
512
615
  process.exitCode = 1
513
616
  }
514
617
  return
@@ -533,7 +636,7 @@ async function main(): Promise<void> {
533
636
  )
534
637
  process.exitCode = 1
535
638
  } catch (err) {
536
- console.error(err instanceof Error ? err.message : String(err))
639
+ console.error(formatCliError(err))
537
640
  process.exitCode = 1
538
641
  }
539
642
  return
@@ -574,7 +677,7 @@ async function main(): Promise<void> {
574
677
  throw new Error("[nifra] capabilities needs `snapshot` or `check`")
575
678
  }
576
679
  } catch (err) {
577
- console.error(err instanceof Error ? err.message : String(err))
680
+ console.error(formatCliError(err))
578
681
  process.exitCode = 1
579
682
  }
580
683
  return
@@ -616,7 +719,7 @@ async function main(): Promise<void> {
616
719
  throw new Error("[nifra] manifest needs `emit` or `diff`")
617
720
  }
618
721
  } catch (err) {
619
- console.error(err instanceof Error ? err.message : String(err))
722
+ console.error(formatCliError(err))
620
723
  process.exitCode = 1
621
724
  }
622
725
  return
@@ -647,13 +750,13 @@ async function main(): Promise<void> {
647
750
  )
648
751
  process.exitCode = 1
649
752
  } catch (err) {
650
- console.error(err instanceof Error ? err.message : String(err))
753
+ console.error(formatCliError(err))
651
754
  process.exitCode = 1
652
755
  }
653
756
  return
654
757
  }
655
758
  // `upgrade` is a pure cwd file-transformer (package.json pins + import moves) driven by a per-release
656
- // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` — it must run on any
759
+ // recipe, then verified with `nifra check`. Dispatch before the eager `loadApp` - it must run on any
657
760
  // repo (API-only, not built, or mid-upgrade with edits that don't yet typecheck under dry-run).
658
761
  if (command === "upgrade") {
659
762
  const { runUpgrade } = await import("./upgrade.ts")
@@ -668,12 +771,12 @@ async function main(): Promise<void> {
668
771
  })
669
772
  if (!ok) process.exitCode = 1
670
773
  } catch (err) {
671
- console.error(err instanceof Error ? err.message : String(err))
774
+ console.error(formatCliError(err))
672
775
  process.exitCode = 1
673
776
  }
674
777
  return
675
778
  }
676
- // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
779
+ // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) - like `check`/
677
780
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
678
781
  if (command === "port") {
679
782
  const { runPort } = await import("./port.ts")
@@ -693,7 +796,7 @@ async function main(): Promise<void> {
693
796
  })
694
797
  if (!passed) process.exitCode = 1
695
798
  } catch (err) {
696
- console.error(err instanceof Error ? err.message : String(err))
799
+ console.error(formatCliError(err))
697
800
  process.exitCode = 1
698
801
  }
699
802
  return
@@ -748,7 +851,7 @@ async function main(): Promise<void> {
748
851
  // exported `parseFlags`. `import.meta.main` is true only for the process's entry module.
749
852
  if (import.meta.main) {
750
853
  main().catch((err) => {
751
- console.error(err instanceof Error ? err.message : String(err))
854
+ console.error(formatCliError(err))
752
855
  process.exitCode = 1
753
856
  })
754
857
  }
package/src/diff-tool.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `nifra snapshot` / `nifra diff` — the API breaking-change gate.
2
+ * `nifra snapshot` / `nifra diff` - the API breaking-change gate.
3
3
  *
4
4
  * nifra snapshot [--out api-snapshot.json] Write the backend's route contract as plain JSON.
5
5
  * nifra diff <baseline.json> [--json] Re-snapshot and compare against the committed
@@ -10,7 +10,7 @@
10
10
  * before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
11
11
  * this module only loads `backend.ts` and renders the result.
12
12
  *
13
- * Loads ONLY `backend.ts` — the API contract — so it works on an API-only project with no
13
+ * Loads ONLY `backend.ts` - the API contract - so it works on an API-only project with no
14
14
  * framework config or routes/ directory (unlike the eager `loadApp`).
15
15
  */
16
16
 
@@ -24,7 +24,7 @@ import {
24
24
  snapshotRoutes,
25
25
  } from "@nifrajs/core/diff"
26
26
 
27
- /** Snapshot file envelope — versioned so a future format change can migrate instead of misparse. */
27
+ /** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
28
28
  export interface SnapshotFile {
29
29
  readonly nifraSnapshot: 1
30
30
  readonly routes: readonly RouteSnapshot[]
@@ -39,7 +39,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
39
39
  parsed = JSON.parse(content)
40
40
  } catch {
41
41
  throw new Error(
42
- `[nifra] ${sourcePath} is not valid JSON — regenerate it with \`nifra snapshot\`.`,
42
+ `[nifra] ${sourcePath} is not valid JSON - regenerate it with \`nifra snapshot\`.`,
43
43
  )
44
44
  }
45
45
  const record = parsed as Partial<SnapshotFile> | null
@@ -50,7 +50,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
50
50
  !Array.isArray(record.routes)
51
51
  ) {
52
52
  throw new Error(
53
- `[nifra] ${sourcePath} is not a nifra API snapshot — expected { "nifraSnapshot": 1, "routes": [...] }.`,
53
+ `[nifra] ${sourcePath} is not a nifra API snapshot - expected { "nifraSnapshot": 1, "routes": [...] }.`,
54
54
  )
55
55
  }
56
56
  return record as SnapshotFile
@@ -76,7 +76,7 @@ export function formatDiff(diff: RoutesDiff): string {
76
76
  .map(describeChange)
77
77
  const breaking = diff.changes.filter((change) => change.severity === "breaking").length
78
78
  const summary = diff.hasBreaking
79
- ? `${breaking} breaking change${breaking === 1 ? "" : "s"} — existing clients will fail.`
79
+ ? `${breaking} breaking change${breaking === 1 ? "" : "s"} - existing clients will fail.`
80
80
  : "No breaking changes."
81
81
  return `${lines.join("\n")}\n\n${summary}`
82
82
  }
@@ -86,7 +86,7 @@ async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
86
86
  const backendPath = resolve(cwd, "backend.ts")
87
87
  if (!existsSync(backendPath)) {
88
88
  throw new Error(
89
- `[nifra] no backend.ts in ${cwd} — \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
89
+ `[nifra] no backend.ts in ${cwd} - \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
90
90
  )
91
91
  }
92
92
  const backend = ((await import(backendPath)) as { backend?: unknown }).backend
@@ -115,7 +115,7 @@ export async function runDiff(
115
115
  const baselineFile = Bun.file(resolved)
116
116
  if (!(await baselineFile.exists())) {
117
117
  throw new Error(
118
- `[nifra] baseline not found: ${resolved} — create it on your main branch with \`nifra snapshot\`.`,
118
+ `[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
119
119
  )
120
120
  }
121
121
  const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `nifra_docs` — searchable framework documentation for agents. The corpus is the generated
2
+ * `nifra_docs` - searchable framework documentation for agents. The corpus is the generated
3
3
  * `llms-full.txt` (written into this package by `bun run gen:llms`, so it can't drift from the
4
4
  * source it's generated from). Instead of dumping the whole ~150 KB into an agent's context, the
5
- * tool keyword-scores the document's `##` sections and returns only the top matches — or, with no
5
+ * tool keyword-scores the document's `##` sections and returns only the top matches - or, with no
6
6
  * query, just the section index (the cheap discovery call).
7
7
  *
8
8
  * Deliberately NOT a vector search: the corpus is one curated document with descriptive headings;
@@ -68,7 +68,7 @@ export function searchSections(
68
68
  return scored.sort((a, b) => b.score - a.score).slice(0, limit)
69
69
  }
70
70
 
71
- /** Cap a section body for the tool result — whole sections can be pages long; the agent can ask
71
+ /** Cap a section body for the tool result - whole sections can be pages long; the agent can ask
72
72
  * again with a narrower query if the trim cuts what it needed. */
73
73
  const MAX_SECTION_CHARS = 2800
74
74
 
@@ -81,7 +81,7 @@ export function renderDocsResult(
81
81
  const sections = splitSections(markdown)
82
82
  if (query === undefined || query.trim() === "") {
83
83
  const index = sections.map((s) => `- ${s.heading}`).join("\n")
84
- return `# nifra docs — section index (pass \`query\` to fetch matching sections)\n\n${index}`
84
+ return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
85
85
  }
86
86
  const matches = searchSections(sections, query, limit)
87
87
  if (matches.length === 0) {
@@ -91,7 +91,7 @@ export function renderDocsResult(
91
91
  .map((m) => {
92
92
  const body =
93
93
  m.body.length > MAX_SECTION_CHARS
94
- ? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed — narrow the query for the rest)`
94
+ ? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed - narrow the query for the rest)`
95
95
  : m.body
96
96
  return `## ${m.heading}\n\n${body}`
97
97
  })
@@ -99,7 +99,7 @@ export function renderDocsResult(
99
99
  }
100
100
 
101
101
  /** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
102
- * and `dist/` (published) alike — `docs/llms-full.txt` sits at the package root next to both. */
102
+ * and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
103
103
  export async function loadDocsCorpus(): Promise<string | undefined> {
104
104
  const url = new URL("../docs/llms-full.txt", import.meta.url)
105
105
  try {
package/src/doctor.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
- * `nifra doctor` — catches the Bun-workspace footgun the typed-client lint can't: a package that's
2
+ * `nifra doctor` - catches the Bun-workspace footgun the typed-client lint can't: a package that's
3
3
  * IMPORTED in source but NOT declared in `package.json`. Bun resolves it at runtime (via hoisting or a
4
- * workspace), so tests pass and `bun install` reports "no changes" — false confidence — yet `tsc` fails
4
+ * workspace), so tests pass and `bun install` reports "no changes" - false confidence - yet `tsc` fails
5
5
  * and a fresh or standalone install can't resolve it. doctor diffs every bare import specifier against
6
6
  * the package's declared dependencies and flags the gap, with a stable `--json` shape for agents/CI.
7
7
  *
8
8
  * Scope is intentionally per-package: it checks the `package.json` at `cwd`, because a dependency must be
9
9
  * declared by the package that imports it (for `tsc` and for that package to install on its own) even
10
10
  * when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
11
- * the package's own name, and tsconfig `paths` aliases are excluded — none of them are npm deps.
11
+ * the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
12
12
  */
13
13
  import { realpath, stat } from "node:fs/promises"
14
14
  import { builtinModules } from "node:module"
@@ -25,7 +25,7 @@ const BUILTINS: ReadonlySet<string> = new Set<string>([
25
25
 
26
26
  // The four specifier-bearing forms: static import/re-export with a source, side-effect import, dynamic
27
27
  // import, and CJS require. Anchored with `(?<![.\w$])` so `myimport`/`.import`/`foorequire` never match.
28
- // Comments are stripped before these run (see stripComments) — else a doc-comment usage example would be
28
+ // Comments are stripped before these run (see stripComments) - else a doc-comment usage example would be
29
29
  // flagged as a real import.
30
30
  const IMPORT_PATTERNS: readonly RegExp[] = [
31
31
  /(?<![.\w$])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g,
@@ -56,7 +56,7 @@ export function packageOf(spec: string): string | undefined {
56
56
  if (spec.startsWith("node:") || spec.startsWith("bun:")) return undefined
57
57
  if (spec.startsWith("@")) {
58
58
  const [scope, name] = spec.split("/")
59
- if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo` — not a real package
59
+ if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo` - not a real package
60
60
  const pkg = `${scope}/${name}`
61
61
  return isNpmPackageName(pkg) ? pkg : undefined
62
62
  }
@@ -66,7 +66,7 @@ export function packageOf(spec: string): string | undefined {
66
66
  }
67
67
 
68
68
  /** Build a predicate matching tsconfig `paths` aliases (e.g. `@/*`, `~/utils`), which resolve to local
69
- * source, not npm packages — so doctor must not flag them. */
69
+ * source, not npm packages - so doctor must not flag them. */
70
70
  export function aliasMatcher(
71
71
  paths: Readonly<Record<string, unknown>> | undefined,
72
72
  ): (spec: string) => boolean {
@@ -113,13 +113,13 @@ export function scanUndeclaredImports(
113
113
  export interface DoctorFinding {
114
114
  readonly file: string
115
115
  readonly line: number
116
- /** The undeclared package name — add it to `package.json` dependencies. */
116
+ /** The undeclared package name - add it to `package.json` dependencies. */
117
117
  readonly package: string
118
118
  }
119
119
 
120
120
  export interface DoctorResult {
121
121
  readonly ok: boolean
122
- /** `false` when no `package.json` was found at cwd — doctor can't run (reported, not a crash). */
122
+ /** `false` when no `package.json` was found at cwd - doctor can't run (reported, not a crash). */
123
123
  readonly ran: boolean
124
124
  readonly findings: readonly DoctorFinding[]
125
125
  /** Packages that resolve to more than one physical install across this workspace. */
@@ -622,7 +622,7 @@ export async function runDoctor(
622
622
  }
623
623
  console.log("nifra doctor\n")
624
624
  if (!result.ran) {
625
- console.log("• no package.json at this directory — nothing to check")
625
+ console.log("• no package.json at this directory - nothing to check")
626
626
  return true
627
627
  }
628
628
  if (result.fixed && result.fixed.length > 0) {
@@ -635,7 +635,7 @@ export async function runDoctor(
635
635
  if (result.skippedFixes && result.skippedFixes.length > 0) {
636
636
  console.log("• not auto-fixed:")
637
637
  for (const f of result.skippedFixes) {
638
- console.log(` ${f.package} — ${f.reason}; run \`${f.command.join(" ")}\``)
638
+ console.log(` ${f.package} - ${f.reason}; run \`${f.command.join(" ")}\``)
639
639
  }
640
640
  console.log("")
641
641
  }
package/src/examples.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * `nifra_example` — verified, copy-pasteable code examples for agents. The corpus is `examples.json`
2
+ * `nifra_example` - verified, copy-pasteable code examples for agents. The corpus is `examples.json`
3
3
  * (written into this package by `bun run gen:llms`): the same doc snippets `check:docs` typechecks
4
4
  * against the live `@nifrajs/*` API. So every example this returns is GUARANTEED to compile against the
5
- * installed version — the antidote to an agent hallucinating a framework API from stale training.
5
+ * installed version - the antidote to an agent hallucinating a framework API from stale training.
6
6
  *
7
7
  * Keyword-scored over each example's name/topic/slug (curated identifiers, heavily weighted) and its
8
8
  * code body. No query → a grouped index of what's available (the cheap discovery call).
@@ -12,9 +12,9 @@ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-
12
12
 
13
13
  export interface Example {
14
14
  readonly name: string
15
- /** The doc page's title, e.g. "Nifra — Auth & sessions". */
15
+ /** The doc page's title, e.g. "Nifra - Auth & sessions". */
16
16
  readonly topic: string
17
- /** The doc page slug, e.g. "auth" — a short, matchable tag. */
17
+ /** The doc page slug, e.g. "auth" - a short, matchable tag. */
18
18
  readonly slug: string
19
19
  readonly code: string
20
20
  }
@@ -63,19 +63,19 @@ export function renderExamplesResult(
63
63
  const index = [...byTopic.entries()]
64
64
  .map(([topic, names]) => `- **${topic}**: ${names.join(", ")}`)
65
65
  .join("\n")
66
- return `# nifra examples — index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version — prefer these over recalling an API.\n\n${index}`
66
+ return `# nifra examples - index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version - prefer these over recalling an API.\n\n${index}`
67
67
  }
68
68
  const matches = searchExamples(examples, query, limit)
69
69
  if (matches.length === 0) {
70
70
  return `No examples matched ${JSON.stringify(query)}. Call without a query for the index, or try a broader term (e.g. "auth", "upload", "isr", "client").`
71
71
  }
72
72
  return matches
73
- .map((m) => `## ${m.name} — ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
73
+ .map((m) => `## ${m.name} - ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
74
74
  .join("\n\n---\n\n")
75
75
  }
76
76
 
77
77
  /** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
78
- * and `dist/` (published) alike — `docs/examples.json` sits at the package root next to both. */
78
+ * and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
79
79
  export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
80
80
  const url = new URL("../docs/examples.json", import.meta.url)
81
81
  try {