galbe 0.13.1 → 0.14.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.
@@ -0,0 +1,535 @@
1
+ import { $ } from 'bun'
2
+ import { resolve } from 'path'
3
+ import { mkdtemp, rm } from 'fs/promises'
4
+ import { tmpdir } from 'os'
5
+ import type { GalbeCLICommand, GalbeCLIOptions } from '../../../../../src'
6
+ import { CWD } from '../../../../util'
7
+
8
+ export interface GenerateOptions {
9
+ commands: GalbeCLICommand[]
10
+ mode: 'standalone' | 'module'
11
+ out: string
12
+ pckg: any
13
+ options?: GalbeCLIOptions
14
+ }
15
+
16
+ export async function generate(opts: GenerateOptions): Promise<void> {
17
+ if (opts.mode === 'standalone') await buildStandalone(opts)
18
+ else await buildModule(opts)
19
+ }
20
+
21
+ async function buildStandalone(opts: GenerateOptions): Promise<void> {
22
+ const code = generateSource(opts.commands, 'standalone', opts.pckg, opts.options)
23
+ const buildDir = await mkdtemp(resolve(tmpdir(), 'galbe-cli-'))
24
+ try {
25
+ await Bun.write(resolve(buildDir, 'package.json'), JSON.stringify({ dependencies: { cac: 'latest' } }))
26
+ await $`bun install --cwd ${buildDir}`.quiet()
27
+ await Bun.write(resolve(buildDir, 'cli.ts'), code)
28
+ const outPath = resolve(CWD, opts.out)
29
+ await $`bun build --compile ${resolve(buildDir, 'cli.ts')} --outfile ${outPath}`.quiet()
30
+ } finally {
31
+ await rm(buildDir, { recursive: true })
32
+ }
33
+ }
34
+
35
+ async function buildModule(opts: GenerateOptions): Promise<void> {
36
+ const code = generateSource(opts.commands, 'module', opts.pckg, opts.options)
37
+ await Bun.write(resolve(CWD, opts.out), code)
38
+ }
39
+
40
+ function serializeValue(value: any): string {
41
+ if (value === undefined) return 'undefined'
42
+ if (value === null) return 'null'
43
+ if (typeof value === 'string') return JSON.stringify(value)
44
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
45
+ if (typeof value === 'function') return value.toString()
46
+ if (Array.isArray(value)) return `[${value.map(serializeValue).join(', ')}]`
47
+ if (typeof value === 'object')
48
+ return `{ ${Object.entries(value)
49
+ .map(([k, v]) => `${JSON.stringify(k)}: ${serializeValue(v)}`)
50
+ .join(', ')} }`
51
+ return 'undefined'
52
+ }
53
+
54
+ // cac camelCases hyphenated option names when accessing via options object
55
+ function toCamelCase(name: string): string {
56
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
57
+ }
58
+
59
+ // Strip characters that are invalid in JS identifiers / cac option names
60
+ function sanitizeOptionName(name: string): string {
61
+ return name.replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+/, '') || 'opt'
62
+ }
63
+
64
+ function generateSingleCommand(c: GalbeCLICommand, cliVar: string, includeTag: boolean): string {
65
+ const builtinShorts = new Set(['H', 'Q', 'b', 'B'])
66
+ const hide = new Set(c.hideOptions ?? [])
67
+ const tagPrefix = includeTag && c.tags.length > 0 ? c.tags.map(t => t.toLowerCase()).join(' ') + ' ' : ''
68
+ const pathArgs = (c.arguments || []).map(a => ` <${a.name}>`).join('')
69
+ const cmdStr = `${tagPrefix}${c.name}${pathArgs}`
70
+ const desc = JSON.stringify(c.description || '')
71
+
72
+ const routeOptsLines = (c.options || [])
73
+ .map(o => {
74
+ const cleanName = sanitizeOptionName(o.name)
75
+ const short = o.short && !builtinShorts.has(o.short) ? `-${o.short}, ` : ''
76
+ const optType = o.type || '[string]'
77
+ const defVal = o.default !== undefined ? `, { default: ${serializeValue(o.default)} }` : ''
78
+ return ` .option('${short}--${cleanName} ${optType}', ${JSON.stringify(o.description || o.name)}${defVal})`
79
+ })
80
+ .join('\n')
81
+
82
+ const actionParams = (c.arguments || []).length
83
+ ? (c.arguments || []).map(a => a.name).join(', ') + ', options'
84
+ : 'options'
85
+
86
+ const routeOptsObj = (c.options || []).length
87
+ ? `{ ${(c.options || []).map(o => `${JSON.stringify(o.name)}: (options as any).${toCamelCase(sanitizeOptionName(o.name))}`).join(', ')} }`
88
+ : '{}'
89
+
90
+ const pathLiteral = '`' + c.pathT.replace(/`/g, '\\`') + '`'
91
+ const method = c.route.method.toUpperCase()
92
+ const customAction = c.action ? `await (${c.action.toString()})(options as any)\n ` : ''
93
+
94
+ const argsObj = (c.arguments || []).length
95
+ ? `{ ${(c.arguments || []).map(a => a.name).join(', ')} }`
96
+ : '{}'
97
+
98
+ const commandMeta: Record<string, any> = { name: c.name, tags: c.tags }
99
+ if (c.description !== undefined) commandMeta.description = c.description
100
+ commandMeta.pathT = c.pathT
101
+ if (c.arguments !== undefined) commandMeta.arguments = c.arguments
102
+ if (c.options !== undefined) commandMeta.options = c.options
103
+ if (c.hideOptions !== undefined) commandMeta.hideOptions = c.hideOptions
104
+ const commandLiteral = serializeValue(commandMeta)
105
+
106
+ const builtinOptLines = [
107
+ !hide.has('header') && ` .option('-H, --header [string...]', 'request header as name=value', { default: [] })`,
108
+ !hide.has('query') && ` .option('-Q, --query [string...]', 'query param as name=value', { default: [] })`,
109
+ !hide.has('body') && ` .option('-b, --body [string]', 'request body')`,
110
+ !hide.has('body-file') && ` .option('-B, --body-file [string]', 'request body file path')`,
111
+ ]
112
+ .filter(Boolean)
113
+ .join('\n')
114
+
115
+ return `${cliVar}.command(${JSON.stringify(cmdStr)}, ${desc})
116
+ ${builtinOptLines}
117
+ ${routeOptsLines}
118
+ .action(async (${actionParams}) => {
119
+ const { header, query, body, bodyFile } = options as any
120
+ const _args = ${argsObj}
121
+ ${customAction}return _fetchApi(${JSON.stringify(method)}, ${pathLiteral}, header ?? [], query ?? [], body ?? '', bodyFile ?? '', ${routeOptsObj}, ${commandLiteral}, _args, options as any)
122
+ })`
123
+ }
124
+
125
+ type CommandNode = {
126
+ subgroups: Map<string, CommandNode>
127
+ commands: GalbeCLICommand[]
128
+ }
129
+
130
+ function buildCommandTree(commands: GalbeCLICommand[]): CommandNode {
131
+ const root: CommandNode = { subgroups: new Map(), commands: [] }
132
+ for (const cmd of commands) {
133
+ let node = root
134
+ for (const tag of cmd.tags) {
135
+ const t = tag.toLowerCase()
136
+ if (!node.subgroups.has(t)) node.subgroups.set(t, { subgroups: new Map(), commands: [] })
137
+ node = node.subgroups.get(t)!
138
+ }
139
+ node.commands.push(cmd)
140
+ }
141
+ return root
142
+ }
143
+
144
+ function nodeToBlock(node: CommandNode, tagPath: string[], appName: string, version: string): string {
145
+ const depth = tagPath.length
146
+ const safeId = (t: string) => t.replace(/[^a-zA-Z0-9]/g, '_')
147
+ const cliVar = depth === 0 ? '_cli' : `_sub_${tagPath.map(safeId).join('_')}`
148
+ const knownVar = depth === 0 ? '_known' : `_known_${tagPath.map(safeId).join('_')}`
149
+ const cliName = [appName, ...tagPath].join(' ')
150
+ const argvExpr = `_argv[${depth}]`
151
+
152
+ const subGroupEntries = [...node.subgroups.entries()]
153
+
154
+ const subBlocks = subGroupEntries.map(([tag, subNode], i) => {
155
+ const sub = nodeToBlock(subNode, [...tagPath, tag], appName, version)
156
+ const indented = sub.split('\n').map(l => ' ' + l).join('\n')
157
+ return `${i === 0 ? 'if' : 'else if'} (${argvExpr} === ${JSON.stringify(tag)}) {\n${indented}\n}`
158
+ })
159
+
160
+ const groupListings = subGroupEntries
161
+ .map(([tag]) => `${cliVar}.command(${JSON.stringify(tag)}, ${JSON.stringify(tag + ' commands')})`)
162
+ .join('\n')
163
+
164
+ const commandBlocks = node.commands.map(c => generateSingleCommand(c, cliVar, false)).join('\n\n')
165
+
166
+ const versionLine = depth === 0 ? `\n${cliVar}.version(${JSON.stringify(version)})` : ''
167
+ const parseArg = depth > 0 ? `['', '', ..._argv.slice(${depth})]` : ''
168
+
169
+ const cliBlockLines = [
170
+ `const ${cliVar} = cac(${JSON.stringify(cliName)})${versionLine}`,
171
+ groupListings,
172
+ commandBlocks,
173
+ `${cliVar}.help()`,
174
+ `const ${knownVar} = new Set(${cliVar}.commands.map((c: any) => c.name))`,
175
+ `if (!${argvExpr} || (!${argvExpr}.startsWith('-') && !${knownVar}.has(${argvExpr}))) {`,
176
+ ` ${cliVar}.outputHelp()`,
177
+ ` process.exit(${argvExpr} ? 1 : 0)`,
178
+ `}`,
179
+ `try { ${cliVar}.parse(${parseArg}) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }`,
180
+ ]
181
+ .filter(Boolean)
182
+ .join('\n')
183
+
184
+ if (subBlocks.length > 0) {
185
+ const indentedCli = cliBlockLines.split('\n').map(l => ' ' + l).join('\n')
186
+ return `${subBlocks.join('\n')}\nelse {\n${indentedCli}\n}`
187
+ }
188
+ return cliBlockLines
189
+ }
190
+
191
+ function generateStandaloneBlock(commands: GalbeCLICommand[], name: string, version: string): string {
192
+ return nodeToBlock(buildCommandTree(commands), [], name, version)
193
+ }
194
+
195
+ function generateModuleCommands(commands: GalbeCLICommand[], appName: string): string {
196
+ const tree = buildCommandTree(commands)
197
+ const safeId = (t: string) => t.replace(/[^a-zA-Z0-9]/g, '_')
198
+ const lines: string[] = []
199
+
200
+ function genSubCacSetup(node: CommandNode, tagPath: string[]): string {
201
+ const subVar = `_sub_${tagPath.map(safeId).join('_')}`
202
+ const cliName = [appName, ...tagPath].join(' ')
203
+ const nodeLines: string[] = []
204
+
205
+ nodeLines.push(`const ${subVar} = cac(${JSON.stringify(cliName)})`)
206
+ for (const cmd of node.commands) nodeLines.push(generateSingleCommand(cmd, subVar, false))
207
+
208
+ for (const [tag, subNode] of node.subgroups.entries()) {
209
+ const subTagPath = [...tagPath, tag]
210
+ const subSubVar = `_sub_${subTagPath.map(safeId).join('_')}`
211
+ const grpVar = `_grp_${subTagPath.map(safeId).join('_')}`
212
+ const knownVar = `_known_${subTagPath.map(safeId).join('_')}`
213
+ nodeLines.push(genSubCacSetup(subNode, subTagPath))
214
+ nodeLines.push(
215
+ `const ${grpVar} = ${subVar}.command(${JSON.stringify(tag + ' [..._sub]')}, ${JSON.stringify(tag + ' commands')})` +
216
+ `\n .allowUnknownOptions()` +
217
+ `\n .action(() => {` +
218
+ `\n const _rawArgv = process.argv.slice(2)` +
219
+ `\n const _tagIdx = _rawArgv.findIndex((a: string) => a === ${JSON.stringify(tag)})` +
220
+ `\n const _subArgv = _tagIdx >= 0 ? _rawArgv.slice(_tagIdx + 1) : []` +
221
+ `\n const ${knownVar} = new Set(${subSubVar}.commands.map((c: any) => c.name))` +
222
+ `\n if (!_subArgv[0] || (!_subArgv[0].startsWith('-') && !${knownVar}.has(_subArgv[0]))) {` +
223
+ `\n ${subSubVar}.outputHelp()` +
224
+ `\n process.exit(_subArgv[0] ? 1 : 0)` +
225
+ `\n }` +
226
+ `\n try { ${subSubVar}.parse(['', '', ..._subArgv]) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }` +
227
+ `\n })` +
228
+ `\n;(${grpVar} as any).rawName = ${JSON.stringify(tag)}` +
229
+ `\n;(${grpVar} as any).outputHelp = () => ${subSubVar}.outputHelp()`
230
+ )
231
+ }
232
+
233
+ nodeLines.push(`${subVar}.help()`)
234
+ return nodeLines.join('\n')
235
+ }
236
+
237
+ for (const [tag, subNode] of tree.subgroups.entries()) {
238
+ const subVar = `_sub_${safeId(tag)}`
239
+ const grpVar = `_grp_${safeId(tag)}`
240
+ const knownVar = `_known_${safeId(tag)}`
241
+ lines.push(genSubCacSetup(subNode, [tag]))
242
+ lines.push(
243
+ `const ${grpVar} = parent.command(${JSON.stringify(tag + ' [..._sub]')}, ${JSON.stringify(tag + ' commands')})` +
244
+ `\n .allowUnknownOptions()` +
245
+ `\n .action(() => {` +
246
+ `\n const _rawArgv = process.argv.slice(2)` +
247
+ `\n const _tagIdx = _rawArgv.findIndex((a: string) => a === ${JSON.stringify(tag)})` +
248
+ `\n const _subArgv = _tagIdx >= 0 ? _rawArgv.slice(_tagIdx + 1) : []` +
249
+ `\n const ${knownVar} = new Set(${subVar}.commands.map((c: any) => c.name))` +
250
+ `\n if (!_subArgv[0] || (!_subArgv[0].startsWith('-') && !${knownVar}.has(_subArgv[0]))) {` +
251
+ `\n ${subVar}.outputHelp()` +
252
+ `\n process.exit(_subArgv[0] ? 1 : 0)` +
253
+ `\n }` +
254
+ `\n try { ${subVar}.parse(['', '', ..._subArgv]) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }` +
255
+ `\n })` +
256
+ `\n;(${grpVar} as any).rawName = ${JSON.stringify(tag)}` +
257
+ `\n;(${grpVar} as any).outputHelp = () => ${subVar}.outputHelp()`
258
+ )
259
+ }
260
+
261
+ for (const cmd of tree.commands) lines.push(generateSingleCommand(cmd, 'parent', false))
262
+
263
+ lines.push(
264
+ `const _parseOrig = (parent as any).parse.bind(parent)` +
265
+ `\n;(parent as any).parse = (argv?: string[]) => {` +
266
+ `\n const _a: string[] = argv ?? process.argv` +
267
+ `\n const _positionals = _a.slice(2).filter((x: string) => !x.startsWith('-'))` +
268
+ `\n if (_positionals.length === 0 && !_a.slice(2).includes('--help') && !_a.slice(2).includes('-h')) {` +
269
+ `\n parent.outputHelp()` +
270
+ `\n process.exit(0)` +
271
+ `\n }` +
272
+ `\n const _r = _parseOrig(argv)` +
273
+ `\n if (!(parent as any).matchedCommand && _positionals.length > 0 && !_a.slice(2).includes('--help') && !_a.slice(2).includes('-h')) {` +
274
+ `\n console.error(\`error: Unknown command "\${_positionals[0]}"\`)` +
275
+ `\n process.exit(1)` +
276
+ `\n }` +
277
+ `\n return _r` +
278
+ `\n}`
279
+ )
280
+
281
+ return lines.filter(Boolean).join('\n\n')
282
+ }
283
+
284
+ function generateUtilities(): string {
285
+ return `
286
+ const _ansi = (p: boolean, c: string, str: unknown): string =>
287
+ p ? \`\\x1b[\${c}m\${str}\\x1b[0m\` : String(str)
288
+
289
+ const _fmtObject = (o: unknown, p = false, idt = 2, iidt = 0): string => {
290
+ const _ = ' '.repeat(iidt)
291
+ const __ = ' '.repeat(iidt + idt)
292
+ const lr = idt === 0 ? '' : '\\n'
293
+ if (o === null) return _ansi(p, '38;2;255;128;0', 'null')
294
+ if (typeof o === 'boolean') return _ansi(p, '38;2;255;128;0', String(o))
295
+ if (typeof o === 'number') return _ansi(p, '38;2;10;180;220', String(o))
296
+ if (typeof o === 'string') return _ansi(p, '38;2;125;170;0', \`"\${o}"\`)
297
+ if (Array.isArray(o))
298
+ return \`[\${lr}\${o.map(e => \`\${__}\${_fmtObject(e, p, idt, iidt + idt)}\`).join(\`,\${lr}\`)}\${lr}\${_}]\`
299
+ if (typeof o === 'object' && o !== null)
300
+ return \`{\${lr}\${Object.entries(o as object)
301
+ .map(([k, v]) => v === undefined ? '' : \`\${__}\${_ansi(p, '38;2;170;120;200', \`"\${k}"\`)}: \${_fmtObject(v, p, idt, iidt + idt)}\`)
302
+ .filter(Boolean)
303
+ .join(\`,\${lr}\`)}\${lr}\${_}}\`
304
+ return String(o)
305
+ }
306
+
307
+ const _headersToObj = (h: Headers): Record<string, string> => {
308
+ const r: Record<string, string> = {}
309
+ h.forEach((v, k) => { r[k] = v })
310
+ return r
311
+ }`
312
+ }
313
+
314
+ function generateFetchApi(
315
+ nameVersion: string,
316
+ bakedBaseUrl: string,
317
+ bakedHeaders: string,
318
+ bakedRequestInterceptor: string,
319
+ bakedResponseFormatter: string,
320
+ isStandalone: boolean
321
+ ): string {
322
+ const bakedConfig = isStandalone
323
+ ? `
324
+ const _baseUrl: string | (() => string) | undefined = ${bakedBaseUrl}
325
+ const _defaultHeaders: Record<string, string> = ${bakedHeaders}
326
+ const _requestInterceptor: ((req: Request, command: any, args: Record<string, string>, options: Record<string, any>) => Promise<Request> | Request) | undefined = ${bakedRequestInterceptor}
327
+ const _responseFormatter: ((res: Response) => Promise<string> | string) | undefined = ${bakedResponseFormatter}
328
+ `
329
+ : ''
330
+
331
+ const getBaseUrl = isStandalone
332
+ ? `
333
+ const _getBaseUrl = (): string => {
334
+ if (_baseUrl) return typeof _baseUrl === 'function' ? _baseUrl() : _baseUrl
335
+ return process.env.GCLI_SERVER_URL ?? ''
336
+ }`
337
+ : `
338
+ const _getBaseUrl = (_opts: CLIOptions): string => {
339
+ if (_opts.baseUrl) return typeof _opts.baseUrl === 'function' ? _opts.baseUrl() : _opts.baseUrl
340
+ return process.env.GCLI_SERVER_URL ?? ''
341
+ }`
342
+
343
+ const fetchApiSignature = isStandalone
344
+ ? `const _fetchApi = async (
345
+ method: string,
346
+ path: string,
347
+ header: string[],
348
+ query: string[],
349
+ body: string,
350
+ bodyFile: string,
351
+ routeOpts: Record<string, unknown>,
352
+ command: any,
353
+ cliArgs: Record<string, string>,
354
+ cliOptions: Record<string, any>
355
+ ): Promise<void> => {`
356
+ : `const _makeFetchApi = (_opts: CLIOptions) => async (
357
+ method: string,
358
+ path: string,
359
+ header: string[],
360
+ query: string[],
361
+ body: string,
362
+ bodyFile: string,
363
+ routeOpts: Record<string, unknown>,
364
+ command: CommandInfo,
365
+ cliArgs: Record<string, string>,
366
+ cliOptions: Record<string, any>
367
+ ): Promise<void> => {`
368
+
369
+ const baseUrlCall = isStandalone ? '_getBaseUrl()' : '_getBaseUrl(_opts)'
370
+ const headersRef = isStandalone ? '_defaultHeaders' : '(_opts.headers ?? {})'
371
+ const interceptorRef = isStandalone ? '_requestInterceptor' : '_opts.requestInterceptor'
372
+ const formatterRef = isStandalone ? '_responseFormatter' : '_opts.responseFormatter'
373
+
374
+ return `${bakedConfig}${getBaseUrl}
375
+
376
+ ${fetchApiSignature}
377
+ const fmt = new Set('sbp'.split(''))
378
+ const _toArr = (v: any): string[] => Array.isArray(v) ? v : v ? [String(v)] : []
379
+ const extraHeaders = Object.fromEntries(_toArr(header).map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)] }))
380
+ const queryParams = Object.fromEntries(_toArr(query).map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)] }))
381
+ const queryString = Object.entries({ ...queryParams, ...routeOpts })
382
+ .filter(([_, v]) => v !== undefined && v !== '')
383
+ .map(([k, v]) => \`\${encodeURIComponent(k)}=\${encodeURIComponent(String(v))}\`)
384
+ .join('&')
385
+
386
+ const baseUrl = ${baseUrlCall}
387
+ if (!baseUrl) { console.error('error: Missing GCLI_SERVER_URL env'); process.exit(1) }
388
+
389
+ const url = \`\${baseUrl}\${path}\${queryString ? '?' + queryString : ''}\`
390
+ let bodyData: BodyInit | undefined
391
+ if (bodyFile) bodyData = await Bun.file(bodyFile).arrayBuffer()
392
+ else if (body) bodyData = body
393
+
394
+ let req = new Request(url, {
395
+ method,
396
+ headers: {
397
+ 'user-agent': ${JSON.stringify(nameVersion)},
398
+ ...(${headersRef} ?? {}),
399
+ ...(bodyFile ? { 'content-type': 'application/octet-stream' } : {}),
400
+ ...extraHeaders,
401
+ },
402
+ ...(bodyData !== undefined ? { body: bodyData } : {}),
403
+ })
404
+
405
+ if (${interceptorRef}) req = await ${interceptorRef}(req, command, cliArgs, cliOptions)
406
+
407
+ let _res: Response | undefined
408
+ let _resClone: Response | undefined
409
+ try {
410
+ const t0 = Bun.nanoseconds()
411
+ _res = await fetch(req)
412
+ _resClone = _res.clone()
413
+ const elapsed = (Bun.nanoseconds() - t0) / 1_000_000
414
+
415
+ if (${formatterRef}) {
416
+ Bun.write(Bun.stdout, await ${formatterRef}(_res))
417
+ } else {
418
+ if (fmt.has('s')) Bun.write(Bun.stdout, \`\${_res.status}\\n\`)
419
+ if (fmt.has('h')) Bun.write(Bun.stdout, _fmtObject(_headersToObj(_res.headers), fmt.has('p')) + '\\n')
420
+ if (fmt.has('b')) {
421
+ const ct = _res.headers.get('content-type') ?? ''
422
+ if (ct.includes('application/json')) Bun.write(Bun.stdout, _fmtObject(await _res.json(), fmt.has('p')) + '\\n')
423
+ else if (ct.match(/^text\\//)) Bun.write(Bun.stdout, (await _res.text()) + '\\n')
424
+ else Bun.write(Bun.stdout, new Uint8Array(await _res.arrayBuffer()))
425
+ }
426
+ if (fmt.has('t')) Bun.write(Bun.stdout, \`\${elapsed.toFixed(2)}ms\\n\`)
427
+ }
428
+
429
+ process.exit(_res.ok ? 0 : 1)
430
+ } catch (e: any) {
431
+ if (process.env.GCLI_DEBUG) {
432
+ console.error(\`\\n[debug] \${req.method} \${req.url}\`)
433
+ console.error(\`[debug] request headers: \${JSON.stringify(_headersToObj(req.headers), null, 2)}\`)
434
+ if (_resClone) {
435
+ try { console.error(\`[debug] response \${_resClone.status}: \${await _resClone.text()}\`) } catch {}
436
+ }
437
+ console.error(\`[debug] \${e?.stack ?? e}\`)
438
+ }
439
+ console.error(\`error: \${e?.message ?? e}\`)
440
+ process.exit(1)
441
+ }
442
+ }`
443
+ }
444
+
445
+ function generateSource(
446
+ commands: GalbeCLICommand[],
447
+ mode: 'standalone' | 'module',
448
+ pckg: any,
449
+ options?: GalbeCLIOptions
450
+ ): string {
451
+ const name = pckg?.name || 'galbe-cli'
452
+ const version = pckg?.version || '0.1.0'
453
+ const nameVersion = `${name}/${version}/cli`
454
+
455
+ const bakedBaseUrl = serializeValue(options?.baseUrl)
456
+ const bakedHeaders = serializeValue(options?.headers ?? {})
457
+ const bakedRequestInterceptor = serializeValue(options?.requestInterceptor)
458
+ const bakedResponseFormatter = serializeValue(options?.responseFormatter)
459
+
460
+ const utils = generateUtilities()
461
+ const fetchApi = generateFetchApi(
462
+ nameVersion,
463
+ bakedBaseUrl,
464
+ bakedHeaders,
465
+ bakedRequestInterceptor,
466
+ bakedResponseFormatter,
467
+ mode === 'standalone'
468
+ )
469
+
470
+ const header = `/**
471
+ * This file was auto-generated by \`galbe generate cli\`
472
+ * Source: ${name}@${version}
473
+ */`
474
+
475
+ if (mode === 'standalone') {
476
+ const block = generateStandaloneBlock(commands, name, version)
477
+ return `#!/usr/bin/env bun
478
+ ${header}
479
+
480
+ import { cac } from 'cac'
481
+ ${utils}
482
+ ${fetchApi}
483
+
484
+ const _argv = process.argv.slice(2)
485
+
486
+ ${block}
487
+ `
488
+ } else {
489
+ const cmds = generateModuleCommands(commands, name)
490
+
491
+ const defaultOptsEntries: string[] = []
492
+ if (options?.baseUrl !== undefined) defaultOptsEntries.push(` baseUrl: ${serializeValue(options.baseUrl)}`)
493
+ if (options?.headers !== undefined) defaultOptsEntries.push(` headers: ${serializeValue(options.headers)}`)
494
+ if (options?.requestInterceptor !== undefined)
495
+ defaultOptsEntries.push(` requestInterceptor: ${serializeValue(options.requestInterceptor)}`)
496
+ if (options?.responseFormatter !== undefined)
497
+ defaultOptsEntries.push(` responseFormatter: ${serializeValue(options.responseFormatter)}`)
498
+ const defaultOpts = defaultOptsEntries.length ? `{\n${defaultOptsEntries.join(',\n')}\n}` : '{}'
499
+
500
+ return `${header}
501
+
502
+ import { cac, type CAC } from 'cac'
503
+
504
+ export type CommandInfo = {
505
+ name: string
506
+ tags: string[]
507
+ description?: string
508
+ pathT: string
509
+ arguments?: { name: string; type: string; description: string }[]
510
+ options?: { name: string; short: string; type: string; description: string; default: any }[]
511
+ hideOptions?: string[]
512
+ }
513
+
514
+ export type CLIOptions = {
515
+ baseUrl?: string | (() => string)
516
+ headers?: Record<string, string>
517
+ requestInterceptor?: (req: Request, command: CommandInfo, args: Record<string, string>, options: Record<string, any>) => Promise<Request> | Request
518
+ responseFormatter?: (res: Response) => Promise<string> | string
519
+ }
520
+ ${utils}
521
+ ${fetchApi}
522
+
523
+ const _defaultOptions: CLIOptions = ${defaultOpts}
524
+
525
+ export function register(parent: CAC, options?: CLIOptions): CAC {
526
+ const _opts: CLIOptions = { ..._defaultOptions, ...options }
527
+ const _fetchApi = _makeFetchApi(_opts)
528
+
529
+ ${cmds.split('\n').join('\n ')}
530
+
531
+ return parent
532
+ }
533
+ `
534
+ }
535
+ }