@nifrajs/cli 3.1.0 → 3.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 (158) hide show
  1. package/README.md +10 -0
  2. package/dist/assure-hydration.d.ts.map +1 -1
  3. package/dist/assure-hydration.js +6 -1
  4. package/dist/assure-hydration.js.map +1 -1
  5. package/dist/assure.d.ts.map +1 -1
  6. package/dist/assure.js +3 -2
  7. package/dist/assure.js.map +1 -1
  8. package/dist/check-diagnostics.d.ts +165 -0
  9. package/dist/check-diagnostics.d.ts.map +1 -0
  10. package/dist/check-diagnostics.js +1009 -0
  11. package/dist/check-diagnostics.js.map +1 -0
  12. package/dist/check-scan.d.ts +245 -0
  13. package/dist/check-scan.d.ts.map +1 -0
  14. package/dist/check-scan.js +1599 -0
  15. package/dist/check-scan.js.map +1 -0
  16. package/dist/check.d.ts +4 -324
  17. package/dist/check.d.ts.map +1 -1
  18. package/dist/check.js +8 -2366
  19. package/dist/check.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +247 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/command-catalog.d.ts +21 -1
  24. package/dist/command-catalog.d.ts.map +1 -1
  25. package/dist/command-catalog.js +122 -0
  26. package/dist/command-catalog.js.map +1 -1
  27. package/dist/env-file.js +5 -5
  28. package/dist/env-file.js.map +1 -1
  29. package/dist/frontend-guidance.d.ts +57 -0
  30. package/dist/frontend-guidance.d.ts.map +1 -0
  31. package/dist/frontend-guidance.js +195 -0
  32. package/dist/frontend-guidance.js.map +1 -0
  33. package/dist/levels-tool.d.ts.map +1 -1
  34. package/dist/levels-tool.js +6 -0
  35. package/dist/levels-tool.js.map +1 -1
  36. package/dist/load.d.ts.map +1 -1
  37. package/dist/load.js +4 -1
  38. package/dist/load.js.map +1 -1
  39. package/dist/mcp-context.d.ts +48 -0
  40. package/dist/mcp-context.d.ts.map +1 -0
  41. package/dist/mcp-context.js +215 -0
  42. package/dist/mcp-context.js.map +1 -0
  43. package/dist/mcp-docs-tools.d.ts.map +1 -1
  44. package/dist/mcp-docs-tools.js +31 -0
  45. package/dist/mcp-docs-tools.js.map +1 -1
  46. package/dist/mcp-exec.d.ts +74 -0
  47. package/dist/mcp-exec.d.ts.map +1 -0
  48. package/dist/mcp-exec.js +844 -0
  49. package/dist/mcp-exec.js.map +1 -0
  50. package/dist/mcp-http.js +1 -1
  51. package/dist/mcp-io.d.ts +26 -0
  52. package/dist/mcp-io.d.ts.map +1 -0
  53. package/dist/mcp-io.js +100 -0
  54. package/dist/mcp-io.js.map +1 -0
  55. package/dist/mcp-reflect.d.ts +16 -0
  56. package/dist/mcp-reflect.d.ts.map +1 -0
  57. package/dist/mcp-reflect.js +101 -0
  58. package/dist/mcp-reflect.js.map +1 -0
  59. package/dist/mcp-root.d.ts +7 -0
  60. package/dist/mcp-root.d.ts.map +1 -1
  61. package/dist/mcp-root.js +51 -3
  62. package/dist/mcp-root.js.map +1 -1
  63. package/dist/mcp.d.ts +10 -92
  64. package/dist/mcp.d.ts.map +1 -1
  65. package/dist/mcp.js +13 -1232
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/openapi-tool.d.ts +5 -10
  68. package/dist/openapi-tool.d.ts.map +1 -1
  69. package/dist/openapi-tool.js +21 -2
  70. package/dist/openapi-tool.js.map +1 -1
  71. package/dist/openapi-types.d.ts +21 -0
  72. package/dist/openapi-types.d.ts.map +1 -0
  73. package/dist/openapi-types.js +214 -0
  74. package/dist/openapi-types.js.map +1 -0
  75. package/dist/port.d.ts.map +1 -1
  76. package/dist/port.js +2 -1
  77. package/dist/port.js.map +1 -1
  78. package/dist/project-facts.d.ts +2 -1
  79. package/dist/project-facts.d.ts.map +1 -1
  80. package/dist/project-facts.js.map +1 -1
  81. package/dist/release-verification.d.ts +4 -1
  82. package/dist/release-verification.d.ts.map +1 -1
  83. package/dist/release-verification.js +6 -138
  84. package/dist/release-verification.js.map +1 -1
  85. package/dist/rules/codes.d.ts +4 -0
  86. package/dist/rules/codes.d.ts.map +1 -1
  87. package/dist/rules/codes.js +4 -0
  88. package/dist/rules/codes.js.map +1 -1
  89. package/dist/rules/islands.d.ts +4 -0
  90. package/dist/rules/islands.d.ts.map +1 -0
  91. package/dist/rules/islands.js +155 -0
  92. package/dist/rules/islands.js.map +1 -0
  93. package/dist/rules/nano.d.ts +6 -0
  94. package/dist/rules/nano.d.ts.map +1 -0
  95. package/dist/rules/nano.js +264 -0
  96. package/dist/rules/nano.js.map +1 -0
  97. package/dist/rules/security.d.ts.map +1 -1
  98. package/dist/rules/security.js +38 -4
  99. package/dist/rules/security.js.map +1 -1
  100. package/dist/scaffold.d.ts +12 -6
  101. package/dist/scaffold.d.ts.map +1 -1
  102. package/dist/scaffold.js +149 -10
  103. package/dist/scaffold.js.map +1 -1
  104. package/dist/search-terms.d.ts.map +1 -1
  105. package/dist/search-terms.js +5 -3
  106. package/dist/search-terms.js.map +1 -1
  107. package/dist/stylex-migrate.d.ts +38 -0
  108. package/dist/stylex-migrate.d.ts.map +1 -0
  109. package/dist/stylex-migrate.js +1052 -0
  110. package/dist/stylex-migrate.js.map +1 -0
  111. package/dist/sync-manifest.d.ts.map +1 -1
  112. package/dist/sync-manifest.js +2 -1
  113. package/dist/sync-manifest.js.map +1 -1
  114. package/dist/verification-plan.d.ts +24 -0
  115. package/dist/verification-plan.d.ts.map +1 -0
  116. package/dist/verification-plan.js +116 -0
  117. package/dist/verification-plan.js.map +1 -0
  118. package/dist/work-graph.d.ts.map +1 -1
  119. package/dist/work-graph.js +6 -3
  120. package/dist/work-graph.js.map +1 -1
  121. package/docs/examples.json +36 -0
  122. package/docs/llms-full.txt +1226 -14
  123. package/docs/types.json +4185 -87
  124. package/package.json +9 -9
  125. package/src/assure-hydration.ts +6 -1
  126. package/src/assure.ts +4 -2
  127. package/src/check-diagnostics.ts +1219 -0
  128. package/src/check-scan.ts +1825 -0
  129. package/src/check.ts +47 -2752
  130. package/src/cli.ts +252 -10
  131. package/src/command-catalog.ts +155 -0
  132. package/src/env-file.ts +5 -5
  133. package/src/frontend-guidance.ts +259 -0
  134. package/src/levels-tool.ts +6 -0
  135. package/src/load.ts +4 -1
  136. package/src/mcp-context.ts +266 -0
  137. package/src/mcp-docs-tools.ts +37 -0
  138. package/src/mcp-exec.ts +1040 -0
  139. package/src/mcp-http.ts +1 -1
  140. package/src/mcp-io.ts +118 -0
  141. package/src/mcp-reflect.ts +140 -0
  142. package/src/mcp-root.ts +62 -4
  143. package/src/mcp.ts +61 -1511
  144. package/src/openapi-tool.ts +32 -2
  145. package/src/openapi-types.ts +261 -0
  146. package/src/port.ts +2 -1
  147. package/src/project-facts.ts +2 -4
  148. package/src/release-verification.ts +22 -164
  149. package/src/rules/codes.ts +4 -0
  150. package/src/rules/islands.ts +171 -0
  151. package/src/rules/nano.ts +295 -0
  152. package/src/rules/security.ts +40 -4
  153. package/src/scaffold.ts +169 -9
  154. package/src/search-terms.ts +7 -3
  155. package/src/stylex-migrate.ts +1139 -0
  156. package/src/sync-manifest.ts +2 -1
  157. package/src/verification-plan.ts +242 -0
  158. package/src/work-graph.ts +6 -3
