@nifrajs/cli 2.1.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 (126) 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 +64 -22
  11. package/dist/check.d.ts.map +1 -1
  12. package/dist/check.js +400 -83
  13. package/dist/check.js.map +1 -1
  14. package/dist/cli.d.ts +37 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -53
  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 +8 -4
  23. package/dist/doctor.d.ts.map +1 -1
  24. package/dist/doctor.js +74 -14
  25. package/dist/doctor.js.map +1 -1
  26. package/dist/examples.d.ts +5 -5
  27. package/dist/examples.js +5 -5
  28. package/dist/init-agents.d.ts +14 -14
  29. package/dist/init-agents.js +14 -14
  30. package/dist/internal/typescript-import.d.ts +24 -0
  31. package/dist/internal/typescript-import.d.ts.map +1 -0
  32. package/dist/internal/typescript-import.js +32 -0
  33. package/dist/internal/typescript-import.js.map +1 -0
  34. package/dist/introspect.d.ts +12 -12
  35. package/dist/introspect.js +24 -24
  36. package/dist/levels-tool.d.ts +8 -8
  37. package/dist/levels-tool.d.ts.map +1 -1
  38. package/dist/levels-tool.js +33 -30
  39. package/dist/levels-tool.js.map +1 -1
  40. package/dist/load.d.ts +17 -6
  41. package/dist/load.d.ts.map +1 -1
  42. package/dist/load.js +45 -7
  43. package/dist/load.js.map +1 -1
  44. package/dist/manifest-tool.d.ts +1 -1
  45. package/dist/manifest-tool.d.ts.map +1 -1
  46. package/dist/manifest-tool.js +4 -2
  47. package/dist/manifest-tool.js.map +1 -1
  48. package/dist/mcp-docs-tools.d.ts +1 -1
  49. package/dist/mcp-docs-tools.d.ts.map +1 -1
  50. package/dist/mcp-docs-tools.js +14 -10
  51. package/dist/mcp-docs-tools.js.map +1 -1
  52. package/dist/mcp-http.d.ts +5 -5
  53. package/dist/mcp-http.js +7 -7
  54. package/dist/mcp-protocol.d.ts +2 -2
  55. package/dist/mcp-protocol.js +2 -2
  56. package/dist/mcp-render.d.ts +3 -3
  57. package/dist/mcp-render.js +15 -7
  58. package/dist/mcp-render.js.map +1 -1
  59. package/dist/mcp-run.d.ts +2 -2
  60. package/dist/mcp-run.js +2 -2
  61. package/dist/mcp.d.ts +14 -14
  62. package/dist/mcp.d.ts.map +1 -1
  63. package/dist/mcp.js +119 -47
  64. package/dist/mcp.js.map +1 -1
  65. package/dist/openapi-tool.d.ts +1 -1
  66. package/dist/openapi-tool.js +2 -2
  67. package/dist/pipeline-guard.d.ts +83 -0
  68. package/dist/pipeline-guard.d.ts.map +1 -0
  69. package/dist/pipeline-guard.js +136 -0
  70. package/dist/pipeline-guard.js.map +1 -0
  71. package/dist/port.d.ts +9 -9
  72. package/dist/port.js +27 -27
  73. package/dist/recipes/1.8.0.d.ts +2 -2
  74. package/dist/recipes/1.8.0.js +5 -5
  75. package/dist/recipes/index.d.ts +2 -2
  76. package/dist/recipes/index.js +1 -1
  77. package/dist/scaffold.d.ts +3 -3
  78. package/dist/scaffold.js +8 -8
  79. package/dist/sync-routes.d.ts +28 -0
  80. package/dist/sync-routes.d.ts.map +1 -0
  81. package/dist/sync-routes.js +55 -0
  82. package/dist/sync-routes.js.map +1 -0
  83. package/dist/types-search.d.ts +6 -6
  84. package/dist/types-search.d.ts.map +1 -1
  85. package/dist/types-search.js +53 -8
  86. package/dist/types-search.js.map +1 -1
  87. package/dist/upgrade.d.ts +7 -7
  88. package/dist/upgrade.js +12 -12
  89. package/dist/verification.d.ts +51 -0
  90. package/dist/verification.d.ts.map +1 -0
  91. package/dist/verification.js +80 -0
  92. package/dist/verification.js.map +1 -0
  93. package/docs/examples.json +178 -88
  94. package/docs/llms-full.txt +1766 -766
  95. package/docs/types.json +2171 -2807
  96. package/package.json +22 -11
  97. package/src/assure.ts +17 -18
  98. package/src/capabilities-tool.ts +82 -16
  99. package/src/check.ts +493 -100
  100. package/src/cli.ts +306 -54
  101. package/src/diff-tool.ts +8 -8
  102. package/src/docs-search.ts +6 -6
  103. package/src/doctor.ts +81 -16
  104. package/src/examples.ts +7 -7
  105. package/src/init-agents.ts +17 -17
  106. package/src/internal/typescript-import.ts +33 -0
  107. package/src/introspect.ts +28 -28
  108. package/src/levels-tool.ts +37 -31
  109. package/src/load.ts +65 -12
  110. package/src/manifest-tool.ts +4 -2
  111. package/src/mcp-docs-tools.ts +20 -10
  112. package/src/mcp-http.ts +7 -7
  113. package/src/mcp-protocol.ts +2 -2
  114. package/src/mcp-render.ts +15 -7
  115. package/src/mcp-run.ts +2 -2
  116. package/src/mcp.ts +127 -47
  117. package/src/openapi-tool.ts +2 -2
  118. package/src/pipeline-guard.ts +186 -0
  119. package/src/port.ts +31 -31
  120. package/src/recipes/1.8.0.ts +5 -5
  121. package/src/recipes/index.ts +2 -2
  122. package/src/scaffold.ts +8 -8
  123. package/src/sync-routes.ts +69 -0
  124. package/src/types-search.ts +57 -8
  125. package/src/upgrade.ts +12 -12
  126. package/src/verification.ts +123 -0
