@nifrajs/cli 2.1.0 → 2.3.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 +25 -25
- package/dist/assure.d.ts +2 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +17 -10
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +7 -0
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +68 -16
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +64 -22
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +400 -83
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +37 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +291 -53
- package/dist/cli.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.js +7 -7
- package/dist/docs-search.d.ts +3 -3
- package/dist/docs-search.js +6 -6
- package/dist/doctor.d.ts +8 -4
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +74 -14
- package/dist/doctor.js.map +1 -1
- package/dist/examples.d.ts +5 -5
- package/dist/examples.js +5 -5
- package/dist/init-agents.d.ts +14 -14
- package/dist/init-agents.js +14 -14
- package/dist/internal/typescript-import.d.ts +24 -0
- package/dist/internal/typescript-import.d.ts.map +1 -0
- package/dist/internal/typescript-import.js +32 -0
- package/dist/internal/typescript-import.js.map +1 -0
- package/dist/introspect.d.ts +12 -12
- package/dist/introspect.js +24 -24
- package/dist/levels-tool.d.ts +8 -8
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -30
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +17 -6
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +45 -7
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +4 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-docs-tools.d.ts +1 -1
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +14 -10
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-http.d.ts +5 -5
- package/dist/mcp-http.js +7 -7
- package/dist/mcp-protocol.d.ts +2 -2
- package/dist/mcp-protocol.js +2 -2
- package/dist/mcp-render.d.ts +3 -3
- package/dist/mcp-render.js +15 -7
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-run.d.ts +2 -2
- package/dist/mcp-run.js +2 -2
- package/dist/mcp.d.ts +14 -14
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +119 -47
- package/dist/mcp.js.map +1 -1
- package/dist/openapi-tool.d.ts +1 -1
- package/dist/openapi-tool.js +2 -2
- package/dist/pipeline-guard.d.ts +83 -0
- package/dist/pipeline-guard.d.ts.map +1 -0
- package/dist/pipeline-guard.js +136 -0
- package/dist/pipeline-guard.js.map +1 -0
- package/dist/port.d.ts +9 -9
- package/dist/port.js +27 -27
- package/dist/recipes/1.8.0.d.ts +2 -2
- package/dist/recipes/1.8.0.js +5 -5
- package/dist/recipes/index.d.ts +2 -2
- package/dist/recipes/index.js +1 -1
- package/dist/scaffold.d.ts +3 -3
- package/dist/scaffold.js +8 -8
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.d.ts.map +1 -0
- package/dist/sync-routes.js +55 -0
- package/dist/sync-routes.js.map +1 -0
- package/dist/types-search.d.ts +6 -6
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +53 -8
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +7 -7
- package/dist/upgrade.js +12 -12
- package/dist/verification.d.ts +51 -0
- package/dist/verification.d.ts.map +1 -0
- package/dist/verification.js +80 -0
- package/dist/verification.js.map +1 -0
- package/docs/examples.json +178 -88
- package/docs/llms-full.txt +1766 -766
- package/docs/types.json +2171 -2807
- package/package.json +22 -11
- package/src/assure.ts +17 -18
- package/src/capabilities-tool.ts +82 -16
- package/src/check.ts +493 -100
- package/src/cli.ts +306 -54
- package/src/diff-tool.ts +8 -8
- package/src/docs-search.ts +6 -6
- package/src/doctor.ts +81 -16
- package/src/examples.ts +7 -7
- package/src/init-agents.ts +17 -17
- package/src/internal/typescript-import.ts +33 -0
- package/src/introspect.ts +28 -28
- package/src/levels-tool.ts +37 -31
- package/src/load.ts +65 -12
- package/src/manifest-tool.ts +4 -2
- package/src/mcp-docs-tools.ts +20 -10
- package/src/mcp-http.ts +7 -7
- package/src/mcp-protocol.ts +2 -2
- package/src/mcp-render.ts +15 -7
- package/src/mcp-run.ts +2 -2
- package/src/mcp.ts +127 -47
- package/src/openapi-tool.ts +2 -2
- package/src/pipeline-guard.ts +186 -0
- package/src/port.ts +31 -31
- package/src/recipes/1.8.0.ts +5 -5
- package/src/recipes/index.ts +2 -2
- package/src/scaffold.ts +8 -8
- package/src/sync-routes.ts +69 -0
- package/src/types-search.ts +57 -8
- package/src/upgrade.ts +12 -12
- package/src/verification.ts +123 -0
package/src/diff-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra snapshot` / `nifra diff`
|
|
2
|
+
* `nifra snapshot` / `nifra diff` - the API breaking-change gate.
|
|
3
3
|
*
|
|
4
4
|
* nifra snapshot [--out api-snapshot.json] Write the backend's route contract as plain JSON.
|
|
5
5
|
* nifra diff <baseline.json> [--json] Re-snapshot and compare against the committed
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
|
|
11
11
|
* this module only loads `backend.ts` and renders the result.
|
|
12
12
|
*
|
|
13
|
-
* Loads ONLY `backend.ts`
|
|
13
|
+
* Loads ONLY `backend.ts` - the API contract - so it works on an API-only project with no
|
|
14
14
|
* framework config or routes/ directory (unlike the eager `loadApp`).
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
snapshotRoutes,
|
|
25
25
|
} from "@nifrajs/core/diff"
|
|
26
26
|
|
|
27
|
-
/** Snapshot file envelope
|
|
27
|
+
/** Snapshot file envelope - versioned so a future format change can migrate instead of misparse. */
|
|
28
28
|
export interface SnapshotFile {
|
|
29
29
|
readonly nifraSnapshot: 1
|
|
30
30
|
readonly routes: readonly RouteSnapshot[]
|
|
@@ -39,7 +39,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
|
|
|
39
39
|
parsed = JSON.parse(content)
|
|
40
40
|
} catch {
|
|
41
41
|
throw new Error(
|
|
42
|
-
`[nifra] ${sourcePath} is not valid JSON
|
|
42
|
+
`[nifra] ${sourcePath} is not valid JSON - regenerate it with \`nifra snapshot\`.`,
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
45
|
const record = parsed as Partial<SnapshotFile> | null
|
|
@@ -50,7 +50,7 @@ export function parseSnapshotFile(content: string, sourcePath: string): Snapshot
|
|
|
50
50
|
!Array.isArray(record.routes)
|
|
51
51
|
) {
|
|
52
52
|
throw new Error(
|
|
53
|
-
`[nifra] ${sourcePath} is not a nifra API snapshot
|
|
53
|
+
`[nifra] ${sourcePath} is not a nifra API snapshot - expected { "nifraSnapshot": 1, "routes": [...] }.`,
|
|
54
54
|
)
|
|
55
55
|
}
|
|
56
56
|
return record as SnapshotFile
|
|
@@ -76,7 +76,7 @@ export function formatDiff(diff: RoutesDiff): string {
|
|
|
76
76
|
.map(describeChange)
|
|
77
77
|
const breaking = diff.changes.filter((change) => change.severity === "breaking").length
|
|
78
78
|
const summary = diff.hasBreaking
|
|
79
|
-
? `${breaking} breaking change${breaking === 1 ? "" : "s"}
|
|
79
|
+
? `${breaking} breaking change${breaking === 1 ? "" : "s"} - existing clients will fail.`
|
|
80
80
|
: "No breaking changes."
|
|
81
81
|
return `${lines.join("\n")}\n\n${summary}`
|
|
82
82
|
}
|
|
@@ -86,7 +86,7 @@ async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
|
|
|
86
86
|
const backendPath = resolve(cwd, "backend.ts")
|
|
87
87
|
if (!existsSync(backendPath)) {
|
|
88
88
|
throw new Error(
|
|
89
|
-
`[nifra] no backend.ts in ${cwd}
|
|
89
|
+
`[nifra] no backend.ts in ${cwd} - \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
|
|
90
90
|
)
|
|
91
91
|
}
|
|
92
92
|
const backend = ((await import(backendPath)) as { backend?: unknown }).backend
|
|
@@ -115,7 +115,7 @@ export async function runDiff(
|
|
|
115
115
|
const baselineFile = Bun.file(resolved)
|
|
116
116
|
if (!(await baselineFile.exists())) {
|
|
117
117
|
throw new Error(
|
|
118
|
-
`[nifra] baseline not found: ${resolved}
|
|
118
|
+
`[nifra] baseline not found: ${resolved} - create it on your main branch with \`nifra snapshot\`.`,
|
|
119
119
|
)
|
|
120
120
|
}
|
|
121
121
|
const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
|
package/src/docs-search.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra_docs`
|
|
2
|
+
* `nifra_docs` - searchable framework documentation for agents. The corpus is the generated
|
|
3
3
|
* `llms-full.txt` (written into this package by `bun run gen:llms`, so it can't drift from the
|
|
4
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
|
|
5
|
+
* tool keyword-scores the document's `##` sections and returns only the top matches - or, with no
|
|
6
6
|
* query, just the section index (the cheap discovery call).
|
|
7
7
|
*
|
|
8
8
|
* Deliberately NOT a vector search: the corpus is one curated document with descriptive headings;
|
|
@@ -68,7 +68,7 @@ export function searchSections(
|
|
|
68
68
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** Cap a section body for the tool result
|
|
71
|
+
/** Cap a section body for the tool result - whole sections can be pages long; the agent can ask
|
|
72
72
|
* again with a narrower query if the trim cuts what it needed. */
|
|
73
73
|
const MAX_SECTION_CHARS = 2800
|
|
74
74
|
|
|
@@ -81,7 +81,7 @@ export function renderDocsResult(
|
|
|
81
81
|
const sections = splitSections(markdown)
|
|
82
82
|
if (query === undefined || query.trim() === "") {
|
|
83
83
|
const index = sections.map((s) => `- ${s.heading}`).join("\n")
|
|
84
|
-
return `# nifra docs
|
|
84
|
+
return `# nifra docs - section index (pass \`query\` to fetch matching sections)\n\n${index}`
|
|
85
85
|
}
|
|
86
86
|
const matches = searchSections(sections, query, limit)
|
|
87
87
|
if (matches.length === 0) {
|
|
@@ -91,7 +91,7 @@ export function renderDocsResult(
|
|
|
91
91
|
.map((m) => {
|
|
92
92
|
const body =
|
|
93
93
|
m.body.length > MAX_SECTION_CHARS
|
|
94
|
-
? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed
|
|
94
|
+
? `${m.body.slice(0, MAX_SECTION_CHARS)}\n…(trimmed - narrow the query for the rest)`
|
|
95
95
|
: m.body
|
|
96
96
|
return `## ${m.heading}\n\n${body}`
|
|
97
97
|
})
|
|
@@ -99,7 +99,7 @@ export function renderDocsResult(
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/** Load the bundled corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
102
|
-
* and `dist/` (published) alike
|
|
102
|
+
* and `dist/` (published) alike - `docs/llms-full.txt` sits at the package root next to both. */
|
|
103
103
|
export async function loadDocsCorpus(): Promise<string | undefined> {
|
|
104
104
|
const url = new URL("../docs/llms-full.txt", import.meta.url)
|
|
105
105
|
try {
|
package/src/doctor.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra doctor`
|
|
2
|
+
* `nifra doctor` - catches the Bun-workspace footgun the typed-client lint can't: a package that's
|
|
3
3
|
* IMPORTED in source but NOT declared in `package.json`. Bun resolves it at runtime (via hoisting or a
|
|
4
|
-
* workspace), so tests pass and `bun install` reports "no changes"
|
|
4
|
+
* workspace), so tests pass and `bun install` reports "no changes" - false confidence - yet `tsc` fails
|
|
5
5
|
* and a fresh or standalone install can't resolve it. doctor diffs every bare import specifier against
|
|
6
6
|
* the package's declared dependencies and flags the gap, with a stable `--json` shape for agents/CI.
|
|
7
7
|
*
|
|
8
8
|
* Scope is intentionally per-package: it checks the `package.json` at `cwd`, because a dependency must be
|
|
9
9
|
* declared by the package that imports it (for `tsc` and for that package to install on its own) even
|
|
10
10
|
* when a monorepo would hoist it. Relative paths, runtime builtins (node core, `node:`/`bun:`, `bun`),
|
|
11
|
-
* the package's own name, and tsconfig `paths` aliases are excluded
|
|
11
|
+
* the package's own name, and tsconfig `paths` aliases are excluded - none of them are npm deps.
|
|
12
12
|
*/
|
|
13
|
-
import { realpath } from "node:fs/promises"
|
|
13
|
+
import { realpath, stat } from "node:fs/promises"
|
|
14
14
|
import { builtinModules } from "node:module"
|
|
15
15
|
import { dirname, join, relative } from "node:path"
|
|
16
16
|
import { codePositionMask, type SourceFinding, stripComments, walkSource } from "./check.ts"
|
|
@@ -25,7 +25,7 @@ const BUILTINS: ReadonlySet<string> = new Set<string>([
|
|
|
25
25
|
|
|
26
26
|
// The four specifier-bearing forms: static import/re-export with a source, side-effect import, dynamic
|
|
27
27
|
// import, and CJS require. Anchored with `(?<![.\w$])` so `myimport`/`.import`/`foorequire` never match.
|
|
28
|
-
// Comments are stripped before these run (see stripComments)
|
|
28
|
+
// Comments are stripped before these run (see stripComments) - else a doc-comment usage example would be
|
|
29
29
|
// flagged as a real import.
|
|
30
30
|
const IMPORT_PATTERNS: readonly RegExp[] = [
|
|
31
31
|
/(?<![.\w$])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g,
|
|
@@ -56,7 +56,7 @@ export function packageOf(spec: string): string | undefined {
|
|
|
56
56
|
if (spec.startsWith("node:") || spec.startsWith("bun:")) return undefined
|
|
57
57
|
if (spec.startsWith("@")) {
|
|
58
58
|
const [scope, name] = spec.split("/")
|
|
59
|
-
if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo`
|
|
59
|
+
if (!scope || scope === "@" || !name) return undefined // `@/alias`, `@foo` - not a real package
|
|
60
60
|
const pkg = `${scope}/${name}`
|
|
61
61
|
return isNpmPackageName(pkg) ? pkg : undefined
|
|
62
62
|
}
|
|
@@ -66,7 +66,7 @@ export function packageOf(spec: string): string | undefined {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Build a predicate matching tsconfig `paths` aliases (e.g. `@/*`, `~/utils`), which resolve to local
|
|
69
|
-
* source, not npm packages
|
|
69
|
+
* source, not npm packages - so doctor must not flag them. */
|
|
70
70
|
export function aliasMatcher(
|
|
71
71
|
paths: Readonly<Record<string, unknown>> | undefined,
|
|
72
72
|
): (spec: string) => boolean {
|
|
@@ -113,13 +113,13 @@ export function scanUndeclaredImports(
|
|
|
113
113
|
export interface DoctorFinding {
|
|
114
114
|
readonly file: string
|
|
115
115
|
readonly line: number
|
|
116
|
-
/** The undeclared package name
|
|
116
|
+
/** The undeclared package name - add it to `package.json` dependencies. */
|
|
117
117
|
readonly package: string
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export interface DoctorResult {
|
|
121
121
|
readonly ok: boolean
|
|
122
|
-
/** `false` when no `package.json` was found at cwd
|
|
122
|
+
/** `false` when no `package.json` was found at cwd - doctor can't run (reported, not a crash). */
|
|
123
123
|
readonly ran: boolean
|
|
124
124
|
readonly findings: readonly DoctorFinding[]
|
|
125
125
|
/** Packages that resolve to more than one physical install across this workspace. */
|
|
@@ -336,13 +336,75 @@ const displayPath = (cwd: string, path: string): string => {
|
|
|
336
336
|
return rel === "" ? "." : rel
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the workspace root that actually governs `cwd`, or `undefined` when there is none.
|
|
341
|
+
*
|
|
342
|
+
* Everything below is anchored to a package that declares `workspaces`. In a monorepo you run
|
|
343
|
+
* `nifra check` from the app - `apps/web` - and THAT manifest declares nothing, so the scan collapses
|
|
344
|
+
* to the app itself and the sibling package holding the second copy is never probed. Running from an
|
|
345
|
+
* app subdirectory is the normal case, which made this the configuration the check was blind in.
|
|
346
|
+
*
|
|
347
|
+
* An ancestor is adopted only when its `workspaces` patterns genuinely match `cwd`. A parent
|
|
348
|
+
* directory that happens to contain a manifest is not automatically this project's root, and adopting
|
|
349
|
+
* one would silently widen the scan to an unrelated tree. The walk also stops at a `.git` boundary
|
|
350
|
+
* (checked after the manifest, so a repo root that IS the workspace root still counts) and at the
|
|
351
|
+
* filesystem root.
|
|
352
|
+
*/
|
|
353
|
+
/** Whether `path` exists at all. `stat`, not a file read: a `.git` is a directory in a normal
|
|
354
|
+
* checkout and a FILE in a worktree or submodule, and both mark the same boundary. */
|
|
355
|
+
const pathExists = (path: string): Promise<boolean> =>
|
|
356
|
+
stat(path).then(
|
|
357
|
+
() => true,
|
|
358
|
+
() => false,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
async function findWorkspaceRoot(
|
|
362
|
+
cwd: string,
|
|
363
|
+
): Promise<{ root: string; package: Record<string, unknown> } | undefined> {
|
|
364
|
+
let dir = dirname(cwd)
|
|
365
|
+
for (;;) {
|
|
366
|
+
const manifestPath = join(dir, "package.json")
|
|
367
|
+
const manifest = await readJson(manifestPath)
|
|
368
|
+
if (manifest !== undefined) {
|
|
369
|
+
const patterns = workspacePatterns(manifest)
|
|
370
|
+
if (patterns.length > 0 && (await patternsCover(dir, patterns, cwd))) {
|
|
371
|
+
return { root: dir, package: manifest }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// A `.git` here means we have reached this project's boundary; anything above belongs to a
|
|
375
|
+
// different repo (or a parent checkout) and must not be adopted.
|
|
376
|
+
if (await pathExists(join(dir, ".git"))) return undefined
|
|
377
|
+
const parent = dirname(dir)
|
|
378
|
+
if (parent === dir) return undefined
|
|
379
|
+
dir = parent
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Whether any of `root`'s workspace patterns resolves to a manifest inside `target`'s package. */
|
|
384
|
+
async function patternsCover(root: string, patterns: string[], target: string): Promise<boolean> {
|
|
385
|
+
for (const pattern of patterns) {
|
|
386
|
+
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
387
|
+
for await (const rel of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
388
|
+
if (rel.split(/[\\/]/).includes("node_modules")) continue
|
|
389
|
+
if (dirname(join(root, rel)) === target) return true
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
|
|
339
395
|
/** Find identity-sensitive dependencies that resolve to multiple physical directories. Two copies at
|
|
340
|
-
* the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
|
|
396
|
+
* the same version still fail: module identity (React hooks, Nifra symbols/registries) is path-based.
|
|
397
|
+
*
|
|
398
|
+
* `cwd` is where the user ran the check; the scan is anchored at the workspace root above it (see
|
|
399
|
+
* {@link findWorkspaceRoot}) so sibling packages are probed, while paths are still REPORTED relative
|
|
400
|
+
* to `cwd` so the findings read the way the user's terminal does. */
|
|
341
401
|
export async function collectDuplicateInstalls(
|
|
342
402
|
cwd: string,
|
|
343
403
|
rootPackage: Record<string, unknown>,
|
|
344
404
|
): Promise<DuplicateInstallFinding[]> {
|
|
345
|
-
const
|
|
405
|
+
const workspace = await findWorkspaceRoot(cwd)
|
|
406
|
+
const scanRoot = workspace?.root ?? cwd
|
|
407
|
+
const importers = await workspaceImporters(scanRoot, workspace?.package ?? rootPackage)
|
|
346
408
|
const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
|
|
347
409
|
const record = (
|
|
348
410
|
name: string,
|
|
@@ -364,7 +426,10 @@ export async function collectDuplicateInstalls(
|
|
|
364
426
|
for (const name of duplicateTargets(importer.package)) {
|
|
365
427
|
if (importer.package.name === name) continue
|
|
366
428
|
targets.add(name)
|
|
367
|
-
|
|
429
|
+
// Boundary is the SCAN root, not `cwd`: an importer can now sit beside or above the directory
|
|
430
|
+
// the user ran from, and stopping the upward walk at `cwd` would abandon it before reaching the
|
|
431
|
+
// hoisted copy it actually resolves.
|
|
432
|
+
const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
|
|
368
433
|
if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
|
|
369
434
|
}
|
|
370
435
|
}
|
|
@@ -374,8 +439,8 @@ export async function collectDuplicateInstalls(
|
|
|
374
439
|
// out hides the very split it should catch: every declaring package nested onto one copy while the
|
|
375
440
|
// root resolved a different one, which reads as a single copy when only declarers are consulted.
|
|
376
441
|
for (const name of targets) {
|
|
377
|
-
const copy = await resolvedInstalledCopy(
|
|
378
|
-
if (copy !== undefined) record(name, copy,
|
|
442
|
+
const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
|
|
443
|
+
if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
|
|
379
444
|
}
|
|
380
445
|
|
|
381
446
|
const findings: DuplicateInstallFinding[] = []
|
|
@@ -557,7 +622,7 @@ export async function runDoctor(
|
|
|
557
622
|
}
|
|
558
623
|
console.log("nifra doctor\n")
|
|
559
624
|
if (!result.ran) {
|
|
560
|
-
console.log("• no package.json at this directory
|
|
625
|
+
console.log("• no package.json at this directory - nothing to check")
|
|
561
626
|
return true
|
|
562
627
|
}
|
|
563
628
|
if (result.fixed && result.fixed.length > 0) {
|
|
@@ -570,7 +635,7 @@ export async function runDoctor(
|
|
|
570
635
|
if (result.skippedFixes && result.skippedFixes.length > 0) {
|
|
571
636
|
console.log("• not auto-fixed:")
|
|
572
637
|
for (const f of result.skippedFixes) {
|
|
573
|
-
console.log(` ${f.package}
|
|
638
|
+
console.log(` ${f.package} - ${f.reason}; run \`${f.command.join(" ")}\``)
|
|
574
639
|
}
|
|
575
640
|
console.log("")
|
|
576
641
|
}
|
package/src/examples.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra_example`
|
|
2
|
+
* `nifra_example` - verified, copy-pasteable code examples for agents. The corpus is `examples.json`
|
|
3
3
|
* (written into this package by `bun run gen:llms`): the same doc snippets `check:docs` typechecks
|
|
4
4
|
* against the live `@nifrajs/*` API. So every example this returns is GUARANTEED to compile against the
|
|
5
|
-
* installed version
|
|
5
|
+
* installed version - the antidote to an agent hallucinating a framework API from stale training.
|
|
6
6
|
*
|
|
7
7
|
* Keyword-scored over each example's name/topic/slug (curated identifiers, heavily weighted) and its
|
|
8
8
|
* code body. No query → a grouped index of what's available (the cheap discovery call).
|
|
@@ -12,9 +12,9 @@ import { countBodyHits, queryTermGroups, tokenize, tokenSetHas } from "./search-
|
|
|
12
12
|
|
|
13
13
|
export interface Example {
|
|
14
14
|
readonly name: string
|
|
15
|
-
/** The doc page's title, e.g. "Nifra
|
|
15
|
+
/** The doc page's title, e.g. "Nifra - Auth & sessions". */
|
|
16
16
|
readonly topic: string
|
|
17
|
-
/** The doc page slug, e.g. "auth"
|
|
17
|
+
/** The doc page slug, e.g. "auth" - a short, matchable tag. */
|
|
18
18
|
readonly slug: string
|
|
19
19
|
readonly code: string
|
|
20
20
|
}
|
|
@@ -63,19 +63,19 @@ export function renderExamplesResult(
|
|
|
63
63
|
const index = [...byTopic.entries()]
|
|
64
64
|
.map(([topic, names]) => `- **${topic}**: ${names.join(", ")}`)
|
|
65
65
|
.join("\n")
|
|
66
|
-
return `# nifra examples
|
|
66
|
+
return `# nifra examples - index (${examples.length} verified snippets; pass \`query\` to fetch code)\n\nEvery snippet is typechecked against the installed nifra version - prefer these over recalling an API.\n\n${index}`
|
|
67
67
|
}
|
|
68
68
|
const matches = searchExamples(examples, query, limit)
|
|
69
69
|
if (matches.length === 0) {
|
|
70
70
|
return `No examples matched ${JSON.stringify(query)}. Call without a query for the index, or try a broader term (e.g. "auth", "upload", "isr", "client").`
|
|
71
71
|
}
|
|
72
72
|
return matches
|
|
73
|
-
.map((m) => `## ${m.name}
|
|
73
|
+
.map((m) => `## ${m.name} - ${m.topic}\n\n\`\`\`ts\n${m.code}\n\`\`\``)
|
|
74
74
|
.join("\n\n---\n\n")
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Load the bundled example corpus. Resolves relative to this module so it works from `src/` (repo dev)
|
|
78
|
-
* and `dist/` (published) alike
|
|
78
|
+
* and `dist/` (published) alike - `docs/examples.json` sits at the package root next to both. */
|
|
79
79
|
export async function loadExamplesCorpus(): Promise<Example[] | undefined> {
|
|
80
80
|
const url = new URL("../docs/examples.json", import.meta.url)
|
|
81
81
|
try {
|
package/src/init-agents.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `nifra init-agents`
|
|
2
|
+
* `nifra init-agents` - retrofit an EXISTING app with the agent-discovery files a freshly scaffolded
|
|
3
3
|
* app ships, so an already-built project adopts the nifra MCP in one command:
|
|
4
4
|
*
|
|
5
|
-
* .mcp.json
|
|
6
|
-
* .cursor/mcp.json
|
|
7
|
-
* CLAUDE.md
|
|
8
|
-
* AGENTS.md
|
|
5
|
+
* .mcp.json - Claude Code's project MCP registry (launches `bunx @nifrajs/cli mcp`)
|
|
6
|
+
* .cursor/mcp.json - Cursor's MCP registry (same server config)
|
|
7
|
+
* CLAUDE.md - Claude's MCP-first preamble + `@AGENTS.md` import
|
|
8
|
+
* AGENTS.md - a `## MCP server` section appended (or a minimal file if none exists)
|
|
9
9
|
*
|
|
10
|
-
* The generators are imported from `create-nifra/agent-files`
|
|
11
|
-
* uses at scaffold time
|
|
10
|
+
* The generators are imported from `create-nifra/agent-files` - the SAME source of truth `create-nifra`
|
|
11
|
+
* uses at scaffold time - so a retrofitted app and a freshly scaffolded one get byte-identical configs.
|
|
12
12
|
*
|
|
13
13
|
* Safety: this writes into the user's existing tree, so it NEVER silently clobbers a file they may have
|
|
14
14
|
* customized. By default an existing `.mcp.json` / `CLAUDE.md` / `.cursor/mcp.json` is SKIPPED with a
|
|
15
|
-
* notice; `--force` overwrites. `AGENTS.md` is special-cased
|
|
15
|
+
* notice; `--force` overwrites. `AGENTS.md` is special-cased - if it already has the MCP section it's
|
|
16
16
|
* left alone, otherwise the section is APPENDED (never overwriting the user's conventions), and `--force`
|
|
17
17
|
* is not needed for that append since it's additive. Every write path is resolved + confined under the
|
|
18
18
|
* cwd (no `..` traversal escaping the project root).
|
|
@@ -34,10 +34,10 @@ import {
|
|
|
34
34
|
export interface InitAgentsFileResult {
|
|
35
35
|
/** Project-root-relative POSIX path. */
|
|
36
36
|
readonly path: string
|
|
37
|
-
/** `wrote`
|
|
38
|
-
* AGENTS.md; `skipped`
|
|
37
|
+
/** `wrote` - created or (with --force) overwrote; `appended` - added the MCP section to an existing
|
|
38
|
+
* AGENTS.md; `skipped` - already present and not forced; `present` - MCP section already there. */
|
|
39
39
|
readonly action: "wrote" | "appended" | "skipped" | "present"
|
|
40
|
-
/** Why it was skipped/left, for the notice (e.g. "exists
|
|
40
|
+
/** Why it was skipped/left, for the notice (e.g. "exists - pass --force to overwrite"). */
|
|
41
41
|
readonly note?: string
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -52,7 +52,7 @@ export interface InitAgentsOptions {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** Confine a project-relative path under `cwd` and return the absolute path. Rejects a spec that escapes
|
|
55
|
-
* the root (defense-in-depth
|
|
55
|
+
* the root (defense-in-depth - these specs are constants, but the cwd-confinement invariant is enforced
|
|
56
56
|
* at the seam, not assumed). Exported so the invariant is directly testable. */
|
|
57
57
|
export function safeJoin(cwd: string, rel: string): string {
|
|
58
58
|
const abs = resolve(cwd, rel)
|
|
@@ -80,7 +80,7 @@ async function writeOwned(
|
|
|
80
80
|
): Promise<InitAgentsFileResult> {
|
|
81
81
|
const abs = safeJoin(cwd, rel)
|
|
82
82
|
if (!force && (await fileExists(abs))) {
|
|
83
|
-
return { path: rel, action: "skipped", note: "exists
|
|
83
|
+
return { path: rel, action: "skipped", note: "exists - pass --force to overwrite" }
|
|
84
84
|
}
|
|
85
85
|
// `.cursor/mcp.json` needs its parent dir; `recursive` is a no-op for the root-level files.
|
|
86
86
|
await mkdir(resolve(abs, ".."), { recursive: true })
|
|
@@ -91,7 +91,7 @@ async function writeOwned(
|
|
|
91
91
|
/**
|
|
92
92
|
* AGENTS.md is additive, not owned: if it exists and already has the MCP section, leave it; if it exists
|
|
93
93
|
* without the section, append the section (preserving the user's conventions); if it's absent, write a
|
|
94
|
-
* minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here
|
|
94
|
+
* minimal AGENTS.md that is just the MCP section under a heading. `--force` is irrelevant here - we never
|
|
95
95
|
* overwrite the user's existing guidance.
|
|
96
96
|
*/
|
|
97
97
|
async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
@@ -121,7 +121,7 @@ async function ensureAgentsMd(cwd: string): Promise<InitAgentsFileResult> {
|
|
|
121
121
|
|
|
122
122
|
/**
|
|
123
123
|
* Retrofit `cwd` with the four agent-discovery files. Pure enough to unit-test (no argv, no process.exit,
|
|
124
|
-
* no console)
|
|
124
|
+
* no console) - the CLI wrapper handles printing + the exit code.
|
|
125
125
|
*/
|
|
126
126
|
export async function initAgents(
|
|
127
127
|
cwd: string,
|
|
@@ -153,13 +153,13 @@ export function renderInitAgents(result: InitAgentsResult): string {
|
|
|
153
153
|
const wroteAny = result.files.some((f) => f.action === "wrote" || f.action === "appended")
|
|
154
154
|
const footer = wroteAny
|
|
155
155
|
? "\nThe nifra MCP is now registered. Restart your agent so it picks up .mcp.json, then prefer nifra_docs / nifra_example and gate on nifra check."
|
|
156
|
-
: "\nNothing to do
|
|
156
|
+
: "\nNothing to do - every file was already present (use --force to overwrite the owned ones)."
|
|
157
157
|
return `nifra init-agents\n\n${lines.join("\n")}\n${footer}`
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
/**
|
|
161
161
|
* CLI entry: run the retrofit at `cwd` and print the result. Returns `true` (the command always succeeds
|
|
162
|
-
* unless a write throws
|
|
162
|
+
* unless a write throws - which propagates as a non-zero exit via the dispatcher's catch). `--json`
|
|
163
163
|
* emits the structured result for agents/CI.
|
|
164
164
|
*/
|
|
165
165
|
export async function runInitAgents(
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the CLI imports TypeScript from.
|
|
3
|
+
*
|
|
4
|
+
* The interpolated-SQL rule parses source with the TypeScript compiler, because the regex it replaced
|
|
5
|
+
* could not tell `sql\`… ${id} …\`` (bound) from a plain template (injectable) without guessing at
|
|
6
|
+
* string and comment boundaries. That is the right tool, but it is a ~25 MB dependency, and the CLI's
|
|
7
|
+
* own typecheck step already treats `tsc` as something the PROJECT provides rather than something the
|
|
8
|
+
* CLI ships. Making it a hard dependency would have contradicted that for every install.
|
|
9
|
+
*
|
|
10
|
+
* So it is an optional peer, resolved at the moment the rule runs. Every Nifra project has TypeScript
|
|
11
|
+
* - the templates all ship a `typecheck` script - so this resolves in practice; the point is that an
|
|
12
|
+
* install does not pay for it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
|
|
16
|
+
export type TypeScriptApi = typeof import("typescript")
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Import the project's TypeScript, or `undefined` when it is not installed.
|
|
20
|
+
*
|
|
21
|
+
* Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
|
|
22
|
+
* broken install, not a missing one, and telling that user to install what they already have sends
|
|
23
|
+
* them the wrong way - so that error is rethrown for the caller to report.
|
|
24
|
+
*/
|
|
25
|
+
export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
26
|
+
try {
|
|
27
|
+
return (await import("typescript")) as TypeScriptApi
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
const message = cause instanceof Error ? cause.message : String(cause)
|
|
30
|
+
if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
|
|
31
|
+
throw cause
|
|
32
|
+
}
|
|
33
|
+
}
|