@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/cli.ts ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env bun
2
+ import { inProcessClient } from "@nifrajs/client"
3
+ import { createWebApp, type RenderAdapter } from "@nifrajs/web"
4
+ import { discoverRoutes } from "@nifrajs/web/fs"
5
+ /**
6
+ * `nifra` — the zero-config CLI for a nifra app. Reads `framework.ts` + `backend.ts` + `routes/` from
7
+ * the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
8
+ *
9
+ * nifra dev true-HMR dev server (Vite middleware + nifra SSR) — @nifrajs/web/vite
10
+ * nifra build bundle the client (content-hashed) + write manifest.json — @nifrajs/web/build
11
+ * nifra start serve the built client + SSR on Bun.serve — @nifrajs/web
12
+ *
13
+ * Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
14
+ */
15
+ import type { BunPlugin } from "bun"
16
+ import { describeProject } from "./introspect.ts"
17
+ import { type LoadedApp, loadApp, resolvePlugins } from "./load.ts"
18
+
19
+ interface Flags {
20
+ readonly port: number
21
+ readonly out: string
22
+ readonly poll: boolean
23
+ }
24
+
25
+ const HELP = `nifra — zero-config dev/build/start for a nifra app
26
+
27
+ Usage:
28
+ nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite).
29
+ nifra build [--out <dir>] Bundle the client + write manifest.json.
30
+ nifra start [--port <n>] [--out <dir>] Serve the built app (client + SSR) on Bun.
31
+ nifra context Print this project's API + page routes + conventions
32
+ (for piping into an AI coding agent's prompt).
33
+ nifra mcp Start an MCP server (stdio) exposing this project to a coding
34
+ agent: nifra_context, nifra_routes (routes+schemas as JSON),
35
+ nifra_run (backend), nifra_render (SSR a page), nifra_docs,
36
+ nifra_example (verified snippets), nifra_scaffold (route→file),
37
+ nifra_check (drift gate + fixes), nifra_doctor (undeclared deps).
38
+ nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
39
+ self-host on a VPS so any remote agent can learn nifra. Default :8787.
40
+ nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
41
+ server-only imports in routes/). Run as "done"; --json for agents;
42
+ --lints-only skips tsc for a near-instant inner-loop pass.
43
+ nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
44
+ (resolve at Bun runtime, break tsc + standalone install);
45
+ --auto-fix writes safe local-version dependency fixes.
46
+
47
+ Reads nifra.config.ts (adapter + clientModule + plugins; or framework.ts), backend.ts (optional), and
48
+ routes/ from the current directory. Run from your project root.`
49
+
50
+ const CLI_VERSION = "0.1.0-alpha.1"
51
+
52
+ // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
53
+ const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
54
+ const asBunPlugins = (v: readonly unknown[]): BunPlugin[] => v as BunPlugin[]
55
+ const apiOf = (backend: unknown): { api?: unknown } =>
56
+ backend === undefined ? {} : { api: inProcessClient(backend as never) }
57
+
58
+ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
59
+ const { plugin } = await import("bun")
60
+ const { createViteDevServer } = await import("@nifrajs/web/vite")
61
+ const { framework: fw, routesDir, cwd, backend } = app
62
+ // nifra SSRs each request by importing the route modules through Bun (not Vite's ssrLoadModule), so
63
+ // `.vue`/`.svelte`/Solid routes need their SSR compile plugin registered on Bun's runtime first —
64
+ // the dev analog of `nifra start`. React/Preact JSX is Bun-native → no server plugins → no-op.
65
+ for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
66
+ const server = await createViteDevServer({
67
+ root: cwd,
68
+ routesDir,
69
+ clientModule: fw.clientModule,
70
+ plugins: await resolvePlugins(fw.vitePlugins),
71
+ poll: flags.poll,
72
+ port: flags.port,
73
+ ...(fw.conditions ? { conditions: fw.conditions } : {}),
74
+ ...(fw.define ? { define: fw.define } : {}),
75
+ createApp: (clientEntry, importQuery) =>
76
+ createWebApp({
77
+ adapter: asAdapter(fw.adapter),
78
+ manifest: discoverRoutes(routesDir, { importQuery }),
79
+ clientEntry,
80
+ ...apiOf(backend),
81
+ }),
82
+ })
83
+ console.log(`nifra dev → http://localhost:${server.port}`)
84
+ }
85
+
86
+ async function build(app: LoadedApp): Promise<void> {
87
+ const { buildClient } = await import("@nifrajs/web/build")
88
+ const { framework: fw, routesDir, outDir } = app
89
+ const manifest = await buildClient({
90
+ routesDir,
91
+ outDir,
92
+ clientModule: fw.clientModule,
93
+ plugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
94
+ define: { "process.env.NODE_ENV": '"production"', ...(fw.define ?? {}) },
95
+ ...(fw.conditions ? { conditions: fw.conditions } : {}),
96
+ })
97
+ console.log(`nifra build → ${outDir} (entry ${manifest.entry}${manifest.css ? ", + css" : ""})`)
98
+ }
99
+
100
+ interface BuiltManifest {
101
+ readonly entry: string
102
+ readonly routes?: Readonly<Record<string, readonly string[]>>
103
+ readonly css?: readonly string[]
104
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>>
105
+ }
106
+
107
+ async function start(app: LoadedApp, flags: Flags): Promise<void> {
108
+ const { plugin } = await import("bun")
109
+ const { framework: fw, routesDir, outDir, backend } = app
110
+ // Register the framework's SSR Bun plugins so `.vue`/`.svelte`/Solid route files compile on the
111
+ // server's runtime import (React/Preact JSX is Bun-native → none).
112
+ for (const p of asBunPlugins(await resolvePlugins(fw.serverPlugins))) plugin(p)
113
+
114
+ const manifestFile = Bun.file(`${outDir}/manifest.json`)
115
+ if (!(await manifestFile.exists())) {
116
+ throw new Error(`[nifra] no ${outDir}/manifest.json — run \`nifra build\` first.`)
117
+ }
118
+ const manifest = JSON.parse(await manifestFile.text()) as BuiltManifest
119
+
120
+ const server = createWebApp({
121
+ adapter: asAdapter(fw.adapter),
122
+ manifest: discoverRoutes(routesDir),
123
+ clientEntry: manifest.entry,
124
+ ...(manifest.routes ? { routePreload: manifest.routes } : {}),
125
+ ...(manifest.css ? { styles: manifest.css } : {}),
126
+ ...(manifest.routeStyles ? { routeStyles: manifest.routeStyles } : {}),
127
+ ...apiOf(backend),
128
+ })
129
+ // Serve the content-hashed bundle (immutable). `.css` → text/css, else JS. Name is path-validated.
130
+ server.get("/assets/*", async (c) => {
131
+ const name = new URL(c.req.url).pathname.slice("/assets/".length)
132
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })
133
+ const file = Bun.file(`${outDir}/${name}`)
134
+ if (!(await file.exists())) return new Response("Not Found", { status: 404 })
135
+ const type = name.endsWith(".css") ? "text/css" : "text/javascript"
136
+ return new Response(file, {
137
+ headers: {
138
+ "content-type": `${type}; charset=utf-8`,
139
+ "cache-control": "public, max-age=31536000, immutable",
140
+ },
141
+ })
142
+ })
143
+ const running = server.listen(flags.port)
144
+ console.log(`nifra start → http://localhost:${running.port}`)
145
+ }
146
+
147
+ function parseFlags(args: readonly string[]): Flags {
148
+ let port = Number(Bun.env.PORT ?? 3000)
149
+ let out = "dist"
150
+ let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
151
+ for (let i = 0; i < args.length; i++) {
152
+ const a = args[i]
153
+ if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
154
+ else if (a === "--out" && args[i + 1]) out = args[++i] as string
155
+ else if (a === "--poll") poll = true
156
+ }
157
+ if (!Number.isFinite(port) || port < 0 || port > 65535) {
158
+ throw new Error(`[nifra] invalid --port: ${port}`)
159
+ }
160
+ return { port, out, poll }
161
+ }
162
+
163
+ async function main(): Promise<void> {
164
+ const argv = Bun.argv.slice(2)
165
+ const command = argv[0]
166
+ if (command === undefined || command === "--help" || command === "-h" || command === "help") {
167
+ console.log(HELP)
168
+ return
169
+ }
170
+ if (command === "--version" || command === "-v") {
171
+ console.log(`nifra (@nifrajs/cli) ${CLI_VERSION}`)
172
+ return
173
+ }
174
+ // `mcp` runs a long-lived stdio server and loads the project lazily per-tool — it must not go through
175
+ // the eager `loadApp` below (which would fail fast on a project that's API-only / not yet built).
176
+ if (command === "mcp") {
177
+ const { runMcpServer } = await import("./mcp.ts")
178
+ await runMcpServer(process.cwd(), CLI_VERSION)
179
+ return
180
+ }
181
+ // `docs-mcp` runs the PUBLIC docs MCP over HTTP — project-independent (serves the bundled corpus), so
182
+ // it self-hosts anywhere Bun runs (a VPS behind a reverse proxy, a container). Long-lived; no loadApp.
183
+ if (command === "docs-mcp") {
184
+ const { handleMcpHttp } = await import("./mcp-http.ts")
185
+ const portArg = argv[argv.indexOf("--port") + 1]
186
+ const port = Number(portArg && argv.includes("--port") ? portArg : (Bun.env.PORT ?? 8787))
187
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
188
+ throw new Error(`[nifra] invalid --port: ${port}`)
189
+ }
190
+ const server = Bun.serve({ port, fetch: handleMcpHttp })
191
+ console.log(`nifra docs MCP (HTTP) → ${server.url}`)
192
+ return
193
+ }
194
+ // `check` is a pure cwd-based gate (typecheck + lint) — it must run even when the project doesn't
195
+ // load (API-only, not built yet), so it dispatches before the eager `loadApp` below.
196
+ if (command === "check") {
197
+ const { runCheck } = await import("./check.ts")
198
+ if (
199
+ !(await runCheck(process.cwd(), {
200
+ json: argv.includes("--json"),
201
+ lintsOnly: argv.includes("--lints-only"),
202
+ }))
203
+ )
204
+ process.exitCode = 1
205
+ return
206
+ }
207
+ // `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
208
+ // so it runs on an API-only / not-yet-built project.
209
+ if (command === "doctor") {
210
+ const { runDoctor } = await import("./doctor.ts")
211
+ if (
212
+ !(await runDoctor(process.cwd(), {
213
+ json: argv.includes("--json"),
214
+ autoFix: argv.includes("--auto-fix") || argv.includes("--fix"),
215
+ }))
216
+ )
217
+ process.exitCode = 1
218
+ return
219
+ }
220
+ if (command !== "dev" && command !== "build" && command !== "start" && command !== "context") {
221
+ console.error(`[nifra] unknown command: ${command}\n`)
222
+ console.error(HELP)
223
+ process.exitCode = 1
224
+ return
225
+ }
226
+ const flags = parseFlags(argv.slice(1))
227
+ const app = await loadApp(process.cwd(), flags.out)
228
+ if (command === "dev") await dev(app, flags)
229
+ else if (command === "build") await build(app)
230
+ else if (command === "context") console.log(describeProject(app))
231
+ else await start(app, flags)
232
+ }
233
+
234
+ main().catch((err) => {
235
+ console.error(err instanceof Error ? err.message : String(err))
236
+ process.exitCode = 1
237
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `nifra_docs` — searchable framework documentation for agents. The corpus is the generated
3
+ * `llms-full.txt` (written into this package by `bun run gen:llms`, so it can't drift from the
4
+ * source it's generated from). Instead of dumping the whole ~150 KB into an agent's context, the
5
+ * tool keyword-scores the document's `##` sections and returns only the top matches — or, with no
6
+ * query, just the section index (the cheap discovery call).
7
+ *
8
+ * Deliberately NOT a vector search: the corpus is one curated document with descriptive headings;
9
+ * weighted keyword scoring over sections is deterministic, dependency-free, and good enough that
10
+ * embedding infra would be complexity without a payoff.
11
+ */
12
+
13
+ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-terms.ts"
14
+
15
+ export interface DocSection {
16
+ readonly heading: string
17
+ readonly body: string
18
+ }
19
+
20
+ /** Split the generated doc into its `##` sections (the `# title` intro becomes "Overview"). */
21
+ export function splitSections(markdown: string): DocSection[] {
22
+ const out: DocSection[] = []
23
+ const lines = markdown.split("\n")
24
+ let heading = "Overview"
25
+ let buf: string[] = []
26
+ const flush = (): void => {
27
+ const body = buf.join("\n").trim()
28
+ if (body.length > 0) out.push({ heading, body })
29
+ buf = []
30
+ }
31
+ for (const line of lines) {
32
+ if (line.startsWith("## ")) {
33
+ flush()
34
+ heading = line.slice(3).trim()
35
+ } else {
36
+ buf.push(line)
37
+ }
38
+ }
39
+ flush()
40
+ return out
41
+ }
42
+
43
+ export interface ScoredSection extends DocSection {
44
+ readonly score: number
45
+ }
46
+
47
+ /** Score sections against the query: a term in the HEADING is worth far more than body mentions
48
+ * (headings are curated names for exactly these lookups); body occurrences cap per term so one
49
+ * spammy section can't drown a precisely-titled one. */
50
+ export function searchSections(
51
+ sections: readonly DocSection[],
52
+ query: string,
53
+ limit: number,
54
+ ): ScoredSection[] {
55
+ const terms = queryTermGroups(query)
56
+ if (terms.length === 0) return []
57
+ const scored: ScoredSection[] = []
58
+ for (const section of sections) {
59
+ const headingTokens = new Set(tokenize(section.heading))
60
+ const bodyLower = section.body.toLowerCase()
61
+ let score = 0
62
+ for (const term of terms) {
63
+ if (tokenSetHas(headingTokens, term)) score += 8
64
+ score += countBodyHits(bodyLower, term, 5)
65
+ }
66
+ if (score > 0) scored.push({ ...section, score })
67
+ }
68
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit)
69
+ }
70
+
71
+ /** Cap a section body for the tool result — whole sections can be pages long; the agent can ask
72
+ * again with a narrower query if the trim cuts what it needed. */
73
+ const MAX_SECTION_CHARS = 2800
74
+
75
+ /** Render the tool result: matched sections, or the index when no query was given. */
76
+ export function renderDocsResult(
77
+ markdown: string,
78
+ query: string | undefined,
79
+ limit: number,
80
+ ): string {
81
+ const sections = splitSections(markdown)
82
+ if (query === undefined || query.trim() === "") {
83
+ const index = sections.map((s) => `- ${s.heading}`).join("\n")
84
+ return `# nifra docs — section index (pass \`query\` to fetch matching sections)\n\n${index}`
85
+ }
86
+ const matches = searchSections(sections, query, limit)
87
+ if (matches.length === 0) {
88
+ return `No docs sections matched ${JSON.stringify(query)}. Call without a query for the section index.`
89
+ }
90
+ return matches
91
+ .map((m) => {
92
+ const body =
93
+ m.body.length > MAX_SECTION_CHARS
94
+ ? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed — narrow the query for the rest)`
95
+ : m.body
96
+ return `## ${m.heading}\n\n${body}`
97
+ })
98
+ .join("\n\n---\n\n")
99
+ }
100
+
101
+ /** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
102
+ * and `dist/` (published) alike — `docs/llms-full.txt` sits at the package root next to both. */
103
+ export async function loadDocsCorpus(): Promise<string | undefined> {
104
+ const url = new URL("../docs/llms-full.txt", import.meta.url)
105
+ try {
106
+ const text = await Bun.file(url).text()
107
+ return text.length > 0 ? text : undefined
108
+ } catch {
109
+ return undefined
110
+ }
111
+ }