package/src/mcp-http.ts CHANGED
@@ -29,7 +29,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
29
29
  export type { TypeEntry } from "./types-search.ts"
30
30
 
31
31
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
32
- const VERSION = "3.1.0"
32
+ const VERSION = "3.2.0"
33
33
  const SERVER_INFO = { name: "nifra-docs", version: VERSION }
34
34
  // Derive the GET/health tool list from the tools actually served, so the line can never drift from them.
35
35
  const docsHealth = (tools: McpTool[]): string =>
package/src/mcp-io.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Transport-neutral bounded I/O for project MCP tools.
3
+ *
4
+ * HTTP docs and verification WebSocket transport keep their own adapters; stdio/run/render share only
5
+ * these limits and stream readers so an untrusted local endpoint or child process cannot exhaust MCP.
6
+ */
7
+
8
+ export const CHILD_TIMEOUT_MS = 30_000
9
+ /** Maximum stdout or stderr retained from a project subprocess. The process is killed at the limit. */
10
+ export const CHILD_OUTPUT_MAX_BYTES = 1_048_576
11
+
12
+ /** Local dev-tool reads are intentionally bounded: MCP runs in an agent process and must not hang on or
13
+ * buffer an unrelated loopback service just because a caller supplied its port. */
14
+ export const LOCAL_TOOL_FETCH_TIMEOUT_MS = 2_000
15
+ export const LOCAL_TOOL_MAX_RESPONSE_BYTES = 1_048_576
16
+
17
+ /** Accept only a concrete TCP port. Port 0 (bind-any-free-port) is not a valid read target. */
18
+ export function validateLocalPort(port: unknown): number | undefined {
19
+ return typeof port === "number" && Number.isInteger(port) && port >= 1 && port <= 65_535
20
+ ? port
21
+ : undefined
22
+ }
23
+
24
+ /** Read a dev-tool response without allowing an untrusted localhost service to allocate unbounded memory. */
25
+ export async function readBoundedResponse(
26
+ response: Response,
27
+ maxBytes = LOCAL_TOOL_MAX_RESPONSE_BYTES,
28
+ ): Promise<string> {
29
+ const declared = response.headers.get("content-length")
30
+ if (declared !== null) {
31
+ const length = Number(declared)
32
+ if (Number.isFinite(length) && length > maxBytes)
33
+ throw new Error("response exceeded the size limit")
34
+ }
35
+ if (response.body === null) return ""
36
+ const reader = response.body.getReader()
37
+ const chunks: Uint8Array[] = []
38
+ let total = 0
39
+ try {
40
+ for (;;) {
41
+ const { done, value } = await reader.read()
42
+ if (done) break
43
+ if (value === undefined) continue
44
+ total += value.byteLength
45
+ if (total > maxBytes) {
46
+ await reader.cancel()
47
+ throw new Error("response exceeded the size limit")
48
+ }
49
+ chunks.push(value)
50
+ }
51
+ } finally {
52
+ reader.releaseLock()
53
+ }
54
+ const bytes = new Uint8Array(total)
55
+ let offset = 0
56
+ for (const chunk of chunks) {
57
+ bytes.set(chunk, offset)
58
+ offset += chunk.byteLength
59
+ }
60
+ return new TextDecoder().decode(bytes)
61
+ }
62
+
63
+ export const notNifraResponse = (service: string): string =>
64
+ JSON.stringify(
65
+ {
66
+ code: "NIFRA_NOT_DEV_SERVER",
67
+ message: `The service on this port is not a Nifra ${service} endpoint.`,
68
+ },
69
+ null,
70
+ 2,
71
+ )
72
+
73
+ export const timeoutMessage = (label: string, ms: number): string =>
74
+ `${label} timed out after ${ms / 1000}s and was terminated.\n` +
75
+ `The app was loaded but the process did not finish. Most often this is a module-level side effect ` +
76
+ `that keeps the event loop alive (a database pool, a Redis client, an interval) opened during import ` +
77
+ `rather than lazily. Check for top-level connections in the app entry or anything it imports.`
78
+
79
+ export interface BoundedOutput {
80
+ readonly text: string
81
+ readonly truncated: boolean
82
+ }
83
+
84
+ /** Read child output incrementally and cancel as soon as the byte budget is crossed. */
85
+ export async function readBoundedStream(
86
+ stream: ReadableStream<Uint8Array>,
87
+ maxBytes = CHILD_OUTPUT_MAX_BYTES,
88
+ onLimit?: () => void,
89
+ ): Promise<BoundedOutput> {
90
+ const reader = stream.getReader()
91
+ const decoder = new TextDecoder()
92
+ const chunks: string[] = []
93
+ let totalBytes = 0
94
+ try {
95
+ for (;;) {
96
+ const { done, value } = await reader.read()
97
+ if (done) {
98
+ chunks.push(decoder.decode())
99
+ return { text: chunks.join(""), truncated: false }
100
+ }
101
+ if (value === undefined) continue
102
+ const remaining = maxBytes - totalBytes
103
+ if (remaining <= 0 || value.byteLength > remaining) {
104
+ if (remaining > 0) {
105
+ chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }))
106
+ chunks.push(decoder.decode())
107
+ }
108
+ await reader.cancel()
109
+ onLimit?.()
110
+ return { text: chunks.join(""), truncated: true }
111
+ }
112
+ totalBytes += value.byteLength
113
+ chunks.push(decoder.decode(value, { stream: true }))
114
+ }
115
+ } finally {
116
+ reader.releaseLock()
117
+ }
118
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Backend reflection adapters for the project MCP surface.
3
+ *
4
+ * These functions only translate reflected Nifra declarations into MCP descriptors. They do not know
5
+ * about stdio, HTTP, root trust, or project loading, so the reflection seam is independently testable.
6
+ */
7
+
8
+ import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
9
+ import type {
10
+ McpPrompt,
11
+ McpPromptMessage,
12
+ McpResource,
13
+ McpTool,
14
+ McpToolResult,
15
+ } from "./mcp-protocol.ts"
16
+
17
+ type ToolBackend = {
18
+ readonly routes?: () => readonly unknown[]
19
+ readonly fetch: (req: Request) => Promise<Response>
20
+ }
21
+
22
+ /** Extract tools registered via .tool() routes on the Nifra backend. Exported for the test that proves a
23
+ * `server().tool()` route surfaces in `tools/list` and executes through `tools/call`. */
24
+ export function extractBackendTools(backend: unknown): McpTool[] {
25
+ const b = backend as ToolBackend | null
26
+ if (!b || typeof b.routes !== "function") return []
27
+ const routes = reflectRoutes(b)
28
+
29
+ return routes
30
+ .filter(
31
+ (
32
+ r,
33
+ ): r is ReflectedRoute & {
34
+ schema: NonNullable<ReflectedRoute["schema"]>
35
+ tool: NonNullable<ReflectedRoute["tool"]>
36
+ } => r.schema !== undefined && r.tool !== undefined,
37
+ )
38
+ .map((r) => {
39
+ const toolInfo = r.tool
40
+ const s = r.schema
41
+ return {
42
+ name: toolInfo.name,
43
+ description: toolInfo.description,
44
+ inputSchema: (s.body?.jsonSchema ?? {
45
+ type: "object",
46
+ properties: {},
47
+ }) as McpTool["inputSchema"],
48
+ ...(toolInfo.annotations !== undefined ? { annotations: toolInfo.annotations } : {}),
49
+ handler: async (args): Promise<string | McpToolResult> => {
50
+ const res = await b.fetch(
51
+ new Request(`http://localhost/_nifra/tool/${toolInfo.name}`, {
52
+ method: "POST",
53
+ headers: { "content-type": "application/json" },
54
+ body: JSON.stringify(args),
55
+ }),
56
+ )
57
+ if (!res.ok) {
58
+ const text = await res.text()
59
+ throw new Error(`Tool execution failed (${res.status}): ${text}`)
60
+ }
61
+ const body: unknown = await res.json()
62
+ if (typeof body === "string") return body
63
+ if (body && typeof body === "object") {
64
+ if ("content" in body || "structuredContent" in body) {
65
+ return body as McpToolResult
66
+ }
67
+ return {
68
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
69
+ structuredContent: body as Record<string, unknown>,
70
+ }
71
+ }
72
+ return String(body)
73
+ },
74
+ }
75
+ })
76
+ }
77
+
78
+ /** A resource declared on the backend via `server().resource()`, seen through `mcpResources()`. */
79
+ type ResourceDescriptor = {
80
+ readonly uri: string
81
+ readonly name: string
82
+ readonly description?: string
83
+ readonly mimeType?: string
84
+ readonly read: () => unknown | Promise<unknown>
85
+ }
86
+ type ResourceBackend = { readonly mcpResources?: () => readonly ResourceDescriptor[] }
87
+
88
+ /** Extract MCP resources registered via `.resource()` on the Nifra backend. Exported for the surfacing test. */
89
+ export function extractBackendResources(backend: unknown): McpResource[] {
90
+ const b = backend as ResourceBackend | null
91
+ if (!b || typeof b.mcpResources !== "function") return []
92
+ let list: readonly ResourceDescriptor[]
93
+ try {
94
+ list = b.mcpResources()
95
+ } catch {
96
+ return []
97
+ }
98
+ return list.map((r) => ({
99
+ uri: r.uri,
100
+ name: r.name,
101
+ ...(r.description !== undefined ? { description: r.description } : {}),
102
+ ...(r.mimeType !== undefined ? { mimeType: r.mimeType } : {}),
103
+ read: async () => {
104
+ const out = await r.read()
105
+ if (typeof out === "string") return { text: out }
106
+ const o = out as { text: string; mimeType?: string }
107
+ return o.mimeType !== undefined ? { text: o.text, mimeType: o.mimeType } : { text: o.text }
108
+ },
109
+ }))
110
+ }
111
+
112
+ /** A prompt declared on the backend via `server().prompt()`, seen through `mcpPrompts()`. */
113
+ type PromptDescriptor = {
114
+ readonly name: string
115
+ readonly description: string
116
+ readonly arguments?: readonly { name: string; description?: string; required?: boolean }[]
117
+ readonly handler: (args: Record<string, string>) => unknown | Promise<unknown>
118
+ }
119
+ type PromptBackend = { readonly mcpPrompts?: () => readonly PromptDescriptor[] }
120
+
121
+ /** Extract MCP prompts registered via `.prompt()` on the Nifra backend. Exported for the surfacing test. */
122
+ export function extractBackendPrompts(backend: unknown): McpPrompt[] {
123
+ const b = backend as PromptBackend | null
124
+ if (!b || typeof b.mcpPrompts !== "function") return []
125
+ let list: readonly PromptDescriptor[]
126
+ try {
127
+ list = b.mcpPrompts()
128
+ } catch {
129
+ return []
130
+ }
131
+ return list.map((p) => ({
132
+ name: p.name,
133
+ description: p.description,
134
+ ...(p.arguments !== undefined ? { arguments: p.arguments } : {}),
135
+ handler: async (args: Record<string, unknown>) =>
136
+ (await p.handler(args as Record<string, string>)) as readonly McpPromptMessage[],
137
+ }))
138
+ }
139
+
140
+ /** Prefix every tool name and resource URI for a named app in a monorepo. */
package/src/mcp-root.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { readFile } from "node:fs/promises"
18
- import { dirname, sep } from "node:path"
18
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
19
19
  import { fileURLToPath } from "node:url"
