@nifrajs/cli 2.9.1 → 2.11.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.
Files changed (55) hide show
  1. package/README.md +2 -1
  2. package/dist/check.d.ts +9 -1
  3. package/dist/check.d.ts.map +1 -1
  4. package/dist/check.js +38 -6
  5. package/dist/check.js.map +1 -1
  6. package/dist/cli.d.ts +2 -18
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +129 -66
  9. package/dist/cli.js.map +1 -1
  10. package/dist/dev-bun-config.d.ts +17 -6
  11. package/dist/dev-bun-config.d.ts.map +1 -1
  12. package/dist/dev-bun-config.js +51 -14
  13. package/dist/dev-bun-config.js.map +1 -1
  14. package/dist/doctor.d.ts +10 -0
  15. package/dist/doctor.d.ts.map +1 -1
  16. package/dist/doctor.js +17 -1
  17. package/dist/doctor.js.map +1 -1
  18. package/dist/introspect.d.ts +26 -6
  19. package/dist/introspect.d.ts.map +1 -1
  20. package/dist/introspect.js +92 -2
  21. package/dist/introspect.js.map +1 -1
  22. package/dist/load.d.ts +4 -0
  23. package/dist/load.d.ts.map +1 -1
  24. package/dist/load.js +1 -0
  25. package/dist/load.js.map +1 -1
  26. package/dist/mcp-http.js +1 -1
  27. package/dist/mcp-http.js.map +1 -1
  28. package/dist/mcp.js +2 -2
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/pipeline-guard.d.ts +25 -10
  31. package/dist/pipeline-guard.d.ts.map +1 -1
  32. package/dist/pipeline-guard.js +35 -15
  33. package/dist/pipeline-guard.js.map +1 -1
  34. package/dist/pipeline-report.d.ts +34 -0
  35. package/dist/pipeline-report.d.ts.map +1 -0
  36. package/dist/pipeline-report.js +282 -0
  37. package/dist/pipeline-report.js.map +1 -0
  38. package/dist/sdk.d.ts +10 -0
  39. package/dist/sdk.d.ts.map +1 -0
  40. package/dist/sdk.js +229 -0
  41. package/dist/sdk.js.map +1 -0
  42. package/docs/llms-full.txt +246 -17
  43. package/docs/types.json +725 -16
  44. package/package.json +9 -9
  45. package/src/check.ts +54 -5
  46. package/src/cli.ts +131 -67
  47. package/src/dev-bun-config.ts +57 -14
  48. package/src/doctor.ts +28 -1
  49. package/src/introspect.ts +124 -4
  50. package/src/load.ts +5 -0
  51. package/src/mcp-http.ts +1 -1
  52. package/src/mcp.ts +2 -2
  53. package/src/pipeline-guard.ts +44 -14
  54. package/src/pipeline-report.ts +341 -0
  55. package/src/sdk.ts +260 -0
