@nifrajs/cli 1.3.1 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/cli",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "The nifra CLI — zero-config dev/build/start for a nifra app (`nifra dev`, `nifra build`, `nifra start`).",
5
5
  "keywords": [
6
6
  "bun",
@@ -41,12 +41,13 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "1.3.1",
45
- "@nifrajs/mcp": "1.3.1",
46
- "@nifrajs/runner": "1.3.1",
47
- "@nifrajs/schema": "1.3.1",
48
- "@nifrajs/web": "1.3.1",
49
- "create-nifra": "1.3.1"
44
+ "@nifrajs/client": "1.5.0",
45
+ "@nifrajs/core": "1.5.0",
46
+ "@nifrajs/mcp": "1.5.0",
47
+ "@nifrajs/runner": "1.5.0",
48
+ "@nifrajs/schema": "1.5.0",
49
+ "@nifrajs/web": "1.5.0",
50
+ "create-nifra": "1.5.0"
50
51
  },
51
52
  "repository": {
52
53
  "type": "git",
package/src/cli.ts CHANGED
@@ -65,6 +65,13 @@ Usage:
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 snapshot [--out <file>] Write the backend's API contract (routes + schemas) as plain
69
+ JSON — the baseline for \`nifra diff\`. Default api-snapshot.json.
70
+ nifra diff [<baseline>] [--json] Breaking-change gate: re-snapshot the contract and compare
71
+ against the committed baseline. Direction-aware (a new required
72
+ request field or a removed response field breaks; widening a
73
+ request enum or adding a response field doesn't) and fails
74
+ closed. Exits non-zero on any breaking change — run it in CI.
68
75
  nifra doctor [--json] [--auto-fix] Flag packages imported in source but missing from package.json
69
76
  (resolve at Bun runtime, break tsc + standalone install);
70
77
  --auto-fix writes safe local-version dependency fixes.
@@ -82,7 +89,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
82
89
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
83
90
 
84
91
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
85
- const CLI_VERSION = "1.3.1"
92
+ const CLI_VERSION = "1.5.0"
86
93
 
87
94
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
88
95
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -332,6 +339,38 @@ async function main(): Promise<void> {
332
339
  process.exitCode = 1
333
340
  return
334
341
  }
342
+ // `snapshot` / `diff` load ONLY backend.ts (the API contract) — like `check`, they must run on an
343
+ // API-only project, so they dispatch before the eager `loadApp`.
344
+ if (command === "snapshot") {
345
+ const { runSnapshot } = await import("./diff-tool.ts")
346
+ const outIdx = argv.indexOf("--out")
347
+ const out = outIdx !== -1 ? argv[outIdx + 1] : undefined
348
+ if (outIdx !== -1 && (out === undefined || out.startsWith("-"))) {
349
+ console.error("[nifra] --out needs a file path")
350
+ process.exitCode = 1
351
+ return
352
+ }
353
+ try {
354
+ await runSnapshot(process.cwd(), out !== undefined ? { out } : {})
355
+ } catch (err) {
356
+ console.error(err instanceof Error ? err.message : String(err))
357
+ process.exitCode = 1
358
+ }
359
+ return
360
+ }
361
+ if (command === "diff") {
362
+ const { runDiff, DEFAULT_SNAPSHOT_FILE } = await import("./diff-tool.ts")
363
+ const baseline = argv.slice(1).find((arg) => !arg.startsWith("-")) ?? DEFAULT_SNAPSHOT_FILE
364
+ try {
365
+ if (!(await runDiff(process.cwd(), baseline, { json: argv.includes("--json") }))) {
366
+ process.exitCode = 1
367
+ }
368
+ } catch (err) {
369
+ console.error(err instanceof Error ? err.message : String(err))
370
+ process.exitCode = 1
371
+ }
372
+ return
373
+ }
335
374
  // `port` is a pure cwd-based portability linter (scans source, doesn't run the app) — like `check`/
336
375
  // `doctor`, dispatch before the eager `loadApp` so it runs on an API-only / not-yet-built project.
337
376
  if (command === "port") {
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `nifra snapshot` / `nifra diff` — the API breaking-change gate.
3
+ *
4
+ * nifra snapshot [--out api-snapshot.json] Write the backend's route contract as plain JSON.
5
+ * nifra diff <baseline.json> [--json] Re-snapshot and compare against the committed
6
+ * baseline; exit non-zero on any breaking change.
7
+ *
8
+ * The CI loop: commit a snapshot on main, run `nifra diff api-snapshot.json` on every PR, and an
9
+ * accidental contract break (removed route, new required field, retyped response) fails the build
10
+ * before it ships. Classification lives in `@nifrajs/core/diff` (direction-aware, fails closed);
11
+ * this module only loads `backend.ts` and renders the result.
12
+ *
13
+ * Loads ONLY `backend.ts` — the API contract — so it works on an API-only project with no
14
+ * framework config or routes/ directory (unlike the eager `loadApp`).
15
+ */
16
+
17
+ import { existsSync } from "node:fs"
18
+ import { resolve } from "node:path"
19
+ import {
20
+ diffRouteSnapshots,
21
+ type RouteChange,
22
+ type RouteSnapshot,
23
+ type RoutesDiff,
24
+ snapshotRoutes,
25
+ } from "@nifrajs/core/diff"
26
+
27
+ /** Snapshot file envelope — versioned so a future format change can migrate instead of misparse. */
28
+ export interface SnapshotFile {
29
+ readonly nifraSnapshot: 1
30
+ readonly routes: readonly RouteSnapshot[]
31
+ }
32
+
33
+ export const DEFAULT_SNAPSHOT_FILE = "api-snapshot.json"
34
+
35
+ /** Parse + validate a snapshot file's content. Throws with an actionable message on any mismatch. */
36
+ export function parseSnapshotFile(content: string, sourcePath: string): SnapshotFile {
37
+ let parsed: unknown
38
+ try {
39
+ parsed = JSON.parse(content)
40
+ } catch {
41
+ throw new Error(
42
+ `[nifra] ${sourcePath} is not valid JSON — regenerate it with \`nifra snapshot\`.`,
43
+ )
44
+ }
45
+ const record = parsed as Partial<SnapshotFile> | null
46
+ if (
47
+ record === null ||
48
+ typeof record !== "object" ||
49
+ record.nifraSnapshot !== 1 ||
50
+ !Array.isArray(record.routes)
51
+ ) {
52
+ throw new Error(
53
+ `[nifra] ${sourcePath} is not a nifra API snapshot — expected { "nifraSnapshot": 1, "routes": [...] }.`,
54
+ )
55
+ }
56
+ return record as SnapshotFile
57
+ }
58
+
59
+ const SEVERITY_MARK = {
60
+ breaking: "✖ breaking",
61
+ compatible: "✓ compatible",
62
+ info: "ℹ info",
63
+ } as const
64
+
65
+ const describeChange = (change: RouteChange): string => {
66
+ const where = change.section === "route" ? "" : ` ${change.section}`
67
+ return `${SEVERITY_MARK[change.severity]} ${change.method} ${change.path}${where}: ${change.message}`
68
+ }
69
+
70
+ /** Render a diff for terminals: breaking first, then info, then compatible. */
71
+ export function formatDiff(diff: RoutesDiff): string {
72
+ if (diff.changes.length === 0) return "No API contract changes."
73
+ const order: Record<RouteChange["severity"], number> = { breaking: 0, info: 1, compatible: 2 }
74
+ const lines = [...diff.changes]
75
+ .sort((a, b) => order[a.severity] - order[b.severity])
76
+ .map(describeChange)
77
+ const breaking = diff.changes.filter((change) => change.severity === "breaking").length
78
+ const summary = diff.hasBreaking
79
+ ? `${breaking} breaking change${breaking === 1 ? "" : "s"} — existing clients will fail.`
80
+ : "No breaking changes."
81
+ return `${lines.join("\n")}\n\n${summary}`
82
+ }
83
+
84
+ /** Import `backend.ts` from `cwd` and snapshot its routes. */
85
+ async function snapshotBackend(cwd: string): Promise<readonly RouteSnapshot[]> {
86
+ const backendPath = resolve(cwd, "backend.ts")
87
+ if (!existsSync(backendPath)) {
88
+ throw new Error(
89
+ `[nifra] no backend.ts in ${cwd} — \`nifra snapshot\`/\`nifra diff\` compare the API contract, which lives in backend.ts.`,
90
+ )
91
+ }
92
+ const backend = ((await import(backendPath)) as { backend?: unknown }).backend
93
+ if (backend === undefined) {
94
+ throw new Error(`[nifra] ${backendPath} does not export \`backend\`.`)
95
+ }
96
+ return snapshotRoutes(backend)
97
+ }
98
+
99
+ /** `nifra snapshot`: write the current contract as the CI baseline. */
100
+ export async function runSnapshot(cwd: string, options: { out?: string }): Promise<void> {
101
+ const routes = await snapshotBackend(cwd)
102
+ const file: SnapshotFile = { nifraSnapshot: 1, routes }
103
+ const outPath = resolve(cwd, options.out ?? DEFAULT_SNAPSHOT_FILE)
104
+ await Bun.write(outPath, `${JSON.stringify(file, null, 2)}\n`)
105
+ console.log(`[nifra] wrote ${routes.length} route${routes.length === 1 ? "" : "s"} to ${outPath}`)
106
+ }
107
+
108
+ /** `nifra diff`: compare the current contract against a baseline. Returns false on breaking changes. */
109
+ export async function runDiff(
110
+ cwd: string,
111
+ baselinePath: string,
112
+ options: { json?: boolean },
113
+ ): Promise<boolean> {
114
+ const resolved = resolve(cwd, baselinePath)
115
+ const baselineFile = Bun.file(resolved)
116
+ if (!(await baselineFile.exists())) {
117
+ throw new Error(
118
+ `[nifra] baseline not found: ${resolved} — create it on your main branch with \`nifra snapshot\`.`,
119
+ )
120
+ }
121
+ const baseline = parseSnapshotFile(await baselineFile.text(), baselinePath)
122
+ const current = await snapshotBackend(cwd)
123
+ const diff = diffRouteSnapshots(baseline.routes, current)
124
+ if (options.json === true) {
125
+ console.log(JSON.stringify({ hasBreaking: diff.hasBreaking, changes: diff.changes }, null, 2))
126
+ } else {
127
+ console.log(formatDiff(diff))
128
+ }
129
+ return !diff.hasBreaking
130
+ }
package/src/introspect.ts CHANGED
@@ -5,38 +5,14 @@
5
5
  * *this* project's actual surface, not just generic docs.