package/src/mcp-http.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
- * The PUBLIC nifra docs MCP — Streamable-HTTP transport exposing the project-INDEPENDENT tools
2
+ * The PUBLIC nifra docs MCP - Streamable-HTTP transport exposing the project-INDEPENDENT tools
3
3
  * (`nifra_docs` + `nifra_example`) so any remote AI agent can learn nifra without a local checkout.
4
4
  * Both tools read the bundled corpus (llms-full.txt / examples.json shipped in this package) and ignore
5
- * `cwd`, so they reuse the exact definitions from {@link projectTools} — one source, no drift.
5
+ * `cwd`, so they reuse the exact definitions from {@link projectTools} - one source, no drift.
6
6
  *
7
7
  * The transport itself (body cap, CORS, JSON-RPC dispatch) lives in `@nifrajs/mcp/http`; this module is
8
8
  * the docs-specific layer over it: it supplies the bundled corpus tools and the `nifra-docs` server info.
9
- * {@link handleMcpHttp} self-hosts ANYWHERE Bun runs — the simplest path is `nifra docs-mcp` on a VPS; the
10
- * same handler runs on Cloudflare/Vercel edge (there inline the corpus as string imports — a Worker has no
9
+ * {@link handleMcpHttp} self-hosts ANYWHERE Bun runs - the simplest path is `nifra docs-mcp` on a VPS; the
10
+ * same handler runs on Cloudflare/Vercel edge (there inline the corpus as string imports - a Worker has no
11
11
  * filesystem). `export default { port, fetch }` also lets `bun run mcp-http.ts` serve it directly.
12
12
  */
13
13
 
@@ -26,10 +26,10 @@ export { docsTools } from "./mcp-docs-tools.ts"
26
26
  export type { TypeEntry } from "./types-search.ts"
27
27
 
28
28
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
29
- const VERSION = "2.1.0"
29
+ const VERSION = "2.3.0"
30
30
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
31
31
  const DOCS_HEALTH =
32
- "nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
32
+ "nifra docs MCP - POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
33
33
 
34
34
  /** The two project-independent tools, reading the package's bundled corpus from disk (CLI use). */
35
35
  export function publicDocsTools(): McpTool[] {
@@ -57,7 +57,7 @@ export function handleMcpHttp(request: Request): Promise<Response> {
57
57
  /**
58
58
  * Worker/edge + local entry. `export default { fetch }` is the universal server shape: Cloudflare /
59
59
  * Vercel edge / Deno deploy use `fetch` (and ignore `port`); `bun run mcp-http.ts` auto-serves it on
60
- * `port` (PORT env, default 8787) — Bun serves a module's default-exported server, so NO manual
60
+ * `port` (PORT env, default 8787) - Bun serves a module's default-exported server, so NO manual
61
61
  * `Bun.serve` here (that would double-bind the port).
62
62
  */
63
63
  export default {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The MCP JSON-RPC dispatch now lives in `@nifrajs/mcp` (shared with nifra apps that expose their own
3
- * MCP server / MCP Apps widgets). This module is a thin re-export so the CLI's existing imports — and
4
- * the `@nifrajs/cli/mcp` self-host surface — keep resolving here unchanged.
3
+ * MCP server / MCP Apps widgets). This module is a thin re-export so the CLI's existing imports - and
4
+ * the `@nifrajs/cli/mcp` self-host surface - keep resolving here unchanged.
5
5
  */
6
6
 
7
7
  export * from "@nifrajs/mcp/protocol"
package/src/mcp-render.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * The `nifra_render` engine + its child-process entry — the page half of `nifra_run`. Where `nifra_run`
2
+ * The `nifra_render` engine + its child-process entry - the page half of `nifra_run`. Where `nifra_run`
3
3
  * runs requests through the BACKEND, this SSRs page routes through the WEB app (`createWebApp`), so an
4
4
  * agent can verify a page renders and its loader ran after an edit. Spawned fresh per call (`bun
5
5
  * mcp-render.ts <cwd>`) so the project's CURRENT route/loader code is loaded.
6
6
  *
7
- * No build required: a placeholder `clientEntry` is injected (the SSR HTML renders regardless — the
7
+ * No build required: a placeholder `clientEntry` is injected (the SSR HTML renders regardless - the
8
8
  * client bundle only matters for hydration in the browser, not for "did this page render server-side").
9
- * {@link renderPages} never throws — every failure (no config, a loader error, a render throw) becomes
9
+ * {@link renderPages} never throws - every failure (no config, a loader error, a render throw) becomes
10
10
  * `{ error }` so the agent gets the actionable message.
11
11
  */
12
12
 
@@ -19,7 +19,7 @@ import { loadApp, resolvePlugins } from "./load.ts"
19
19
  const errString = (err: unknown): string =>
20
20
  err instanceof Error ? `${err.name}: ${err.message}` : String(err)
21
21
 
22
- /** Load the project, register its SSR plugins, and build the SSR `createWebApp` once — the cost the warm
22
+ /** Load the project, register its SSR plugins, and build the SSR `createWebApp` once - the cost the warm
23
23
  * worker amortizes across calls. Never throws (every failure → `{ error }`). Factored out of
24
24
  * {@link renderPages} so the cold path (fresh process per call) and the warm worker share one builder. */
25
25
  async function buildWebApp(cwd: string): Promise<{ app: AppLike } | { error: string }> {
@@ -92,7 +92,7 @@ function redirectConsoleToStderr(): void {
92
92
  /**
93
93
  * Warm-worker loop (`--worker`): build the web app ONCE, then SSR each `{ id, input: { requests } }` line
94
94
  * against that hot app and reply `{ id, output }`. The parent (`mcp.ts`'s warm-render handler) spawns this,
95
- * fingerprints the source tree, and restarts the worker when a file changes — so warm reuse never serves a
95
+ * fingerprints the source tree, and restarts the worker when a file changes - so warm reuse never serves a
96
96
  * stale render. Mirrors `mcp-run.ts`'s worker (single backend entry → no per-call entry key needed here).
97
97
  */
98
98
  async function runWorker(cwd: string): Promise<void> {
@@ -148,7 +148,7 @@ if (import.meta.main) {
148
148
  // The parent (mcp.ts) spawns `bun mcp-render.ts <cwd>` WITHOUT setting the subprocess `cwd` option, so
149
149
  // this process inherits the MCP server's working dir, not the app's. SSR resolves `react-dom/server`
150
150
  // from `process.cwd()` to share ONE React core with the app's route components (the dual-React fix in
151
- // @nifrajs/web-react) — so we must chdir to the app root before any render. Best-effort: a bad path
151
+ // @nifrajs/web-react) - so we must chdir to the app root before any render. Best-effort: a bad path
152
152
  // surfaces later as a load error rather than crashing the child here.
153
153
  if (cwd !== process.cwd()) {
154
154
  try {
@@ -169,5 +169,13 @@ if (import.meta.main) {
169
169
  } catch {
170
170
  output = { error: "invalid input: expected JSON { requests: [...] }" }
171
171
  }
172
- process.stdout.write(JSON.stringify(output, null, 2))
172
+ await Bun.write(Bun.stdout, JSON.stringify(output, null, 2))
173
+ // Exit explicitly, exactly as the `--worker` branch above already does.
174
+ //
175
+ // Loading the app runs its module side effects, and a real app opens things: a database pool, a
176
+ // Redis client, a metrics interval. Any one of those keeps the event loop alive, so falling off
177
+ // the end of this module never terminates the process - and the parent is sitting in
178
+ // `await proc.exited`. The render itself finished; there is nothing left to wait for, and this
179
+ // process is single-use. `Bun.write` above is awaited so the output is flushed before we exit.
180
+ process.exit(0)
173
181
  }
package/src/mcp-run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The `nifra_run` engine + its child-process entry. The MCP server spawns this file fresh on every
3
- * `nifra_run` call (`bun mcp-run.ts <cwd>`) so the project's CURRENT backend code is loaded — picking
3
+ * `nifra_run` call (`bun mcp-run.ts <cwd>`) so the project's CURRENT backend code is loaded - picking
4
4
  * up the agent's latest edits. {@link runBackend} is the pure-ish core (resolve entry → import → run),
5
5
  * exported so it's unit-testable in-process; the entry below wires it to stdin/stdout.
6
6
  */
@@ -48,7 +48,7 @@ export async function loadBackend(
48
48
 
49
49
  /**
50
50
  * Resolve the backend entry under `cwd`, import it, and run `requests` through its exported app. Never
51
- * throws — every failure (missing/invalid entry, import/compile/runtime error) becomes `{ error }`, so
51
+ * throws - every failure (missing/invalid entry, import/compile/runtime error) becomes `{ error }`, so
52
52
  * the caller (and the agent) always gets actionable output. An import error here IS the failure the
53
53
  * agent needs to see and fix.
54
54
  */
package/src/mcp.ts CHANGED
@@ -1,27 +1,27 @@
1
1
  /**
2
- * `nifra mcp` — a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
2
+ * `nifra mcp` - a Model Context Protocol server (stdio) that lets a coding agent (Claude, Cursor, …)
3
3
  * act on a nifra project, not just read about it:
4
4
  *
5
- * - `nifra_context` — this project's API routes + page routes + conventions (see {@link describeProject}).
6
- * - `nifra_run` — run HTTP requests through the project's backend and return structured results
5
+ * - `nifra_context` - this project's API routes + page routes + conventions (see {@link describeProject}).
6
+ * - `nifra_run` - run HTTP requests through the project's backend and return structured results
7
7
  * (status, body, errors). The write → run → see-the-failure → fix loop, powered by `@nifrajs/runner`.
8
8
  * Each call runs the backend in a FRESH subprocess, so it reflects the agent's latest edits.
9
- * - `nifra_render` — SSR a page route through the project's web app; returns the rendered HTML (the
9
+ * - `nifra_render` - SSR a page route through the project's web app; returns the rendered HTML (the
10
10
  * page half of `nifra_run`). Fresh subprocess per call; no build needed.
11
- * - `nifra_ws` — verify a WebSocket route with a real Bun WebSocket round-trip.
12
- * - `nifra_docs` — keyword-search the framework docs; returns only the matching sections.
13
- * - `nifra_example` — a verified, copy-pasteable snippet for a task (typechecked against the live API,
11
+ * - `nifra_ws` - verify a WebSocket route with a real Bun WebSocket round-trip.
12
+ * - `nifra_docs` - keyword-search the framework docs; returns only the matching sections.
13
+ * - `nifra_example` - a verified, copy-pasteable snippet for a task (typechecked against the live API,
14
14
  * so it can't hallucinate a drifted nifra API).
15
- * - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
16
- * - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
- * - `nifra_assure` — route classification + enforcement-evidence gate.
15
+ * - `nifra_scaffold`- map a URL path to the correct `routes/` file + a contract-correct page stub.
16
+ * - `nifra_check` - the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
17
+ * - `nifra_assure` - route classification + enforcement-evidence gate.
18
18
  * - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
19
19
  * project actually proves, and why each level it misses does not hold.
20
- * - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
20
+ * - `nifra_doctor` - package.json dependency drift detector, with safe local-version auto-fix.
21
21
  *
22
22
  * Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
23
23
  * project root. The protocol is hand-rolled (newline-delimited JSON-RPC 2.0 over stdio), including
24
- * standard MCP progress notifications and request cancellation — no SDK dependency, the same
24
+ * standard MCP progress notifications and request cancellation - no SDK dependency, the same
25
25
  * minimal-surface choice as the rest of nifra. The pure dispatch lives in `./mcp-protocol.ts`; this
26
26
  * module is the I/O shell (stdin loop, tool wiring, the run subprocess).
27
27
  */
@@ -61,6 +61,26 @@ function childPath(name: "mcp-run" | "mcp-render" | "mcp-ws"): string {
61
61
  }
62
62
 
63
63
  /** Spawn `bun <child> <cwd>`, pipe `input` to its stdin, return its stdout (or a stderr-backed error). */
64
+ /**
65
+ * How long a child gets before it is killed.
66
+ *
67
+ * A child loads the app, which runs its module side effects - a database pool, a Redis client, a
68
+ * metrics interval. Any of those keeps the child's event loop alive after the work is done, and the
69
+ * parent is sitting in `await proc.exited`. The children now exit explicitly, so this is the backstop
70
+ * rather than the fix: without it a single wedged child blocks an agent indefinitely, and the caller
71
+ * has no way to tell "still working" from "never returning".
72
+ *
73
+ * Generous enough for a cold start that compiles route modules, short enough that a hang is reported
74
+ * within one attention span rather than after minutes of polling.
75
+ */
76
+ const CHILD_TIMEOUT_MS = 30_000
77
+
78
+ const timeoutMessage = (label: string, ms: number): string =>
79
+ `${label} timed out after ${ms / 1000}s and was terminated.\n` +
80
+ `The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
81
+ `that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
82
+ `rather than lazily. Check for top-level connections in the app entry or anything it imports.`
83
+
64
84
  async function spawnChild(
65
85
  child: "mcp-run" | "mcp-render" | "mcp-ws",
66
86
  cwd: string,
@@ -76,6 +96,11 @@ async function spawnChild(
76
96
  })
77
97
  const abort = (): void => proc.kill()
78
98
  signal?.addEventListener("abort", abort, { once: true })
99
+ let timedOut = false
100
+ const timer = setTimeout(() => {
101
+ timedOut = true
102
+ proc.kill()
103
+ }, CHILD_TIMEOUT_MS)
79
104
  try {
80
105
  proc.stdin.write(JSON.stringify(input))
81
106
  await proc.stdin.end()
@@ -88,8 +113,13 @@ async function spawnChild(
88
113
  const reason = typeof signal.reason === "string" ? `: ${signal.reason}` : ""
89
114
  return `${label} cancelled${reason}.`
90
115
  }
91
- return out.trim() || `${label} failed:\n${err.trim() || "(no output)"}`
116
+ // A killed child may still have flushed a complete result before the timer fired; prefer real
117
+ // output over the timeout message so a slow-but-successful render is not reported as a failure.
118
+ const text = out.trim()
119
+ if (timedOut && text === "") return timeoutMessage(label, CHILD_TIMEOUT_MS)
120
+ return text || `${label} failed:\n${err.trim() || "(no output)"}`
92
121
  } finally {
122
+ clearTimeout(timer)
93
123
  signal?.removeEventListener("abort", abort)
94
124
  }
95
125
  }
@@ -129,7 +159,7 @@ type PipeSubprocess = ReturnType<typeof Bun.spawn> & {
129
159
 
130
160
  /** A persistent `mcp-run`/`mcp-render` `--worker` subprocess: the backend/web app is loaded ONCE and
131
161
  * reused across newline-delimited `{ id, input }` requests, replying `{ id, output }`. The same machinery
132
- * powers both `nifra_run warm` and `nifra_render warm` — `child` selects which engine, `label` shapes the
162
+ * powers both `nifra_run warm` and `nifra_render warm` - `child` selects which engine, `label` shapes the
133
163
  * cancellation message. The owning handler ({@link createWarmHandler}) fingerprints the source tree and
134
164
  * replaces the worker when a file changes, so warm reuse never serves a stale result. Exported for the
135
165
  * concurrency test that proves a single per-request cancel doesn't tear down the shared worker. */
@@ -187,12 +217,25 @@ export class WarmWorker {
187
217
  // Per-request cancel: drop just THIS id and resolve its cancellation. The worker is shared
188
218
  // across concurrent requests (`pending` is id-keyed for exactly this reason), so killing the
189
219
  // process here would reject every OTHER in-flight request via the `exited` handler and force a
190
- // cold rebuild. Leave it hot — `createWarmHandler` already replaces it on file change.
220
+ // cold rebuild. Leave it hot - `createWarmHandler` already replaces it on file change.
191
221
  this.pending.delete(id)
222
+ clearTimeout(timer)
192
223
  const reason = typeof signal?.reason === "string" ? `: ${signal.reason}` : ""
193
224
  resolve(`${this.label} cancelled${reason}.`)
194
225
  }
195
- const cleanup = (): void => signal?.removeEventListener("abort", abort)
226
+ // Same backstop as the cold path: a worker wedged mid-request would otherwise leave this
227
+ // promise unsettled forever. Drop the id and replace the worker - unlike a per-request cancel,
228
+ // a timeout means the worker itself is suspect, so the next call should start from a fresh one.
229
+ const timer = setTimeout(() => {
230
+ if (!this.pending.delete(id)) return
231
+ signal?.removeEventListener("abort", abort)
232
+ this.stop()
233
+ resolve(timeoutMessage(this.label, CHILD_TIMEOUT_MS))
234
+ }, CHILD_TIMEOUT_MS)
235
+ const cleanup = (): void => {
236
+ clearTimeout(timer)
237
+ signal?.removeEventListener("abort", abort)
238
+ }
196
239
  signal?.addEventListener("abort", abort, { once: true })
197
240
  this.pending.set(id, { resolve, reject, cleanup })
198
241
  try {
@@ -254,7 +297,7 @@ export class WarmWorker {
254
297
 
255
298
  /** A warm handler that reuses a hot {@link WarmWorker} across calls and falls back to a one-shot fresh
256
299
  * subprocess on any worker failure. Shared by `nifra_run` (`child: "mcp-run"`, `label: "run"`) and
257
- * `nifra_render` (`child: "mcp-render"`, `label: "render"`) — the only differences are the engine and the
300
+ * `nifra_render` (`child: "mcp-render"`, `label: "render"`) - the only differences are the engine and the
258
301
  * message label, so there's a single source for the reuse + auto-restart-on-file-change logic. */
259
302
  function createWarmHandler(
260
303
  child: "mcp-run" | "mcp-render",
@@ -573,7 +616,7 @@ export function projectFeatures(
573
616
  }
574
617
 
575
618
  /**
576
- * Resolve an optional `dir` tool argument — a subdirectory of the project root the caller wants to scope a
619
+ * Resolve an optional `dir` tool argument - a subdirectory of the project root the caller wants to scope a
577
620
  * check/test to (e.g. `nifra check` on `app/` when the MCP server's root is a monorepo). Returns the
578
621
  * absolute target, or `null` if `dir` escapes the root (a path-traversal guard: no `..` out, no absolute
579
622
  * path elsewhere). `undefined`/empty → the root itself.
@@ -587,7 +630,7 @@ export function resolveProjectDir(root: string, dir: string | undefined): string
587
630
  /** Consistent error string for a `dir` that escapes the project root. */
588
631
  function dirError(dir: string | undefined): string {
589
632
  return JSON.stringify(
590
- { ok: false, error: `dir must be a subdirectory of the project root — "${dir}" escapes it.` },
633
+ { ok: false, error: `dir must be a subdirectory of the project root - "${dir}" escapes it.` },
591
634
  null,
592
635
  2,
593
636
  )
@@ -604,7 +647,7 @@ export function projectTools(
604
647
  {
605
648
  name: "nifra_context",
606
649
  description:
607
- "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer — cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
650
+ "Get this nifra project's surface. Call it once UNFILTERED for a tight INDEX: the route list (API routes as `METHOD path`, page routes as `pattern → file`) + framework conventions + a pointer - cheap even on a big app, no per-route schema dump. Then pass `path` (a route prefix like /api/orders) and/or `kind` (api|pages) to fetch the FULL contract for that slice (body/query/response TS shapes + the exact typed-client call form).",
608
651
  inputSchema: {
609
652
  type: "object",
610
653
  properties: {
@@ -628,7 +671,7 @@ export function projectTools(
628
671
  {
629
672
  name: "nifra_routes",
630
673
  description:
631
- "List this project's API routes as STRUCTURED JSON — each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
674
+ "List this project's API routes as STRUCTURED JSON - each `{ method, path, call, body?, query?, response? }`, where `call` is the exact typed-client call form and the shapes are compact TS-typed contracts. For programmatic use (list_routes / get_route_schema) instead of parsing the nifra_context Markdown. No args = every route; pass `path` (a path or prefix like /api/orders) to narrow to those routes.",
632
675
  inputSchema: {
633
676
  type: "object",
634
677
  properties: {
@@ -808,7 +851,7 @@ export function projectTools(
808
851
  {
809
852
  name: "nifra_scaffold",
810
853
  description:
811
- 'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong — this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns the file to create + the route-module contract (loader/action/meta/default) + a stub (ready-to-write for react/preact/solid; path+contract for vue/svelte/vanilla — use nifra_example for those bodies).',
854
+ 'Map a URL path to the CORRECT routes/ file and get a contract-correct page stub. Agents routinely place file routes wrong - this applies the convention for you: ":id"/"[id]" → [id], "*rest" → [...rest], "/" → index. Pass path (e.g. "/users/:id"). Returns the file to create + the route-module contract (loader/action/meta/default) + a stub (ready-to-write for react/preact/solid; path+contract for vue/svelte/vanilla - use nifra_example for those bodies).',
812
855
  inputSchema: {
813
856
  type: "object",
814
857
  properties: {
@@ -844,7 +887,7 @@ export function projectTools(
844
887
  {
845
888
  name: "nifra_check",
846
889
  description:
847
- 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done — fix every diagnostic before finishing.',
890
+ 'Run the project\'s drift gate and return a structured result { ok, typecheck, diagnostics[] }: typecheck (the frontend↔backend contract), plus lints for hand-rolled fetch() to your own API, untyped client("…") calls missing <typeof app>, and server-only imports in routes/. Pass lintsOnly:true for a near-instant lint pass while iterating; run the full gate (default) to confirm the work is done - fix every diagnostic before finishing.',
848
891
  inputSchema: {
849
892
  type: "object",
850
893
  properties: {
@@ -1043,7 +1086,7 @@ export function projectTools(
1043
1086
  {
1044
1087
  name: "nifra_doctor",
1045
1088
  description:
1046
- "Check this project for packages imported in source but NOT declared in package.json — the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1089
+ "Check this project for packages imported in source but NOT declared in package.json - the Bun-workspace trap where an import resolves at runtime (hoisting/workspace) so tests pass and `bun install` says no changes, yet tsc fails and a fresh/standalone install can't resolve it. Returns { ok, ran, findings[], fixed?, skippedFixes? }. Pass autoFix:true to update package.json only when the dependency version can be inferred locally from an ancestor package.json or installed package metadata; otherwise the tool returns the exact bun add command to run.",
1047
1090
  inputSchema: {
1048
1091
  type: "object",
1049
1092
  properties: {
@@ -1216,6 +1259,54 @@ function namespaceForApp(
1216
1259
  }
1217
1260
  }
1218
1261
 
1262
+ /**
1263
+ * Merge the backend-derived resources/prompts into `base` - and keep a project that cannot be loaded
1264
+ * from deciding whether the server starts at all.
1265
+ *
1266
+ * Resources and prompts are OPTIONAL capabilities extracted from the app's backend, so this load is a
1267
+ * best-effort enrichment. It was awaited unguarded, which made it a hard boot requirement: `loadApp`
1268
+ * needs a web config (`adapter` + `clientModule`) and a `routes/` directory, and a backend-only
1269
+ * project - the shape `create-nifra`'s DEFAULT template produces - has neither. On those projects the
1270
+ * throw escaped before the JSON-RPC loop began, so the server wrote nothing and exited. `nifra
1271
+ * init-agents` would happily register an `.mcp.json` pointing at a server that could never start, and
1272
+ * every tool went with it, including the twelve that never touch the app (docs, examples, types,
1273
+ * check, doctor, levels, test).
1274
+ *
1275
+ * Degrading costs only the backend resources/prompts. Tools that genuinely need the app still fail,
1276
+ * but per call, with their own message - which is a diagnosis instead of a silence.
1277
+ */
1278
+ async function backendFeatures(
1279
+ loader: () => Promise<LoadedApp>,
1280
+ base: McpServerFeatures,
1281
+ ): Promise<McpServerFeatures> {
1282
+ const resources = [...(base.resources ?? [])]
1283
+ const prompts = [...(base.prompts ?? [])]
1284
+ try {
1285
+ const app = await loader()
1286
+ resources.push(...extractBackendResources(app.backend))
1287
+ prompts.push(...extractBackendPrompts(app.backend))
1288
+ } catch {
1289
+ // Not loadable here (no web config, or the config itself throws). The server still serves.
1290
+ }
1291
+ return { resources, prompts }
1292
+ }
1293
+
1294
+ /**
1295
+ * The MCP tools the app itself declares via `app.tool(...)`, or none when the app cannot be loaded.
1296
+ *
1297
+ * Same reasoning as {@link backendFeatures}, on the hotter path: this ran on EVERY JSON-RPC message,
1298
+ * so an unloadable project failed `initialize` itself with a `-32603` and the session never opened.
1299
+ * An app's own tools are an extension of the built-in set, so their absence must not withdraw the
1300
+ * built-ins - a backend-only project still gets docs, examples, types, check, doctor, levels and test.
1301
+ */
1302
+ async function appDeclaredTools(loader: () => Promise<LoadedApp>): Promise<McpTool[]> {
1303
+ try {
1304
+ return extractBackendTools((await loader()).backend)
1305
+ } catch {
1306
+ return []
1307
+ }
1308
+ }
1309
+
1219
1310
  /** Run the stdio MCP server: read newline-delimited JSON-RPC from stdin, write responses to stdout. */
1220
1311
  export async function runMcpServer(cwd: string, version: string): Promise<void> {
1221
1312
  let features: McpServerFeatures
@@ -1227,24 +1318,18 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1227
1318
  const allPrompts: McpPrompt[] = []
1228
1319
  for (const { name, cwd: appCwd } of appEntries) {
1229
1320
  const loader = createCachedAppLoader(appCwd)
1230
- const app = await loader()
1231
- const base = projectFeatures(appCwd, loader)
1232
- const ns = namespaceForApp(name, [], {
1233
- resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1234
- prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1235
- })
1321
+ const ns = namespaceForApp(
1322
+ name,
1323
+ [],
1324
+ await backendFeatures(loader, projectFeatures(appCwd, loader)),
1325
+ )
1236
1326
  allResources.push(...(ns.features.resources ?? []))
1237
1327
  allPrompts.push(...(ns.features.prompts ?? []))
1238
1328
  }
1239
1329
  features = { resources: allResources, prompts: allPrompts }
1240
1330
  } else {
1241
1331
  const loadAppCached = createCachedAppLoader(cwd)
1242
- const base = projectFeatures(cwd, loadAppCached)
1243
- const app = await loadAppCached()
1244
- features = {
1245
- resources: [...(base.resources ?? []), ...extractBackendResources(app.backend)],
1246
- prompts: [...(base.prompts ?? []), ...extractBackendPrompts(app.backend)],
1247
- }
1332
+ features = await backendFeatures(loadAppCached, projectFeatures(cwd, loadAppCached))
1248
1333
  }
1249
1334
  const loadAppCached = createCachedAppLoader(cwd)
1250
1335
  const serverInfo = { name: "nifra", version }
@@ -1259,21 +1344,16 @@ export async function runMcpServer(cwd: string, version: string): Promise<void>
1259
1344
  const allTools: McpTool[] = []
1260
1345
  for (const { name, cwd: appCwd } of appEntries) {
1261
1346
  const loader = createCachedAppLoader(appCwd)
1262
- const app = await loader()
1263
- const baseTools = projectTools(appCwd, loader)
1264
- const backendTools = extractBackendTools(app.backend)
1265
- const ns = namespaceForApp(name, [...baseTools, ...backendTools], {
1266
- resources: [],
1267
- prompts: [],
1268
- })
1347
+ const tools = [...projectTools(appCwd, loader), ...(await appDeclaredTools(loader))]
1348
+ const ns = namespaceForApp(name, tools, { resources: [], prompts: [] })
1269
1349
  allTools.push(...ns.tools)
1270
1350
  }
1271
1351
  activeTools = [...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus), ...allTools]
1272
1352
  } else {
1273
- const app = await loadAppCached()
1274
- const baseTools = projectTools(cwd, loadAppCached)
1275
- const backendTools = extractBackendTools(app.backend)
1276
- activeTools = [...baseTools, ...backendTools]
1353
+ activeTools = [
1354
+ ...projectTools(cwd, loadAppCached),
1355
+ ...(await appDeclaredTools(loadAppCached)),
1356
+ ]
1277
1357
  }
1278
1358
 
1279
1359
  const response = await handleRpc(message, activeTools, serverInfo, features, {
@@ -45,7 +45,7 @@ function toYaml(value: unknown, depth = 0): string {
45
45
  * Render a project's backend as OpenAPI 3.1. Frontend-only apps get a valid empty document, which is
46
46
  * easier for agents and API tooling to handle than a tool error.
47
47
  *
48
- * `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix —
48
+ * `pathPrefix` narrows the document to operations whose (OpenAPI-templated) path starts with the prefix -
49
49
  * mirroring `nifra_routes`'s `path` filter so a large backend needn't return the whole document. The keys
50
50
  * are templated (`/users/{id}`), so a prefix up to the first param (`/api/orders`) matches `/api/orders`
51
51
  * and `/api/orders/{id}` alike. `components.schemas` is left intact: it's keyed by schema name (not path),
@@ -57,7 +57,7 @@ export function renderOpenApi(app: LoadedApp, format: OpenApiFormat, pathPrefix?
57
57
  const input = hasRoutesMethod(app.backend) ? app.backend : {}
58
58
  const generated = toOpenAPI(input, { title, version: "1.0.0" })
59
59
  // Narrow to operations under the prefix, rebuilding the doc (its `paths` is readonly). `components`
60
- // stays — see the doc comment for why the schema set isn't recomputed.
60
+ // stays - see the doc comment for why the schema set isn't recomputed.
61
61
  const document =
62
62
  pathPrefix !== undefined && pathPrefix !== ""
63
63
  ? {
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pipeline separation guard - one bundler owns a phase, never two.
3
+ *
4
+ * nifra supports both Vite and Bun, and the config keeps them in separate slots: `vitePlugins` for
5
+ * the Vite pipeline, `clientPlugins`/`serverPlugins` for the Bun one. Nothing enforces the split, so
6
+ * a plugin lands in the wrong slot and is silently ignored - a Vite plugin in `clientPlugins` is
7
+ * handed to `Bun.build`, which has no `transform` hook to call, so the transform simply never runs
8
+ * and the build succeeds.
9
+ *
10
+ * That failure mode is the whole reason for this file. nifra's expensive bugs are not caused by Vite
11
+ * or by Bun; they are caused by two toolchains disagreeing about one module. Dual-React is that, the
12
+ * dedupe plugins are patches for that, and a `resolve.alias` that never reaches SSR is that. Mixing
13
+ * is the defect - choice is not - so the separation has to be checked rather than documented.
14
+ *
15
+ * Detection is by hook shape, which is what each bundler actually dispatches on:
16
+ * - a Bun plugin has `setup(build)`
17
+ * - a Rollup/Vite plugin has `resolveId` / `load` / `transform` / `config` / `configResolved` /
18
+ * `transformIndexHtml` and no `setup`
19
+ *
20
+ * Deliberately conservative: a plugin that matches neither shape is left alone rather than guessed
21
+ * at. A guard that fires on correct config is a guard people turn off.
22
+ */
23
+
24
+ /** Which pipeline a slot belongs to. */
25
+ export type PipelineSlot = "vitePlugins" | "clientPlugins" | "serverPlugins"
26
+
27
+ export interface PipelineMismatch {
28
+ readonly slot: PipelineSlot
29
+ /** The plugin's `name`, or a positional label when it has none. */
30
+ readonly plugin: string
31
+ /** The pipeline the slot feeds. */
32
+ readonly expected: "vite" | "bun"
33
+ /** The pipeline the plugin's hooks say it belongs to. */
34
+ readonly actual: "vite" | "bun"
35
+ readonly fix: string
36
+ }
37
+
38
+ const ROLLUP_HOOKS = [
39
+ "resolveId",
40
+ "load",
41
+ "transform",
42
+ "config",
43
+ "configResolved",
44
+ "configureServer",
45
+ "transformIndexHtml",
46
+ "generateBundle",
47
+ "renderChunk",
48
+ ] as const
49
+
50
+ /** Classify a plugin by the hooks it exposes, or `undefined` when its shape says nothing. */
51
+ function classify(plugin: unknown): "vite" | "bun" | undefined {
52
+ if (typeof plugin !== "object" || plugin === null) return undefined
53
+ const record = plugin as Record<string, unknown>
54
+ // `setup` is Bun's only entry point, so its presence is decisive even alongside other keys.
55
+ if (typeof record.setup === "function") return "bun"
56
+ if (ROLLUP_HOOKS.some((hook) => typeof record[hook] === "function")) return "vite"
57
+ return undefined
58
+ }
59
+
60
+ const SLOT_PIPELINE: Readonly<Record<PipelineSlot, "vite" | "bun">> = {
61
+ vitePlugins: "vite",
62
+ clientPlugins: "bun",
63
+ serverPlugins: "bun",
64
+ }
65
+
66
+ const MOVE_TO: Readonly<Record<"vite" | "bun", string>> = {
67
+ vite: "`vitePlugins`",
68
+ bun: "`clientPlugins` (client bundle) or `serverPlugins` (SSR)",
69
+ }
70
+
71
+ /**
72
+ * Check one slot's resolved plugin list. Pure: takes the already-resolved array so a thunk-valued
73
+ * field is awaited by the caller, and so this stays testable without loading a project.
74
+ */
75
+ export function checkPipelineSlot(
76
+ slot: PipelineSlot,
77
+ plugins: readonly unknown[],
78
+ ): PipelineMismatch[] {
79
+ const expected = SLOT_PIPELINE[slot]
80
+ const out: PipelineMismatch[] = []
81
+ for (let i = 0; i < plugins.length; i++) {
82
+ const plugin = plugins[i]
83
+ const actual = classify(plugin)
84
+ if (actual === undefined || actual === expected) continue
85
+ const named = (plugin as { name?: unknown }).name
86
+ const label = typeof named === "string" && named !== "" ? named : `${slot}[${i}]`
87
+ out.push({
88
+ slot,
89
+ plugin: label,
90
+ expected,
91
+ actual,
92
+ fix:
93
+ actual === "vite"
94
+ ? `"${label}" is a Vite/Rollup plugin (it defines Rollup hooks, not \`setup\`) but sits in \`${slot}\`, which feeds Bun.build. Bun never calls those hooks, so the transform silently does not run. Move it to ${MOVE_TO.vite}.`
95
+ : `"${label}" is a Bun plugin (it defines \`setup\`) but sits in \`${slot}\`, which feeds Vite. Vite never calls \`setup\`, so the transform silently does not run. Move it to ${MOVE_TO.bun}.`,
96
+ })
97
+ }
98
+ return out
99
+ }
100
+
101
+ /** Check every slot of a resolved framework config. */
102
+ export function checkPipelineSeparation(slots: {
103
+ readonly vitePlugins?: readonly unknown[]
104
+ readonly clientPlugins?: readonly unknown[]
105
+ readonly serverPlugins?: readonly unknown[]
106
+ }): PipelineMismatch[] {
107
+ return [
108
+ ...checkPipelineSlot("vitePlugins", slots.vitePlugins ?? []),
109
+ ...checkPipelineSlot("clientPlugins", slots.clientPlugins ?? []),
110
+ ...checkPipelineSlot("serverPlugins", slots.serverPlugins ?? []),
111
+ ]
112
+ }
113
+
114
+ /** Which bundler a build runs on. */
115
+ export type BuildPipeline = "bun" | "vite"
116
+
117
+ export interface PipelineDecision {
118
+ readonly pipeline: BuildPipeline
119
+ /**
120
+ * Why this pipeline, for the `nifra build` log line - so an auto-selected Vite build never looks
121
+ * like the user got the default. `undefined` when the choice was the plain default or was forced.
122
+ */
123
+ readonly reason?: string
124
+ }
125
+
126
+ /** The plugin slots a decision reads, already resolved. */
127
+ export interface PipelineSlots {
128
+ readonly vitePlugins?: readonly unknown[]
129
+ readonly clientPlugins?: readonly unknown[]
130
+ readonly serverPlugins?: readonly unknown[]
131
+ }
132
+
133
+ const pluginNames = (plugins: readonly unknown[]): string =>
134
+ plugins
135
+ .map((plugin, i) => {
136
+ const named = (plugin as { name?: unknown }).name
137
+ return typeof named === "string" && named !== "" ? named : `vitePlugins[${i}]`
138
+ })
139
+ .join(", ")
140
+
141
+ /**
142
+ * Pick the bundler for `nifra build`, per app rather than per global default.
143
+ *
144
+ * The phase defaults are deliberately asymmetric - `nifra dev` is Vite (for the plugin ecosystem, and
145
+ * because Bun's DEV-server bundler cannot compile CSS Modules), `nifra build` is Bun (faster and
146
+ * Bun-native). For an app with no transforms that asymmetry is free: there is nothing to disagree about.
147
+ * For an app whose ONLY transforms are Vite plugins it is not. Those plugins run in dev and are then
148
+ * dropped by the Bun build - `buildForTarget` reads `clientPlugins`/`serverPlugins`, never `vitePlugins`
149
+ * - so the build succeeds, the output looks plausible, and the transform simply did not happen. That is
150
+ * the same failure {@link checkPipelineSeparation} exists to prevent, reached by crossing PHASES instead
151
+ * of slots, and the slot check cannot see it because the plugins are correctly placed.
152
+ *
153
+ * So the default follows the app: Vite plugins with no Bun counterpart means the app has exactly one
154
+ * pipeline that can build it, and that is the one used. An app declaring BOTH has supplied the Bun
155
+ * equivalent on purpose - nothing is dropped - so the fast Bun default stands.
156
+ *
157
+ * `forced` is `--vite` / `--bun` and always wins, except for the one combination that silently discards
158
+ * work, which throws instead. Refusing rather than warning matches the slot guard: a build that succeeds
159
+ * and omits the transform is worse than one that stops and says so.
160
+ */
161
+ export function chooseBuildPipeline(
162
+ slots: PipelineSlots,
163
+ forced?: BuildPipeline,
164
+ ): PipelineDecision {
165
+ const vitePlugins = slots.vitePlugins ?? []
166
+ const bunPlugins = [...(slots.clientPlugins ?? []), ...(slots.serverPlugins ?? [])]
167
+ const viteOnly = vitePlugins.length > 0 && bunPlugins.length === 0
168
+
169
+ if (forced === "bun" && viteOnly) {
170
+ throw new Error(
171
+ "[nifra] `nifra build --bun` would silently drop this app's only transforms. `vitePlugins` are " +
172
+ "read by the Vite build; the Bun build reads `clientPlugins`/`serverPlugins`, so these would " +
173
+ "never run and the output would look plausible without them.\n" +
174
+ ` Would be dropped: ${pluginNames(vitePlugins)}\n` +
175
+ " Drop the flag to build with Vite, or add the Bun equivalents to `clientPlugins`/`serverPlugins`.",
176
+ )
177
+ }
178
+ if (forced !== undefined) return { pipeline: forced }
179
+ if (viteOnly) {
180
+ return {
181
+ pipeline: "vite",
182
+ reason: `auto: this app's only transforms are \`vitePlugins\` (${pluginNames(vitePlugins)}), which the Bun build cannot run`,
183
+ }
184
+ }
185
+ return { pipeline: "bun" }
186
+ }