@@ -0,0 +1,341 @@
1
+ /**
2
+ * The two-pipeline rule, reported STATICALLY - which bundler this app runs on, and the config hazards
3
+ * that only exist because nifra supports two.
4
+ *
5
+ * `loadApp` already refuses a plugin in the wrong slot ({@link ./load.ts}), and `nifra dev`/`nifra build`
6
+ * print the chosen bundler. Both of those need the app's config to be IMPORTED, which is code execution -
7
+ * something `nifra check` deliberately never does, and something an agent inspecting a repo it has not
8
+ * installed cannot do at all. So this reads the config as text instead.
9
+ *
10
+ * Text, not types, so the rules are chosen for the ones that survive being read rather than run:
11
+ *
12
+ * - Which pipeline the app is on. `chooseBuildPipeline` decides from whether the plugin slots are
13
+ * empty, and "does this file declare a non-empty `vitePlugins`" is a question the source answers.
14
+ * - The adapter-entry hazard. `nifra build` imports the adapter from `framework.ts`, or from
15
+ * `nifra.config.ts` when there is no `framework.ts`, and bundles everything that file imports into
16
+ * the production server. A Vite plugin or an SFC compiler reached that way builds cleanly and then
17
+ * dies at startup on a missing native binding. Nothing runtime-side catches it: by then the build
18
+ * has already succeeded.
19
+ * - A plugin in the wrong slot, for the subset whose import specifier names its pipeline. The load
20
+ * guard classifies by hook shape and is authoritative; this catches the same thing earlier, before
21
+ * anything is installed or started.
22
+ * - `conditions` on the Bun pipeline, which reach SSR and cannot reach the dev client bundle.
23
+ *
24
+ * Everything here degrades to silence rather than guessing. A config that computes its plugins, or
25
+ * re-exports them from elsewhere, yields `certain: false` and no findings - a static reader that
26
+ * invents a pipeline is worse than one that says it could not tell.
27
+ */
28
+ import { existsSync } from "node:fs"
29
+ import { join } from "node:path"
30
+ import { codePositionMask, stripComments } from "./check.ts"
31
+ import type { BuildPipeline } from "./pipeline-guard.ts"
32
+
33
+ /** A plugin slot, and the pipeline it feeds. */
34
+ const SLOT_PIPELINE = {
35
+ vitePlugins: "vite",
36
+ clientPlugins: "bun",
37
+ serverPlugins: "bun",
38
+ } as const satisfies Readonly<Record<string, BuildPipeline>>
39
+
40
+ type Slot = keyof typeof SLOT_PIPELINE
41
+
42
+ const SLOTS = Object.keys(SLOT_PIPELINE) as readonly Slot[]
43
+
44
+ export interface PipelineFinding {
45
+ readonly rule: "adapter-entry" | "plugin-slot" | "bun-client-conditions"
46
+ readonly severity: "error" | "warning"
47
+ /** Project-relative file the finding is in. */
48
+ readonly file: string
49
+ readonly line?: number
50
+ readonly message: string
51
+ readonly fix: string
52
+ }
53
+
54
+ export interface PipelineReport {
55
+ /** `false` when the directory holds no `nifra.config.ts` or `framework.ts` - nothing to report on. */
56
+ readonly ran: boolean
57
+ /** The bundler `nifra dev` and `nifra build` will use, or `unknown` when the source can't say. */
58
+ readonly pipeline: BuildPipeline | "unknown"
59
+ /** Plain-language "why this bundler", in the same words the CLI prints. */
60
+ readonly reason: string
61
+ /**
62
+ * Whether the config states its plugin slots outright. `false` when a slot is a thunk, an identifier,
63
+ * or a re-export - the pipeline shown is then nifra's best reading, and `nifra dev` prints the real one.
64
+ */
65
+ readonly certain: boolean
66
+ /** The config file read, project-relative. */
67
+ readonly configFile?: string
68
+ /** The file `nifra build` imports the adapter from - `framework.ts`, else the config. */
69
+ readonly adapterEntry?: string
70
+ readonly findings: readonly PipelineFinding[]
71
+ }
72
+
73
+ /**
74
+ * Specifiers that carry a dev toolchain: Vite itself, a Vite/Rollup plugin, or a framework's SFC
75
+ * compiler plugin (`@nifrajs/web-vue/plugin` and friends, which import the compiler). A relative
76
+ * `./plugin` is NOT matched - a local file of that name is as likely to be the app's own tiny plugin as
77
+ * a compiler, and this rule fails a gate.
78
+ */
79
+ const DEV_TOOLCHAIN =
80
+ /^vite$|^vite\/|^@vitejs\/|vite-plugin|^rollup$|^@rollup\/|^esbuild$|^(?:@[^/]+\/)?[^./][^/]*\/(?:.*\/)?plugin(?:\.ts)?$/
81
+
82
+ /** Specifiers that name the Vite pipeline outright. */
83
+ const VITE_SPECIFIER = /^vite$|^vite\/|^@vitejs\/|vite-plugin/
84
+ /** Specifiers whose package subpath is nifra's Bun-plugin convention (`@nifrajs/web-vue/plugin`). */
85
+ const BUN_SPECIFIER = /^(?:@[^/]+\/)?[^./][^/]*\/(?:.*\/)?plugin(?:\.ts)?$/
86
+
87
+ const lineAt = (src: string, index: number): number => {
88
+ let line = 1
89
+ for (let i = 0; i < index && i < src.length; i++) if (src[i] === "\n") line++
90
+ return line
91
+ }
92
+
93
+ /** Local binding names introduced by one import clause (`a`, `{ b as c }`, `* as ns`). */
94
+ function localNames(clause: string): string[] {
95
+ const names: string[] = []
96
+ const braced = /\{([^}]*)\}/.exec(clause)
97
+ if (braced !== null) {
98
+ for (const part of (braced[1] ?? "").split(",")) {
99
+ const named = /([A-Za-z_$][\w$]*)\s*$/.exec(part.trim())
100
+ if (named?.[1] !== undefined) names.push(named[1])
101
+ }
102
+ }
103
+ const head = clause.replace(/\{[^}]*\}/, " ").replace(/,/g, " ")
104
+ const star = /\*\s*as\s+([A-Za-z_$][\w$]*)/.exec(head)
105
+ if (star?.[1] !== undefined) names.push(star[1])
106
+ else {
107
+ const def = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(head)
108
+ if (def?.[1] !== undefined) names.push(def[1])
109
+ }
110
+ return names
111
+ }
112
+
113
+ interface ImportedBinding {
114
+ readonly specifier: string
115
+ readonly index: number
116
+ }
117
+
118
+ /**
119
+ * Every value-importing binding in a module, mapped to the specifier it came from. `import type` is
120
+ * skipped: a type has no runtime cost, so it is neither a bundled toolchain nor a plugin in a slot.
121
+ */
122
+ function importedBindings(stripped: string): Map<string, ImportedBinding> {
123
+ const out = new Map<string, ImportedBinding>()
124
+ const re = /(?<![.\w$])import\s+(?!type[\s{])([^'";]+?)\s+from\s*['"]([^'"]+)['"]/g
125
+ for (let m = re.exec(stripped); m !== null; m = re.exec(stripped)) {
126
+ const specifier = m[2]
127
+ if (specifier === undefined) continue
128
+ for (const name of localNames(m[1] ?? "")) out.set(name, { specifier, index: m.index })
129
+ }
130
+ return out
131
+ }
132
+
133
+ /** Every specifier a module imports for its side effects or bindings, with the position to report. */
134
+ function importedSpecifiers(stripped: string): Array<{ specifier: string; index: number }> {
135
+ const out: Array<{ specifier: string; index: number }> = []
136
+ const re = /(?<![.\w$])import\s+(?:(?!type[\s{])[^'";]*?\s+from\s*)?['"]([^'"]+)['"]/g
137
+ for (let m = re.exec(stripped); m !== null; m = re.exec(stripped)) {
138
+ if (m[1] !== undefined) out.push({ specifier: m[1], index: m.index })
139
+ }
140
+ return out
141
+ }
142
+
143
+ /** Index just past the `]` matching the `[` at `open`, or `undefined` when it never closes. */
144
+ function closeBracket(masked: string, open: number): number | undefined {
145
+ let depth = 0
146
+ for (let i = open; i < masked.length; i++) {
147
+ const c = masked[i]
148
+ if (c === "[") depth++
149
+ else if (c === "]") {
150
+ depth--
151
+ if (depth === 0) return i
152
+ }
153
+ }
154
+ return undefined
155
+ }
156
+
157
+ interface SlotState {
158
+ /** Whether the slot holds at least one plugin (an empty array literal reads as absent, as at runtime). */
159
+ readonly present: boolean
160
+ /** Whether the source states that outright, rather than deferring to a thunk or a re-export. */
161
+ readonly certain: boolean
162
+ readonly index?: number
163
+ /** Identifiers inside the array literal, when there is one. */
164
+ readonly items?: readonly string[]
165
+ }
166
+
167
+ const ABSENT: SlotState = { present: false, certain: true }
168
+
169
+ /**
170
+ * Read one exported binding as a plugin slot.
171
+ *
172
+ * Only an array LITERAL is read precisely; a thunk (`() => import(…)`), a bare identifier or a
173
+ * re-export is recorded as present-but-uncertain. That asymmetry is deliberate: `[]` and `[vue()]`
174
+ * decide the pipeline, and anything else is a promise the source cannot keep.
175
+ */
176
+ function readSlot(masked: string, stripped: string, name: string): SlotState {
177
+ const declaration = new RegExp(
178
+ `(?<![.\\w$])export\\s+(?:const|let|var)\\s+${name}\\b[^=\\n]*=\\s*`,
179
+ )
180
+ const m = declaration.exec(masked)
181
+ if (m !== null) {
182
+ const start = m.index + m[0].length
183
+ if (masked[start] === "[") {
184
+ const end = closeBracket(masked, start)
185
+ if (end !== undefined) {
186
+ // Both views are offset-preserving, and each answers a different question: the mask has string
187
+ // CONTENTS blanked, so identifiers found in it are real code; the stripped source keeps them, so
188
+ // it is the one that can tell `["solid"]` from `[]`.
189
+ const code = masked.slice(start + 1, end)
190
+ const text = stripped.slice(start + 1, end)
191
+ const items = [...code.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)].map((i) => i[1] ?? "")
192
+ return { present: text.trim() !== "", certain: true, index: m.index, items }
193
+ }
194
+ }
195
+ return { present: true, certain: false, index: m.index }
196
+ }
197
+ // `export { vitePlugins } from "./plugins.ts"` - present, but its contents live in another file.
198
+ const reexport = new RegExp(`(?<![.\\w$])export\\s*\\{[^}]*\\b${name}\\b[^}]*\\}`)
199
+ const r = reexport.exec(stripped)
200
+ if (r !== null) return { present: true, certain: false, index: r.index }
201
+ // `export * from …` could carry the slot; say so rather than reporting an absence that isn't one.
202
+ if (/(?<![.\w$])export\s*\*\s*from/.test(stripped)) return { present: false, certain: false }
203
+ return ABSENT
204
+ }
205
+
206
+ /** Which pipeline a plugin reference belongs to, or `undefined` when its name and origin say nothing. */
207
+ function classifyRef(name: string, specifier: string | undefined): BuildPipeline | undefined {
208
+ // nifra's own Bun plugins are named for what they are, and the name survives an alias-free import.
209
+ if (/BunPlugin$/.test(name)) return "bun"
210
+ if (specifier === undefined) return undefined
211
+ if (VITE_SPECIFIER.test(specifier)) return "vite"
212
+ if (BUN_SPECIFIER.test(specifier)) return "bun"
213
+ return undefined
214
+ }
215
+
216
+ /** Read one file as text, or `undefined` when it is missing or unreadable. */
217
+ async function readSource(path: string): Promise<string | undefined> {
218
+ try {
219
+ return await Bun.file(path).text()
220
+ } catch {
221
+ return undefined
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Report the pipeline and its config hazards for the app at `cwd`, without executing anything.
227
+ * Total: a missing or unreadable config yields `ran: false`, never a throw.
228
+ */
229
+ export async function collectPipelineReport(cwd: string): Promise<PipelineReport> {
230
+ const configFile = existsSync(join(cwd, "nifra.config.ts")) ? "nifra.config.ts" : "framework.ts"
231
+ const source = await readSource(join(cwd, configFile))
232
+ if (source === undefined) {
233
+ return {
234
+ ran: false,
235
+ pipeline: "unknown",
236
+ reason: "no nifra.config.ts or framework.ts here",
237
+ certain: false,
238
+ findings: [],
239
+ }
240
+ }
241
+ const masked = codePositionMask(source)
242
+ const stripped = stripComments(source)
243
+ const bindings = importedBindings(stripped)
244
+ const slots = Object.fromEntries(
245
+ SLOTS.map((slot) => [slot, readSlot(masked, stripped, slot)]),
246
+ ) as Record<Slot, SlotState>
247
+
248
+ const findings: PipelineFinding[] = []
249
+
250
+ // A plugin whose specifier names the other pipeline. The load-time guard classifies by hook shape and
251
+ // remains the authority; this is the half of it that can run on a repo nobody has installed yet.
252
+ for (const slot of SLOTS) {
253
+ const state = slots[slot]
254
+ const expected = SLOT_PIPELINE[slot]
255
+ for (const item of state.items ?? []) {
256
+ const binding = bindings.get(item)
257
+ const actual = classifyRef(item, binding?.specifier)
258
+ if (actual === undefined || actual === expected) continue
259
+ const from = binding !== undefined ? ` (imported from "${binding.specifier}")` : ""
260
+ findings.push({
261
+ rule: "plugin-slot",
262
+ severity: "error",
263
+ file: configFile,
264
+ line: lineAt(source, state.index ?? 0),
265
+ message:
266
+ `\`${item}\`${from} is a ${actual === "vite" ? "Vite" : "Bun"} plugin but sits in \`${slot}\`, which feeds ` +
267
+ `${expected === "vite" ? "Vite" : "Bun.build"}. The wrong bundler never calls its hooks, so the transform silently does not run and the build still succeeds.`,
268
+ fix:
269
+ actual === "vite"
270
+ ? `move \`${item}\` to \`vitePlugins\``
271
+ : `move \`${item}\` to \`clientPlugins\` (client bundle) or \`serverPlugins\` (SSR)`,
272
+ })
273
+ }
274
+ }
275
+
276
+ // Which bundler this app gets, by the same rule `chooseBuildPipeline` applies to the loaded config.
277
+ const viteOnly =
278
+ slots.vitePlugins.present && !slots.clientPlugins.present && !slots.serverPlugins.present
279
+ const certain = SLOTS.every((slot) => slots[slot].certain)
280
+ const pipeline: BuildPipeline = viteOnly ? "vite" : "bun"
281
+ const reason = viteOnly
282
+ ? "auto: this app's only transforms are `vitePlugins`, which the Bun build cannot run"
283
+ : slots.clientPlugins.present || slots.serverPlugins.present
284
+ ? "default: this app declares Bun plugins, so both phases stay on Bun"
285
+ : "default: no transforms to place, so both phases stay on Bun"
286
+
287
+ // The adapter entry is bundled into every `nifra build` server entry. Whatever it imports goes with it.
288
+ const adapterEntry = existsSync(join(cwd, "framework.ts")) ? "framework.ts" : configFile
289
+ const entrySource =
290
+ adapterEntry === configFile ? source : await readSource(join(cwd, adapterEntry))
291
+ if (entrySource !== undefined) {
292
+ const seen = new Set<string>()
293
+ for (const { specifier, index } of importedSpecifiers(stripComments(entrySource))) {
294
+ if (!DEV_TOOLCHAIN.test(specifier) || seen.has(specifier)) continue
295
+ seen.add(specifier)
296
+ findings.push({
297
+ rule: "adapter-entry",
298
+ severity: "error",
299
+ file: adapterEntry,
300
+ line: lineAt(entrySource, index),
301
+ message:
302
+ `${adapterEntry} imports "${specifier}", and \`nifra build\` imports the adapter from ${adapterEntry} - ` +
303
+ "so the dev toolchain is bundled into the production server entry. The build succeeds and the server then fails at startup on a dependency of the bundler (a missing native binding, typically), which reads like a broken install rather than a config split.",
304
+ fix:
305
+ adapterEntry === "framework.ts"
306
+ ? 'move "' +
307
+ specifier +
308
+ '" to `nifra.config.ts`, which only the CLI imports, and keep `framework.ts` to the adapter'
309
+ : 'add a `framework.ts` exporting just the adapter, re-export it from `nifra.config.ts` (`export { adapter } from "./framework"`), and leave the toolchain imports in the config',
310
+ })
311
+ }
312
+ }
313
+
314
+ // `conditions` reach SSR on the Bun pipeline and cannot reach the client bundle Bun's dev server
315
+ // serves - it takes no resolve conditions from bunfig or anywhere else. `nifra dev` says so at
316
+ // startup; a project whose CI only runs `nifra check` would otherwise never hear it.
317
+ const conditions = readSlot(masked, stripped, "conditions")
318
+ if (conditions.present && pipeline === "bun") {
319
+ findings.push({
320
+ rule: "bun-client-conditions",
321
+ severity: "warning",
322
+ file: configFile,
323
+ line: lineAt(source, conditions.index ?? 0),
324
+ message:
325
+ "`conditions` are honoured by SSR and by `nifra build`, but Bun's dev-server bundler accepts no resolve conditions - so under `nifra dev` a package with an `exports` map can resolve to one file in the browser and another everywhere else.",
326
+ fix: "run `nifra dev --vite` when the client bundle must resolve exactly as production does",
327
+ })
328
+ }
329
+
330
+ return {
331
+ ran: true,
332
+ pipeline: certain ? pipeline : "unknown",
333
+ reason: certain
334
+ ? reason
335
+ : "the config states its plugins indirectly (a thunk, an identifier or a re-export), so the bundler can only be read by loading it - `nifra dev` and `nifra build` print the real one",
336
+ certain,
337
+ configFile,
338
+ adapterEntry,
339
+ findings,
340
+ }
341
+ }
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
+ }