@nifrajs/cli 1.12.0 → 2.0.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/README.md +17 -2
- package/dist/check.d.ts +17 -6
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +256 -15
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +58 -71
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +131 -61
- package/dist/doctor.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +42 -0
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/2.0.0.d.ts +9 -0
- package/dist/recipes/2.0.0.d.ts.map +1 -0
- package/dist/recipes/2.0.0.js +25 -0
- package/dist/recipes/2.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/sync-manifest.d.ts +33 -0
- package/dist/sync-manifest.d.ts.map +1 -0
- package/dist/sync-manifest.js +97 -0
- package/dist/sync-manifest.js.map +1 -0
- package/dist/upgrade.d.ts +23 -2
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +144 -13
- package/dist/upgrade.js.map +1 -1
- package/docs/examples.json +69 -21
- package/docs/llms-full.txt +1065 -104
- package/docs/types.json +933 -181
- package/package.json +9 -9
- package/src/check.ts +251 -18
- package/src/cli.ts +57 -73
- package/src/doctor.ts +160 -61
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +52 -0
- package/src/recipes/2.0.0.ts +26 -0
- package/src/recipes/index.ts +10 -0
- package/src/sync-manifest.ts +111 -0
- package/src/upgrade.ts +163 -11
package/src/cli.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* the project root (see {@link loadApp}) and wires the right `@nifrajs/web` entrypoint:
|
|
5
5
|
*
|
|
6
6
|
* nifra dev true-HMR dev server (Vite middleware + nifra SSR) — @nifrajs/web/vite
|
|
7
|
-
* nifra build
|
|
8
|
-
* nifra start
|
|
7
|
+
* nifra build emit a complete target-specific deploy directory — @nifrajs/web/build
|
|
8
|
+
* nifra start run the default Bun build — dist/server.js
|
|
9
9
|
*
|
|
10
10
|
* Bun-only (it runs the framework's TS + Bun plugins directly). The *output* runs anywhere.
|
|
11
11
|
*/
|
|
@@ -22,9 +22,8 @@ export interface Flags {
|
|
|
22
22
|
readonly port: number
|
|
23
23
|
readonly out: string
|
|
24
24
|
readonly poll: boolean
|
|
25
|
-
/** `nifra build --target <t>`: emit a full deploy dir for
|
|
26
|
-
|
|
27
|
-
readonly target?: string
|
|
25
|
+
/** `nifra build --target <t>`: emit a full deploy dir for this target. Defaults to `bun`. */
|
|
26
|
+
readonly target: string
|
|
28
27
|
/** `nifra build --report`: print a per-chunk size + gzip table after the build. */
|
|
29
28
|
readonly report: boolean
|
|
30
29
|
}
|
|
@@ -33,15 +32,15 @@ const HELP = `nifra — zero-config dev/build/start for a nifra app
|
|
|
33
32
|
|
|
34
33
|
Usage:
|
|
35
34
|
nifra dev [--port <n>] [--poll] Start the true-HMR dev server (Vite). Default port ${DEFAULT_DEV_PORT}.
|
|
36
|
-
nifra build [--out <dir>]
|
|
37
|
-
[--target <t>]
|
|
35
|
+
nifra build [--out <dir>] [--report] Emit a complete Bun deploy directory by default.
|
|
36
|
+
[--target <t>] Target a FULL deploy dir for <t>:
|
|
38
37
|
bun | node | deno | cf-pages | vercel | static. Packages
|
|
39
38
|
buildClient + buildServer (+ prerender for static) so an app
|
|
40
39
|
no longer hand-writes build-<target>.ts + _worker.ts +
|
|
41
40
|
_routes.json. The server entry is generated from your
|
|
42
41
|
framework.ts (adapter) + backend.ts + routes/. --report prints
|
|
43
42
|
a per-chunk size + gzip table (biggest first).
|
|
44
|
-
nifra start [--port <n>] [--out <dir>]
|
|
43
|
+
nifra start [--port <n>] [--out <dir>] Run the Bun build at <out>/server.js. Default port ${DEFAULT_DEV_PORT}.
|
|
45
44
|
nifra context Print this project's route INDEX (API + page routes) + conventions
|
|
46
45
|
for an AI agent's prompt. Per-route schemas: nifra mcp's
|
|
47
46
|
nifra_context (path/kind slice) or nifra routes --json.
|
|
@@ -59,12 +58,16 @@ Usage:
|
|
|
59
58
|
agent: nifra_context, nifra_routes (routes+schemas as JSON),
|
|
60
59
|
nifra_run (backend), nifra_render (SSR a page), nifra_docs,
|
|
61
60
|
nifra_example (verified snippets), nifra_scaffold (route→file),
|
|
62
|
-
nifra_check (drift gate + fixes),
|
|
61
|
+
nifra_check (drift gate + fixes), nifra_levels (verification
|
|
62
|
+
ladder), nifra_doctor (deps + duplicate installs).
|
|
63
63
|
nifra docs-mcp [--port <n>] Serve the PUBLIC docs MCP over HTTP (nifra_docs + nifra_example) —
|
|
64
64
|
self-host on a VPS so any remote agent can learn nifra. Default :8787.
|
|
65
65
|
nifra check [--json] [--lints-only] Gate: typecheck + lints (hand-rolled fetch(), untyped client("…"),
|
|
66
66
|
server-only imports in routes/). Run as "done"; --json for agents;
|
|
67
67
|
--lints-only skips tsc for a near-instant inner-loop pass.
|
|
68
|
+
nifra sync-manifest Regenerate a committed web server-manifest.ts from routes/ WITHOUT
|
|
69
|
+
a full build - clears server-manifest drift after a route add/rename
|
|
70
|
+
(a new hydrating component still needs a full build).
|
|
68
71
|
nifra snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
|
|
69
72
|
JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
|
|
70
73
|
nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
|
|
@@ -95,8 +98,8 @@ Usage:
|
|
|
95
98
|
@nifrajs/core/React identity-sensitive dependencies;
|
|
96
99
|
--auto-fix writes safe local-version dependency fixes.
|
|
97
100
|
nifra upgrade <version> Run the per-release upgrade recipe for <version>: sweep every
|
|
98
|
-
[--write] [--no-verify] matching dependency pin (preserving ^/~/exact)
|
|
99
|
-
[--list] [--json]
|
|
101
|
+
[--write] [--no-verify] matching dependency pin (preserving ^/~/exact), move removed
|
|
102
|
+
[--list] [--json] packages, apply exact imports, then verify with nifra check.
|
|
100
103
|
Dry-run by default; --write applies then verifies (--no-verify
|
|
101
104
|
to skip). --list shows available targets. Fail-closed on an
|
|
102
105
|
unknown version. Deterministic + idempotent.
|
|
@@ -114,7 +117,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
|
|
|
114
117
|
\`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
|
|
115
118
|
|
|
116
119
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
117
|
-
const CLI_VERSION = "
|
|
120
|
+
const CLI_VERSION = "2.0.0"
|
|
118
121
|
|
|
119
122
|
// A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
|
|
120
123
|
const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
|
|
@@ -123,6 +126,17 @@ const apiOf = (backend: unknown): { api?: unknown } =>
|
|
|
123
126
|
backend === undefined ? {} : { api: inProcessClient(backend as never) }
|
|
124
127
|
|
|
125
128
|
async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
129
|
+
// Preflight: `nifra dev` needs `vite` resolvable from the project. Run via `bunx @nifrajs/cli dev` the CLI
|
|
130
|
+
// sits in an isolated install where the project's peer deps don't resolve, so the vite import below fails
|
|
131
|
+
// with an opaque ERR_MODULE_NOT_FOUND. Surface the real fix instead.
|
|
132
|
+
try {
|
|
133
|
+
Bun.resolveSync("vite", app.cwd)
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(
|
|
136
|
+
"[nifra] `nifra dev` needs `vite` installed in this project. Run it via your workspace-local " +
|
|
137
|
+
"script (`bun run dev`), not `bunx @nifrajs/cli dev` (which can't resolve the project's peers).",
|
|
138
|
+
)
|
|
139
|
+
}
|
|
126
140
|
const { plugin } = await import("bun")
|
|
127
141
|
const { createViteDevServer } = await import("@nifrajs/web/vite")
|
|
128
142
|
const { framework: fw, routesDir, cwd, backend } = app
|
|
@@ -150,20 +164,6 @@ async function dev(app: LoadedApp, flags: Flags): Promise<void> {
|
|
|
150
164
|
console.log(`nifra dev → http://localhost:${server.port}`)
|
|
151
165
|
}
|
|
152
166
|
|
|
153
|
-
async function build(app: LoadedApp): Promise<void> {
|
|
154
|
-
const { buildClient } = await import("@nifrajs/web/build")
|
|
155
|
-
const { framework: fw, routesDir, outDir } = app
|
|
156
|
-
const manifest = await buildClient({
|
|
157
|
-
routesDir,
|
|
158
|
-
outDir,
|
|
159
|
-
clientModule: fw.clientModule,
|
|
160
|
-
plugins: asBunPlugins(await resolvePlugins(fw.clientPlugins)),
|
|
161
|
-
define: { "process.env.NODE_ENV": '"production"', ...(fw.define ?? {}) },
|
|
162
|
-
...(fw.conditions ? { conditions: fw.conditions } : {}),
|
|
163
|
-
})
|
|
164
|
-
console.log(`nifra build → ${outDir} (entry ${manifest.entry}${manifest.css ? ", + css" : ""})`)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
167
|
/**
|
|
168
168
|
* `nifra build --target <t>` — package the engine (buildClient + buildServer + prerender) into one
|
|
169
169
|
* command that emits a full deploy dir, so an app no longer hand-writes build-bun.ts + _worker.ts +
|
|
@@ -206,12 +206,12 @@ async function buildForTarget(app: LoadedApp, target: string, report: boolean):
|
|
|
206
206
|
if (report) console.log(`\n${renderSizeReport(result.size)}`)
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
/** Build the app FACTORY for the `static` target's prerender pass.
|
|
209
|
+
/** Build the app FACTORY for the `static` target's prerender pass. Registers the
|
|
210
210
|
* framework's SSR Bun plugins (so `.vue`/`.svelte`/Solid routes import) once, then return a factory that
|
|
211
211
|
* `createWebApp`s the app for the client build's manifest. The client entry MUST be the real content-hashed
|
|
212
212
|
* bundle (`client.entry`) — it's the hydration `<script src>` the prerendered HTML emits, so a placeholder
|
|
213
213
|
* would 404 and the pages would render but never hydrate (inert controls). Styles/route-preload are wired
|
|
214
|
-
* from the same manifest so the static HTML matches
|
|
214
|
+
* from the same manifest so the static HTML matches the deployed app. */
|
|
215
215
|
async function buildPrerenderApp(
|
|
216
216
|
app: LoadedApp,
|
|
217
217
|
): Promise<(client: BuiltManifest) => { fetch(req: Request): Response | Promise<Response> }> {
|
|
@@ -238,43 +238,23 @@ interface BuiltManifest {
|
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
async function start(app: LoadedApp, flags: Flags): Promise<void> {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
const serverFile = resolve(app.outDir, "server.js")
|
|
242
|
+
if (!existsSync(serverFile)) {
|
|
243
|
+
// A Cloudflare Pages build emits `_worker.js` (a Workers bundle), not a self-hosting `server.js` - so
|
|
244
|
+
// `nifra start` on a dir built for cf-pages would otherwise fail with a bare "no server.js". Name the
|
|
245
|
+
// actual mismatch and the fix.
|
|
246
|
+
if (existsSync(resolve(app.outDir, "_worker.js"))) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[nifra] ${app.outDir} holds a Cloudflare Pages worker bundle (_worker.js), not a self-hosting ` +
|
|
249
|
+
"server. Run `nifra build --target bun` then `nifra start`, or serve this dir with `wrangler pages`.",
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
throw new Error(
|
|
253
|
+
`[nifra] no ${serverFile} — run \`nifra build\` or \`nifra build --target bun\` first.`,
|
|
254
|
+
)
|
|
250
255
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const server = createWebApp({
|
|
254
|
-
adapter: asAdapter(fw.adapter),
|
|
255
|
-
manifest: discoverRoutes(routesDir),
|
|
256
|
-
clientEntry: manifest.entry,
|
|
257
|
-
...(manifest.routes ? { routePreload: manifest.routes } : {}),
|
|
258
|
-
...(manifest.css ? { styles: manifest.css } : {}),
|
|
259
|
-
...(manifest.routeStyles ? { routeStyles: manifest.routeStyles } : {}),
|
|
260
|
-
...apiOf(backend),
|
|
261
|
-
})
|
|
262
|
-
// Serve the content-hashed bundle (immutable). `.css` → text/css, else JS. Name is path-validated.
|
|
263
|
-
server.get("/assets/*", async (c) => {
|
|
264
|
-
const name = new URL(c.req.url).pathname.slice("/assets/".length)
|
|
265
|
-
if (!/^[A-Za-z0-9._-]+$/.test(name)) return new Response("bad request", { status: 400 })
|
|
266
|
-
const file = Bun.file(`${outDir}/${name}`)
|
|
267
|
-
if (!(await file.exists())) return new Response("Not Found", { status: 404 })
|
|
268
|
-
const type = name.endsWith(".css") ? "text/css" : "text/javascript"
|
|
269
|
-
return new Response(file, {
|
|
270
|
-
headers: {
|
|
271
|
-
"content-type": `${type}; charset=utf-8`,
|
|
272
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
273
|
-
},
|
|
274
|
-
})
|
|
275
|
-
})
|
|
276
|
-
const running = server.listen(flags.port)
|
|
277
|
-
console.log(`nifra start → http://localhost:${running.port}`)
|
|
256
|
+
Bun.env.PORT = String(flags.port)
|
|
257
|
+
await import(serverFile)
|
|
278
258
|
}
|
|
279
259
|
|
|
280
260
|
export function parseFlags(args: readonly string[]): Flags {
|
|
@@ -284,20 +264,20 @@ export function parseFlags(args: readonly string[]): Flags {
|
|
|
284
264
|
let port = Number(Bun.env.PORT ?? DEFAULT_DEV_PORT)
|
|
285
265
|
let out = "dist"
|
|
286
266
|
let poll = Bun.env.CHOKIDAR_USEPOLLING === "1"
|
|
287
|
-
let target
|
|
267
|
+
let target = "bun"
|
|
288
268
|
let report = false
|
|
289
269
|
for (let i = 0; i < args.length; i++) {
|
|
290
270
|
const a = args[i]
|
|
291
271
|
if ((a === "--port" || a === "-p") && args[i + 1]) port = Number(args[++i])
|
|
292
272
|
else if (a === "--out" && args[i + 1]) out = args[++i] as string
|
|
293
273
|
else if (a === "--poll") poll = true
|
|
294
|
-
else if (a === "--target" && args[i + 1]) target = args[++i]
|
|
274
|
+
else if (a === "--target" && args[i + 1]) target = args[++i] as string
|
|
295
275
|
else if (a === "--report") report = true
|
|
296
276
|
}
|
|
297
277
|
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
|
298
278
|
throw new Error(`[nifra] invalid --port: ${port}`)
|
|
299
279
|
}
|
|
300
|
-
return { port, out, poll,
|
|
280
|
+
return { port, out, poll, target, report }
|
|
301
281
|
}
|
|
302
282
|
|
|
303
283
|
async function main(): Promise<void> {
|
|
@@ -355,6 +335,14 @@ async function main(): Promise<void> {
|
|
|
355
335
|
})
|
|
356
336
|
return
|
|
357
337
|
}
|
|
338
|
+
// `sync-manifest` rewrites a committed web `server-manifest.ts` from the current `routes/` tree, without
|
|
339
|
+
// a full build. Pure fs/text (re-scan routes, preserve baked client assets), so - like `check` - dispatch
|
|
340
|
+
// it before the eager `loadApp`.
|
|
341
|
+
if (command === "sync-manifest") {
|
|
342
|
+
const { runSyncManifest } = await import("./sync-manifest.ts")
|
|
343
|
+
if (!(await runSyncManifest(process.cwd()))) process.exitCode = 1
|
|
344
|
+
return
|
|
345
|
+
}
|
|
358
346
|
// `doctor` is a pure cwd check (imports vs declared deps) — like `check`, dispatch before `loadApp`
|
|
359
347
|
// so it runs on an API-only / not-yet-built project.
|
|
360
348
|
if (command === "doctor") {
|
|
@@ -600,12 +588,8 @@ async function main(): Promise<void> {
|
|
|
600
588
|
const flags = parseFlags(argv.slice(1))
|
|
601
589
|
const app = await loadApp(process.cwd(), flags.out)
|
|
602
590
|
if (command === "dev") await dev(app, flags)
|
|
603
|
-
else if (command === "build")
|
|
604
|
-
|
|
605
|
-
if (flags.target !== undefined || flags.report) {
|
|
606
|
-
await buildForTarget(app, flags.target ?? "cf-pages", flags.report)
|
|
607
|
-
} else await build(app)
|
|
608
|
-
} else if (command === "context") console.log(describeProject(app))
|
|
591
|
+
else if (command === "build") await buildForTarget(app, flags.target, flags.report)
|
|
592
|
+
else if (command === "context") console.log(describeProject(app))
|
|
609
593
|
else if (command === "routes") {
|
|
610
594
|
console.log(await describeRoutes(app, { json: argv.includes("--json") }))
|
|
611
595
|
} else await start(app, flags)
|
package/src/doctor.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { realpath } from "node:fs/promises"
|
|
14
14
|
import { builtinModules } from "node:module"
|
|
15
15
|
import { dirname, join, relative } from "node:path"
|
|
16
|
-
import { type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
16
|
+
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
17
17
|
|
|
18
18
|
// Runtime-provided modules that are never an npm dependency: Node core (bare + `node:` form) and Bun's
|
|
19
19
|
// own `bun` module. `node:`/`bun:`-prefixed specifiers are filtered in packageOf by prefix.
|
|
@@ -89,9 +89,13 @@ export function scanUndeclaredImports(
|
|
|
89
89
|
const out: SourceFinding[] = []
|
|
90
90
|
const seen = new Set<string>()
|
|
91
91
|
const code = stripComments(content) // so doc-comment usage examples aren't read as real imports
|
|
92
|
+
const positions = codePositionMask(content)
|
|
92
93
|
for (const rx of IMPORT_PATTERNS) {
|
|
93
94
|
rx.lastIndex = 0
|
|
94
95
|
for (let m = rx.exec(code); m !== null; m = rx.exec(code)) {
|
|
96
|
+
// The specifier regex must run over quoted literals, but its import/require token must begin in
|
|
97
|
+
// executable code. Otherwise documentation and code-generator strings look like real imports.
|
|
98
|
+
if (positions[m.index] === " ") continue
|
|
95
99
|
const spec = m[1] ?? ""
|
|
96
100
|
if (isAlias(spec)) continue
|
|
97
101
|
const pkg = packageOf(spec)
|
|
@@ -191,11 +195,13 @@ function workspacePatterns(pkg: Record<string, unknown>): string[] {
|
|
|
191
195
|
.sort()
|
|
192
196
|
}
|
|
193
197
|
|
|
194
|
-
/** Discover package roots declared by npm/Bun's
|
|
195
|
-
*
|
|
198
|
+
/** Discover package roots declared by npm/Bun's workspace manifest shapes plus nested package
|
|
199
|
+
* boundaries (scaffold templates and benchmark fixtures are commonly real standalone packages even
|
|
200
|
+
* when they are not workspace members). Bounded so a hostile tree cannot make doctor unbounded. */
|
|
196
201
|
async function workspaceImporters(
|
|
197
202
|
cwd: string,
|
|
198
203
|
rootPackage: Record<string, unknown>,
|
|
204
|
+
includeNestedPackages = false,
|
|
199
205
|
): Promise<Array<{ root: string; package: Record<string, unknown> }>> {
|
|
200
206
|
const manifests = new Set<string>([join(cwd, "package.json")])
|
|
201
207
|
for (const pattern of workspacePatterns(rootPackage)) {
|
|
@@ -208,6 +214,22 @@ async function workspaceImporters(
|
|
|
208
214
|
if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
|
|
209
215
|
}
|
|
210
216
|
}
|
|
217
|
+
if (includeNestedPackages) {
|
|
218
|
+
for await (const rel of new Bun.Glob("**/package.json").scan({ cwd, dot: false })) {
|
|
219
|
+
const segments = rel.split(/[\\/]/)
|
|
220
|
+
if (
|
|
221
|
+
segments.some((segment) =>
|
|
222
|
+
["node_modules", "dist", "build", ".git", ".nifra", ".next", "coverage"].includes(
|
|
223
|
+
segment,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
) {
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
manifests.add(join(cwd, rel))
|
|
230
|
+
if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
|
|
231
|
+
}
|
|
232
|
+
}
|
|
211
233
|
|
|
212
234
|
const out: Array<{ root: string; package: Record<string, unknown> }> = []
|
|
213
235
|
for (const manifest of [...manifests].sort()) {
|
|
@@ -217,6 +239,62 @@ async function workspaceImporters(
|
|
|
217
239
|
return out
|
|
218
240
|
}
|
|
219
241
|
|
|
242
|
+
interface DoctorPackageScope {
|
|
243
|
+
readonly root: string
|
|
244
|
+
readonly relativeRoot: string
|
|
245
|
+
readonly declared: ReadonlySet<string>
|
|
246
|
+
readonly isAlias: (specifier: string) => boolean
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const declaredPackages = (pkg: Record<string, unknown>): ReadonlySet<string> => {
|
|
250
|
+
const declared = new Set<string>()
|
|
251
|
+
if (typeof pkg.name === "string") declared.add(pkg.name)
|
|
252
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
253
|
+
for (const name of depNames(pkg, field)) declared.add(name)
|
|
254
|
+
}
|
|
255
|
+
return declared
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const tsconfigPaths = async (
|
|
259
|
+
root: string,
|
|
260
|
+
): Promise<Readonly<Record<string, unknown>> | undefined> => {
|
|
261
|
+
const tsconfig = await readJson(join(root, "tsconfig.json"))
|
|
262
|
+
const compilerOptions = tsconfig?.compilerOptions as
|
|
263
|
+
| { paths?: Record<string, unknown> }
|
|
264
|
+
| undefined
|
|
265
|
+
return compilerOptions?.paths
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build per-package declaration scopes for a workspace. Longest roots are first so nested packages
|
|
269
|
+
* own their source; files outside a workspace package remain owned by the root manifest. */
|
|
270
|
+
async function doctorPackageScopes(
|
|
271
|
+
cwd: string,
|
|
272
|
+
rootPackage: Record<string, unknown>,
|
|
273
|
+
): Promise<readonly DoctorPackageScope[]> {
|
|
274
|
+
const importers = await workspaceImporters(cwd, rootPackage, true)
|
|
275
|
+
const packages = importers.length > 0 ? importers : [{ root: cwd, package: rootPackage }]
|
|
276
|
+
const rootPaths = await tsconfigPaths(cwd)
|
|
277
|
+
const scopes = await Promise.all(
|
|
278
|
+
packages.map(async (entry): Promise<DoctorPackageScope> => {
|
|
279
|
+
const paths = (await tsconfigPaths(entry.root)) ?? rootPaths
|
|
280
|
+
return {
|
|
281
|
+
root: entry.root,
|
|
282
|
+
relativeRoot: relative(cwd, entry.root).split("\\").join("/"),
|
|
283
|
+
declared: declaredPackages(entry.package),
|
|
284
|
+
isAlias: aliasMatcher(paths),
|
|
285
|
+
}
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
return scopes.sort((a, b) => b.relativeRoot.length - a.relativeRoot.length)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const scopeForFile = (scopes: readonly DoctorPackageScope[], file: string): DoctorPackageScope =>
|
|
292
|
+
scopes.find(
|
|
293
|
+
(scope) =>
|
|
294
|
+
scope.relativeRoot !== "" &&
|
|
295
|
+
(file === scope.relativeRoot || file.startsWith(`${scope.relativeRoot}/`)),
|
|
296
|
+
) ?? (scopes.find((scope) => scope.relativeRoot === "") as DoctorPackageScope)
|
|
297
|
+
|
|
220
298
|
function duplicateTargets(pkg: Record<string, unknown>): string[] {
|
|
221
299
|
const targets = new Set<string>()
|
|
222
300
|
for (const field of DEPENDENCY_FIELDS) {
|
|
@@ -266,25 +344,40 @@ export async function collectDuplicateInstalls(
|
|
|
266
344
|
): Promise<DuplicateInstallFinding[]> {
|
|
267
345
|
const importers = await workspaceImporters(cwd, rootPackage)
|
|
268
346
|
const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
|
|
347
|
+
const record = (
|
|
348
|
+
name: string,
|
|
349
|
+
copy: { path: string; version: string },
|
|
350
|
+
importer: string,
|
|
351
|
+
): void => {
|
|
352
|
+
let copies = byPackage.get(name)
|
|
353
|
+
if (copies === undefined) {
|
|
354
|
+
copies = new Map()
|
|
355
|
+
byPackage.set(name, copies)
|
|
356
|
+
}
|
|
357
|
+
const entry = copies.get(copy.path) ?? { version: copy.version, importers: new Set<string>() }
|
|
358
|
+
entry.importers.add(importer)
|
|
359
|
+
copies.set(copy.path, entry)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const targets = new Set<string>()
|
|
269
363
|
for (const importer of importers) {
|
|
270
364
|
for (const name of duplicateTargets(importer.package)) {
|
|
271
365
|
if (importer.package.name === name) continue
|
|
366
|
+
targets.add(name)
|
|
272
367
|
const copy = await resolvedInstalledCopy(importer.root, cwd, name)
|
|
273
|
-
if (copy
|
|
274
|
-
let copies = byPackage.get(name)
|
|
275
|
-
if (copies === undefined) {
|
|
276
|
-
copies = new Map()
|
|
277
|
-
byPackage.set(name, copies)
|
|
278
|
-
}
|
|
279
|
-
const record = copies.get(copy.path) ?? {
|
|
280
|
-
version: copy.version,
|
|
281
|
-
importers: new Set<string>(),
|
|
282
|
-
}
|
|
283
|
-
record.importers.add(displayPath(cwd, importer.root))
|
|
284
|
-
copies.set(copy.path, record)
|
|
368
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
|
|
285
369
|
}
|
|
286
370
|
}
|
|
287
371
|
|
|
372
|
+
// Probe the workspace root for every target too, even when the root manifest doesn't declare it.
|
|
373
|
+
// A hoisted root copy is reachable from any package that lacks its own nested one, so leaving it
|
|
374
|
+
// out hides the very split it should catch: every declaring package nested onto one copy while the
|
|
375
|
+
// root resolved a different one, which reads as a single copy when only declarers are consulted.
|
|
376
|
+
for (const name of targets) {
|
|
377
|
+
const copy = await resolvedInstalledCopy(cwd, cwd, name)
|
|
378
|
+
if (copy !== undefined) record(name, copy, ".")
|
|
379
|
+
}
|
|
380
|
+
|
|
288
381
|
const findings: DuplicateInstallFinding[] = []
|
|
289
382
|
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
290
383
|
if (copies.size < 2) continue
|
|
@@ -362,21 +455,12 @@ export async function collectDoctorResult(cwd: string): Promise<DoctorResult> {
|
|
|
362
455
|
const pkg = await readJson(join(cwd, "package.json"))
|
|
363
456
|
if (pkg === undefined) return { ok: true, ran: false, findings: [], duplicateInstalls: [] }
|
|
364
457
|
|
|
365
|
-
const
|
|
366
|
-
if (typeof pkg.name === "string") declared.add(pkg.name) // a package may import its own name (exports map)
|
|
367
|
-
for (const field of DEPENDENCY_FIELDS) {
|
|
368
|
-
for (const name of depNames(pkg, field)) declared.add(name)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const tsconfig = await readJson(join(cwd, "tsconfig.json"))
|
|
372
|
-
const compilerOptions = tsconfig?.compilerOptions as
|
|
373
|
-
| { paths?: Record<string, unknown> }
|
|
374
|
-
| undefined
|
|
375
|
-
const isAlias = aliasMatcher(compilerOptions?.paths)
|
|
458
|
+
const scopes = await doctorPackageScopes(cwd, pkg)
|
|
376
459
|
|
|
377
460
|
const findings: DoctorFinding[] = []
|
|
378
461
|
await walkSource(cwd, (rel, content) => {
|
|
379
|
-
|
|
462
|
+
const scope = scopeForFile(scopes, rel)
|
|
463
|
+
for (const f of scanUndeclaredImports(rel, content, scope.declared, scope.isAlias)) {
|
|
380
464
|
findings.push({ file: f.file, line: f.line, package: f.snippet })
|
|
381
465
|
}
|
|
382
466
|
})
|
|
@@ -395,49 +479,64 @@ export async function applyDoctorAutoFix(cwd: string): Promise<DoctorResult> {
|
|
|
395
479
|
const before = await collectDoctorResult(cwd)
|
|
396
480
|
if (!before.ran || before.findings.length === 0) return before
|
|
397
481
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
command: ["bun", "add", name],
|
|
408
|
-
}),
|
|
409
|
-
)
|
|
410
|
-
return { ...before, skippedFixes }
|
|
482
|
+
const rootPackage = await readJson(join(cwd, "package.json"))
|
|
483
|
+
if (rootPackage === undefined) return before
|
|
484
|
+
const scopes = await doctorPackageScopes(cwd, rootPackage)
|
|
485
|
+
const byRoot = new Map<string, Set<string>>()
|
|
486
|
+
for (const finding of before.findings) {
|
|
487
|
+
const root = scopeForFile(scopes, finding.file).root
|
|
488
|
+
const names = byRoot.get(root) ?? new Set<string>()
|
|
489
|
+
names.add(finding.package)
|
|
490
|
+
byRoot.set(root, names)
|
|
411
491
|
}
|
|
412
492
|
|
|
413
|
-
const deps = (dependencies ?? {}) as Record<string, unknown>
|
|
414
|
-
if (dependencies === undefined) pkg.dependencies = deps
|
|
415
|
-
|
|
416
493
|
const fixed: DoctorAppliedFix[] = []
|
|
417
494
|
const skippedFixes: DoctorSkippedFix[] = []
|
|
418
|
-
for (const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
495
|
+
for (const [root, names] of [...byRoot.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
496
|
+
const pkgPath = join(root, "package.json")
|
|
497
|
+
const pkg = await readJson(pkgPath)
|
|
498
|
+
if (pkg === undefined) continue
|
|
499
|
+
const dependencies = pkg.dependencies
|
|
500
|
+
if (dependencies !== undefined && !isRecord(dependencies)) {
|
|
501
|
+
for (const name of [...names].sort()) {
|
|
502
|
+
skippedFixes.push({
|
|
503
|
+
package: name,
|
|
504
|
+
reason:
|
|
505
|
+
"`dependencies` exists but is not an object; refusing to rewrite it automatically",
|
|
506
|
+
command: ["bun", "add", name],
|
|
507
|
+
})
|
|
508
|
+
}
|
|
425
509
|
continue
|
|
426
510
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
511
|
+
|
|
512
|
+
const deps = (dependencies ?? {}) as Record<string, unknown>
|
|
513
|
+
if (dependencies === undefined) pkg.dependencies = deps
|
|
514
|
+
let changed = false
|
|
515
|
+
for (const name of [...names].sort()) {
|
|
516
|
+
if (!isNpmPackageName(name)) {
|
|
517
|
+
skippedFixes.push({
|
|
518
|
+
package: name,
|
|
519
|
+
reason: "package name did not match npm package-name syntax",
|
|
520
|
+
command: ["bun", "add", name],
|
|
521
|
+
})
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
const inferred = await inferDependencyFix(root, name)
|
|
525
|
+
if (inferred === undefined) {
|
|
526
|
+
skippedFixes.push({
|
|
527
|
+
package: name,
|
|
528
|
+
reason: "no declared ancestor version or installed package metadata was found locally",
|
|
529
|
+
command: ["bun", "add", name],
|
|
530
|
+
})
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
deps[name] = inferred.version
|
|
534
|
+
changed = true
|
|
535
|
+
fixed.push({ package: name, field: "dependencies", ...inferred })
|
|
435
536
|
}
|
|
436
|
-
|
|
437
|
-
fixed.push({ package: name, field: "dependencies", ...inferred })
|
|
537
|
+
if (changed) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
438
538
|
}
|
|
439
539
|
|
|
440
|
-
if (fixed.length > 0) await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
|
|
441
540
|
const after = fixed.length > 0 ? await collectDoctorResult(cwd) : before
|
|
442
541
|
return {
|
|
443
542
|
...after,
|
package/src/mcp-http.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { docsTools } from "./mcp-docs-tools.ts"
|
|
|
26
26
|
export type { TypeEntry } from "./types-search.ts"
|
|
27
27
|
|
|
28
28
|
// Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
|
|
29
|
-
const VERSION = "
|
|
29
|
+
const VERSION = "2.0.0"
|
|
30
30
|
const SERVER_INFO = { name: "nifra-docs", version: VERSION }
|
|
31
31
|
const DOCS_HEALTH =
|
|
32
32
|
"nifra docs MCP — POST JSON-RPC 2.0 here (methods: initialize, tools/list, tools/call). Tools: nifra_docs, nifra_example."
|
package/src/mcp.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* - `nifra_scaffold`— map a URL path to the correct `routes/` file + a contract-correct page stub.
|
|
16
16
|
* - `nifra_check` — the drift gate (typecheck + lints, each with a structured fix), for an agent to fix against.
|
|
17
17
|
* - `nifra_assure` — route classification + enforcement-evidence gate.
|
|
18
|
+
* - `nifra_levels` - the cumulative verification ladder (L0 contract → L4 invariants): what the
|
|
19
|
+
* project actually proves, and why each level it misses does not hold.
|
|
18
20
|
* - `nifra_doctor` — package.json dependency drift detector, with safe local-version auto-fix.
|
|
19
21
|
*
|
|
20
22
|
* Wire it into a client (e.g. Claude Desktop / Cursor) as: command `nifra`, args `["mcp"]`, run in the
|
|
@@ -988,6 +990,56 @@ export function projectTools(
|
|
|
988
990
|
return JSON.stringify(await collectAssuranceReport(target, config), null, 2)
|
|
989
991
|
},
|
|
990
992
|
},
|
|
993
|
+
{
|
|
994
|
+
name: "nifra_levels",
|
|
995
|
+
description:
|
|
996
|
+
"Compute this project's verification ladder and return { achieved, levels[] }, where each level carries { level, name, ok, reasons[] }. The ladder is cumulative: L0 typed contract (nifra check), L1 route assurance, L2 reviewed capability lockfile, L3 route trust manifest in sync, L4 contract invariants, so a level only holds when every level below it holds, and `achieved` is -1 when even L0 fails. This is the single gate that reports what a change actually proves: run it after finishing work, and treat any `reasons` on a level the project already claimed as a regression to fix.",
|
|
997
|
+
inputSchema: {
|
|
998
|
+
type: "object",
|
|
999
|
+
properties: {
|
|
1000
|
+
config: {
|
|
1001
|
+
type: "string",
|
|
1002
|
+
description:
|
|
1003
|
+
"Assurance config path relative to the selected project directory. Default: nifra.assurance.ts.",
|
|
1004
|
+
},
|
|
1005
|
+
seed: {
|
|
1006
|
+
type: "number",
|
|
1007
|
+
description: "Deterministic seed for the L4 invariant run. Default 1.",
|
|
1008
|
+
},
|
|
1009
|
+
dir: {
|
|
1010
|
+
type: "string",
|
|
1011
|
+
description:
|
|
1012
|
+
'Evaluate this project subdirectory (relative to the MCP root), e.g. "apps/api". Default: the project root.',
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
additionalProperties: false,
|
|
1016
|
+
},
|
|
1017
|
+
handler: async (args) => {
|
|
1018
|
+
const opts = args as { config?: string; seed?: number; dir?: string }
|
|
1019
|
+
const target = resolveProjectDir(cwd, opts.dir)
|
|
1020
|
+
if (target === null) return dirError(opts.dir)
|
|
1021
|
+
if (opts.seed !== undefined && !Number.isSafeInteger(opts.seed)) {
|
|
1022
|
+
return JSON.stringify({ ok: false, error: "seed must be a safe integer" }, null, 2)
|
|
1023
|
+
}
|
|
1024
|
+
const config = opts.config === undefined ? undefined : resolve(target, opts.config)
|
|
1025
|
+
if (config !== undefined && config !== target && !config.startsWith(target + sep)) {
|
|
1026
|
+
return JSON.stringify(
|
|
1027
|
+
{ ok: false, error: "config must stay inside the selected project directory" },
|
|
1028
|
+
null,
|
|
1029
|
+
2,
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
const { collectVerificationLevels } = await import("./levels-tool.ts")
|
|
1033
|
+
return JSON.stringify(
|
|
1034
|
+
await collectVerificationLevels(target, {
|
|
1035
|
+
...(opts.config === undefined ? {} : { config: opts.config }),
|
|
1036
|
+
...(opts.seed === undefined ? {} : { seed: opts.seed }),
|
|
1037
|
+
}),
|
|
1038
|
+
null,
|
|
1039
|
+
2,
|
|
1040
|
+
)
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
991
1043
|
{
|
|
992
1044
|
name: "nifra_doctor",
|
|
993
1045
|
description:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { UpgradeRecipe } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Upgrade recipe for the Nifra 2.0 fixed-group cutover.
|
|
5
|
+
*
|
|
6
|
+
* The runner handles deterministic package pins and the removed budget import. Structural API changes
|
|
7
|
+
* stay as explicit notes so the tool never guesses at application intent.
|
|
8
|
+
*/
|
|
9
|
+
export const recipe: UpgradeRecipe = {
|
|
10
|
+
version: "2.0.0",
|
|
11
|
+
pins: [
|
|
12
|
+
{ match: "@nifrajs/", to: "2.0.0" },
|
|
13
|
+
{ match: "create-nifra", to: "2.0.0" },
|
|
14
|
+
{ match: "nifra", to: "2.0.0" },
|
|
15
|
+
],
|
|
16
|
+
dependencyMoves: [{ from: "@nifrajs/budget", to: "@nifrajs/core", toVersion: "2.0.0" }],
|
|
17
|
+
importMoves: [{ from: "@nifrajs/budget", to: "@nifrajs/core/budget" }],
|
|
18
|
+
notes: [
|
|
19
|
+
"Read the 1.x → 2.0 migration guide: https://nifra.dev/docs/migrate-2.",
|
|
20
|
+
"Optional server systems are instance-scoped plugins in 2.0. Add `.use(idempotency())`, `.use(effectLedger())`, `.use(mcp())`, `.use(streaming())`, `.use(websocket())`, or `.use(nodeDirect())` where used.",
|
|
21
|
+
"The old `@nifrajs/budget` package is removed. This recipe moves its dependency to `@nifrajs/core` and its imports to `@nifrajs/core/budget`.",
|
|
22
|
+
"Custom web backend mounts must implement the symbol-keyed BackendMount interface. `inProcessClient(app)` and `testClient(app)` already do.",
|
|
23
|
+
'If Better Auth or another library owns a non-Nifra route prefix, declare it in `nifra.check.json`, for example `{ "externalMounts": ["/auth"] }`.',
|
|
24
|
+
"Client failures with declared error schemas now narrow by `status`; narrow `res.status` before reading failure data.",
|
|
25
|
+
],
|
|
26
|
+
}
|