@nifrajs/cli 0.1.0-alpha.1

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/check.d.ts +100 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +690 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/cli.d.ts +3 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +208 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/docs-search.d.ts +30 -0
  12. package/dist/docs-search.d.ts.map +1 -0
  13. package/dist/docs-search.js +94 -0
  14. package/dist/docs-search.js.map +1 -0
  15. package/dist/doctor.d.ts +52 -0
  16. package/dist/doctor.d.ts.map +1 -0
  17. package/dist/doctor.js +291 -0
  18. package/dist/doctor.js.map +1 -0
  19. package/dist/examples.d.ts +30 -0
  20. package/dist/examples.d.ts.map +1 -0
  21. package/dist/examples.js +70 -0
  22. package/dist/examples.js.map +1 -0
  23. package/dist/introspect.d.ts +64 -0
  24. package/dist/introspect.d.ts.map +1 -0
  25. package/dist/introspect.js +243 -0
  26. package/dist/introspect.js.map +1 -0
  27. package/dist/load.d.ts +51 -0
  28. package/dist/load.d.ts.map +1 -0
  29. package/dist/load.js +70 -0
  30. package/dist/load.js.map +1 -0
  31. package/dist/mcp-docs-tools.d.ts +13 -0
  32. package/dist/mcp-docs-tools.d.ts.map +1 -0
  33. package/dist/mcp-docs-tools.js +63 -0
  34. package/dist/mcp-docs-tools.js.map +1 -0
  35. package/dist/mcp-http.d.ts +43 -0
  36. package/dist/mcp-http.d.ts.map +1 -0
  37. package/dist/mcp-http.js +136 -0
  38. package/dist/mcp-http.js.map +1 -0
  39. package/dist/mcp-protocol.d.ts +97 -0
  40. package/dist/mcp-protocol.d.ts.map +1 -0
  41. package/dist/mcp-protocol.js +248 -0
  42. package/dist/mcp-protocol.js.map +1 -0
  43. package/dist/mcp-render.d.ts +17 -0
  44. package/dist/mcp-render.d.ts.map +1 -0
  45. package/dist/mcp-render.js +73 -0
  46. package/dist/mcp-render.js.map +1 -0
  47. package/dist/mcp-run.d.ts +24 -0
  48. package/dist/mcp-run.d.ts.map +1 -0
  49. package/dist/mcp-run.js +142 -0
  50. package/dist/mcp-run.js.map +1 -0
  51. package/dist/mcp-ws.d.ts +26 -0
  52. package/dist/mcp-ws.d.ts.map +1 -0
  53. package/dist/mcp-ws.js +258 -0
  54. package/dist/mcp-ws.js.map +1 -0
  55. package/dist/mcp.d.ts +42 -0
  56. package/dist/mcp.d.ts.map +1 -0
  57. package/dist/mcp.js +712 -0
  58. package/dist/mcp.js.map +1 -0
  59. package/dist/openapi-tool.d.ts +6 -0
  60. package/dist/openapi-tool.d.ts.map +1 -0
  61. package/dist/openapi-tool.js +48 -0
  62. package/dist/openapi-tool.js.map +1 -0
  63. package/dist/scaffold.d.ts +35 -0
  64. package/dist/scaffold.d.ts.map +1 -0
  65. package/dist/scaffold.js +140 -0
  66. package/dist/scaffold.js.map +1 -0
  67. package/dist/search-terms.d.ts +17 -0
  68. package/dist/search-terms.d.ts.map +1 -0
  69. package/dist/search-terms.js +82 -0
  70. package/dist/search-terms.js.map +1 -0
  71. package/dist/test-tool.d.ts +29 -0
  72. package/dist/test-tool.d.ts.map +1 -0
  73. package/dist/test-tool.js +150 -0
  74. package/dist/test-tool.js.map +1 -0
  75. package/docs/examples.json +314 -0
  76. package/docs/llms-full.txt +4325 -0
  77. package/package.json +58 -0
  78. package/src/check.ts +790 -0
  79. package/src/cli.ts +237 -0
  80. package/src/docs-search.ts +111 -0
  81. package/src/doctor.ts +349 -0
  82. package/src/examples.ts +89 -0
  83. package/src/introspect.ts +284 -0
  84. package/src/load.ts +129 -0
  85. package/src/mcp-docs-tools.ts +72 -0
  86. package/src/mcp-http.ts +155 -0
  87. package/src/mcp-protocol.ts +326 -0
  88. package/src/mcp-render.ts +78 -0
  89. package/src/mcp-run.ts +153 -0
  90. package/src/mcp-ws.ts +328 -0
  91. package/src/mcp.ts +863 -0
  92. package/src/openapi-tool.ts +51 -0
  93. package/src/scaffold.ts +164 -0
  94. package/src/search-terms.ts +89 -0
  95. package/src/test-tool.ts +181 -0
