@nifrajs/cli 2.7.1 → 2.8.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.
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +161 -12
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +42 -55
- package/dist/cli.js.map +1 -1
- package/dist/dev-bun-config.d.ts +36 -0
- package/dist/dev-bun-config.d.ts.map +1 -0
- package/dist/dev-bun-config.js +179 -0
- package/dist/dev-bun-config.js.map +1 -0
- package/dist/mcp-app-tool.d.ts +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +8 -4
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-docs-tools.js +1 -1
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +3 -3
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +4 -4
- package/dist/mcp-http.js.map +1 -1
- package/docs/llms-full.txt +26 -4
- package/docs/types.json +11 -4
- package/package.json +9 -9
- package/src/check.ts +174 -12
- package/src/cli.ts +47 -60
- package/src/dev-bun-config.ts +204 -0
- package/src/mcp-app-tool.ts +8 -4
- package/src/mcp-docs-tools.ts +1 -1
- package/src/mcp-http.ts +4 -4
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `nifra dev --bun` boundary-plugin channel.
|
|
3
|
+
*
|
|
4
|
+
* Bun's dev-server bundling (`Bun.serve` HTML imports) accepts plugins ONLY through bunfig's
|
|
5
|
+
* `[serve.static] plugins` - there is no programmatic option (upstream ask: oven-sh/bun#36830), and
|
|
6
|
+
* a runtime `Bun.plugin()` never reaches it. Without a plugin there, a `*.fn` server-function module
|
|
7
|
+
* would ship to the browser WHOLE (body, DB handles, secrets) instead of as the RPC stub the
|
|
8
|
+
* production client build emits, and a `*.server` module would ship instead of being emptied.
|
|
9
|
+
*
|
|
10
|
+
* So the CLI generates, under `.nifra/dev-bun/`:
|
|
11
|
+
* - `boundary-plugin.ts` - default-exports one plugin composing the SAME `serverFnStubPlugin` +
|
|
12
|
+
* `serverOnlyEmptyPlugin` the production build uses (imported from `@nifrajs/web/build`, resolved
|
|
13
|
+
* from the app - identical stubs, not a re-implementation);
|
|
14
|
+
* - `bunfig.toml` - the app's ENTIRE bunfig carried over verbatim (jsx, loaders, defines, install
|
|
15
|
+
* settings - dropping any of them would give dev a different Bun than the app configured), with
|
|
16
|
+
* `[serve.static] plugins` merged to put the boundary plugin first and path-bearing entries
|
|
17
|
+
* (`preload`, plugin paths) re-rooted at the app, because bunfig resolves relative entries
|
|
18
|
+
* against the CONFIG file's directory;
|
|
19
|
+
* - `launch-token` - a per-launch random value (see below).
|
|
20
|
+
*
|
|
21
|
+
* `dev --bun` then re-execs itself once with `--config=<generated>` (bunfig is read at process
|
|
22
|
+
* start, so the already-running CLI can't adopt it) - see `cli.ts`.
|
|
23
|
+
*
|
|
24
|
+
* ## Why a random launch token, not a fixed marker
|
|
25
|
+
*
|
|
26
|
+
* The child must know it IS the configured child. A fixed sentinel (`SOME_VAR=1`) would let any
|
|
27
|
+
* inherited environment - a CI wrapper, a shell profile, another tool - satisfy the check and skip
|
|
28
|
+
* the config generation entirely, serving the app with NO boundary plugins: a silent secret leak
|
|
29
|
+
* switched on by an environment variable. So the parent mints a fresh `crypto.randomUUID()` per
|
|
30
|
+
* launch, writes it beside the generated config, and passes it in the child's env; the child accepts
|
|
31
|
+
* only a token that matches the file, and CONSUMES the file on first read so a stale env value can
|
|
32
|
+
* never satisfy a later launch. An unverifiable token means the process acts as a parent and
|
|
33
|
+
* regenerates - fail closed, never serve unproven.
|
|
34
|
+
*/
|
|
35
|
+
import { timingSafeEqual } from "node:crypto"
|
|
36
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
37
|
+
import { resolve } from "node:path"
|
|
38
|
+
|
|
39
|
+
/** TOML string literal: basic (double-quoted) form with the two escapes it needs. */
|
|
40
|
+
function tomlString(value: string): string {
|
|
41
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A parsed bunfig: plain data from `Bun.TOML.parse`. */
|
|
45
|
+
type BunfigData = Record<string, unknown>
|
|
46
|
+
|
|
47
|
+
/** Parse the app's bunfig. Malformed TOML fails LOUDLY with the parse error - silently treating a
|
|
48
|
+
* broken config as empty would run dev with different Bun settings than the app declares. */
|
|
49
|
+
export function parseUserBunfig(toml: string | undefined): BunfigData {
|
|
50
|
+
if (toml === undefined) return {}
|
|
51
|
+
try {
|
|
52
|
+
return (Bun.TOML.parse(toml) ?? {}) as BunfigData
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[nifra] the app's bunfig.toml does not parse, so \`nifra dev --bun\` cannot carry it into ` +
|
|
56
|
+
`the dev server's config: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const BARE_KEY = /^[A-Za-z0-9_-]+$/
|
|
62
|
+
const tomlKey = (key: string): string => (BARE_KEY.test(key) ? key : tomlString(key))
|
|
63
|
+
|
|
64
|
+
function tomlValue(value: unknown, path: string): string {
|
|
65
|
+
if (typeof value === "string") return tomlString(value)
|
|
66
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return `[${value.map((item, i) => tomlValue(item, `${path}[${i}]`)).join(", ")}]`
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`[nifra] bunfig field \`${path}\` has a shape \`nifra dev --bun\` cannot re-serialize ` +
|
|
72
|
+
`(supported: strings, numbers, booleans, arrays of those, and nested tables). ` +
|
|
73
|
+
`Move it out of bunfig.toml for the Bun dev loop, or report the shape.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isTable = (value: unknown): value is BunfigData =>
|
|
78
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
79
|
+
|
|
80
|
+
/** Serialize a parsed bunfig back to TOML: scalar/array keys first, then nested tables as dotted
|
|
81
|
+
* `[section]` headers, recursively. Every field round-trips or the serializer throws - a field
|
|
82
|
+
* silently dropped here would be a dev server running different Bun settings than the app's. */
|
|
83
|
+
export function serializeBunfig(data: BunfigData): string {
|
|
84
|
+
const lines: string[] = []
|
|
85
|
+
const walk = (table: BunfigData, prefix: string): void => {
|
|
86
|
+
const scalars = Object.entries(table).filter(([, v]) => !isTable(v))
|
|
87
|
+
const tables = Object.entries(table).filter((entry): entry is [string, BunfigData] =>
|
|
88
|
+
isTable(entry[1]),
|
|
89
|
+
)
|
|
90
|
+
if (prefix !== "" && scalars.length > 0) lines.push(`[${prefix}]`)
|
|
91
|
+
for (const [key, value] of scalars) {
|
|
92
|
+
lines.push(`${tomlKey(key)} = ${tomlValue(value, prefix === "" ? key : `${prefix}.${key}`)}`)
|
|
93
|
+
}
|
|
94
|
+
for (const [key, value] of tables) {
|
|
95
|
+
const next = prefix === "" ? tomlKey(key) : `${prefix}.${tomlKey(key)}`
|
|
96
|
+
// A table with ONLY sub-tables still needs no header of its own; recursion emits the leaves.
|
|
97
|
+
walk(value, next)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
walk(data, "")
|
|
101
|
+
return `${lines.join("\n")}\n`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render the generated bunfig: the user's config carried whole, with the boundary plugin prepended
|
|
105
|
+
* to `[serve.static] plugins` and path-bearing entries (`preload`, plugin paths) re-rooted at the
|
|
106
|
+
* APP - bunfig resolves relative entries against the config file's own directory, which is
|
|
107
|
+
* `.nifra/dev-bun/`. */
|
|
108
|
+
export function renderDevBunfig(
|
|
109
|
+
boundaryPluginPath: string,
|
|
110
|
+
user: BunfigData,
|
|
111
|
+
appRoot: string,
|
|
112
|
+
): string {
|
|
113
|
+
const resolveEntry = (entry: unknown): unknown =>
|
|
114
|
+
typeof entry === "string" && entry.startsWith(".") ? resolve(appRoot, entry) : entry
|
|
115
|
+
const data: BunfigData = structuredClone(user)
|
|
116
|
+
if (data.preload !== undefined) {
|
|
117
|
+
const entries = Array.isArray(data.preload) ? data.preload : [data.preload]
|
|
118
|
+
data.preload = entries.map(resolveEntry)
|
|
119
|
+
}
|
|
120
|
+
const serve = isTable(data.serve) ? data.serve : {}
|
|
121
|
+
data.serve = serve
|
|
122
|
+
const serveStatic = isTable(serve.static) ? serve.static : {}
|
|
123
|
+
serve.static = serveStatic
|
|
124
|
+
const existing = Array.isArray(serveStatic.plugins) ? serveStatic.plugins : []
|
|
125
|
+
serveStatic.plugins = [boundaryPluginPath, ...existing.map(resolveEntry)]
|
|
126
|
+
return (
|
|
127
|
+
"# Generated by `nifra dev --bun` - do not edit. Regenerated on every dev start.\n" +
|
|
128
|
+
"# The app's own bunfig is carried over verbatim; [serve.static] plugins additionally deliver\n" +
|
|
129
|
+
"# the client-boundary plugins (server-fn stubs, server-only emptying) to Bun's dev-server\n" +
|
|
130
|
+
"# bundler, which only accepts plugins via this channel (oven-sh/bun#36830).\n" +
|
|
131
|
+
serializeBunfig(data)
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The generated plugin module: compose the two production boundary plugins into one default
|
|
136
|
+
* export. Import specifiers are bare so they resolve from the APP's install (the module lives
|
|
137
|
+
* under the app's `.nifra/`), keeping dev stubs byte-identical to production's. */
|
|
138
|
+
export function renderBoundaryPluginModule(): string {
|
|
139
|
+
return `// Generated by \`nifra dev --bun\` - do not edit. Regenerated on every dev start.
|
|
140
|
+
import { serverFnStubPlugin, serverOnlyEmptyPlugin } from "@nifrajs/web/build"
|
|
141
|
+
|
|
142
|
+
const fn = serverFnStubPlugin()
|
|
143
|
+
const serverOnly = serverOnlyEmptyPlugin()
|
|
144
|
+
|
|
145
|
+
export default {
|
|
146
|
+
name: "nifra-dev-boundary",
|
|
147
|
+
setup(build) {
|
|
148
|
+
serverOnly.setup(build)
|
|
149
|
+
fn.setup(build)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface BunDevConfig {
|
|
156
|
+
readonly bunfigPath: string
|
|
157
|
+
/** Per-launch random value the re-exec'd child must present (and the file copy it must match). */
|
|
158
|
+
readonly launchToken: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const TOKEN_FILE = "launch-token"
|
|
162
|
+
|
|
163
|
+
/** Write the generated config for an app; returns the bunfig path to re-exec with and the fresh
|
|
164
|
+
* launch token to pass in the child's environment. */
|
|
165
|
+
export async function writeBunDevConfig(appRoot: string): Promise<BunDevConfig> {
|
|
166
|
+
const dir = resolve(appRoot, ".nifra", "dev-bun")
|
|
167
|
+
mkdirSync(dir, { recursive: true })
|
|
168
|
+
const pluginPath = resolve(dir, "boundary-plugin.ts")
|
|
169
|
+
writeFileSync(pluginPath, renderBoundaryPluginModule())
|
|
170
|
+
const userToml = await Bun.file(resolve(appRoot, "bunfig.toml"))
|
|
171
|
+
.text()
|
|
172
|
+
.catch(() => undefined)
|
|
173
|
+
const bunfigPath = resolve(dir, "bunfig.toml")
|
|
174
|
+
writeFileSync(bunfigPath, renderDevBunfig(pluginPath, parseUserBunfig(userToml), appRoot))
|
|
175
|
+
const launchToken = crypto.randomUUID()
|
|
176
|
+
writeFileSync(resolve(dir, TOKEN_FILE), launchToken)
|
|
177
|
+
return { bunfigPath, launchToken }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Child-side proof of a configured launch: the presented token must match the token file the parent
|
|
182
|
+
* just wrote, compared in constant time. The file is CONSUMED (deleted) on the first attempt either
|
|
183
|
+
* way, so a stale environment value can never satisfy a later launch. `false` means this process
|
|
184
|
+
* cannot prove the boundary config is active and must act as a parent (regenerate + re-exec) - the
|
|
185
|
+
* fail-closed direction.
|
|
186
|
+
*/
|
|
187
|
+
export function consumeLaunchToken(appRoot: string, presented: string | undefined): boolean {
|
|
188
|
+
if (presented === undefined || presented === "") return false
|
|
189
|
+
const path = resolve(appRoot, ".nifra", "dev-bun", TOKEN_FILE)
|
|
190
|
+
let stored: string
|
|
191
|
+
try {
|
|
192
|
+
stored = readFileSync(path, "utf8")
|
|
193
|
+
} catch {
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(path)
|
|
198
|
+
} catch {
|
|
199
|
+
// Already consumed by a race - the comparison below still decides on the bytes read.
|
|
200
|
+
}
|
|
201
|
+
const a = Buffer.from(stored)
|
|
202
|
+
const b = Buffer.from(presented)
|
|
203
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
204
|
+
}
|
package/src/mcp-app-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `
|
|
2
|
+
* The `nifra_gallery` MCP tool: nifra's verified code examples as an MCP Apps widget. It returns the
|
|
3
3
|
* examples as `structuredContent`, rendered by the {@link examplesWidget} `ui://` widget in MCP Apps hosts
|
|
4
4
|
* (ChatGPT Apps, MCPJam, Goose); text-only hosts still get the example names as plain text. Part of the
|
|
5
5
|
* public docs-MCP tool set (served by `handleMcpHttp`), so every self-host gets it from one definition.
|
|
@@ -50,10 +50,14 @@ export const examplesWidget = defineMcpWidget({
|
|
|
50
50
|
/** The widget-backed tool. `loadExamples` is injected (disk on the CLI, cached fetch on the edge). */
|
|
51
51
|
export function examplesAppTool(loadExamples: () => Promise<Example[] | undefined>): McpTool {
|
|
52
52
|
return defineMcpTool({
|
|
53
|
-
name: "
|
|
54
|
-
annotations: {
|
|
53
|
+
name: "nifra_gallery",
|
|
54
|
+
annotations: {
|
|
55
|
+
title: "Browse the nifra example gallery",
|
|
56
|
+
readOnlyHint: true,
|
|
57
|
+
openWorldHint: false,
|
|
58
|
+
},
|
|
55
59
|
description:
|
|
56
|
-
"
|
|
60
|
+
"Open an interactive, filterable gallery of ALL of nifra's verified code examples (MCP Apps widget) - for browsing and discovering what exists. NOT for fetching one snippet as text: use nifra_example for that. Pass query to pre-filter; the widget also filters client-side.",
|
|
57
61
|
inputSchema: {
|
|
58
62
|
type: "object",
|
|
59
63
|
properties: { query: { type: "string", description: "Pre-filter examples by keyword." } },
|
package/src/mcp-docs-tools.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function docsTools(
|
|
|
58
58
|
openWorldHint: false,
|
|
59
59
|
},
|
|
60
60
|
description:
|
|
61
|
-
'Get a VERIFIED, copy-pasteable nifra code example for a task - auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
|
|
61
|
+
'Get a VERIFIED, copy-pasteable nifra code example for a task - auth route, file upload, ISR page, loader/action, typed client, SSE, deployment, etc. Every snippet is typechecked against the installed nifra version, so it compiles as-is. PREFER THIS over writing nifra code from memory (training data drifts). Returns code as TEXT for direct use; to visually browse the whole example set instead, use nifra_gallery. Call with no query for the grouped index; pass query (e.g. "protected route", "upload", "isr revalidate") for matching snippets.',
|
|
62
62
|
inputSchema: {
|
|
63
63
|
type: "object",
|
|
64
64
|
properties: {
|
package/src/mcp-http.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The PUBLIC nifra docs MCP - Streamable-HTTP transport exposing the project-INDEPENDENT tools
|
|
3
|
-
* (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `
|
|
3
|
+
* (`nifra_docs`, `nifra_example`, `nifra_types`, `nifra_learn` + the `nifra_gallery` MCP Apps widget)
|
|
4
4
|
* so any remote AI agent can learn nifra without a local checkout. They read the bundled corpus
|
|
5
5
|
* (llms-full.txt / examples.json / types.json shipped in this package) and ignore `cwd`, so they reuse the
|
|
6
6
|
* exact definitions from {@link projectTools} - one source, no drift.
|
|
@@ -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 = "2.
|
|
32
|
+
const VERSION = "2.8.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 =>
|
|
@@ -38,7 +38,7 @@ const docsHealth = (tools: McpTool[]): string =>
|
|
|
38
38
|
.join(", ")}.`
|
|
39
39
|
|
|
40
40
|
/** The project-independent tools, reading the package's bundled corpus from disk (CLI use): the text
|
|
41
|
-
* docs tools plus the `
|
|
41
|
+
* docs tools plus the `nifra_gallery` MCP Apps widget tool. */
|
|
42
42
|
export function publicDocsTools(): McpTool[] {
|
|
43
43
|
return [
|
|
44
44
|
...docsTools(loadDocsCorpus, loadExamplesCorpus, loadTypesCorpus),
|
|
@@ -60,7 +60,7 @@ export function respondMcpHttp(
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** The CLI HTTP handler: serves the disk-backed corpus tools + registers the examples widget's `ui://`
|
|
63
|
-
* resource so MCP Apps hosts can render `
|
|
63
|
+
* resource so MCP Apps hosts can render `nifra_gallery`. (`nifra docs-mcp` / `bun run` this file.) */
|
|
64
64
|
export function handleMcpHttp(request: Request): Promise<Response> {
|
|
65
65
|
return respondMcpHttp(request, publicDocsTools(), {
|
|
66
66
|
features: {
|