@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.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- 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
|
+
}
|