@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.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/check.d.ts +100 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +690 -0
- package/dist/check.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +208 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs-search.d.ts +30 -0
- package/dist/docs-search.d.ts.map +1 -0
- package/dist/docs-search.js +94 -0
- package/dist/docs-search.js.map +1 -0
- package/dist/doctor.d.ts +52 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +291 -0
- package/dist/doctor.js.map +1 -0
- package/dist/examples.d.ts +30 -0
- package/dist/examples.d.ts.map +1 -0
- package/dist/examples.js +70 -0
- package/dist/examples.js.map +1 -0
- package/dist/introspect.d.ts +64 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +243 -0
- package/dist/introspect.js.map +1 -0
- package/dist/load.d.ts +51 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +70 -0
- package/dist/load.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts +13 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -0
- package/dist/mcp-docs-tools.js +63 -0
- package/dist/mcp-docs-tools.js.map +1 -0
- package/dist/mcp-http.d.ts +43 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +136 -0
- package/dist/mcp-http.js.map +1 -0
- package/dist/mcp-protocol.d.ts +97 -0
- package/dist/mcp-protocol.d.ts.map +1 -0
- package/dist/mcp-protocol.js +248 -0
- package/dist/mcp-protocol.js.map +1 -0
- package/dist/mcp-render.d.ts +17 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-render.js +73 -0
- package/dist/mcp-render.js.map +1 -0
- package/dist/mcp-run.d.ts +24 -0
- package/dist/mcp-run.d.ts.map +1 -0
- package/dist/mcp-run.js +142 -0
- package/dist/mcp-run.js.map +1 -0
- package/dist/mcp-ws.d.ts +26 -0
- package/dist/mcp-ws.d.ts.map +1 -0
- package/dist/mcp-ws.js +258 -0
- package/dist/mcp-ws.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +712 -0
- package/dist/mcp.js.map +1 -0
- package/dist/openapi-tool.d.ts +6 -0
- package/dist/openapi-tool.d.ts.map +1 -0
- package/dist/openapi-tool.js +48 -0
- package/dist/openapi-tool.js.map +1 -0
- package/dist/scaffold.d.ts +35 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +140 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/search-terms.d.ts +17 -0
- package/dist/search-terms.d.ts.map +1 -0
- package/dist/search-terms.js +82 -0
- package/dist/search-terms.js.map +1 -0
- package/dist/test-tool.d.ts +29 -0
- package/dist/test-tool.d.ts.map +1 -0
- package/dist/test-tool.js +150 -0
- package/dist/test-tool.js.map +1 -0
- package/docs/examples.json +314 -0
- package/docs/llms-full.txt +4325 -0
- package/package.json +58 -0
- package/src/check.ts +790 -0
- package/src/cli.ts +237 -0
- package/src/docs-search.ts +111 -0
- package/src/doctor.ts +349 -0
- package/src/examples.ts +89 -0
- package/src/introspect.ts +284 -0
- package/src/load.ts +129 -0
- package/src/mcp-docs-tools.ts +72 -0
- package/src/mcp-http.ts +155 -0
- package/src/mcp-protocol.ts +326 -0
- package/src/mcp-render.ts +78 -0
- package/src/mcp-run.ts +153 -0
- package/src/mcp-ws.ts +328 -0
- package/src/mcp.ts +863 -0
- package/src/openapi-tool.ts +51 -0
- package/src/scaffold.ts +164 -0
- package/src/search-terms.ts +89 -0
- 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
|
+
}
|