@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.
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `nifra_examples_app` MCP tool: nifra's verified code examples as an MCP Apps widget. It returns 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: "nifra_examples_app",
54
- annotations: { title: "Browse nifra examples", readOnlyHint: true, openWorldHint: false },
53
+ name: "nifra_gallery",
54
+ annotations: {
55
+ title: "Browse the nifra example gallery",
56
+ readOnlyHint: true,
57
+ openWorldHint: false,
58
+ },
55
59
  description:
56
- "Browse nifra's verified code examples as an interactive, filterable list (MCP Apps widget). Pass query to pre-filter; the widget also filters client-side.",
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." } },
@@ -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 `nifra_examples_app` MCP Apps widget)
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.7.1"
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 `nifra_examples_app` MCP Apps widget tool. */
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 `nifra_examples_app`. (`nifra docs-mcp` / `bun run` this file.) */
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: {