@lovrozagar/honey 0.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.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,1180 @@
1
+ /* Go CLI emitter — generates a complete cobra-based Go CLI module from an OpenAPI 3.1 spec.
2
+ *
3
+ * Mirrors the pattern used by codegen-go.ts:
4
+ * - Static runtime files (config.go, auth.go, output.go, errors.go, stream.go, multipart.go,
5
+ * version.go, root.go) are read from ./cli-go/ at module load time and copied verbatim
6
+ * into the output file map under "cli-go/<name>".
7
+ * - Per-resource cobra command files are emitted as cmd/<resource>.go.
8
+ * - main.go and cmd/root.go wire cobra + auth + exit-code mapping.
9
+ *
10
+ * Two SDK wiring modes:
11
+ * - sdkModulePath set → go.mod emits `require` + `replace`; no internal/sdk/ files.
12
+ * - sdkModulePath omitted → client-go/* + generated types.go + client.go embedded under
13
+ * internal/sdk/; emitted imports use "<modulePath>/internal/sdk".
14
+ */
15
+
16
+ import { readFileSync } from "node:fs"
17
+ import { fileURLToPath } from "node:url"
18
+ import { generateGoSDK, safeResourceName } from "./codegen-go.ts"
19
+ import { collectSDKMethods, extractOpenApiPathParams, isSSEOperation, resolveRefs } from "./codegen.ts"
20
+ import { toIR } from "./codegen-ir.ts"
21
+ import { goPascal } from "./go-type-emitter.ts"
22
+
23
+ const DEFAULT_MODULE_PATH = "example.com/cli"
24
+
25
+ function resolveModulePath(options: GoCLIOptions): string {
26
+ return options.modulePath ?? DEFAULT_MODULE_PATH
27
+ }
28
+
29
+ function enumStrings(schema: Record<string, unknown>): string[] {
30
+ return Array.isArray(schema.enum) ? (schema.enum as unknown[]).map((v) => String(v)) : []
31
+ }
32
+
33
+ function isLastEventIdName(name: string): boolean {
34
+ return name === "last-event-id" || name === "last_event_id" || name === "lastEventId"
35
+ }
36
+
37
+ /* ── types ── */
38
+
39
+ export type GoCLIOptions = {
40
+ binaryName: string
41
+ modulePath?: string
42
+ sdkModulePath?: string
43
+ envPrefix?: string
44
+ configName?: string
45
+ defaultBaseURL?: string
46
+ description?: string
47
+ }
48
+
49
+ export type GeneratedGoCLI = {
50
+ files: Record<string, string>
51
+ serviceMap: Record<string, Record<string, unknown>>
52
+ skippedOperations: string[]
53
+ }
54
+
55
+ type OpenApiSpec = {
56
+ components?: { schemas?: Record<string, Record<string, unknown>> }
57
+ info: { description?: string; title: string; version: string }
58
+ openapi: string
59
+ paths: Record<string, Record<string, Record<string, unknown>>>
60
+ }
61
+
62
+ type ParamInfo = {
63
+ name: string
64
+ flagName: string
65
+ varName: string
66
+ required: boolean
67
+ schema: Record<string, unknown>
68
+ defaultValue: unknown
69
+ enumValues: string[]
70
+ }
71
+
72
+ type BodyScalarInfo = {
73
+ propName: string
74
+ flagName: string
75
+ varName: string
76
+ schemaType: string
77
+ enumValues: string[]
78
+ }
79
+
80
+ type BodyShape =
81
+ | { kind: "none" }
82
+ | { kind: "json-flat"; scalars: BodyScalarInfo[]; required: boolean }
83
+ | { kind: "json-complex"; required: boolean }
84
+ | {
85
+ kind: "multipart"
86
+ binaryFields: { name: string; required: boolean }[]
87
+ scalarFields: BodyScalarInfo[]
88
+ useGenericFileFlag: boolean
89
+ }
90
+
91
+ type CommandInfo = {
92
+ opId: string
93
+ resource: string
94
+ action: string /* joined-with-dash deeper-than-2 dot segments */
95
+ cmdVarName: string /* e.g. "usersGetCmd" */
96
+ cmdUse: string /* e.g. "get" */
97
+ method: string /* GET, POST, ... */
98
+ path: string
99
+ summary: string
100
+ description: string
101
+ pathParams: ParamInfo[]
102
+ queryParams: ParamInfo[]
103
+ body: BodyShape
104
+ isSSE: boolean
105
+ hasLastEventId: boolean
106
+ has204: boolean
107
+ responseType: "json" | "none"
108
+ }
109
+
110
+ type ResourceGroup = {
111
+ resource: string /* raw resource e.g. "apiKey" */
112
+ cmdUse: string /* kebab e.g. "api-key" */
113
+ cmdVarBase: string /* Go var prefix e.g. "apiKey" or "type_" */
114
+ fileName: string /* e.g. "apiKey.go" (matches raw resource) */
115
+ commands: CommandInfo[]
116
+ }
117
+
118
+ /* ── case transforms ── */
119
+
120
+ /** Converts camelCase / snake_case / acronyms to kebab-case (e.g. "APIKey" → "api-key"). */
121
+ export function toKebab(s: string): string {
122
+ if (!s) return s
123
+ return s
124
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
125
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
126
+ .replace(/[\s_]+/g, "-")
127
+ .replace(/-+/g, "-")
128
+ .toLowerCase()
129
+ }
130
+
131
+ export function toFlagName(paramName: string): string {
132
+ return toKebab(paramName)
133
+ }
134
+
135
+ /** Upper snake case for env var: toSnakeUpper("project-id") → "PROJECT_ID". */
136
+ function toSnakeUpper(s: string): string {
137
+ return toKebab(s).toUpperCase().replace(/-/g, "_")
138
+ }
139
+
140
+ export function envVarFromFlag(flagName: string, envPrefix: string): string {
141
+ return `${envPrefix}_${toSnakeUpper(flagName)}`
142
+ }
143
+
144
+ /** Go var name from a param name: snake_case or kebab-case → camelCase with preserved acronyms.
145
+ * e.g. "project_id" → "projectID", "user-name" → "userName", "type" → "type_". */
146
+ function goCamelVar(name: string): string {
147
+ const pascal = goPascal(name)
148
+ if (!pascal) return pascal
149
+ /* preserve acronym tail: "Id" → "ID", "Url" → "URL" */
150
+ const fixed = pascal
151
+ .replace(/\bId\b/g, "ID")
152
+ .replace(/\bUrl\b/g, "URL")
153
+ .replace(/Id$/, "ID")
154
+ .replace(/Url$/, "URL")
155
+ const camel = fixed.charAt(0).toLowerCase() + fixed.slice(1)
156
+ return safeResourceName(camel)
157
+ }
158
+
159
+ /* ── runtime template loader ── */
160
+
161
+ let runtimeCache: Map<string, string> | null = null
162
+
163
+ /** Reads static .go runtime files from ./cli-go/ and caches them. */
164
+ export function loadGoCliRuntimeTemplates(): Map<string, string> {
165
+ if (runtimeCache) return runtimeCache
166
+ const names = [
167
+ "config.go",
168
+ "auth.go",
169
+ "output.go",
170
+ "errors.go",
171
+ "stream.go",
172
+ "multipart.go",
173
+ "version.go",
174
+ "root.go",
175
+ ]
176
+ const cache = new Map<string, string>()
177
+ for (const name of names) {
178
+ const filePath = fileURLToPath(new URL(`./cli-go/${name}`, import.meta.url))
179
+ let content: string
180
+ try {
181
+ content = readFileSync(filePath, "utf8")
182
+ } catch {
183
+ throw new Error(`loadGoCliRuntimeTemplates: missing file ${filePath}`)
184
+ }
185
+ cache.set(name, content)
186
+ }
187
+ runtimeCache = cache
188
+ return cache
189
+ }
190
+
191
+ /* ── param + body helpers ── */
192
+
193
+ function getPathParams(
194
+ op: Record<string, unknown>,
195
+ rawPath: string,
196
+ ): ParamInfo[] {
197
+ const declared = extractOpenApiPathParams(rawPath)
198
+ const byName = new Map<string, Record<string, unknown>>()
199
+ const parameters = (op.parameters as Array<Record<string, unknown>> | undefined) ?? []
200
+ for (const p of parameters) {
201
+ if (p.in !== "path") continue
202
+ byName.set(String(p.name), p)
203
+ }
204
+
205
+ const out: ParamInfo[] = []
206
+ for (const name of declared) {
207
+ const param = byName.get(name)
208
+ const schema = (param?.schema as Record<string, unknown>) ?? { type: "string" }
209
+ out.push({
210
+ defaultValue: schema.default,
211
+ enumValues: enumStrings(schema),
212
+ flagName: toFlagName(name),
213
+ name,
214
+ required: true,
215
+ schema,
216
+ varName: goCamelVar(name),
217
+ })
218
+ }
219
+ return out
220
+ }
221
+
222
+ function getQueryParams(op: Record<string, unknown>): ParamInfo[] {
223
+ const parameters = (op.parameters as Array<Record<string, unknown>> | undefined) ?? []
224
+ const out: ParamInfo[] = []
225
+ for (const p of parameters) {
226
+ if (p.in !== "query") continue
227
+ const name = String(p.name)
228
+ const schema = (p.schema as Record<string, unknown>) ?? { type: "string" }
229
+ out.push({
230
+ defaultValue: schema.default,
231
+ enumValues: enumStrings(schema),
232
+ flagName: toFlagName(name),
233
+ name,
234
+ required: p.required === true,
235
+ schema,
236
+ varName: goCamelVar(name),
237
+ })
238
+ }
239
+ return out
240
+ }
241
+
242
+ function isScalarType(t: unknown): t is "string" | "integer" | "number" | "boolean" {
243
+ return t === "string" || t === "integer" || t === "number" || t === "boolean"
244
+ }
245
+
246
+ function detectBodyShape(op: Record<string, unknown>): BodyShape {
247
+ const requestBody = op.requestBody as Record<string, unknown> | undefined
248
+ if (!requestBody) return { kind: "none" }
249
+ const required = requestBody.required === true
250
+ const content = requestBody.content as Record<string, Record<string, unknown>> | undefined
251
+ if (!content) return { kind: "none" }
252
+
253
+ /* multipart wins over json */
254
+ const multipart = content["multipart/form-data"]
255
+ if (multipart) {
256
+ const schema = (multipart.schema as Record<string, unknown>) ?? {}
257
+ const props = (schema.properties as Record<string, Record<string, unknown>>) ?? {}
258
+ const reqList = new Set(Array.isArray(schema.required) ? (schema.required as string[]) : [])
259
+ const binaryFields: { name: string; required: boolean }[] = []
260
+ const scalarFields: BodyScalarInfo[] = []
261
+ for (const [propName, propSchema] of Object.entries(props)) {
262
+ const type = propSchema.type
263
+ const format = propSchema.format
264
+ if (type === "string" && format === "binary") {
265
+ binaryFields.push({ name: propName, required: reqList.has(propName) })
266
+ } else if (isScalarType(type)) {
267
+ scalarFields.push({
268
+ enumValues: enumStrings(propSchema),
269
+ flagName: toFlagName(propName),
270
+ propName,
271
+ schemaType: String(type),
272
+ varName: goCamelVar(propName),
273
+ })
274
+ }
275
+ }
276
+ return {
277
+ binaryFields,
278
+ kind: "multipart",
279
+ scalarFields,
280
+ useGenericFileFlag: binaryFields.length === 1,
281
+ }
282
+ }
283
+
284
+ const jsonContent = content["application/json"]
285
+ if (!jsonContent) return { kind: "none" }
286
+ const schema = (jsonContent.schema as Record<string, unknown>) ?? {}
287
+
288
+ /* oneOf/anyOf/allOf, array-root, or non-object → complex */
289
+ if (schema.oneOf || schema.anyOf || schema.allOf) {
290
+ return { kind: "json-complex", required }
291
+ }
292
+ if (schema.type !== "object") {
293
+ return { kind: "json-complex", required }
294
+ }
295
+
296
+ const props = (schema.properties as Record<string, Record<string, unknown>>) ?? {}
297
+ const scalars: BodyScalarInfo[] = []
298
+ let allScalar = true
299
+ for (const [propName, propSchema] of Object.entries(props)) {
300
+ if (!isScalarType(propSchema.type)) {
301
+ allScalar = false
302
+ break
303
+ }
304
+ scalars.push({
305
+ enumValues: enumStrings(propSchema),
306
+ flagName: toFlagName(propName),
307
+ propName,
308
+ schemaType: String(propSchema.type),
309
+ varName: goCamelVar(propName),
310
+ })
311
+ }
312
+ if (!allScalar) return { kind: "json-complex", required }
313
+ return { kind: "json-flat", required, scalars }
314
+ }
315
+
316
+ function has204Response(op: Record<string, unknown>): boolean {
317
+ const responses = op.responses as Record<string, Record<string, unknown>> | undefined
318
+ if (!responses) return false
319
+ return Object.prototype.hasOwnProperty.call(responses, "204")
320
+ }
321
+
322
+ function hasJsonResponse(op: Record<string, unknown>): boolean {
323
+ const responses = op.responses as Record<string, Record<string, unknown>> | undefined
324
+ if (!responses) return false
325
+ for (const [code, resp] of Object.entries(responses)) {
326
+ const n = Number.parseInt(code, 10)
327
+ if (n < 200 || n >= 300 || n === 204) continue
328
+ const content = resp.content as Record<string, Record<string, unknown>> | undefined
329
+ if (content?.["application/json"]) return true
330
+ }
331
+ return false
332
+ }
333
+
334
+ /* ── command collection ── */
335
+
336
+ function collectCLIMethods(
337
+ spec: Record<string, unknown>,
338
+ ): { groups: Map<string, ResourceGroup>; skipped: string[]; serviceMap: Record<string, Record<string, unknown>> } {
339
+ toIR(spec as Parameters<typeof toIR>[0])
340
+ const { serviceMap } = collectSDKMethods(spec as Parameters<typeof collectSDKMethods>[0])
341
+ const resolved = resolveRefs(spec as Parameters<typeof resolveRefs>[0]) as unknown as OpenApiSpec
342
+
343
+ const groups = new Map<string, ResourceGroup>()
344
+ const skipped: string[] = []
345
+ const seenCmdPaths = new Map<string, string>()
346
+
347
+ for (const [rawPath, pathMethods] of Object.entries(resolved.paths ?? {})) {
348
+ for (const [method, operation] of Object.entries(pathMethods)) {
349
+ const op = operation as Record<string, unknown>
350
+ const operationId = op.operationId as string | undefined
351
+ if (!operationId) continue
352
+
353
+ if (op["x-websocket"] === true) {
354
+ skipped.push(operationId)
355
+ continue
356
+ }
357
+
358
+ const segments = operationId.split(".")
359
+ const resource = segments[0]
360
+ /* Top-level ops (single segment) are conventionally marked `_call` in the
361
+ * SDK IR. For the CLI tree we use "call" so the emitted subcommand is
362
+ * `<resource> call` — cobra rejects leading dashes. */
363
+ const actionSegments = segments.length === 1 ? ["call"] : segments.slice(1).map((seg) => toKebab(seg))
364
+ const action = actionSegments.join("-")
365
+ const cmdUse = action
366
+
367
+ /* collision check: (resource, cmdUse) must be unique */
368
+ const key = `${resource}|${cmdUse}`
369
+ if (seenCmdPaths.has(key)) {
370
+ throw new Error(
371
+ `go-cli codegen: command path collision for ${resource} ${cmdUse} — operations: ${seenCmdPaths.get(key)}, ${operationId}`,
372
+ )
373
+ }
374
+ seenCmdPaths.set(key, operationId)
375
+
376
+ let group = groups.get(resource)
377
+ if (!group) {
378
+ group = {
379
+ cmdUse: toKebab(resource),
380
+ cmdVarBase: safeResourceName(resource.charAt(0).toLowerCase() + resource.slice(1)),
381
+ commands: [],
382
+ fileName: `${resource}.go`,
383
+ resource,
384
+ }
385
+ groups.set(resource, group)
386
+ }
387
+
388
+ const pathParams = getPathParams(op, rawPath)
389
+ const queryParams = getQueryParams(op)
390
+ const body = detectBodyShape(op)
391
+ const sse = isSSEOperation(op)
392
+ const hasLastEventId = queryParams.some((q) => isLastEventIdName(q.name))
393
+
394
+ /* body rename: if a body scalar conflicts with a path param, rename flag to "body-<name>" */
395
+ if (body.kind === "json-flat") {
396
+ const pathNames = new Set(pathParams.map((p) => p.flagName))
397
+ body.scalars = body.scalars.map((s) => {
398
+ if (pathNames.has(s.flagName)) {
399
+ return { ...s, flagName: `body-${s.flagName}`, varName: `body${goPascal(s.propName)}` }
400
+ }
401
+ return s
402
+ })
403
+ }
404
+
405
+ /* query rename: cobra reserves --help, --version */
406
+ for (const q of queryParams) {
407
+ if (q.flagName === "help" || q.flagName === "version") {
408
+ q.flagName = `query-${q.flagName}`
409
+ }
410
+ }
411
+
412
+ const actionPascal = goPascal(action)
413
+ const cmdVarName = `${group.cmdVarBase}${actionPascal}Cmd`
414
+ const has204 = has204Response(op)
415
+ const hasJson = hasJsonResponse(op)
416
+ let responseType: "json" | "none" = "none"
417
+ if (!sse && hasJson) responseType = "json"
418
+
419
+ group.commands.push({
420
+ action,
421
+ body,
422
+ cmdUse,
423
+ cmdVarName,
424
+ description: String(op.description ?? ""),
425
+ has204,
426
+ hasLastEventId,
427
+ isSSE: sse,
428
+ method: method.toUpperCase(),
429
+ opId: operationId,
430
+ path: rawPath,
431
+ pathParams,
432
+ queryParams,
433
+ resource,
434
+ responseType,
435
+ summary: String(op.summary ?? ""),
436
+ })
437
+ }
438
+ }
439
+
440
+ return { groups, serviceMap: serviceMap as Record<string, Record<string, unknown>>, skipped }
441
+ }
442
+
443
+ /* ── emit: cmd/root.go ── */
444
+
445
+ function emitRootFile(groups: ResourceGroup[], options: GoCLIOptions): string {
446
+ const envPrefix = options.envPrefix ?? toSnakeUpper(options.binaryName)
447
+ const configName = options.configName ?? options.binaryName
448
+ const defaultBase = options.defaultBaseURL ?? ""
449
+
450
+ const l: string[] = []
451
+ l.push(`// Code generated by honey. DO NOT EDIT.`)
452
+ l.push(`package cmd`)
453
+ l.push(``)
454
+ l.push(`import (`)
455
+ l.push(`\t"context"`)
456
+ l.push(`\t"fmt"`)
457
+ l.push(`\t"os"`)
458
+ l.push(``)
459
+ l.push(`\t"github.com/spf13/cobra"`)
460
+ l.push(``)
461
+ l.push(`\tcli "${cliImportPath(options)}"`)
462
+ l.push(`)`)
463
+ l.push(``)
464
+ l.push(`var _ = fmt.Sprintf`)
465
+ l.push(`var _ = os.Exit`)
466
+ l.push(``)
467
+ l.push(`// EnvPrefix is the prefix used for all <PREFIX>_<FLAG> environment variables.`)
468
+ l.push(`const EnvPrefix = ${JSON.stringify(envPrefix)}`)
469
+ l.push(``)
470
+ l.push(`// ConfigName is the directory name under $XDG_CONFIG_HOME / ~/.config for the config file.`)
471
+ l.push(`const ConfigName = ${JSON.stringify(configName)}`)
472
+ l.push(``)
473
+ l.push(`// DefaultBaseURL is used when neither --base-url, env var, nor the config file provide one.`)
474
+ l.push(`const DefaultBaseURL = ${JSON.stringify(defaultBase)}`)
475
+ l.push(``)
476
+ l.push(`type configKey struct{}`)
477
+ l.push(``)
478
+ l.push(`// runtimeConfig is the resolved CLI config stashed on the cobra context.`)
479
+ l.push(`type runtimeConfig struct {`)
480
+ l.push(`\tCfg cli.Config`)
481
+ l.push(`}`)
482
+ l.push(``)
483
+ l.push(`// rootCmd is the top-level cobra command for the ${options.binaryName} CLI.`)
484
+ l.push(`var rootCmd = &cobra.Command{`)
485
+ l.push(`\tUse: ${JSON.stringify(options.binaryName)},`)
486
+ l.push(`\tShort: ${JSON.stringify(options.description ?? `${options.binaryName} CLI`)},`)
487
+ l.push(`\tVersion: cli.Version,`)
488
+ l.push(`\tSilenceUsage: true,`)
489
+ l.push(`\tPersistentPreRunE: func(cmd *cobra.Command, args []string) error {`)
490
+ l.push(`\t\tcfg, err := cli.LoadConfig(cmd.Flags(), EnvPrefix, ConfigName)`)
491
+ l.push(`\t\tif err != nil {`)
492
+ l.push(`\t\t\treturn err`)
493
+ l.push(`\t\t}`)
494
+ l.push(`\t\tif cfg.BaseURL == "" {`)
495
+ l.push(`\t\t\tcfg.BaseURL = DefaultBaseURL`)
496
+ l.push(`\t\t}`)
497
+ l.push(`\t\tctx := context.WithValue(cmd.Context(), configKey{}, runtimeConfig{Cfg: cfg})`)
498
+ l.push(`\t\tcmd.SetContext(ctx)`)
499
+ l.push(`\t\treturn nil`)
500
+ l.push(`\t},`)
501
+ l.push(`}`)
502
+ l.push(``)
503
+ l.push(`// Execute runs the root command.`)
504
+ l.push(`func Execute() error {`)
505
+ l.push(`\treturn rootCmd.Execute()`)
506
+ l.push(`}`)
507
+ l.push(``)
508
+ l.push(`func configFromCtx(ctx context.Context) cli.Config {`)
509
+ l.push(`\tif v, ok := ctx.Value(configKey{}).(runtimeConfig); ok {`)
510
+ l.push(`\t\treturn v.Cfg`)
511
+ l.push(`\t}`)
512
+ l.push(`\treturn cli.Config{}`)
513
+ l.push(`}`)
514
+ l.push(``)
515
+ l.push(`func init() {`)
516
+ l.push(`\trootCmd.PersistentFlags().String("api-key", "", "API key (overrides env + config file)")`)
517
+ l.push(`\trootCmd.PersistentFlags().String("base-url", "", "Override API base URL")`)
518
+ l.push(`\trootCmd.PersistentFlags().String("output", "json", "Output mode: json|ndjson|yaml|table")`)
519
+ l.push(`\trootCmd.PersistentFlags().Bool("verbose", false, "Print request metadata to stderr")`)
520
+ l.push(`\trootCmd.PersistentFlags().String("config", "", "Path to config file")`)
521
+ l.push(`\trootCmd.PersistentFlags().String("timeout", "30s", "Request timeout (Go duration)")`)
522
+ for (const g of groups) {
523
+ const cmdVar = `${g.cmdVarBase}Cmd`
524
+ l.push(`\trootCmd.AddCommand(${cmdVar})`)
525
+ }
526
+ l.push(`}`)
527
+ l.push(``)
528
+ return l.join("\n")
529
+ }
530
+
531
+ function cliImportPath(options: GoCLIOptions): string {
532
+ return `${resolveModulePath(options)}/internal/cli`
533
+ }
534
+
535
+ function sdkImportPath(options: GoCLIOptions): string {
536
+ if (options.sdkModulePath) return options.sdkModulePath
537
+ return `${resolveModulePath(options)}/internal/sdk`
538
+ }
539
+
540
+ /* ── emit: cmd/<resource>.go ── */
541
+
542
+ function emitResourceFile(group: ResourceGroup, options: GoCLIOptions): string {
543
+ const l: string[] = []
544
+ const hasSSE = group.commands.some((c) => c.isSSE)
545
+ l.push(`// Code generated by honey. DO NOT EDIT.`)
546
+ l.push(`package cmd`)
547
+ l.push(``)
548
+ l.push(`import (`)
549
+ l.push(`\t"bytes"`)
550
+ l.push(`\t"context"`)
551
+ l.push(`\t"encoding/json"`)
552
+ l.push(`\t"fmt"`)
553
+ l.push(`\t"io"`)
554
+ if (hasSSE) l.push(`\t"iter"`)
555
+ l.push(`\t"net/http"`)
556
+ l.push(`\t"net/url"`)
557
+ l.push(`\t"os"`)
558
+ l.push(`\t"slices"`)
559
+ l.push(`\t"strings"`)
560
+ l.push(`\t"time"`)
561
+ l.push(``)
562
+ l.push(`\t"github.com/spf13/cobra"`)
563
+ l.push(``)
564
+ l.push(`\tcli "${cliImportPath(options)}"`)
565
+ l.push(`\tsdk "${sdkImportPath(options)}"`)
566
+ l.push(`)`)
567
+ l.push(``)
568
+ l.push(`var _ = bytes.NewReader`)
569
+ l.push(`var _ = context.TODO`)
570
+ l.push(`var _ = json.Marshal`)
571
+ l.push(`var _ = fmt.Sprintf`)
572
+ l.push(`var _ = io.Discard`)
573
+ l.push(`var _ = http.MethodGet`)
574
+ l.push(`var _ = url.PathEscape`)
575
+ l.push(`var _ = os.Stdout`)
576
+ l.push(`var _ = slices.Contains[[]string]`)
577
+ l.push(`var _ = strings.NewReader`)
578
+ l.push(`var _ = time.Second`)
579
+ l.push(`var _ = sdk.SSEEvent{}`)
580
+ l.push(`var _ = cli.Version`)
581
+ if (hasSSE) l.push(`var _ iter.Seq2[cli.SSEEvent, error]`)
582
+ l.push(``)
583
+
584
+ /* resource-level var */
585
+ const resShort = group.commands[0]?.summary ?? `${group.cmdUse} commands`
586
+ l.push(`var ${group.cmdVarBase}Cmd = &cobra.Command{`)
587
+ l.push(`\tUse: ${JSON.stringify(group.cmdUse)},`)
588
+ l.push(`\tShort: ${JSON.stringify(resShort)},`)
589
+ l.push(`}`)
590
+ l.push(``)
591
+
592
+ /* per-command vars */
593
+ for (const cmd of group.commands) {
594
+ emitCommand(l, group, cmd, options)
595
+ }
596
+
597
+ /* init() wires subcommands + registers their flags */
598
+ l.push(`func init() {`)
599
+ for (const cmd of group.commands) {
600
+ l.push(`\t${group.cmdVarBase}Cmd.AddCommand(${cmd.cmdVarName})`)
601
+ }
602
+ for (const cmd of group.commands) {
603
+ emitFlagRegistrations(l, cmd)
604
+ }
605
+ l.push(`}`)
606
+ l.push(``)
607
+
608
+ return l.join("\n")
609
+ }
610
+
611
+ /** Emits var declarations for every flag-backing Go variable + the cobra.Command literal. */
612
+ function emitCommand(
613
+ l: string[],
614
+ group: ResourceGroup,
615
+ cmd: CommandInfo,
616
+ options: GoCLIOptions,
617
+ ): void {
618
+ /* flag-backing vars */
619
+ for (const p of cmd.pathParams) {
620
+ l.push(`var ${cmd.cmdVarName}${goPascal(p.name)}Var string`)
621
+ }
622
+ for (const q of cmd.queryParams) {
623
+ l.push(`var ${cmd.cmdVarName}${goPascal(q.name)}Var ${goFlagVarType(q)}`)
624
+ }
625
+ if (cmd.body.kind === "json-flat" || cmd.body.kind === "json-complex") {
626
+ l.push(`var ${cmd.cmdVarName}DataVar string`)
627
+ }
628
+ if (cmd.body.kind === "json-flat") {
629
+ for (const s of cmd.body.scalars) {
630
+ l.push(`var ${cmd.cmdVarName}${goPascal(s.propName)}Var ${goScalarVarType(s.schemaType)}`)
631
+ }
632
+ }
633
+ if (cmd.body.kind === "multipart") {
634
+ for (const s of cmd.body.scalarFields) {
635
+ l.push(`var ${cmd.cmdVarName}${goPascal(s.propName)}Var ${goScalarVarType(s.schemaType)}`)
636
+ }
637
+ if (cmd.body.useGenericFileFlag) {
638
+ l.push(`var ${cmd.cmdVarName}FileVar string`)
639
+ } else {
640
+ for (const f of cmd.body.binaryFields) {
641
+ l.push(`var ${cmd.cmdVarName}${goPascal(f.name)}Var string`)
642
+ }
643
+ }
644
+ }
645
+ if (cmd.hasLastEventId) {
646
+ l.push(`var ${cmd.cmdVarName}LastEventIDVar string`)
647
+ }
648
+ l.push(``)
649
+
650
+ const shortDesc = cmd.summary || cmd.action
651
+ l.push(`var ${cmd.cmdVarName} = &cobra.Command{`)
652
+ l.push(`\tUse: ${JSON.stringify(cmd.cmdUse)},`)
653
+ l.push(`\tShort: ${JSON.stringify(shortDesc)},`)
654
+
655
+ /* PreRunE: validate required body + enums */
656
+ const preRunELines = emitPreRunE(cmd)
657
+ if (preRunELines.length > 0) {
658
+ l.push(`\tPreRunE: func(cmd *cobra.Command, args []string) error {`)
659
+ for (const line of preRunELines) l.push(`\t\t${line}`)
660
+ l.push(`\t\treturn nil`)
661
+ l.push(`\t},`)
662
+ }
663
+
664
+ l.push(`\tRunE: func(cmd *cobra.Command, args []string) error {`)
665
+ emitRunE(l, cmd, options)
666
+ l.push(`\t},`)
667
+ l.push(`}`)
668
+ l.push(``)
669
+ }
670
+
671
+ function goFlagVarType(q: ParamInfo): string {
672
+ const t = q.schema.type
673
+ if (t === "integer") return "int64"
674
+ if (t === "number") return "float64"
675
+ if (t === "boolean") return "bool"
676
+ if (t === "array") return "[]string"
677
+ return "string"
678
+ }
679
+
680
+ function goScalarVarType(t: string): string {
681
+ if (t === "integer") return "int64"
682
+ if (t === "number") return "float64"
683
+ if (t === "boolean") return "bool"
684
+ return "string"
685
+ }
686
+
687
+ function emitFlagRegistrations(l: string[], cmd: CommandInfo): void {
688
+ for (const p of cmd.pathParams) {
689
+ const v = `${cmd.cmdVarName}${goPascal(p.name)}Var`
690
+ /* flag docstring references original param name for discoverability */
691
+ l.push(`\t/* Path flag — Use: ${JSON.stringify(p.name)} */`)
692
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${v}, ${JSON.stringify(p.flagName)}, "", ${JSON.stringify(`Path parameter ${p.name}`)})`)
693
+ l.push(`\t${cmd.cmdVarName}.MarkFlagRequired(${JSON.stringify(p.flagName)})`)
694
+ }
695
+ for (const q of cmd.queryParams) {
696
+ if (isLastEventIdName(q.name)) continue
697
+ emitQueryFlag(l, cmd, q)
698
+ }
699
+ if (cmd.body.kind === "json-flat" || cmd.body.kind === "json-complex") {
700
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${cmd.cmdVarName}DataVar, "data", "", "Request body: JSON literal, @file, or - for stdin")`)
701
+ }
702
+ if (cmd.body.kind === "json-flat") {
703
+ for (const s of cmd.body.scalars) {
704
+ emitBodyScalarFlag(l, cmd, s)
705
+ }
706
+ }
707
+ if (cmd.body.kind === "multipart") {
708
+ for (const s of cmd.body.scalarFields) {
709
+ emitBodyScalarFlag(l, cmd, s)
710
+ }
711
+ if (cmd.body.useGenericFileFlag) {
712
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${cmd.cmdVarName}FileVar, "file", "", "Path to file to upload")`)
713
+ const bin = cmd.body.binaryFields[0]
714
+ if (bin?.required) {
715
+ l.push(`\t${cmd.cmdVarName}.MarkFlagRequired("file")`)
716
+ }
717
+ } else {
718
+ for (const f of cmd.body.binaryFields) {
719
+ const v = `${cmd.cmdVarName}${goPascal(f.name)}Var`
720
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${v}, ${JSON.stringify(toFlagName(f.name))}, "", ${JSON.stringify(`Path to ${f.name} file`)})`)
721
+ if (f.required) {
722
+ l.push(`\t${cmd.cmdVarName}.MarkFlagRequired(${JSON.stringify(toFlagName(f.name))})`)
723
+ }
724
+ }
725
+ }
726
+ }
727
+ if (cmd.hasLastEventId) {
728
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${cmd.cmdVarName}LastEventIDVar, "last-event-id", "", "SSE Last-Event-ID resume token")`)
729
+ }
730
+ }
731
+
732
+ function emitQueryFlag(l: string[], cmd: CommandInfo, q: ParamInfo): void {
733
+ const v = `${cmd.cmdVarName}${goPascal(q.name)}Var`
734
+ const t = q.schema.type
735
+ const help = q.enumValues.length > 0
736
+ ? `Query param ${q.name} (one of: ${q.enumValues.join(", ")})`
737
+ : `Query param ${q.name}`
738
+ const helpStr = JSON.stringify(help)
739
+
740
+ if (t === "integer") {
741
+ const def = typeof q.defaultValue === "number" ? q.defaultValue : 0
742
+ l.push(`\t${cmd.cmdVarName}.Flags().Int64Var(&${v}, ${JSON.stringify(q.flagName)}, ${def}, ${helpStr})`)
743
+ } else if (t === "number") {
744
+ const def = typeof q.defaultValue === "number" ? q.defaultValue : 0
745
+ l.push(`\t${cmd.cmdVarName}.Flags().Float64Var(&${v}, ${JSON.stringify(q.flagName)}, ${def}, ${helpStr})`)
746
+ } else if (t === "boolean") {
747
+ const def = q.defaultValue === true
748
+ l.push(`\t${cmd.cmdVarName}.Flags().BoolVar(&${v}, ${JSON.stringify(q.flagName)}, ${def ? "true" : "false"}, ${helpStr})`)
749
+ } else if (t === "array") {
750
+ const defArr = Array.isArray(q.defaultValue) ? q.defaultValue : []
751
+ const defStr = `[]string{${defArr.map((x) => JSON.stringify(String(x))).join(", ")}}`
752
+ l.push(`\t${cmd.cmdVarName}.Flags().StringSliceVar(&${v}, ${JSON.stringify(q.flagName)}, ${defStr}, ${helpStr})`)
753
+ } else {
754
+ const def = typeof q.defaultValue === "string" ? q.defaultValue : ""
755
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${v}, ${JSON.stringify(q.flagName)}, ${JSON.stringify(def)}, ${helpStr})`)
756
+ }
757
+ if (q.required) {
758
+ l.push(`\t${cmd.cmdVarName}.MarkFlagRequired(${JSON.stringify(q.flagName)})`)
759
+ }
760
+ }
761
+
762
+ function emitBodyScalarFlag(l: string[], cmd: CommandInfo, s: BodyScalarInfo): void {
763
+ const v = `${cmd.cmdVarName}${goPascal(s.propName)}Var`
764
+ const help = s.enumValues.length > 0
765
+ ? `Body field ${s.propName} (one of: ${s.enumValues.join(", ")})`
766
+ : `Body field ${s.propName}`
767
+ const helpStr = JSON.stringify(help)
768
+ if (s.schemaType === "integer") {
769
+ l.push(`\t${cmd.cmdVarName}.Flags().Int64Var(&${v}, ${JSON.stringify(s.flagName)}, 0, ${helpStr})`)
770
+ } else if (s.schemaType === "number") {
771
+ l.push(`\t${cmd.cmdVarName}.Flags().Float64Var(&${v}, ${JSON.stringify(s.flagName)}, 0, ${helpStr})`)
772
+ } else if (s.schemaType === "boolean") {
773
+ l.push(`\t${cmd.cmdVarName}.Flags().BoolVar(&${v}, ${JSON.stringify(s.flagName)}, false, ${helpStr})`)
774
+ } else {
775
+ l.push(`\t${cmd.cmdVarName}.Flags().StringVar(&${v}, ${JSON.stringify(s.flagName)}, "", ${helpStr})`)
776
+ }
777
+ }
778
+
779
+ function emitPreRunE(cmd: CommandInfo): string[] {
780
+ const lines: string[] = []
781
+ /* required body on POST/PUT/PATCH */
782
+ if (
783
+ (cmd.method === "POST" || cmd.method === "PUT" || cmd.method === "PATCH") &&
784
+ (cmd.body.kind === "json-flat" || cmd.body.kind === "json-complex") &&
785
+ cmd.body.required
786
+ ) {
787
+ const dataVar = `${cmd.cmdVarName}DataVar`
788
+ const anyFlag =
789
+ cmd.body.kind === "json-flat" && cmd.body.scalars.length > 0
790
+ ? cmd.body.scalars.map((s) => `cmd.Flags().Changed(${JSON.stringify(s.flagName)})`).join(" || ")
791
+ : "false"
792
+ lines.push(`if ${dataVar} == "" && !(${anyFlag}) {`)
793
+ lines.push(`\treturn fmt.Errorf("body required: pass --data or per-field flags")`)
794
+ lines.push(`}`)
795
+ }
796
+ /* enum validation: query + body-flat */
797
+ for (const q of cmd.queryParams) {
798
+ if (q.enumValues.length === 0) continue
799
+ if (q.schema.type !== "string") continue
800
+ const v = `${cmd.cmdVarName}${goPascal(q.name)}Var`
801
+ const values = q.enumValues.map((x) => JSON.stringify(x)).join(", ")
802
+ lines.push(`if cmd.Flags().Changed(${JSON.stringify(q.flagName)}) && !slices.Contains([]string{${values}}, ${v}) {`)
803
+ lines.push(`\treturn fmt.Errorf("--${q.flagName} must be one of: ${q.enumValues.join(", ")}")`)
804
+ lines.push(`}`)
805
+ }
806
+ if (cmd.body.kind === "json-flat") {
807
+ for (const s of cmd.body.scalars) {
808
+ if (s.enumValues.length === 0) continue
809
+ if (s.schemaType !== "string") continue
810
+ const v = `${cmd.cmdVarName}${goPascal(s.propName)}Var`
811
+ const values = s.enumValues.map((x) => JSON.stringify(x)).join(", ")
812
+ lines.push(`if cmd.Flags().Changed(${JSON.stringify(s.flagName)}) && !slices.Contains([]string{${values}}, ${v}) {`)
813
+ lines.push(`\treturn fmt.Errorf("--${s.flagName} must be one of: ${s.enumValues.join(", ")}")`)
814
+ lines.push(`}`)
815
+ }
816
+ }
817
+ return lines
818
+ }
819
+
820
+ function emitRunE(l: string[], cmd: CommandInfo, _options: GoCLIOptions): void {
821
+ const ind = "\t\t"
822
+ l.push(`${ind}ctx := cmd.Context()`)
823
+ l.push(`${ind}cfg := configFromCtx(ctx)`)
824
+ l.push(`${ind}apiKey, err := cli.ResolveAPIKey(cfg)`)
825
+ l.push(`${ind}if err != nil { return err }`)
826
+ l.push(`${ind}_ = apiKey`)
827
+ l.push(`${ind}baseURL := cfg.BaseURL`)
828
+ l.push(`${ind}if baseURL == "" { return fmt.Errorf("base URL required: set --base-url, env, or config") }`)
829
+ l.push(`${ind}timeout := 30 * time.Second`)
830
+ l.push(`${ind}if cfg.Timeout != "" {`)
831
+ l.push(`${ind}\tif d, perr := time.ParseDuration(cfg.Timeout); perr == nil { timeout = d }`)
832
+ l.push(`${ind}}`)
833
+ l.push(`${ind}httpClient := &http.Client{Timeout: timeout}`)
834
+
835
+ /* path-param local aliases: short Go-idiomatic names used in URL assembly */
836
+ for (const p of cmd.pathParams) {
837
+ const storageVar = `${cmd.cmdVarName}${goPascal(p.name)}Var`
838
+ l.push(`${ind}${p.varName} := ${storageVar}`)
839
+ }
840
+
841
+ /* URL assembly */
842
+ const pathExpr = buildPathExpr(cmd)
843
+ l.push(`${ind}reqURL := baseURL + ${pathExpr}`)
844
+ l.push(`${ind}q := url.Values{}`)
845
+ for (const q of cmd.queryParams) {
846
+ if (isLastEventIdName(q.name)) continue
847
+ emitQueryAssembly(l, ind, cmd, q)
848
+ }
849
+ l.push(`${ind}if len(q) > 0 { reqURL += "?" + q.Encode() }`)
850
+
851
+ /* body prep */
852
+ let bodyReaderExpr = "nil"
853
+ let contentType = ""
854
+ if (cmd.body.kind === "json-flat") {
855
+ l.push(`${ind}// merge --data + per-prop flags`)
856
+ l.push(`${ind}merged := map[string]any{}`)
857
+ l.push(`${ind}if ${cmd.cmdVarName}DataVar != "" {`)
858
+ l.push(`${ind}\tdataBytes, derr := cli.ReadDataFlag(${cmd.cmdVarName}DataVar)`)
859
+ l.push(`${ind}\tif derr != nil { return derr }`)
860
+ l.push(`${ind}\tif len(dataBytes) > 0 {`)
861
+ l.push(`${ind}\t\tif jerr := json.Unmarshal(dataBytes, &merged); jerr != nil { return fmt.Errorf("parse --data: %w", jerr) }`)
862
+ l.push(`${ind}\t}`)
863
+ l.push(`${ind}}`)
864
+ for (const s of cmd.body.scalars) {
865
+ const v = `${cmd.cmdVarName}${goPascal(s.propName)}Var`
866
+ l.push(`${ind}if cmd.Flags().Changed(${JSON.stringify(s.flagName)}) { merged[${JSON.stringify(s.propName)}] = ${v} }`)
867
+ }
868
+ l.push(`${ind}bodyBytes, merr := json.Marshal(merged)`)
869
+ l.push(`${ind}if merr != nil { return merr }`)
870
+ bodyReaderExpr = "bytes.NewReader(bodyBytes)"
871
+ contentType = "application/json"
872
+ } else if (cmd.body.kind === "json-complex") {
873
+ l.push(`${ind}bodyBytes, derr := cli.ReadDataFlag(${cmd.cmdVarName}DataVar)`)
874
+ l.push(`${ind}if derr != nil { return derr }`)
875
+ bodyReaderExpr = "bytes.NewReader(bodyBytes)"
876
+ contentType = "application/json"
877
+ } else if (cmd.body.kind === "multipart") {
878
+ l.push(`${ind}fields := map[string]string{}`)
879
+ for (const s of cmd.body.scalarFields) {
880
+ const v = `${cmd.cmdVarName}${goPascal(s.propName)}Var`
881
+ if (s.schemaType === "string") {
882
+ l.push(`${ind}if cmd.Flags().Changed(${JSON.stringify(s.flagName)}) { fields[${JSON.stringify(s.propName)}] = ${v} }`)
883
+ } else {
884
+ l.push(`${ind}if cmd.Flags().Changed(${JSON.stringify(s.flagName)}) { fields[${JSON.stringify(s.propName)}] = fmt.Sprint(${v}) }`)
885
+ }
886
+ }
887
+ l.push(`${ind}files := map[string]string{}`)
888
+ if (cmd.body.useGenericFileFlag) {
889
+ const bin = cmd.body.binaryFields[0]
890
+ l.push(`${ind}if ${cmd.cmdVarName}FileVar != "" { files[${JSON.stringify(bin.name)}] = ${cmd.cmdVarName}FileVar }`)
891
+ } else {
892
+ for (const f of cmd.body.binaryFields) {
893
+ const v = `${cmd.cmdVarName}${goPascal(f.name)}Var`
894
+ l.push(`${ind}if ${v} != "" { files[${JSON.stringify(f.name)}] = ${v} }`)
895
+ }
896
+ }
897
+ l.push(`${ind}mpReader, mpCT, mpErr := cli.BuildMultipart(fields, files)`)
898
+ l.push(`${ind}if mpErr != nil { return mpErr }`)
899
+ l.push(`${ind}_ = mpCT`)
900
+ bodyReaderExpr = "mpReader"
901
+ contentType = "__multipart__"
902
+ }
903
+
904
+ /* SSE */
905
+ if (cmd.isSSE) {
906
+ l.push(`${ind}req, rerr := http.NewRequestWithContext(ctx, "GET", reqURL, nil)`)
907
+ l.push(`${ind}if rerr != nil { return rerr }`)
908
+ l.push(`${ind}req.Header.Set("Accept", "text/event-stream")`)
909
+ l.push(`${ind}if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) }`)
910
+ if (cmd.hasLastEventId) {
911
+ l.push(`${ind}if ${cmd.cmdVarName}LastEventIDVar != "" { req.Header.Set("Last-Event-ID", ${cmd.cmdVarName}LastEventIDVar) }`)
912
+ }
913
+ l.push(`${ind}resp, herr := httpClient.Do(req)`)
914
+ l.push(`${ind}if herr != nil { return herr }`)
915
+ l.push(`${ind}defer resp.Body.Close()`)
916
+ l.push(`${ind}it := sdk.ParseSSEStreamExported(ctx, resp)`)
917
+ /* wrap SDK's Seq2[sdk.SSEEvent] → Seq2[cli.SSEEvent] so the runtime stays SDK-agnostic. */
918
+ l.push(`${ind}wrapped := func(yield func(cli.SSEEvent, error) bool) {`)
919
+ l.push(`${ind}\tfor ev, iterErr := range it {`)
920
+ l.push(`${ind}\t\tif !yield(cli.SSEEvent{Data: ev.Data, Event: ev.Event, ID: ev.ID, Retry: ev.Retry}, iterErr) {`)
921
+ l.push(`${ind}\t\t\treturn`)
922
+ l.push(`${ind}\t\t}`)
923
+ l.push(`${ind}\t}`)
924
+ l.push(`${ind}}`)
925
+ l.push(`${ind}return cli.StreamSSE(wrapped, os.Stdout, cfg.Output)`)
926
+ return
927
+ }
928
+
929
+ /* regular HTTP */
930
+ l.push(`${ind}req, rerr := http.NewRequestWithContext(ctx, "${cmd.method}", reqURL, ${bodyReaderExpr})`)
931
+ l.push(`${ind}if rerr != nil { return rerr }`)
932
+ if (contentType === "application/json") {
933
+ l.push(`${ind}req.Header.Set("Content-Type", "application/json")`)
934
+ } else if (contentType === "__multipart__") {
935
+ l.push(`${ind}req.Header.Set("Content-Type", mpCT) // multipart/form-data; boundary=...`)
936
+ }
937
+ l.push(`${ind}if apiKey != "" { req.Header.Set("Authorization", "Bearer "+apiKey) }`)
938
+ l.push(`${ind}resp, herr := httpClient.Do(req)`)
939
+ l.push(`${ind}if herr != nil { return herr }`)
940
+ l.push(`${ind}defer resp.Body.Close()`)
941
+ l.push(`${ind}body, berr := io.ReadAll(resp.Body)`)
942
+ l.push(`${ind}if berr != nil { return berr }`)
943
+ l.push(`${ind}if resp.StatusCode >= 400 {`)
944
+ l.push(`${ind}\treturn &sdk.StatusError{StatusCode: resp.StatusCode, Body: body, Response: resp, Message: string(body)}`)
945
+ l.push(`${ind}}`)
946
+ if (cmd.has204) {
947
+ l.push(`${ind}if resp.StatusCode == http.StatusNoContent { return nil }`)
948
+ }
949
+ if (cmd.responseType === "json") {
950
+ l.push(`${ind}var out any`)
951
+ l.push(`${ind}if len(body) > 0 {`)
952
+ l.push(`${ind}\tif jerr := json.Unmarshal(body, &out); jerr != nil { return jerr }`)
953
+ l.push(`${ind}}`)
954
+ l.push(`${ind}return cli.Emit(out, cfg.Output, os.Stdout)`)
955
+ } else {
956
+ l.push(`${ind}return nil`)
957
+ }
958
+ }
959
+
960
+ function buildPathExpr(cmd: CommandInfo): string {
961
+ if (cmd.pathParams.length === 0) return JSON.stringify(cmd.path)
962
+ let expr = JSON.stringify(cmd.path)
963
+ for (const p of cmd.pathParams) {
964
+ expr = expr.replace(`{${p.name}}`, `" + url.PathEscape(${p.varName}) + "`)
965
+ }
966
+ expr = expr.replace(/"" \+ /g, "").replace(/ \+ ""/g, "")
967
+ return expr
968
+ }
969
+
970
+ function querySetStmt(q: ParamInfo, v: string): string {
971
+ const name = JSON.stringify(q.name)
972
+ switch (q.schema.type) {
973
+ case "integer": return `q.Set(${name}, fmt.Sprintf("%d", ${v}))`
974
+ case "number": return `q.Set(${name}, fmt.Sprintf("%g", ${v}))`
975
+ case "boolean": return `q.Set(${name}, fmt.Sprintf("%t", ${v}))`
976
+ case "array": return `for _, v := range ${v} { q.Add(${name}, v) }`
977
+ default: return `q.Set(${name}, ${v})`
978
+ }
979
+ }
980
+
981
+ function emitQueryAssembly(l: string[], ind: string, cmd: CommandInfo, q: ParamInfo): void {
982
+ const v = `${cmd.cmdVarName}${goPascal(q.name)}Var`
983
+ const stmt = querySetStmt(q, v)
984
+ if (q.required) {
985
+ l.push(`${ind}${stmt}`)
986
+ return
987
+ }
988
+ l.push(`${ind}if cmd.Flags().Changed(${JSON.stringify(q.flagName)}) { ${stmt} }`)
989
+ }
990
+
991
+ /* ── emit: main.go ── */
992
+
993
+ function emitMainFile(options: GoCLIOptions): string {
994
+ const module = resolveModulePath(options)
995
+ const l: string[] = []
996
+ l.push(`// Code generated by honey. DO NOT EDIT.`)
997
+ l.push(`package main`)
998
+ l.push(``)
999
+ l.push(`import (`)
1000
+ l.push(`\t"fmt"`)
1001
+ l.push(`\t"os"`)
1002
+ l.push(``)
1003
+ l.push(`\tcli "${module}/internal/cli"`)
1004
+ l.push(`\t"${module}/cmd"`)
1005
+ l.push(`)`)
1006
+ l.push(``)
1007
+ l.push(`func main() {`)
1008
+ l.push(`\tif err := cmd.Execute(); err != nil {`)
1009
+ l.push(`\t\tfmt.Fprintln(os.Stderr, err)`)
1010
+ l.push(`\t\tos.Exit(cli.ExitFor(err))`)
1011
+ l.push(`\t}`)
1012
+ l.push(`}`)
1013
+ l.push(``)
1014
+ return l.join("\n")
1015
+ }
1016
+
1017
+ /* ── emit: go.mod ── */
1018
+
1019
+ function emitGoMod(options: GoCLIOptions, hasEmbeddedSDK: boolean): string {
1020
+ const mod = resolveModulePath(options)
1021
+ const l: string[] = []
1022
+ l.push(`module ${mod}`)
1023
+ l.push(``)
1024
+ l.push(`go 1.23`)
1025
+ l.push(``)
1026
+ l.push(`require (`)
1027
+ l.push(`\tgithub.com/spf13/cobra v1.8.1`)
1028
+ l.push(`\tgithub.com/pelletier/go-toml/v2 v2.2.3`)
1029
+ l.push(`\tsigs.k8s.io/yaml v1.4.0`)
1030
+ if (!hasEmbeddedSDK && options.sdkModulePath) {
1031
+ l.push(`\t${options.sdkModulePath} v0.0.0`)
1032
+ }
1033
+ if (hasEmbeddedSDK) {
1034
+ l.push(`\tnhooyr.io/websocket v1.8.17`)
1035
+ }
1036
+ l.push(`)`)
1037
+ l.push(``)
1038
+ if (!hasEmbeddedSDK && options.sdkModulePath) {
1039
+ l.push(`replace ${options.sdkModulePath} => ../sdk`)
1040
+ l.push(``)
1041
+ }
1042
+ return l.join("\n")
1043
+ }
1044
+
1045
+ /* ── emit: README.md ── */
1046
+
1047
+ function emitReadme(spec: OpenApiSpec, options: GoCLIOptions): string {
1048
+ const envPrefix = options.envPrefix ?? toSnakeUpper(options.binaryName)
1049
+ const configName = options.configName ?? options.binaryName
1050
+ const title = spec.info?.title ?? options.binaryName
1051
+ return [
1052
+ `# ${options.binaryName}`,
1053
+ ``,
1054
+ `CLI for ${title} — auto-generated by honey.`,
1055
+ ``,
1056
+ `## Install`,
1057
+ ``,
1058
+ "```sh",
1059
+ `go build -o ${options.binaryName} ./...`,
1060
+ "```",
1061
+ ``,
1062
+ `## Auth`,
1063
+ ``,
1064
+ `Resolution order (high → low):`,
1065
+ ``,
1066
+ `1. \`--api-key <value>\``,
1067
+ `2. \`${envPrefix}_API_KEY\` env var`,
1068
+ `3. \`~/.config/${configName}/config.toml\` → \`api_key = "..."\``,
1069
+ `4. \`$XDG_CONFIG_HOME/${configName}/config.toml\``,
1070
+ ``,
1071
+ `## Global flags`,
1072
+ ``,
1073
+ `- \`--api-key\`, \`--base-url\`, \`--output\` (json|ndjson|yaml|table)`,
1074
+ `- \`--verbose\`, \`--config\`, \`--timeout\``,
1075
+ ``,
1076
+ `## Exit codes`,
1077
+ ``,
1078
+ `- 0: success`,
1079
+ `- 1: 4xx client error`,
1080
+ `- 2: 5xx server error`,
1081
+ `- 3: network failure / timeout`,
1082
+ `- 4: bad flags / missing api-key`,
1083
+ `- 5: unexpected 3xx`,
1084
+ ``,
1085
+ ].join("\n")
1086
+ }
1087
+
1088
+ /* ── embedded SDK copy ── */
1089
+
1090
+ function copyEmbeddedSDK(
1091
+ spec: Record<string, unknown>,
1092
+ options: GoCLIOptions,
1093
+ outFiles: Record<string, string>,
1094
+ ): void {
1095
+ const sdkModule = `${resolveModulePath(options)}/internal/sdk`
1096
+ const sdkGen = generateGoSDK(spec, { modulePath: sdkModule })
1097
+ for (const [name, content] of Object.entries(sdkGen.files)) {
1098
+ /* skip go.mod — CLI has its own */
1099
+ if (name === "go.mod") continue
1100
+ outFiles[`internal/sdk/${name}`] = content
1101
+ }
1102
+ }
1103
+
1104
+ /* ── ParseSSEStreamExported shim ──
1105
+ * The SDK exports parseSSEStream lowercase (package-private).
1106
+ * We need to re-expose it under the SDK package so the CLI's cmd/*.go can call it.
1107
+ * When embedding, add a small sse_export.go into internal/sdk/.
1108
+ * When using external SDK, we still need the same symbol — emit an adapter file. */
1109
+
1110
+ function emitSSEExportShim(
1111
+ options: GoCLIOptions,
1112
+ files: Record<string, string>,
1113
+ hasEmbedded: boolean,
1114
+ ): void {
1115
+ const shim = [
1116
+ `// Code generated by honey. DO NOT EDIT.`,
1117
+ `package sdk`,
1118
+ ``,
1119
+ `import (`,
1120
+ `\t"context"`,
1121
+ `\t"iter"`,
1122
+ `\t"net/http"`,
1123
+ `)`,
1124
+ ``,
1125
+ `// ParseSSEStreamExported exposes parseSSEStream for CLI consumption.`,
1126
+ `func ParseSSEStreamExported(ctx context.Context, resp *http.Response) iter.Seq2[SSEEvent, error] {`,
1127
+ `\treturn parseSSEStream(ctx, resp)`,
1128
+ `}`,
1129
+ ``,
1130
+ ].join("\n")
1131
+ if (hasEmbedded) {
1132
+ files["internal/sdk/sse_export.go"] = shim
1133
+ }
1134
+ /* external-SDK mode: consumer's SDK must already export or we rely on ParseSSEStreamExported existing.
1135
+ We include the shim in embed mode only; external mode assumes SDK regenerated w/ same honey. */
1136
+ }
1137
+
1138
+ /* ── public entrypoint ── */
1139
+
1140
+ export function generateGoCLI(
1141
+ spec: Record<string, unknown>,
1142
+ options: GoCLIOptions,
1143
+ ): GeneratedGoCLI {
1144
+ const resolved = resolveRefs(spec as Parameters<typeof resolveRefs>[0]) as unknown as OpenApiSpec
1145
+ const { groups, skipped, serviceMap } = collectCLIMethods(spec)
1146
+
1147
+ const hasEmbeddedSDK = !options.sdkModulePath
1148
+ const files: Record<string, string> = {}
1149
+
1150
+ /* runtime templates → internal/cli/ */
1151
+ for (const [name, content] of loadGoCliRuntimeTemplates()) {
1152
+ files[`internal/cli/${name}`] = content
1153
+ }
1154
+
1155
+ /* main.go */
1156
+ files["main.go"] = emitMainFile(options)
1157
+
1158
+ /* cmd/root.go */
1159
+ const sortedGroups = [...groups.values()].sort((a, b) => a.resource.localeCompare(b.resource))
1160
+ files["cmd/root.go"] = emitRootFile(sortedGroups, options)
1161
+
1162
+ /* cmd/<resource>.go */
1163
+ for (const g of sortedGroups) {
1164
+ files[`cmd/${g.fileName}`] = emitResourceFile(g, options)
1165
+ }
1166
+
1167
+ /* go.mod */
1168
+ files["go.mod"] = emitGoMod(options, hasEmbeddedSDK)
1169
+
1170
+ /* README.md */
1171
+ files["README.md"] = emitReadme(resolved, options)
1172
+
1173
+ /* SDK embed if sdkModulePath not set */
1174
+ if (hasEmbeddedSDK) {
1175
+ copyEmbeddedSDK(spec, options, files)
1176
+ }
1177
+ emitSSEExportShim(options, files, hasEmbeddedSDK)
1178
+
1179
+ return { files, serviceMap, skippedOperations: skipped }
1180
+ }