20
20
 
21
21
  /** How the current root was chosen. `arg` = explicit `nifra mcp <dir>`; `cwd` = the spawn directory;
@@ -102,7 +102,16 @@ export function pathsFromRootsResult(result: unknown): string[] {
102
102
  const uri = (entry as { uri?: unknown })?.uri
103
103
  if (typeof uri !== "string" || !uri.startsWith("file://")) continue
104
104
  try {
105
- paths.push(fileURLToPath(uri))
105
+ const parsed = new URL(uri)
106
+ // A remote file host is a UNC path on Windows, not a local workspace root. MCP roots are
107
+ // intentionally local-only here: accepting it would let a client silently point the server
108
+ // at a different machine/share. `localhost` is the only equivalent spelling we accept.
109
+ if (
110
+ parsed.protocol !== "file:" ||
111
+ (parsed.hostname !== "" && parsed.hostname !== "localhost")
112
+ )
113
+ continue
114
+ paths.push(fileURLToPath(parsed))
106
115
  } catch {
107
116
  // Malformed URI - skip.
108
117
  }
@@ -110,8 +119,13 @@ export function pathsFromRootsResult(result: unknown): string[] {
110
119
  return paths
111
120
  }
112
121
 
113
- const contains = (parent: string, child: string): boolean =>
114
- parent === child || child.startsWith(parent + sep)
122
+ const contains = (parent: string, child: string): boolean => {
123
+ const distance = relative(resolve(parent), resolve(child))
124
+ return (
125
+ distance === "" ||
126
+ (distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance))
127
+ )
128
+ }
115
129
 
116
130
  /** True when the server root and the client's workspace are DISJOINT - neither contains the other.
117
131
  * A server rooted at a monorepo whose sub-app is the open workspace (or vice versa) is not a
@@ -269,3 +283,47 @@ export async function detectToolingDrift(
269
283
  }
270
284
  return undefined
271
285
  }
286
+
287
+ /** Tool names served from the bundled corpus; they do not depend on the project root. */
288
+ const PROJECT_FREE_TOOLS = new Set(["nifra_docs", "nifra_example", "nifra_types", "nifra_learn"])
289
+
290
+ /**
291
+ * Wrap project-scoped tools with root trust. A bad root refuses in-band; a good root stamps every
292
+ * result so the agent can see which project answered. Docs tools pass through untouched.
293
+ */
294
+ export function guardTools(
295
+ tools: import("./mcp-protocol.ts").McpTool[],
296
+ verdict: McpRootVerdict,
297
+ ): import("./mcp-protocol.ts").McpTool[] {
298
+ return tools.map((tool) => {
299
+ if (PROJECT_FREE_TOOLS.has(tool.name)) return tool
300
+ return {
301
+ ...tool,
302
+ handler: async (
303
+ args: Record<string, unknown>,
304
+ context: import("./mcp-protocol.ts").McpToolContext,
305
+ ): Promise<import("./mcp-protocol.ts").McpToolResult> => {
306
+ if (verdict.blocked !== undefined) {
307
+ return { content: [{ type: "text", text: verdict.blocked }], isError: true }
308
+ }
309
+ const result = await tool.handler(args, context)
310
+ const note: import("./mcp-protocol.ts").McpContentBlock = {
311
+ type: "text",
312
+ text: verdict.note,
313
+ }
314
+ if (typeof result === "string") {
315
+ return { content: [{ type: "text", text: result }, note] }
316
+ }
317
+ return { ...result, content: [...(result.content ?? []), note] }
318
+ },
319
+ }
320
+ })
321
+ }
322
+
323
+ /** Whether the client's initialize params declare the roots capability. */
324
+ export function clientSupportsRoots(params: Record<string, unknown> | undefined): boolean {
325
+ const capabilities = params?.capabilities
326
+ if (typeof capabilities !== "object" || capabilities === null) return false
327
+ const roots = (capabilities as { roots?: unknown }).roots
328
+ return typeof roots === "object" && roots !== null
329
+ }