@nifrajs/cli 2.13.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities-tool.d.ts +54 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +146 -18
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check.d.ts +11 -11
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +260 -49
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +130 -414
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +250 -0
- package/dist/command-catalog.d.ts.map +1 -0
- package/dist/command-catalog.js +1032 -0
- package/dist/command-catalog.js.map +1 -0
- package/dist/contracts.d.ts +9 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +22 -6
- package/dist/contracts.js.map +1 -1
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +82 -9
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +17 -0
- package/dist/env-file.d.ts.map +1 -0
- package/dist/env-file.js +113 -0
- package/dist/env-file.js.map +1 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +91 -15
- package/dist/fix-recipes.js.map +1 -1
- package/dist/internal/reserved-segment-codemod.d.ts +36 -0
- package/dist/internal/reserved-segment-codemod.d.ts.map +1 -0
- package/dist/internal/reserved-segment-codemod.js +134 -0
- package/dist/internal/reserved-segment-codemod.js.map +1 -0
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +62 -11
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +26 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +74 -24
- package/dist/introspect.js.map +1 -1
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +22 -5
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts +15 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +23 -9
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +127 -355
- package/dist/mcp.js.map +1 -1
- package/dist/project-facts.d.ts +40 -0
- package/dist/project-facts.d.ts.map +1 -0
- package/dist/project-facts.js +36 -0
- package/dist/project-facts.js.map +1 -0
- package/dist/recipes/3.0.0.d.ts +10 -0
- package/dist/recipes/3.0.0.d.ts.map +1 -0
- package/dist/recipes/3.0.0.js +23 -0
- package/dist/recipes/3.0.0.js.map +1 -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/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +15 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +8 -0
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +36 -0
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/index.d.ts +2 -3
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +29 -44
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/security.js +14 -14
- package/dist/rules/security.js.map +1 -1
- package/dist/workspace-link.d.ts +37 -0
- package/dist/workspace-link.d.ts.map +1 -0
- package/dist/workspace-link.js +72 -0
- package/dist/workspace-link.js.map +1 -0
- package/docs/examples.json +1 -1
- package/docs/llms-full.txt +362 -20
- package/docs/types.json +956 -56
- package/package.json +9 -9
- package/src/capabilities-tool.ts +229 -21
- package/src/check.ts +312 -65
- package/src/cli.ts +140 -405
- package/src/command-catalog.ts +1435 -0
- package/src/contracts.ts +28 -7
- package/src/doctor.ts +134 -12
- package/src/env-file.ts +121 -0
- package/src/fix-recipes.ts +101 -15
- package/src/internal/reserved-segment-codemod.ts +150 -0
- package/src/internal/typescript-import.ts +61 -11
- package/src/introspect.ts +82 -21
- package/src/load.ts +25 -6
- package/src/manifest-tool.ts +43 -12
- package/src/mcp-http.ts +1 -1
- package/src/mcp.ts +163 -420
- package/src/project-facts.ts +86 -0
- package/src/recipes/3.0.0.ts +24 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +17 -0
- package/src/rules/comment-markers.ts +36 -0
- package/src/rules/index.ts +3 -4
- package/src/rules/routes.ts +30 -44
- package/src/rules/security.ts +14 -14
- package/src/workspace-link.ts +80 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codemod for reserved-segment collisions in typed-client call sites.
|
|
3
|
+
*
|
|
4
|
+
* A route segment spelling a reserved proxy key (`get`…`options`, `subscribe`, `ws`, `index`, `then`)
|
|
5
|
+
* cannot be reached by property access - the proxy resolves the reserved behavior first - so the client
|
|
6
|
+
* type resolves that child to `ReservedSegmentCollision<"seg">` and every `api.foo.delete.post()` in the
|
|
7
|
+
* project stops compiling at once. The fix is mechanical and identical at every site: call the parent
|
|
8
|
+
* node with the segment instead, `api.foo("delete").post()`. Doing that by hand across a codebase is
|
|
9
|
+
* pure toil, and it is toil the framework caused.
|
|
10
|
+
*
|
|
11
|
+
* The compiler is the site index. `tsc` already reports every broken access, and its message names the
|
|
12
|
+
* segment (`Property 'post' does not exist on type 'ReservedSegmentCollision<"delete">'`), so the
|
|
13
|
+
* rewrite needs no type graph of its own and cannot invent a site the compiler did not reject. That
|
|
14
|
+
* also disambiguates the one case a textual search could not: `.delete` as a real DELETE verb call is
|
|
15
|
+
* valid and never appears in this list.
|
|
16
|
+
*
|
|
17
|
+
* A site the scan cannot rewrite CONFIDENTLY is left alone and reported, never guessed at. Bracket
|
|
18
|
+
* access, a collision node stored in a variable and used elsewhere, and any shape where the text before
|
|
19
|
+
* the failing property is not `.<segment>` all fall into that bucket.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs"
|
|
22
|
+
import { dirname, join } from "node:path"
|
|
23
|
+
|
|
24
|
+
/** `path/to/file.ts(12,16): error TS2339: … 'ReservedSegmentCollision<"delete">'.` */
|
|
25
|
+
const TSC_POSITION = /^(.+?)\((\d+),(\d+)\):\s*(?:error|warning)\s+TS\d+:\s*(.+)$/
|
|
26
|
+
const COLLISION_TYPE = /ReservedSegmentCollision<"([^"]+)">/
|
|
27
|
+
|
|
28
|
+
export interface CollisionSite {
|
|
29
|
+
readonly file: string
|
|
30
|
+
/** 1-based, as `tsc` reports them. */
|
|
31
|
+
readonly line: number
|
|
32
|
+
readonly column: number
|
|
33
|
+
/** The colliding path segment, read out of the type the compiler named. */
|
|
34
|
+
readonly segment: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every reserved-segment collision `tsc` reported, in the order it reported them. */
|
|
38
|
+
export function parseCollisionSites(tscOutput: string): readonly CollisionSite[] {
|
|
39
|
+
const sites: CollisionSite[] = []
|
|
40
|
+
for (const raw of tscOutput.split("\n")) {
|
|
41
|
+
const position = TSC_POSITION.exec(raw.trim())
|
|
42
|
+
if (position === null) continue
|
|
43
|
+
const segment = COLLISION_TYPE.exec(position[4] as string)
|
|
44
|
+
if (segment === null) continue
|
|
45
|
+
sites.push({
|
|
46
|
+
file: position[1] as string,
|
|
47
|
+
line: Number(position[2]),
|
|
48
|
+
column: Number(position[3]),
|
|
49
|
+
segment: segment[1] as string,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
return sites
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Byte offset of a 1-based (line, column) in `source`, or undefined when it is past the end. */
|
|
56
|
+
const offsetOf = (source: string, line: number, column: number): number | undefined => {
|
|
57
|
+
let offset = 0
|
|
58
|
+
for (let current = 1; current < line; current += 1) {
|
|
59
|
+
const next = source.indexOf("\n", offset)
|
|
60
|
+
if (next === -1) return undefined
|
|
61
|
+
offset = next + 1
|
|
62
|
+
}
|
|
63
|
+
const target = offset + column - 1
|
|
64
|
+
return target <= source.length ? target : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const skipSpaceBack = (source: string, from: number): number => {
|
|
68
|
+
let index = from
|
|
69
|
+
while (index >= 0 && /\s/.test(source[index] as string)) index -= 1
|
|
70
|
+
return index
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The span to replace for one site: the `.segment` immediately preceding the failing property access,
|
|
75
|
+
* which becomes `("segment")`. Undefined when the text does not have that shape - the caller reports
|
|
76
|
+
* those rather than rewriting something it did not recognise.
|
|
77
|
+
*
|
|
78
|
+
* The scan runs backwards over the whole source rather than one line, so a chain broken across lines
|
|
79
|
+
* (`api.foo\n .delete\n .post()`) rewrites the same as a chain on one line.
|
|
80
|
+
*/
|
|
81
|
+
export function collisionSpan(
|
|
82
|
+
source: string,
|
|
83
|
+
site: CollisionSite,
|
|
84
|
+
): { readonly start: number; readonly end: number } | undefined {
|
|
85
|
+
const offset = offsetOf(source, site.line, site.column)
|
|
86
|
+
if (offset === undefined) return undefined
|
|
87
|
+
|
|
88
|
+
// Back over the dot that introduces the FAILING property (`.post` in `…delete.post`).
|
|
89
|
+
let index = skipSpaceBack(source, offset - 1)
|
|
90
|
+
if (source[index] !== ".") return undefined
|
|
91
|
+
|
|
92
|
+
// Back over the colliding segment itself.
|
|
93
|
+
index = skipSpaceBack(source, index - 1)
|
|
94
|
+
const segmentEnd = index
|
|
95
|
+
const segmentStart = segmentEnd - site.segment.length + 1
|
|
96
|
+
if (segmentStart < 0) return undefined
|
|
97
|
+
if (source.slice(segmentStart, segmentEnd + 1) !== site.segment) return undefined
|
|
98
|
+
|
|
99
|
+
// Back over the dot that introduces the segment. Anything else (bracket access, a bare identifier
|
|
100
|
+
// holding the node) is a shape this rewrite does not understand.
|
|
101
|
+
const dot = skipSpaceBack(source, segmentStart - 1)
|
|
102
|
+
if (source[dot] !== ".") return undefined
|
|
103
|
+
|
|
104
|
+
// Take any whitespace before that dot into the span too, so a chain broken across lines closes up
|
|
105
|
+
// (`api.jobs\n .subscribe` becomes `api.jobs("subscribe")`) instead of leaving the call argument
|
|
106
|
+
// dangling on its own line.
|
|
107
|
+
return { start: skipSpaceBack(source, dot - 1) + 1, end: segmentEnd }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface FileRewrite {
|
|
111
|
+
readonly source: string
|
|
112
|
+
/** Sites in this file the scan declined to rewrite, so the caller can report them. */
|
|
113
|
+
readonly skipped: readonly CollisionSite[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply every site belonging to one file. Spans are applied back to front so an earlier rewrite never
|
|
118
|
+
* shifts the offsets of one not yet applied, and a duplicate span (two failing properties resolving
|
|
119
|
+
* through the same access) is applied once.
|
|
120
|
+
*/
|
|
121
|
+
export function rewriteFile(source: string, sites: readonly CollisionSite[]): FileRewrite {
|
|
122
|
+
const skipped: CollisionSite[] = []
|
|
123
|
+
const spans: { start: number; end: number; segment: string }[] = []
|
|
124
|
+
for (const site of sites) {
|
|
125
|
+
const span = collisionSpan(source, site)
|
|
126
|
+
if (span === undefined) {
|
|
127
|
+
skipped.push(site)
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (spans.some((other) => other.start === span.start && other.end === span.end)) continue
|
|
131
|
+
spans.push({ ...span, segment: site.segment })
|
|
132
|
+
}
|
|
133
|
+
let out = source
|
|
134
|
+
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
135
|
+
out = `${out.slice(0, span.start)}("${span.segment}")${out.slice(span.end + 1)}`
|
|
136
|
+
}
|
|
137
|
+
return { source: out, skipped }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The project's own `tsc` binary, found upward from `root`. Never installs one. */
|
|
141
|
+
export function resolveTscBin(root: string): string | undefined {
|
|
142
|
+
let dir = root
|
|
143
|
+
while (true) {
|
|
144
|
+
const candidate = join(dir, "node_modules", "typescript", "bin", "tsc")
|
|
145
|
+
if (existsSync(candidate)) return candidate
|
|
146
|
+
const parent = dirname(dir)
|
|
147
|
+
if (parent === dir) return undefined
|
|
148
|
+
dir = parent
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -12,11 +12,37 @@
|
|
|
12
12
|
* install does not pay for it.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { existsSync } from "node:fs"
|
|
16
|
+
import { dirname, join } from "node:path"
|
|
17
|
+
|
|
15
18
|
/** The slice of the compiler the SQL scanner uses. Structural, so the peer stays optional. */
|
|
16
19
|
export type TypeScriptApi = typeof import("typescript")
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
22
|
+
* Is this module actually the compiler, rather than something else published under the same name?
|
|
23
|
+
*
|
|
24
|
+
* The `typescript` package name also carries small non-compiler entry points (a version stub, for
|
|
25
|
+
* one), and a resolver that falls back to a global download cache can hand one of those back. They
|
|
26
|
+
* import cleanly and then explode on first use - `undefined is not an object (evaluating
|
|
27
|
+
* 'ts.ScriptKind.TSX')` deep inside a scan - which reads as a Nifra bug rather than as "the compiler
|
|
28
|
+
* we loaded is not a compiler". Checking the two members every caller needs turns that into a
|
|
29
|
+
* resolution miss, which the fallbacks below already know how to handle.
|
|
30
|
+
*/
|
|
31
|
+
function isCompiler(module: unknown): module is TypeScriptApi {
|
|
32
|
+
if (typeof module !== "object" || module === null) return false
|
|
33
|
+
const api = module as Partial<TypeScriptApi>
|
|
34
|
+
return typeof api.createSourceFile === "function" && api.ScriptKind !== undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Unwrap the CJS default interop wrapper, so both `import * as ts` shapes look the same. */
|
|
38
|
+
function compilerOf(module: unknown): TypeScriptApi | undefined {
|
|
39
|
+
if (isCompiler(module)) return module
|
|
40
|
+
const wrapped = (module as { default?: unknown } | undefined)?.default
|
|
41
|
+
return isCompiler(wrapped) ? wrapped : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Import TypeScript from the CLI's own dependency tree, or `undefined` when it is not installed.
|
|
20
46
|
*
|
|
21
47
|
* Only a RESOLUTION failure is absence. A compiler that resolves and then fails while evaluating is a
|
|
22
48
|
* broken install, not a missing one, and telling that user to install what they already have sends
|
|
@@ -24,7 +50,7 @@ export type TypeScriptApi = typeof import("typescript")
|
|
|
24
50
|
*/
|
|
25
51
|
export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
26
52
|
try {
|
|
27
|
-
return (await import("typescript"))
|
|
53
|
+
return compilerOf(await import("typescript"))
|
|
28
54
|
} catch (cause) {
|
|
29
55
|
const message = cause instanceof Error ? cause.message : String(cause)
|
|
30
56
|
if (/cannot find (?:package|module)|ERR_MODULE_NOT_FOUND/i.test(message)) return undefined
|
|
@@ -32,6 +58,33 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
|
32
58
|
}
|
|
33
59
|
}
|
|
34
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Find the project's compiler the way module resolution would: `node_modules/typescript` in `root`,
|
|
63
|
+
* then each parent directory up to the filesystem root - the same walk the typecheck gate uses to
|
|
64
|
+
* find `tsc`, so both gates agree on which compiler the project has.
|
|
65
|
+
*
|
|
66
|
+
* The walk is deliberate, not a fallback for a missing resolver. `Bun.resolveSync` memoizes a
|
|
67
|
+
* specifier for the life of the process AND auto-installs from the global download cache when the
|
|
68
|
+
* project has none, so a long-lived process (the MCP server) that first resolved `typescript` before
|
|
69
|
+
* `bun install` kept handing back that cache entry afterwards - the typecheck went phantom until the
|
|
70
|
+
* server was restarted. A filesystem probe is re-answered every call, so an install that lands
|
|
71
|
+
* mid-session is picked up on the next scan.
|
|
72
|
+
*/
|
|
73
|
+
function resolveProjectTypeScript(root: string): string | undefined {
|
|
74
|
+
let dir = root
|
|
75
|
+
while (true) {
|
|
76
|
+
const pkg = join(dir, "node_modules", "typescript")
|
|
77
|
+
// `lib/typescript.js` is the compiler entry across every published major; probing it directly
|
|
78
|
+
// (rather than importing the directory) keeps a package whose main entry is a stub out of play.
|
|
79
|
+
const entry = join(pkg, "lib", "typescript.js")
|
|
80
|
+
if (existsSync(entry)) return entry
|
|
81
|
+
if (existsSync(join(pkg, "package.json"))) return pkg
|
|
82
|
+
const parent = dirname(dir)
|
|
83
|
+
if (parent === dir) return undefined
|
|
84
|
+
dir = parent
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
/**
|
|
36
89
|
* Import TypeScript resolved from the PROJECT root first, falling back to the CLI's own dependency
|
|
37
90
|
* tree. A bare `import("typescript")` resolves relative to this file - the CLI's install - so a
|
|
@@ -41,16 +94,13 @@ export async function importTypeScript(): Promise<TypeScriptApi | undefined> {
|
|
|
41
94
|
* the CLI was installed - the same cwd-invariance the typecheck gate's tsc resolution has.
|
|
42
95
|
*/
|
|
43
96
|
export async function importProjectTypeScript(root: string): Promise<TypeScriptApi | undefined> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Unresolvable from the project (not installed there, or a non-Bun host without resolveSync):
|
|
49
|
-
// fall back to the CLI's own tree, which keeps the previous behavior as the floor.
|
|
50
|
-
return importTypeScript()
|
|
51
|
-
}
|
|
97
|
+
const resolved = resolveProjectTypeScript(root)
|
|
98
|
+
// Not installed in the project (or installed without a compiler entry): fall back to the CLI's own
|
|
99
|
+
// tree, which keeps the previous behavior as the floor.
|
|
100
|
+
if (resolved === undefined) return importTypeScript()
|
|
52
101
|
try {
|
|
53
|
-
|
|
102
|
+
const compiler = compilerOf(await import(resolved))
|
|
103
|
+
return compiler ?? (await importTypeScript())
|
|
54
104
|
} catch (cause) {
|
|
55
105
|
// Same contract as importTypeScript: a compiler that RESOLVES and then fails evaluating is a
|
|
56
106
|
// broken install, not a missing one - rethrow rather than silently checking with another copy.
|
package/src/introspect.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* *this* project's actual surface, not just generic docs.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { reservedKeyFor } from "@nifrajs/client"
|
|
8
9
|
import {
|
|
9
10
|
type ProjectEvidenceSchema,
|
|
10
11
|
type ProjectEvidenceSchemaPart,
|
|
@@ -153,6 +154,24 @@ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
|
|
|
153
154
|
|
|
154
155
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
155
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The typed-client proxy chain for a path, without the terminal verb call. Shared by `clientCall`
|
|
159
|
+
* and the `nifra routes` collision annotation so both teach exactly one spelling.
|
|
160
|
+
*/
|
|
161
|
+
function clientChain(path: string): string {
|
|
162
|
+
const segs = path.split("/").filter((seg) => seg !== "")
|
|
163
|
+
if (segs.length === 0) return "api.index"
|
|
164
|
+
let chain = "api"
|
|
165
|
+
for (const seg of segs) {
|
|
166
|
+
if (seg.startsWith(":") || seg.startsWith("*")) {
|
|
167
|
+
const name = seg.replace(/^[:*]/, "") || "value"
|
|
168
|
+
chain += `({ ${name} })`
|
|
169
|
+
} else if (reservedKeyFor(seg) !== undefined) chain += `(${JSON.stringify(seg)})`
|
|
170
|
+
else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
171
|
+
}
|
|
172
|
+
return chain
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
/**
|
|
157
176
|
* The typed-client call form for a route - the exact `client<typeof app>` proxy chain an agent should
|
|
158
177
|
* write, derived from the same convention `@nifrajs/client` implements (so it never has to read the
|
|
@@ -160,20 +179,16 @@ const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
|
160
179
|
* that appends the value (`({ id })`), the root path is `.index`, and the HTTP verb is the terminal call.
|
|
161
180
|
* Body verbs (POST/PUT/PATCH) take the body first then call-options; other verbs take call-options first -
|
|
162
181
|
* so the `{ query }` argument lands in the right slot for each.
|
|
182
|
+
*
|
|
183
|
+
* A segment spelling a reserved proxy key (`delete`, `subscribe`, `then`, ...) is emitted as a call on
|
|
184
|
+
* the parent node instead - and NOT as bracket access, which does not help: the proxy intercepts the
|
|
185
|
+
* key however it is spelled. Emitting `.delete` here would hand an agent a chain that resolves the
|
|
186
|
+
* DELETE verb and silently requests the wrong path.
|
|
163
187
|
*/
|
|
164
188
|
export function clientCall(method: string, path: string, schema: unknown): string {
|
|
165
189
|
const s = schema as { body?: unknown; query?: unknown } | undefined
|
|
166
190
|
const verb = method.toLowerCase()
|
|
167
|
-
const
|
|
168
|
-
let chain = "api"
|
|
169
|
-
if (segs.length === 0) chain += ".index"
|
|
170
|
-
else
|
|
171
|
-
for (const seg of segs) {
|
|
172
|
-
if (seg.startsWith(":") || seg.startsWith("*")) {
|
|
173
|
-
const name = seg.replace(/^[:*]/, "") || "value"
|
|
174
|
-
chain += `({ ${name} })`
|
|
175
|
-
} else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
176
|
-
}
|
|
191
|
+
const chain = clientChain(path)
|
|
177
192
|
const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
|
|
178
193
|
let call: string
|
|
179
194
|
if (isBodyVerb) {
|
|
@@ -460,6 +475,27 @@ export function buildRouteTable(input: RouteTableInput): RouteTableEntry[] {
|
|
|
460
475
|
)
|
|
461
476
|
}
|
|
462
477
|
|
|
478
|
+
/**
|
|
479
|
+
* The typed-client spelling for a route path, and the reserved key it collides with when one of its
|
|
480
|
+
* static segments cannot be reached by property access.
|
|
481
|
+
*
|
|
482
|
+
* This is where a dev meets the reserved set EARLIEST: `nifra routes` is what you run while the
|
|
483
|
+
* route is being written, long before a typed client call site exists to break. The reserved names
|
|
484
|
+
* are deliberately unguessable from the outside (`then` and `index` especially - they exist so the
|
|
485
|
+
* proxy stays await-safe and can address `/`), so the route table names the collision and shows the
|
|
486
|
+
* spelling that reaches the route, rather than leaving it to be discovered by a failing build.
|
|
487
|
+
*/
|
|
488
|
+
export function clientSpellingFor(
|
|
489
|
+
path: string,
|
|
490
|
+
): { readonly spelling: string; readonly reserved: string } | undefined {
|
|
491
|
+
for (const segment of path.split("/")) {
|
|
492
|
+
if (segment === "") continue
|
|
493
|
+
const reserved = reservedKeyFor(segment)
|
|
494
|
+
if (reserved !== undefined) return { spelling: clientChain(path), reserved }
|
|
495
|
+
}
|
|
496
|
+
return undefined
|
|
497
|
+
}
|
|
498
|
+
|
|
463
499
|
/** Render the route table as a terse aligned text table (the `nifra routes` default output). Pure. */
|
|
464
500
|
export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
465
501
|
if (rows.length === 0)
|
|
@@ -468,16 +504,29 @@ export function renderRouteTable(rows: readonly RouteTableEntry[]): string {
|
|
|
468
504
|
methods: r.methods.join(", "),
|
|
469
505
|
kind: r.kind,
|
|
470
506
|
path: r.kind === "api" && r.autoMounted ? `${r.path} (auto-mounted)` : r.path,
|
|
507
|
+
client: clientSpellingFor(r.path),
|
|
471
508
|
}))
|
|
472
509
|
const methodW = Math.max("METHOD".length, ...display.map((d) => d.methods.length))
|
|
473
510
|
const kindW = Math.max("KIND".length, ...display.map((d) => d.kind.length))
|
|
474
511
|
const padEnd = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length))
|
|
475
512
|
const line = (methods: string, kind: string, path: string): string =>
|
|
476
513
|
`${padEnd(methods, methodW)} ${padEnd(kind, kindW)} ${path}`
|
|
477
|
-
|
|
514
|
+
const table = [
|
|
478
515
|
line("METHOD", "KIND", "PATH"),
|
|
479
|
-
...display.
|
|
480
|
-
|
|
516
|
+
...display.flatMap((d) =>
|
|
517
|
+
d.client === undefined
|
|
518
|
+
? [line(d.methods, d.kind, d.path)]
|
|
519
|
+
: [
|
|
520
|
+
line(d.methods, d.kind, d.path),
|
|
521
|
+
line(
|
|
522
|
+
"",
|
|
523
|
+
"",
|
|
524
|
+
` -> '${d.client.reserved}' is a reserved typed-client key; reach this route as \`${d.client.spelling}\``,
|
|
525
|
+
),
|
|
526
|
+
],
|
|
527
|
+
),
|
|
528
|
+
]
|
|
529
|
+
return table.join("\n")
|
|
481
530
|
}
|
|
482
531
|
|
|
483
532
|
/** One JSON row for `nifra routes --json` (the agent-facing, stable shape). */
|
|
@@ -487,21 +536,33 @@ export interface RouteJsonRow {
|
|
|
487
536
|
readonly methods: readonly string[]
|
|
488
537
|
readonly file?: string
|
|
489
538
|
readonly autoMounted?: boolean
|
|
539
|
+
/** The reserved typed-client key a static segment of this path collides with, when one does. */
|
|
540
|
+
readonly reservedKey?: string
|
|
541
|
+
/** The typed-client call spelling that reaches this route, present only alongside `reservedKey`. */
|
|
542
|
+
readonly clientSpelling?: string
|
|
490
543
|
}
|
|
491
544
|
|
|
492
545
|
/** The route table as a JSON document for `--json` (agents). Stable keys; `file`/`autoMounted` present
|
|
493
|
-
* only where meaningful (file on pages, autoMounted on API routes).
|
|
546
|
+
* only where meaningful (file on pages, autoMounted on API routes). A colliding row additionally
|
|
547
|
+
* carries `reservedKey`/`clientSpelling` so an agent writing client code from this table never has to
|
|
548
|
+
* infer the closed set of reserved names - the one thing about it nobody can guess. */
|
|
494
549
|
export function routeTableToJson(rows: readonly RouteTableEntry[]): {
|
|
495
550
|
readonly routes: readonly RouteJsonRow[]
|
|
496
551
|
} {
|
|
497
552
|
return {
|
|
498
|
-
routes: rows.map((r) =>
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
553
|
+
routes: rows.map((r) => {
|
|
554
|
+
const client = clientSpellingFor(r.path)
|
|
555
|
+
return {
|
|
556
|
+
kind: r.kind,
|
|
557
|
+
path: r.path,
|
|
558
|
+
methods: r.methods,
|
|
559
|
+
...(r.file !== undefined ? { file: r.file } : {}),
|
|
560
|
+
...(r.kind === "api" ? { autoMounted: r.autoMounted === true } : {}),
|
|
561
|
+
...(client === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: { reservedKey: client.reserved, clientSpelling: client.spelling }),
|
|
564
|
+
}
|
|
565
|
+
}),
|
|
505
566
|
}
|
|
506
567
|
}
|
|
507
568
|
|
package/src/load.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* reach the edge bundle. A simple single-target app with no edge build can put it all in `framework.ts`.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from "node:fs"
|
|
16
|
-
import { resolve } from "node:path"
|
|
16
|
+
import { isAbsolute, resolve } from "node:path"
|
|
17
17
|
import { checkPipelineSeparation } from "./pipeline-guard.ts"
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -210,11 +210,30 @@ export async function loadApp(
|
|
|
210
210
|
// resolved to absolute here. It is embedded verbatim as an import specifier in the generated client
|
|
211
211
|
// entry, and `nifra dev` and `nifra build` write that entry into DIFFERENT directories - so a relative
|
|
212
212
|
// specifier would resolve against different bases and load in one phase but not the other. A bare or
|
|
213
|
-
// package specifier (`@nifrajs/web-react/client`) is location-independent
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
// package specifier (`@nifrajs/web-react/client`) or an already-absolute path is location-independent
|
|
214
|
+
// and passes through unchanged.
|
|
215
|
+
let clientModule: string
|
|
216
|
+
if (fw.clientModule.startsWith("./") || fw.clientModule.startsWith("../")) {
|
|
217
|
+
clientModule = resolve(cwd, fw.clientModule)
|
|
218
|
+
} else {
|
|
219
|
+
// A specifier that is neither `./`-relative, scoped (`@…`), nor absolute is read as a BARE PACKAGE
|
|
220
|
+
// specifier - resolved against `node_modules`, not `cwd`. If a real local file sits at that path
|
|
221
|
+
// (a `./` was forgotten: `src/client.tsx` instead of `./src/client.tsx`), the bundler would ignore
|
|
222
|
+
// the file, fail to find a package, and surface an opaque "cannot resolve" deep in the build. Reject
|
|
223
|
+
// it here with the one-character fix rather than letting it fail silently.
|
|
224
|
+
if (
|
|
225
|
+
!isAbsolute(fw.clientModule) &&
|
|
226
|
+
!fw.clientModule.startsWith("@") &&
|
|
227
|
+
existsSync(resolve(cwd, fw.clientModule))
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[nifra] clientModule "${fw.clientModule}" has no "./" prefix, so it is read as a bare package ` +
|
|
231
|
+
`specifier - but a local file exists at that path. Prefix it with "./" (e.g. ` +
|
|
232
|
+
`"./${fw.clientModule}") so \`nifra dev\` and \`nifra build\` resolve the same local entry.`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
clientModule = fw.clientModule
|
|
236
|
+
}
|
|
218
237
|
const [vitePlugins, clientPlugins, serverPlugins] = await Promise.all([
|
|
219
238
|
resolvePlugins(fw.vitePlugins),
|
|
220
239
|
resolvePlugins(fw.clientPlugins),
|
package/src/manifest-tool.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve } from "node:path"
|
|
|
5
5
|
import {
|
|
6
6
|
buildNifraManifest,
|
|
7
7
|
diffNifraManifests,
|
|
8
|
+
type NifraManifest,
|
|
8
9
|
type NifraManifestDiff,
|
|
9
10
|
parseNifraManifest,
|
|
10
11
|
serializeNifraManifest,
|
|
@@ -38,11 +39,18 @@ export function formatManifestDiff(diff: NifraManifestDiff): string {
|
|
|
38
39
|
return lines.join("\n")
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
export interface ManifestEmitCommandResult {
|
|
43
|
+
readonly ok: boolean
|
|
44
|
+
readonly path: string
|
|
45
|
+
readonly manifest?: NifraManifest
|
|
46
|
+
readonly signaturePath?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Emit the manifest without printing. This is the execution seam used by all adapters. */
|
|
50
|
+
export async function collectManifestEmit(
|
|
43
51
|
cwd: string,
|
|
44
52
|
options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
|
|
45
|
-
): Promise<
|
|
53
|
+
): Promise<ManifestEmitCommandResult> {
|
|
46
54
|
const verification = await collectProjectVerification(cwd, {
|
|
47
55
|
...(options.config !== undefined ? { config: options.config } : {}),
|
|
48
56
|
})
|
|
@@ -58,9 +66,9 @@ export async function runManifestEmit(
|
|
|
58
66
|
if (assurance === undefined) {
|
|
59
67
|
throw new Error("[nifra] assurance evaluation was unavailable for manifest emission")
|
|
60
68
|
}
|
|
69
|
+
const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
|
|
61
70
|
if (!assurance.ok || (capabilityProject !== undefined && !capabilityProject.report.ok)) {
|
|
62
|
-
|
|
63
|
-
return false
|
|
71
|
+
return { ok: false, path }
|
|
64
72
|
}
|
|
65
73
|
const manifest = await buildNifraManifest({
|
|
66
74
|
source: config.source,
|
|
@@ -68,9 +76,9 @@ export async function runManifestEmit(
|
|
|
68
76
|
assurance,
|
|
69
77
|
...(capabilityProject !== undefined ? { capabilities: capabilityProject.report } : {}),
|
|
70
78
|
})
|
|
71
|
-
const path = resolve(cwd, options.out ?? config.manifest?.path ?? DEFAULT_MANIFEST_FILE)
|
|
72
79
|
await Bun.write(path, `${serializeNifraManifest(manifest)}\n`)
|
|
73
80
|
|
|
81
|
+
let signaturePath: string | undefined
|
|
74
82
|
if (options.sign !== undefined) {
|
|
75
83
|
if (config.manifest?.signer === undefined) {
|
|
76
84
|
throw new Error(
|
|
@@ -79,29 +87,52 @@ export async function runManifestEmit(
|
|
|
79
87
|
}
|
|
80
88
|
const signer = await config.manifest.signer(options.sign)
|
|
81
89
|
const signature = await signNifraManifest(manifest, signer)
|
|
82
|
-
|
|
90
|
+
signaturePath = `${path}.sig`
|
|
91
|
+
await Bun.write(signaturePath, `${serializeNifraManifestSignature(signature)}\n`)
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, path, manifest, ...(signaturePath === undefined ? {} : { signaturePath }) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build only after both assurance layers pass, then optionally create an operator-signed sidecar. */
|
|
97
|
+
export async function runManifestEmit(
|
|
98
|
+
cwd: string,
|
|
99
|
+
options: { readonly config?: string; readonly out?: string; readonly sign?: string } = {},
|
|
100
|
+
): Promise<boolean> {
|
|
101
|
+
const result = await collectManifestEmit(cwd, options)
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
console.error("[nifra] refusing to emit a manifest from failing assurance")
|
|
104
|
+
return false
|
|
83
105
|
}
|
|
84
106
|
console.log(
|
|
85
|
-
`[nifra] wrote manifest to ${path}${
|
|
107
|
+
`[nifra] wrote manifest to ${result.path}${result.signaturePath === undefined ? "" : ` and ${result.signaturePath}`}`,
|
|
86
108
|
)
|
|
87
109
|
return true
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/** Diff two already-emitted, hash-verified artifacts. Suitable for deploy promotion gates. */
|
|
91
|
-
export async function
|
|
113
|
+
export async function collectManifestDiff(
|
|
92
114
|
cwd: string,
|
|
93
115
|
beforePath: string,
|
|
94
116
|
afterPath: string,
|
|
95
|
-
|
|
96
|
-
): Promise<boolean> {
|
|
117
|
+
): Promise<NifraManifestDiff> {
|
|
97
118
|
const before = resolve(cwd, beforePath)
|
|
98
119
|
const after = resolve(cwd, afterPath)
|
|
99
120
|
if (!existsSync(before)) throw new Error(`[nifra] manifest not found: ${before}`)
|
|
100
121
|
if (!existsSync(after)) throw new Error(`[nifra] manifest not found: ${after}`)
|
|
101
|
-
|
|
122
|
+
return diffNifraManifests(
|
|
102
123
|
await parseNifraManifest(await Bun.file(before).text(), before),
|
|
103
124
|
await parseNifraManifest(await Bun.file(after).text(), after),
|
|
104
125
|
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Diff two already-emitted, hash-verified artifacts and print the result. */
|
|
129
|
+
export async function runManifestDiff(
|
|
130
|
+
cwd: string,
|
|
131
|
+
beforePath: string,
|
|
132
|
+
afterPath: string,
|
|
133
|
+
options: { readonly json?: boolean } = {},
|
|
134
|
+
): Promise<boolean> {
|
|
135
|
+
const diff = await collectManifestDiff(cwd, beforePath, afterPath)
|
|
105
136
|
console.log(
|
|
106
137
|
options.json === true
|
|
107
138
|
? JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2)
|
package/src/mcp-http.ts
CHANGED
|
@@ -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 = "
|
|
32
|
+
const VERSION = "3.1.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 =>
|