6
6
  */
7
7
 
8
+ import { type ReflectedRoute, reflectRoutes, type SchemaReflection } from "@nifrajs/core/reflection"
8
9
  import type { Manifest } from "@nifrajs/web"
9
10
  import { discoverRoutes } from "@nifrajs/web/fs"
10
11
  import type { LoadedApp } from "./load.ts"
11
12
 
12
- /** Structural view of a `@nifrajs/core` route descriptor — declared locally so the CLI needs no type
13
- * dependency on core. `app.routes()` returns these. */
14
- interface RouteDesc {
15
- readonly method: string
16
- readonly path: string
17
- readonly schema?: unknown
18
- }
19
-
20
13
  /** Read the backend's registered routes, if it's a `server()` with a `.routes()` method. */
21
- export function backendRoutes(backend: unknown): RouteDesc[] {
22
- if (backend && typeof (backend as { routes?: unknown }).routes === "function") {
23
- try {
24
- return (backend as { routes(): RouteDesc[] }).routes()
25
- } catch {
26
- return []
27
- }
28
- }
29
- return []
30
- }
31
-
32
- /** The JSON Schema a `t`/Standard Schema carries (nifra's `t` is TypeBox-backed and exposes `.jsonSchema`),
33
- * or `undefined` for a validator that doesn't expose one. This is the exact field-level contract. */
34
- function jsonSchemaOf(schema: unknown): unknown {
35
- if (schema && typeof schema === "object" && "jsonSchema" in schema) {
36
- const js = (schema as { jsonSchema?: unknown }).jsonSchema
37
- if (js !== undefined && js !== null) return js
38
- }
39
- return undefined
14
+ export function backendRoutes(backend: unknown): ReflectedRoute[] {
15
+ return [...reflectRoutes(backend)]
40
16
  }
41
17
 
42
18
  interface JsonSchemaNode {
@@ -99,16 +75,16 @@ export function tsTypeOf(schema: unknown, depth = 0): string {
99
75
 
100
76
  /** One route's request/response field shapes, indented under its line. Reads the declared schemas off the
101
77
  * descriptor so an agent gets the exact input + output contract — not just "this route is validated". */
102
- function schemaLines(schema: unknown): string[] {
103
- const s = schema as { body?: unknown; query?: unknown; response?: unknown } | undefined
78
+ function schemaLines(schema: ReflectedRoute["schema"]): string[] {
79
+ const s = schema
104
80
  if (!s) return []
105
81
  const out: string[] = []
106
82
  for (const key of ["query", "body", "response"] as const) {
107
83
  const declared = s[key]
108
84
  if (!declared) continue
109
- const js = jsonSchemaOf(declared)
85
+ const js = declared.jsonSchema
110
86
  out.push(
111
- js
87
+ js !== undefined
112
88
  ? ` - ${key}: \`${tsTypeOf(js)}\``
113
89
  : ` - ${key}: _(validated; shape not introspectable from this validator)_`,
114
90
  )
@@ -153,7 +129,7 @@ export function clientCall(method: string, path: string, schema: unknown): strin
153
129
  }
154
130
 
155
131
  /** Markdown section listing the backend's API routes with their request + response field shapes. */
156
- export function apiRoutesSection(routes: readonly RouteDesc[]): string {
132
+ export function apiRoutesSection(routes: readonly ReflectedRoute[]): string {
157
133
  if (routes.length === 0) {
158
134
  return "## API routes\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
159
135
  }
@@ -171,7 +147,7 @@ export function apiRoutesSection(routes: readonly RouteDesc[]): string {
171
147
  * body/query/response shapes or the per-route `call` line. Bounds the first-call payload on a big backend:
172
148
  * an agent gets the full route list (so it knows what's mounted) plus a pointer to fetch any route's full
173
149
  * contract via the `path`/`kind` slice, instead of every schema up front. */
174
- export function apiRoutesIndexSection(routes: readonly RouteDesc[]): string {
150
+ export function apiRoutesIndexSection(routes: readonly ReflectedRoute[]): string {
175
151
  if (routes.length === 0) {
176
152
  return "## API routes (backend.ts)\n\nNo `backend.ts` server routes found (this app may be frontend-only)."
177
153
  }
@@ -287,8 +263,10 @@ export interface RouteJson {
287
263
  export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
288
264
  const shape = (v: unknown): string | undefined => {
289
265
  if (!v) return undefined
290
- const js = jsonSchemaOf(v)
291
- return js ? tsTypeOf(js) : "(validated; shape not introspectable from this validator)"
266
+ const js = (v as SchemaReflection).jsonSchema
267
+ return js !== undefined
268
+ ? tsTypeOf(js)
269
+ : "(validated; shape not introspectable from this validator)"
292
270
  }
293
271
  let routes = backendRoutes(app.backend)
294
272
  if (pathPrefix !== undefined && pathPrefix !== "") {
@@ -297,7 +275,7 @@ export function routesToJson(app: LoadedApp, pathPrefix?: string): RouteJson[] {
297
275
  return [...routes]
298
276
  .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method))
299
277
  .map((r) => {
300
- const s = r.schema as { body?: unknown; query?: unknown; response?: unknown } | undefined
278
+ const s = r.schema
301
279
  const body = shape(s?.body)
302
280
  const query = shape(s?.query)
303
281
  const response = shape(s?.response)
@@ -353,7 +331,7 @@ export interface PageRouteInput {
353
331
 
354
332
  export interface RouteTableInput {
355
333
  readonly pages: readonly PageRouteInput[]
356
- readonly api: readonly RouteDesc[]
334
+ readonly api: ReadonlyArray<Pick<ReflectedRoute, "method" | "path">>
357
335
  /** The page router's `apiPrefix` (default `/api`); an API route at/under it is auto-mounted. `""`
358
336
  * disables the auto-mount, so no API route is marked auto-mounted. */
359
337
  readonly apiPrefix?: string
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 = "1.3.1"
29
+ const VERSION = "1.5.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
@@ -26,6 +26,7 @@
26
26
  import { readFile, stat } from "node:fs/promises"
27
27
  import { basename, resolve, sep } from "node:path"
28
28
  import { fileURLToPath } from "node:url"
29
+ import { type ReflectedRoute, reflectRoutes } from "@nifrajs/core/reflection"
29
30
  import { Glob } from "bun"
30
31
  import { loadDocsCorpus } from "./docs-search.ts"
31
32
  import { loadExamplesCorpus } from "./examples.ts"
@@ -978,17 +979,8 @@ export function projectTools(
978
979
  ]
979
980
  }
980
981
 
981
- /** A `.tool()`-registered route as seen through the backend's `routes()` introspection. */
982
- type ToolRoute = {
983
- readonly schema?: { readonly body?: unknown; readonly response?: unknown }
984
- readonly tool?: {
985
- readonly name: string
986
- readonly description: string
987
- readonly annotations?: McpTool["annotations"]
988
- }
989
- }
990
982
  type ToolBackend = {
991
- readonly routes?: () => ToolRoute[]
983
+ readonly routes?: () => readonly unknown[]
992
984
  readonly fetch: (req: Request) => Promise<Response>
993
985
  }
994
986
 
@@ -997,28 +989,15 @@ type ToolBackend = {
997
989
  export function extractBackendTools(backend: unknown): McpTool[] {
998
990
  const b = backend as ToolBackend | null
999
991
  if (!b || typeof b.routes !== "function") return []
1000
- let routes: ToolRoute[] = []
1001
- try {
1002
- routes = b.routes()
1003
- } catch {
1004
- return []
1005
- }
1006
-
1007
- const jsonSchemaOf = (schema: unknown): unknown => {
1008
- if (schema && typeof schema === "object" && "jsonSchema" in schema) {
1009
- const js = (schema as { jsonSchema?: unknown }).jsonSchema
1010
- if (js !== undefined && js !== null) return js
1011
- }
1012
- return undefined
1013
- }
992
+ const routes = reflectRoutes(b)
1014
993
 
1015
994
  return routes
1016
995
  .filter(
1017
996
  (
1018
997
  r,
1019
- ): r is ToolRoute & {
1020
- schema: NonNullable<ToolRoute["schema"]>
1021
- tool: NonNullable<ToolRoute["tool"]>
998
+ ): r is ReflectedRoute & {
999
+ schema: NonNullable<ReflectedRoute["schema"]>
1000
+ tool: NonNullable<ReflectedRoute["tool"]>
1022
1001
  } => r.schema !== undefined && r.tool !== undefined,
1023
1002
  )
1024
1003
  .map((r) => {
@@ -1027,7 +1006,7 @@ export function extractBackendTools(backend: unknown): McpTool[] {
1027
1006
  return {
1028
1007
  name: toolInfo.name,
1029
1008
  description: toolInfo.description,
1030
- inputSchema: (jsonSchemaOf(s.body) ?? {
1009
+ inputSchema: (s.body?.jsonSchema ?? {
1031
1010
  type: "object",
1032
1011
  properties: {},
1033
1012
  }) as McpTool["inputSchema"],