package/src/mcp-ws.ts ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The `nifra_ws` engine + child-process entry. The MCP server spawns this file fresh on every
3
+ * WebSocket verification so the project's current backend code loads, then this module starts the
4
+ * app on an ephemeral localhost port, connects with Bun's WebSocket client, and returns structured
5
+ * evidence. It intentionally only targets the current app; this is a verification tool, not a
6
+ * general-purpose network client.
7
+ */
8
+
9
+ import { loadBackend } from "./mcp-run.ts"
10
+
11
+ const DEFAULT_TIMEOUT_MS = 3_000
12
+ const MAX_TIMEOUT_MS = 30_000
13
+ const MAX_MESSAGES = 50
14
+ const MAX_MESSAGE_BYTES = 64 * 1024
15
+ const MAX_PATH_LENGTH = 2_048
16
+ const ENCODER = new TextEncoder()
17
+
18
+ interface RunningServerLike {
19
+ readonly port: number
20
+ stop(closeActiveConnections?: boolean): void
21
+ }
22
+
23
+ interface ListenableApp {
24
+ listen(port: number): RunningServerLike
25
+ }
26
+
27
+ interface NormalizedInput {
28
+ readonly path: string
29
+ readonly entry?: string
30
+ readonly messages: readonly string[]
31
+ readonly expectMessages: number
32
+ readonly timeoutMs: number
33
+ }
34
+
35
+ interface CloseInfo {
36
+ readonly code: number
37
+ readonly reason: string
38
+ readonly wasClean: boolean
39
+ }
40
+
41
+ export interface WebSocketVerificationResult {
42
+ readonly ok: boolean
43
+ readonly opened: boolean
44
+ readonly path: string
45
+ readonly sent: readonly string[]
46
+ readonly received: readonly string[]
47
+ readonly durationMs: number
48
+ readonly url?: string
49
+ readonly close?: CloseInfo
50
+ readonly error?: string
51
+ }
52
+
53
+ const errString = (err: unknown): string =>
54
+ err instanceof Error ? `${err.name}: ${err.message}` : String(err)
55
+
56
+ function hasControlCharacter(value: string): boolean {
57
+ for (let i = 0; i < value.length; i++) {
58
+ const code = value.charCodeAt(i)
59
+ if (code < 0x20 || code === 0x7f) return true
60
+ }
61
+ return false
62
+ }
63
+
64
+ function fail(
65
+ path: string,
66
+ sent: readonly string[],
67
+ startedAt: number,
68
+ error: string,
69
+ extra: Partial<Pick<WebSocketVerificationResult, "opened" | "received" | "url" | "close">> = {},
70
+ ): WebSocketVerificationResult {
71
+ return {
72
+ ok: false,
73
+ opened: extra.opened ?? false,
74
+ path,
75
+ sent,
76
+ received: extra.received ?? [],
77
+ durationMs: Math.round(performance.now() - startedAt),
78
+ error,
79
+ ...(extra.url === undefined ? {} : { url: extra.url }),
80
+ ...(extra.close === undefined ? {} : { close: extra.close }),
81
+ }
82
+ }
83
+
84
+ function normalizeInput(args: unknown): NormalizedInput | { error: string; path: string } {
85
+ if (args === null || typeof args !== "object") {
86
+ return { error: 'expected JSON { "path": "/..." }', path: "" }
87
+ }
88
+ const input = args as Record<string, unknown>
89
+ const path = input.path
90
+ if (typeof path !== "string" || path.length === 0) {
91
+ return { error: 'expected "path" to be a non-empty string starting with "/"', path: "" }
92
+ }
93
+ if (!path.startsWith("/") || path.startsWith("//")) {
94
+ return { error: 'expected "path" to be an app-local path such as "/ws"', path }
95
+ }
96
+ if (path.length > MAX_PATH_LENGTH) {
97
+ return { error: `"path" is too long (max ${MAX_PATH_LENGTH} characters)`, path }
98
+ }
99
+ if (hasControlCharacter(path)) {
100
+ return { error: '"path" must not contain control characters', path }
101
+ }
102
+ if (path.includes("#")) return { error: '"path" must not include a fragment', path }
103
+
104
+ const messagesValue = input.messages
105
+ const messages =
106
+ messagesValue === undefined ? [] : Array.isArray(messagesValue) ? messagesValue : undefined
107
+ if (messages === undefined) return { error: '"messages" must be an array of strings', path }
108
+ if (messages.length > MAX_MESSAGES) {
109
+ return { error: `"messages" can include at most ${MAX_MESSAGES} frames`, path }
110
+ }
111
+ const strings: string[] = []
112
+ for (const [index, message] of messages.entries()) {
113
+ if (typeof message !== "string") {
114
+ return { error: `"messages[${index}]" must be a string`, path }
115
+ }
116
+ if (ENCODER.encode(message).byteLength > MAX_MESSAGE_BYTES) {
117
+ return { error: `"messages[${index}]" is too large (max ${MAX_MESSAGE_BYTES} bytes)`, path }
118
+ }
119
+ strings.push(message)
120
+ }
121
+
122
+ const expectRaw = input.expectMessages
123
+ const expectMessages =
124
+ expectRaw === undefined
125
+ ? strings.length > 0
126
+ ? strings.length
127
+ : 1
128
+ : typeof expectRaw === "number" && Number.isInteger(expectRaw)
129
+ ? expectRaw
130
+ : undefined
131
+ if (expectMessages === undefined || expectMessages < 0 || expectMessages > MAX_MESSAGES) {
132
+ return { error: `"expectMessages" must be an integer from 0 to ${MAX_MESSAGES}`, path }
133
+ }
134
+
135
+ const timeoutRaw = input.timeoutMs
136
+ const timeoutMs =
137
+ timeoutRaw === undefined
138
+ ? DEFAULT_TIMEOUT_MS
139
+ : typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw)
140
+ ? Math.trunc(timeoutRaw)
141
+ : undefined
142
+ if (timeoutMs === undefined || timeoutMs <= 0) {
143
+ return { error: '"timeoutMs" must be a positive number', path }
144
+ }
145
+
146
+ const entry = input.entry
147
+ if (entry !== undefined && typeof entry !== "string") {
148
+ return { error: '"entry" must be a string when provided', path }
149
+ }
150
+
151
+ return {
152
+ path,
153
+ messages: strings,
154
+ expectMessages,
155
+ timeoutMs: Math.min(Math.max(timeoutMs, 250), MAX_TIMEOUT_MS),
156
+ ...(entry === undefined || entry.length === 0 ? {} : { entry }),
157
+ }
158
+ }
159
+
160
+ async function dataToString(data: unknown): Promise<string> {
161
+ if (typeof data === "string") return data
162
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data)
163
+ if (ArrayBuffer.isView(data)) {
164
+ return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))
165
+ }
166
+ if (typeof Blob !== "undefined" && data instanceof Blob) return data.text()
167
+ return String(data)
168
+ }
169
+
170
+ function closeInfo(event: CloseEvent): CloseInfo {
171
+ return { code: event.code, reason: event.reason, wasClean: event.wasClean }
172
+ }
173
+
174
+ function redirectConsoleToStderr(): void {
175
+ const write = (level: string, args: unknown[]): void => {
176
+ Bun.stderr.write(`[${level}] ${args.map((arg) => String(arg)).join(" ")}\n`)
177
+ }
178
+ console.debug = (...args: unknown[]) => write("debug", args)
179
+ console.info = (...args: unknown[]) => write("info", args)
180
+ console.log = (...args: unknown[]) => write("log", args)
181
+ console.warn = (...args: unknown[]) => write("warn", args)
182
+ console.error = (...args: unknown[]) => write("error", args)
183
+ }
184
+
185
+ export async function runWebSocket(
186
+ cwd: string,
187
+ args: unknown,
188
+ ): Promise<WebSocketVerificationResult> {
189
+ const startedAt = performance.now()
190
+ const normalized = normalizeInput(args)
191
+ if ("error" in normalized) return fail(normalized.path, [], startedAt, normalized.error)
192
+
193
+ const loaded = await loadBackend(cwd, normalized.entry)
194
+ if ("error" in loaded) return fail(normalized.path, [], startedAt, loaded.error)
195
+
196
+ const app = loaded.app as typeof loaded.app & Partial<ListenableApp>
197
+ if (typeof app.listen !== "function") {
198
+ return fail(
199
+ normalized.path,
200
+ [],
201
+ startedAt,
202
+ "backend app does not expose listen(); WebSocket verification requires a Bun-listenable nifra app.",
203
+ )
204
+ }
205
+
206
+ let running: RunningServerLike | undefined
207
+ let socket: WebSocket | undefined
208
+ try {
209
+ running = app.listen(0)
210
+ const url = `ws://127.0.0.1:${running.port}${normalized.path}`
211
+ socket = new WebSocket(url)
212
+ socket.binaryType = "arraybuffer"
213
+ return await new Promise<WebSocketVerificationResult>((resolve) => {
214
+ const received: string[] = []
215
+ const sent: string[] = []
216
+ let opened = false
217
+ let close: CloseInfo | undefined
218
+ let settled = false
219
+ let timer: ReturnType<typeof setTimeout>
220
+
221
+ const settle = (
222
+ ok: boolean,
223
+ error?: string,
224
+ extra: Partial<Pick<WebSocketVerificationResult, "close">> = {},
225
+ ): void => {
226
+ if (settled) return
227
+ settled = true
228
+ clearTimeout(timer)
229
+ if (extra.close !== undefined) close = extra.close
230
+ resolve({
231
+ ok,
232
+ opened,
233
+ path: normalized.path,
234
+ url,
235
+ sent,
236
+ received,
237
+ durationMs: Math.round(performance.now() - startedAt),
238
+ ...(close === undefined ? {} : { close }),
239
+ ...(error === undefined ? {} : { error }),
240
+ })
241
+ }
242
+
243
+ timer = setTimeout(() => {
244
+ settle(
245
+ false,
246
+ `timed out after ${normalized.timeoutMs}ms waiting for ${normalized.expectMessages} message(s); received ${received.length}`,
247
+ close === undefined ? {} : { close },
248
+ )
249
+ }, normalized.timeoutMs)
250
+
251
+ socket?.addEventListener("open", () => {
252
+ opened = true
253
+ try {
254
+ for (const message of normalized.messages) {
255
+ socket?.send(message)
256
+ sent.push(message)
257
+ }
258
+ } catch (err) {
259
+ settle(false, errString(err))
260
+ return
261
+ }
262
+ if (normalized.expectMessages === 0) settle(true)
263
+ })
264
+
265
+ socket?.addEventListener("message", (event) => {
266
+ void dataToString(event.data)
267
+ .then((text) => {
268
+ received.push(text)
269
+ if (received.length >= normalized.expectMessages) settle(true)
270
+ })
271
+ .catch((err) => settle(false, errString(err)))
272
+ })
273
+
274
+ socket?.addEventListener("error", () => {
275
+ settle(false, opened ? "websocket error" : "websocket failed before open")
276
+ })
277
+
278
+ socket?.addEventListener("close", (event) => {
279
+ close = closeInfo(event)
280
+ if (!settled && received.length < normalized.expectMessages) {
281
+ settle(
282
+ false,
283
+ opened
284
+ ? `websocket closed before ${normalized.expectMessages} message(s); received ${received.length}`
285
+ : "websocket closed before open",
286
+ { close },
287
+ )
288
+ }
289
+ })
290
+ })
291
+ } catch (err) {
292
+ return fail(normalized.path, [], startedAt, errString(err))
293
+ } finally {
294
+ try {
295
+ if (socket !== undefined && socket.readyState < WebSocket.CLOSING) socket.close()
296
+ } catch {
297
+ // Cleanup is best effort; the structured verification result above is the important signal.
298
+ }
299
+ try {
300
+ running?.stop(true)
301
+ } catch {
302
+ // Same as socket.close(): do not mask the verification result with teardown noise.
303
+ }
304
+ }
305
+ }
306
+
307
+ // Child-process entry: read `{ path, messages?, expectMessages?, timeoutMs?, entry? }` from stdin,
308
+ // verify, and print JSON to stdout. Console output from the project is redirected so agents receive
309
+ // parseable JSON even when the app logs during import or request handling.
310
+ if (import.meta.main) {
311
+ redirectConsoleToStderr()
312
+ const cwd = process.argv[2] ?? process.cwd()
313
+ let output: unknown
314
+ try {
315
+ output = await runWebSocket(cwd, JSON.parse(await Bun.stdin.text()))
316
+ } catch {
317
+ output = {
318
+ ok: false,
319
+ opened: false,
320
+ path: "",
321
+ sent: [],
322
+ received: [],
323
+ durationMs: 0,
324
+ error: 'invalid input: expected JSON { "path": "/..." }',
325
+ } satisfies WebSocketVerificationResult
326
+ }
327
+ process.stdout.write(JSON.stringify(output, null, 2))
328
+ }