@nifrajs/cli 2.9.1 → 2.10.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": "2.9.1",
3
+ "version": "2.10.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,14 +41,14 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@nifrajs/client": "2.9.1",
45
- "@nifrajs/core": "2.9.1",
46
- "@nifrajs/mcp": "2.9.1",
47
- "@nifrajs/runner": "2.9.1",
48
- "@nifrajs/schema": "2.9.1",
49
- "@nifrajs/testing": "2.9.1",
50
- "@nifrajs/web": "2.9.1",
51
- "create-nifra": "2.9.1"
44
+ "@nifrajs/client": "2.10.0",
45
+ "@nifrajs/core": "2.10.0",
46
+ "@nifrajs/mcp": "2.10.0",
47
+ "@nifrajs/runner": "2.10.0",
48
+ "@nifrajs/schema": "2.10.0",
49
+ "@nifrajs/testing": "2.10.0",
50
+ "@nifrajs/web": "2.10.0",
51
+ "create-nifra": "2.10.0"
52
52
  },
53
53
  "repository": {
54
54
  "type": "git",
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { inProcessClient } from "@nifrajs/client"
15
15
  import { createWebApp, DEFAULT_DEV_PORT, type RenderAdapter } from "@nifrajs/web"
16
16
  import { discoverRoutes } from "@nifrajs/web/fs"
17
17
  import type { BunPlugin } from "bun"
18
- import { describeProject, describeRoutes } from "./introspect.ts"
18
+ import { describeProject, describeRouteGraph, describeRoutes } from "./introspect.ts"
19
19
  import { type LoadedApp, loadApp } from "./load.ts"
20
20
  import { chooseBuildPipeline } from "./pipeline-guard.ts"
21
21
 
@@ -67,6 +67,8 @@ Usage:
67
67
  routes (routes/) + the in-process backend's API routes,
68
68
  marking which API routes are auto-mounted under apiPrefix.
69
69
  --json for agents (see POST /api/x is/isn't served, not via 405).
70
+ nifra routes --graph [--json] Render the public route surface as a Mermaid graph, or as
71
+ stable JSON for contract explorers.
70
72
  nifra routes --modes [--target <t>] Show each page route's RENDER MODE (static | isr | ssr),
71
73
  hydration, and cache policy in one table. With --target, gate
72
74
  against it: a route the target can't honour (an ssr route on a
@@ -105,6 +107,8 @@ Usage:
105
107
  request field or a removed response field breaks; widening a
106
108
  request enum or adding a response field doesn't) and fails
107
109
  closed. Exits non-zero on any breaking change - run it in CI.
110
+ nifra sdk --lang <python|go> [--out <file>]
111
+ Generate a deterministic non-TypeScript SDK from backend.ts.
108
112
  nifra assure [--config <file>] [--json] Route-assurance gate: load nifra.assurance.ts, classify every
109
113
  reflected backend route, and fail when required enforcement
110
114
  evidence is missing/forbidden or a route is unclassified.
@@ -148,7 +152,7 @@ Port: \`dev\` and \`start\` share the default ${DEFAULT_DEV_PORT}. Override with
148
152
  \`PORT\` env var (\`--port\` wins over \`PORT\`, which wins over the default).`
149
153
 
150
154
  // Kept in lockstep with packages/cli/package.json by check:publish's version-consistency gate.
151
- const CLI_VERSION = "2.9.1"
155
+ const CLI_VERSION = "2.10.0"
152
156
 
153
157
  // A render adapter + nifra server are opaque to the CLI (it just forwards them); cast at the seam.
154
158
  const asAdapter = (v: unknown): RenderAdapter => v as RenderAdapter
@@ -619,6 +623,30 @@ async function main(): Promise<void> {
619
623
  }
620
624
  return
621
625
  }
626
+ if (command === "sdk") {
627
+ const languageIndex = argv.indexOf("--lang")
628
+ const language = languageIndex === -1 ? undefined : argv[languageIndex + 1]
629
+ const outIndex = argv.indexOf("--out")
630
+ const out = outIndex === -1 ? undefined : argv[outIndex + 1]
631
+ if (language !== "python" && language !== "go") {
632
+ console.error("[nifra] sdk needs --lang python or --lang go")
633
+ process.exitCode = 1
634
+ return
635
+ }
636
+ if (outIndex !== -1 && (out === undefined || out.startsWith("-"))) {
637
+ console.error("[nifra] --out needs a file path")
638
+ process.exitCode = 1
639
+ return
640
+ }
641
+ try {
642
+ const { runSdk } = await import("./sdk.ts")
643
+ await runSdk(process.cwd(), { language, ...(out !== undefined ? { out } : {}) })
644
+ } catch (err) {
645
+ console.error(formatCliError(err))
646
+ process.exitCode = 1
647
+ }
648
+ return
649
+ }
622
650
  // Route assurance intentionally loads only its explicit config (which imports the backend it audits),
623
651
  // not the full SSR app. Like snapshot/diff, this works for API-only projects.
624
652
  if (command === "assure") {
@@ -823,7 +851,9 @@ async function main(): Promise<void> {
823
851
  else if (command === "build") await buildForTarget(app, flags.target, flags)
824
852
  else if (command === "context") console.log(describeProject(app))
825
853
  else if (command === "routes") {
826
- if (argv.includes("--modes")) {
854
+ if (argv.includes("--graph")) {
855
+ console.log(await describeRouteGraph(app, { json: argv.includes("--json") }))
856
+ } else if (argv.includes("--modes")) {
827
857
  // `--modes` answers a different question than the default table: not "what URLs exist" but "how
828
858
  // does each render, and can the target I'm about to deploy to actually honour that". Resolves
829
859
  // against `--target` when given, so a `static` build's server-needing route fails here, in one
package/src/introspect.ts CHANGED
@@ -430,6 +430,106 @@ export function routeTableToJson(rows: readonly RouteTableEntry[]): {
430
430
  }
431
431
  }
432
432
 
433
+ export interface RouteGraphNode {
434
+ readonly id: string
435
+ readonly kind: "root" | RouteKind
436
+ readonly path: string
437
+ readonly methods?: readonly string[]
438
+ readonly file?: string
439
+ readonly autoMounted?: boolean
440
+ }
441
+
442
+ export interface RouteGraphEdge {
443
+ readonly from: string
444
+ readonly to: string
445
+ }
446
+
447
+ export interface RouteGraph {
448
+ readonly nodes: readonly RouteGraphNode[]
449
+ readonly edges: readonly RouteGraphEdge[]
450
+ }
451
+
452
+ function parentPath(path: string): string | undefined {
453
+ if (path === "/") return undefined
454
+ const trimmed = path.replace(/\/$/, "")
455
+ const slash = trimmed.lastIndexOf("/")
456
+ return slash <= 0 ? "/" : trimmed.slice(0, slash)
457
+ }
458
+
459
+ /** Build a stable parent/child graph from the public route table. Pure and topology-neutral. */
460
+ export function buildRouteGraph(rows: readonly RouteTableEntry[]): RouteGraph {
461
+ const sorted = [...rows].sort(
462
+ (a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind),
463
+ )
464
+ const nodes: RouteGraphNode[] = [
465
+ { id: "root", kind: "root", path: "/" },
466
+ ...sorted.map((row) => ({
467
+ id: `${row.kind}:${row.path}`,
468
+ kind: row.kind,
469
+ path: row.path,
470
+ methods: row.methods,
471
+ ...(row.file === undefined ? {} : { file: row.file }),
472
+ ...(row.kind === "api" ? { autoMounted: row.autoMounted === true } : {}),
473
+ })),
474
+ ]
475
+ const nodeIds = new Set(nodes.map((node) => node.id))
476
+ const edges: RouteGraphEdge[] = []
477
+ for (const row of sorted) {
478
+ let parent = parentPath(row.path)
479
+ let from = "root"
480
+ while (parent !== undefined) {
481
+ const candidate = `${row.kind}:${parent}`
482
+ if (nodeIds.has(candidate)) {
483
+ from = candidate
484
+ break
485
+ }
486
+ parent = parentPath(parent)
487
+ }
488
+ edges.push({ from, to: `${row.kind}:${row.path}` })
489
+ }
490
+ return { nodes, edges }
491
+ }
492
+
493
+ /** Stable JSON shape for agents and contract explorers. */
494
+ export function routeGraphToJson(graph: RouteGraph): RouteGraph {
495
+ return {
496
+ nodes: graph.nodes.map((node) => ({
497
+ ...node,
498
+ ...(node.methods === undefined ? {} : { methods: [...node.methods] }),
499
+ })),
500
+ edges: graph.edges.map((edge) => ({ ...edge })),
501
+ }
502
+ }
503
+
504
+ function mermaidText(value: string): string {
505
+ return value
506
+ .replaceAll("&", "&amp;")
507
+ .replaceAll('"', "&quot;")
508
+ .replaceAll("<", "&lt;")
509
+ .replaceAll(">", "&gt;")
510
+ .replaceAll(/[\r\n]/g, " ")
511
+ }
512
+
513
+ function mermaidLabel(node: RouteGraphNode): string {
514
+ if (node.kind === "root") return "routes"
515
+ const methods = node.methods?.join(", ") ?? ""
516
+ const mounted = node.autoMounted === true ? " (auto-mounted)" : ""
517
+ return mermaidText(`${node.kind}: ${methods} ${node.path}${mounted}`)
518
+ }
519
+
520
+ /** Render a route graph as Mermaid for terminals, docs, and agent-facing previews. */
521
+ export function renderRouteGraph(graph: RouteGraph): string {
522
+ const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]))
523
+ const lines = ["flowchart TD"]
524
+ for (const node of graph.nodes) {
525
+ lines.push(` ${ids.get(node.id)}["${mermaidLabel(node)}"]`)
526
+ }
527
+ for (const edge of graph.edges) {
528
+ lines.push(` ${ids.get(edge.from)} --> ${ids.get(edge.to)}`)
529
+ }
530
+ return lines.join("\n")
531
+ }
532
+
433
533
  /** A route module that may export an `action` (→ the page serves POST). */
434
534
  interface MaybeActionModule {
435
535
  readonly action?: unknown
@@ -441,10 +541,7 @@ interface MaybeActionModule {
441
541
  * text (default) or JSON (`--json`). The page-route load mirrors what SSR would do; a load failure
442
542
  * degrades to GET-only for that route (never throws the whole command).
443
543
  */
444
- export async function describeRoutes(
445
- app: LoadedApp,
446
- opts: { readonly json?: boolean } = {},
447
- ): Promise<string> {
544
+ async function collectRouteTable(app: LoadedApp): Promise<RouteTableEntry[]> {
448
545
  let manifest: Manifest | undefined
449
546
  try {
450
547
  manifest = discoverRoutes(app.routesDir)
@@ -463,5 +560,22 @@ export async function describeRoutes(
463
560
  pages.push({ pattern: route.pattern, file: route.file, hasAction })
464
561
  }
465
562
  const rows = buildRouteTable({ pages, api: backendRoutes(app.backend) })
563
+ return rows
564
+ }
565
+
566
+ export async function describeRoutes(
567
+ app: LoadedApp,
568
+ opts: { readonly json?: boolean } = {},
569
+ ): Promise<string> {
570
+ const rows = await collectRouteTable(app)
466
571
  return opts.json ? JSON.stringify(routeTableToJson(rows), null, 2) : renderRouteTable(rows)
467
572
  }
573
+
574
+ /** Gather and render the public route graph for `nifra routes --graph`. */
575
+ export async function describeRouteGraph(
576
+ app: LoadedApp,
577
+ opts: { readonly json?: boolean } = {},
578
+ ): Promise<string> {
579
+ const graph = buildRouteGraph(await collectRouteTable(app))
580
+ return opts.json ? JSON.stringify(routeGraphToJson(graph), null, 2) : renderRouteGraph(graph)
581
+ }
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 = "2.9.1"
32
+ const VERSION = "2.10.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 =>
package/src/sdk.ts ADDED
@@ -0,0 +1,260 @@
1
+ /** Deterministic, dependency-free SDK generation from the backend's OpenAPI contract. */
2
+ import { existsSync } from "node:fs"
3
+ import { basename, resolve } from "node:path"
4
+ import { type OpenAPIDocument, toOpenAPI } from "@nifrajs/schema/openapi"
5
+
6
+ export type SdkLanguage = "python" | "go"
7
+
8
+ interface OperationLike {
9
+ readonly operationId?: string
10
+ readonly parameters?: readonly {
11
+ readonly name: string
12
+ readonly in: "path" | "query"
13
+ readonly required: boolean
14
+ }[]
15
+ readonly requestBody?: unknown
16
+ }
17
+
18
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] as const
19
+
20
+ function operations(
21
+ document: OpenAPIDocument,
22
+ ): readonly { method: string; path: string; operation: OperationLike }[] {
23
+ const result: { method: string; path: string; operation: OperationLike }[] = []
24
+ for (const [path, item] of Object.entries(document.paths).sort(([a], [b]) =>
25
+ a.localeCompare(b),
26
+ )) {
27
+ for (const method of HTTP_METHODS) {
28
+ const operation = (item as Record<string, unknown>)[method]
29
+ if (operation !== null && typeof operation === "object") {
30
+ result.push({ method, path, operation: operation as OperationLike })
31
+ }
32
+ }
33
+ }
34
+ return result
35
+ }
36
+
37
+ function identifier(value: string, fallback: string): string {
38
+ const startsWithDigit = /^\d/.test(value)
39
+ const normalized = value
40
+ .replace(/[^A-Za-z0-9_]+/g, "_")
41
+ .replace(/_+/g, "_")
42
+ .replace(/^_+|_+$/g, "")
43
+ const safe = startsWithDigit ? `_${normalized}` : normalized
44
+ return safe === "" || safe === "_" ? fallback : safe
45
+ }
46
+
47
+ function operationName(operation: OperationLike, method: string, path: string): string {
48
+ return identifier(operation.operationId ?? `${method}_${path}`, `${method}_request`)
49
+ }
50
+
51
+ function uniqueNames(items: readonly string[]): readonly string[] {
52
+ const counts = new Map<string, number>()
53
+ return items.map((item) => {
54
+ const count = (counts.get(item) ?? 0) + 1
55
+ counts.set(item, count)
56
+ return count === 1 ? item : `${item}_${count}`
57
+ })
58
+ }
59
+
60
+ function pathParameters(path: string): readonly string[] {
61
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]!).filter(Boolean)
62
+ }
63
+
64
+ function pythonSdk(document: OpenAPIDocument): string {
65
+ const entries = operations(document)
66
+ const names = uniqueNames(
67
+ entries.map(({ method, path, operation }) => operationName(operation, method, path)),
68
+ )
69
+ const methods: string[] = []
70
+ entries.forEach(({ method, path, operation }, index) => {
71
+ const name = names[index]!
72
+ const pathParams = pathParameters(path)
73
+ const queryParams = (operation.parameters ?? []).filter((parameter) => parameter.in === "query")
74
+ const used = new Set(["self", "body", ...pathParams])
75
+ const queryNames = queryParams.map((parameter) => {
76
+ let value = identifier(parameter.name, "query")
77
+ while (used.has(value)) value += "_"
78
+ used.add(value)
79
+ return value
80
+ })
81
+ const args = pathParams.map((parameter) => `${identifier(parameter, "param")}: Any`)
82
+ const keywordArgs = queryParams.map((parameter, queryIndex) => {
83
+ const required = parameter.required ? "" : " = None"
84
+ return `${queryNames[queryIndex]!}: Any${required}`
85
+ })
86
+ if (operation.requestBody !== undefined) keywordArgs.push("body: Any = None")
87
+ const signature = [...args, ...(keywordArgs.length > 0 ? ["*", ...keywordArgs] : [])].join(", ")
88
+ const query =
89
+ queryNames.length === 0
90
+ ? "None"
91
+ : `{${queryNames.map((value) => `${JSON.stringify(value)}: ${value}`).join(", ")}}`
92
+ const paths =
93
+ pathParams.length === 0
94
+ ? "None"
95
+ : `{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
96
+ const body = operation.requestBody === undefined ? "None" : "body"
97
+ methods.push(
98
+ ` def ${name}(self${signature ? `, ${signature}` : ""}) -> Any:\n` +
99
+ ` return self._request(${JSON.stringify(method.toUpperCase())}, ${JSON.stringify(path)}, path_params=${paths}, query=${query}, body=${body})`,
100
+ )
101
+ })
102
+ return `# Generated by nifra. Regenerate with: nifra sdk --lang python
103
+ from __future__ import annotations
104
+
105
+ import json
106
+ import urllib.error
107
+ import urllib.parse
108
+ import urllib.request
109
+ from typing import Any, Mapping
110
+
111
+
112
+ class NifraApiError(RuntimeError):
113
+ def __init__(self, status: int, body: Any):
114
+ super().__init__(f"Nifra API request failed with HTTP {status}")
115
+ self.status = status
116
+ self.body = body
117
+
118
+
119
+ class Client:
120
+ def __init__(self, base_url: str, headers: Mapping[str, str] | None = None, timeout: float = 30.0):
121
+ self.base_url = base_url.rstrip("/")
122
+ self.headers = dict(headers or {})
123
+ self.timeout = timeout
124
+
125
+ def _request(self, method: str, path: str, *, path_params: Mapping[str, Any] | None, query: Mapping[str, Any] | None, body: Any) -> Any:
126
+ route = path
127
+ for key, value in (path_params or {}).items():
128
+ route = route.replace("{" + key + "}", urllib.parse.quote(str(value), safe=""))
129
+ params = [(key, value) for key, value in (query or {}).items() if value is not None]
130
+ url = self.base_url + route
131
+ if params:
132
+ url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params, doseq=True)
133
+ payload = None if body is None else json.dumps(body).encode("utf-8")
134
+ request = urllib.request.Request(url, data=payload, method=method, headers=self.headers)
135
+ if payload is not None:
136
+ request.add_header("Content-Type", "application/json")
137
+ try:
138
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
139
+ raw = response.read()
140
+ return json.loads(raw) if raw else None
141
+ except urllib.error.HTTPError as error:
142
+ raw = error.read()
143
+ try:
144
+ value = json.loads(raw) if raw else None
145
+ except json.JSONDecodeError:
146
+ value = raw.decode("utf-8", errors="replace")
147
+ raise NifraApiError(error.code, value) from error
148
+
149
+ ${methods.length === 0 ? " pass" : methods.join("\n\n")}
150
+ `
151
+ }
152
+
153
+ function goExportName(value: string): string {
154
+ const name = identifier(value, "Request")
155
+ return name[0]!.toUpperCase() + name.slice(1)
156
+ }
157
+
158
+ function goSdk(document: OpenAPIDocument): string {
159
+ const entries = operations(document)
160
+ const names = uniqueNames(
161
+ entries.map(({ method, path, operation }) => operationName(operation, method, path)),
162
+ )
163
+ const methods: string[] = []
164
+ entries.forEach(({ method, path }, index) => {
165
+ const pathParams = pathParameters(path)
166
+ const args = pathParams.map((parameter) => `${identifier(parameter, "param")} string`)
167
+ args.push("query url.Values", "body any")
168
+ const pathMap =
169
+ pathParams.length === 0
170
+ ? "nil"
171
+ : `map[string]string{${pathParams.map((value) => `${JSON.stringify(value)}: ${identifier(value, "param")}`).join(", ")}}`
172
+ methods.push(
173
+ `func (c *Client) ${goExportName(names[index]!)}(${args.join(", ")}) (any, error) {\n` +
174
+ ` return c.request(http.Method${method[0]!.toUpperCase()}${method.slice(1)}, ${JSON.stringify(path)}, ${pathMap}, query, body)\n` +
175
+ `}`,
176
+ )
177
+ })
178
+ return `// Code generated by nifra. Regenerate with: nifra sdk --lang go
179
+ package nifrasdk
180
+
181
+ import (
182
+ "bytes"
183
+ "encoding/json"
184
+ "fmt"
185
+ "io"
186
+ "net/http"
187
+ "net/url"
188
+ "strings"
189
+ )
190
+
191
+ type Client struct {
192
+ BaseURL string
193
+ HTTP *http.Client
194
+ Headers http.Header
195
+ }
196
+
197
+ func NewClient(baseURL string) *Client {
198
+ return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: http.DefaultClient, Headers: make(http.Header)}
199
+ }
200
+
201
+ func (c *Client) request(method, path string, pathParams map[string]string, query url.Values, body any) (any, error) {
202
+ route := path
203
+ for key, value := range pathParams {
204
+ route = strings.ReplaceAll(route, "{"+key+"}", url.PathEscape(value))
205
+ }
206
+ target := c.BaseURL + route
207
+ parsed, err := url.Parse(target)
208
+ if err != nil { return nil, err }
209
+ parsed.RawQuery = query.Encode()
210
+ var reader io.Reader
211
+ if body != nil {
212
+ payload, err := json.Marshal(body)
213
+ if err != nil { return nil, err }
214
+ reader = bytes.NewReader(payload)
215
+ }
216
+ request, err := http.NewRequest(method, parsed.String(), reader)
217
+ if err != nil { return nil, err }
218
+ for key, values := range c.Headers { for _, value := range values { request.Header.Add(key, value) } }
219
+ if body != nil { request.Header.Set("Content-Type", "application/json") }
220
+ response, err := c.HTTP.Do(request)
221
+ if err != nil { return nil, err }
222
+ defer response.Body.Close()
223
+ raw, err := io.ReadAll(response.Body)
224
+ if err != nil { return nil, err }
225
+ var value any
226
+ if len(raw) > 0 { if err := json.Unmarshal(raw, &value); err != nil { value = string(raw) } }
227
+ if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("nifra API request failed with HTTP %d: %v", response.StatusCode, value) }
228
+ return value, nil
229
+ }
230
+
231
+ ${methods.length === 0 ? "// No operations were found.\n" : `${methods.join("\n\n")}\n`}`
232
+ }
233
+
234
+ /** Render a generated SDK. Python uses the standard library; Go uses net/http. */
235
+ export function renderSdk(document: OpenAPIDocument, language: SdkLanguage): string {
236
+ return language === "python" ? pythonSdk(document) : goSdk(document)
237
+ }
238
+
239
+ /** Load `backend.ts`, generate an SDK, and write it to the project. */
240
+ export async function runSdk(
241
+ cwd: string,
242
+ options: { readonly language: SdkLanguage; readonly out?: string },
243
+ ): Promise<void> {
244
+ const backendPath = resolve(cwd, "backend.ts")
245
+ if (!existsSync(backendPath)) {
246
+ throw new Error(`[nifra] no backend.ts in ${cwd} - SDK generation needs the API contract.`)
247
+ }
248
+ const backend = ((await import(backendPath)) as { backend?: unknown }).backend
249
+ if (backend === undefined) throw new Error(`[nifra] ${backendPath} does not export \`backend\`.`)
250
+ const document = toOpenAPI(backend as Parameters<typeof toOpenAPI>[0], {
251
+ title: `${basename(cwd)} API`,
252
+ version: "1.0.0",
253
+ })
254
+ const extension = options.language === "python" ? "py" : "go"
255
+ const out = resolve(cwd, options.out ?? `nifra_sdk.${extension}`)
256
+ await Bun.write(out, renderSdk(document, options.language))
257
+ console.log(
258
+ `[nifra] wrote ${options.language} SDK with ${Object.keys(document.paths).length} path${Object.keys(document.paths).length === 1 ? "" : "s"} to ${out}`,
259
+ )
260
+ }