galbe 0.13.1 → 0.15.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,660 @@
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
+ configPath?: string
15
+ }
16
+
17
+ export async function generate(opts: GenerateOptions): Promise<void> {
18
+ if (opts.mode === 'standalone') await buildStandalone(opts)
19
+ else await buildModule(opts)
20
+ }
21
+
22
+ async function buildStandalone(opts: GenerateOptions): Promise<void> {
23
+ // Bundle config as an IIFE to avoid importing the full config module at startup
24
+ // (a direct import would run all module-level side effects before the CLI is set up).
25
+ let inlinedConfigCode: string | undefined
26
+ if (opts.configPath) {
27
+ const tmpDir = await mkdtemp(resolve(tmpdir(), 'galbe-cli-cfg-'))
28
+ try {
29
+ const allExports = [
30
+ '__galbe_baseUrl',
31
+ '__galbe_headers',
32
+ '__galbe_requestInterceptor',
33
+ '__galbe_responseFormatter',
34
+ ]
35
+ const entry = [
36
+ `import { options as __cfg } from ${JSON.stringify(opts.configPath)}`,
37
+ `const __galbe_baseUrl = __cfg?.baseUrl`,
38
+ `const __galbe_headers = __cfg?.headers`,
39
+ `const __galbe_requestInterceptor = __cfg?.requestInterceptor`,
40
+ `const __galbe_responseFormatter = __cfg?.responseFormatter`,
41
+ `export { ${allExports.join(', ')} }`,
42
+ ].join('\n')
43
+ const entryPath = resolve(tmpDir, 'entry.ts')
44
+ const outPath = resolve(tmpDir, 'bundled.js')
45
+ await Bun.write(entryPath, entry)
46
+ await $`bun build --bundle --format esm ${entryPath} --outfile ${outPath}`.quiet()
47
+ const bundled = await Bun.file(outPath).text()
48
+
49
+ const exportMatch = bundled.match(/export\s*\{([^}]+)\}/)
50
+ const codeBody = exportMatch ? bundled.replace(/export\s*\{[^}]+\}\s*;?\s*/g, '').trim() : bundled.trim()
51
+
52
+ let returnExpr = `{ ${allExports.join(', ')} }`
53
+ if (exportMatch) {
54
+ const entries = exportMatch[1]
55
+ .split(',')
56
+ .map(e => {
57
+ const parts = e.trim().split(/\s+as\s+/)
58
+ return { local: parts[0].trim(), exported: (parts[1] ?? parts[0]).trim() }
59
+ })
60
+ .filter(e => allExports.includes(e.exported))
61
+ if (entries.length) {
62
+ const props = entries.map(e => (e.local === e.exported ? e.exported : `${e.exported}: ${e.local}`)).join(', ')
63
+ returnExpr = `{ ${props} }`
64
+ }
65
+ }
66
+
67
+ const indented = codeBody
68
+ .split('\n')
69
+ .map(l => ' ' + l)
70
+ .join('\n')
71
+ inlinedConfigCode = `const { ${allExports.join(', ')} } = (() => {\n${indented}\n return ${returnExpr}\n})()`
72
+ } finally {
73
+ await rm(tmpDir, { recursive: true })
74
+ }
75
+ }
76
+
77
+ const code = generateSource(opts.commands, 'standalone', opts.pckg, opts.options, undefined, inlinedConfigCode)
78
+ const buildDir = await mkdtemp(resolve(tmpdir(), 'galbe-cli-'))
79
+ try {
80
+ await Bun.write(resolve(buildDir, 'package.json'), JSON.stringify({ dependencies: { cac: 'latest' } }))
81
+ await $`bun install --cwd ${buildDir}`.quiet()
82
+ await Bun.write(resolve(buildDir, 'cli.ts'), code)
83
+ const outPath = resolve(CWD, opts.out)
84
+ await $`bun build --compile ${resolve(buildDir, 'cli.ts')} --outfile ${outPath}`.quiet()
85
+ } finally {
86
+ await rm(buildDir, { recursive: true })
87
+ }
88
+ }
89
+
90
+ async function buildModule(opts: GenerateOptions): Promise<void> {
91
+ // For module mode, the output must be self-contained (only cac as external dep).
92
+ // Bundle the config and its transitive deps into an IIFE snippet that is embedded
93
+ // directly in the generated file.
94
+ let inlinedConfigCode: string | undefined
95
+ if (opts.configPath) {
96
+ const tmpDir = await mkdtemp(resolve(tmpdir(), 'galbe-cli-cfg-'))
97
+ try {
98
+ const entry = [
99
+ `import { options as __cfg } from ${JSON.stringify(opts.configPath)}`,
100
+ `const __galbe_requestInterceptor = __cfg?.requestInterceptor`,
101
+ `const __galbe_responseFormatter = __cfg?.responseFormatter`,
102
+ `export { __galbe_requestInterceptor, __galbe_responseFormatter }`,
103
+ ].join('\n')
104
+ const entryPath = resolve(tmpDir, 'entry.ts')
105
+ const outPath = resolve(tmpDir, 'bundled.js')
106
+ await Bun.write(entryPath, entry)
107
+ await $`bun build --bundle --format esm ${entryPath} --outfile ${outPath}`.quiet()
108
+ const bundled = await Bun.file(outPath).text()
109
+
110
+ // Parse "export { localA as exportedA, ... }" and transform to a return expression
111
+ // so that renaming by the bundler doesn't break the IIFE interface.
112
+ const exportMatch = bundled.match(/export\s*\{([^}]+)\}/)
113
+ const codeBody = exportMatch ? bundled.replace(/export\s*\{[^}]+\}\s*;?\s*/g, '').trim() : bundled.trim()
114
+
115
+ let returnExpr = '{ __galbe_requestInterceptor, __galbe_responseFormatter }'
116
+ if (exportMatch) {
117
+ const entries = exportMatch[1]
118
+ .split(',')
119
+ .map(e => {
120
+ const parts = e.trim().split(/\s+as\s+/)
121
+ return { local: parts[0].trim(), exported: (parts[1] ?? parts[0]).trim() }
122
+ })
123
+ .filter(e => e.exported === '__galbe_requestInterceptor' || e.exported === '__galbe_responseFormatter')
124
+ if (entries.length) {
125
+ const props = entries.map(e => (e.local === e.exported ? e.exported : `${e.exported}: ${e.local}`)).join(', ')
126
+ returnExpr = `{ ${props} }`
127
+ }
128
+ }
129
+
130
+ const indented = codeBody
131
+ .split('\n')
132
+ .map(l => ' ' + l)
133
+ .join('\n')
134
+ inlinedConfigCode = `const { __galbe_requestInterceptor, __galbe_responseFormatter } = (() => {\n${indented}\n return ${returnExpr}\n})()`
135
+ } finally {
136
+ await rm(tmpDir, { recursive: true })
137
+ }
138
+ }
139
+
140
+ const code = generateSource(opts.commands, 'module', opts.pckg, opts.options, undefined, inlinedConfigCode)
141
+ await Bun.write(resolve(CWD, opts.out), code)
142
+ }
143
+
144
+ function serializeValue(value: any): string {
145
+ if (value === undefined) return 'undefined'
146
+ if (value === null) return 'null'
147
+ if (typeof value === 'string') return JSON.stringify(value)
148
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
149
+ if (typeof value === 'function') return value.toString()
150
+ if (Array.isArray(value)) return `[${value.map(serializeValue).join(', ')}]`
151
+ if (typeof value === 'object')
152
+ return `{ ${Object.entries(value)
153
+ .map(([k, v]) => `${JSON.stringify(k)}: ${serializeValue(v)}`)
154
+ .join(', ')} }`
155
+ return 'undefined'
156
+ }
157
+
158
+ // cac camelCases hyphenated option names when accessing via options object
159
+ function toCamelCase(name: string): string {
160
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
161
+ }
162
+
163
+ // Strip characters that are invalid in JS identifiers / cac option names
164
+ function sanitizeOptionName(name: string): string {
165
+ return name.replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+/, '') || 'opt'
166
+ }
167
+
168
+ function generateSingleCommand(c: GalbeCLICommand, cliVar: string, includeTag: boolean): string {
169
+ const builtinShorts = new Set(['H', 'Q', 'b', 'B'])
170
+ const hide = new Set(c.hideOptions ?? [])
171
+ const tagPrefix = includeTag && c.tags.length > 0 ? c.tags.map(t => t.toLowerCase()).join(' ') + ' ' : ''
172
+ const pathArgs = (c.arguments || []).map(a => ` ${a.type.replace(/\w+/, a.name)}`).join('')
173
+ const cmdStr = `${tagPrefix}${c.name}${pathArgs}`
174
+ const desc = JSON.stringify(c.description || '')
175
+
176
+ const routeOptsLines = (c.options || [])
177
+ .map(o => {
178
+ const cleanName = sanitizeOptionName(o.name)
179
+ const short = o.short && !builtinShorts.has(o.short) ? `-${o.short}, ` : ''
180
+ const optType = o.type || '[string]'
181
+ const defVal = o.default !== undefined ? `, { default: ${serializeValue(o.default)} }` : ''
182
+ return ` .option('${short}--${cleanName} ${optType}', ${JSON.stringify(o.description || o.name)}${defVal})`
183
+ })
184
+ .join('\n')
185
+
186
+ const actionParams = (c.arguments || []).length
187
+ ? (c.arguments || []).map(a => a.name).join(', ') + ', options'
188
+ : 'options'
189
+
190
+ const routeOptsObj = (c.options || []).length
191
+ ? `{ ${(c.options || []).map(o => `${JSON.stringify(o.name)}: (options as any).${toCamelCase(sanitizeOptionName(o.name))}`).join(', ')} }`
192
+ : '{}'
193
+
194
+ const pathLiteral = '`' + c.pathT.replace(/`/g, '\\`') + '`'
195
+ const method = c.route.method.toUpperCase()
196
+ const customAction = c.action ? `await (${c.action.toString()})(options as any)\n ` : ''
197
+
198
+ const argsObj = (c.arguments || []).length ? `{ ${(c.arguments || []).map(a => a.name).join(', ')} }` : '{}'
199
+
200
+ const commandMeta: Record<string, any> = { name: c.name, tags: c.tags }
201
+ if (c.description !== undefined) commandMeta.description = c.description
202
+ commandMeta.pathT = c.pathT
203
+ if (c.arguments !== undefined) commandMeta.arguments = c.arguments
204
+ if (c.options !== undefined) commandMeta.options = c.options
205
+ if (c.hideOptions !== undefined) commandMeta.hideOptions = c.hideOptions
206
+ const commandLiteral = serializeValue(commandMeta)
207
+
208
+ const builtinOptLines = [
209
+ !hide.has('header') && ` .option('-H, --header [string...]', 'request header as name=value', { default: [] })`,
210
+ !hide.has('query') && ` .option('-Q, --query [string...]', 'query param as name=value', { default: [] })`,
211
+ !hide.has('body') && ` .option('-b, --body [string]', 'request body')`,
212
+ !hide.has('body-file') && ` .option('-B, --body-file [string]', 'request body file path')`,
213
+ ]
214
+ .filter(Boolean)
215
+ .join('\n')
216
+
217
+ return `${cliVar}.command(${JSON.stringify(cmdStr)}, ${desc})
218
+ ${builtinOptLines}
219
+ ${routeOptsLines}
220
+ .action(async (${actionParams}) => {
221
+ const { header, query, body, bodyFile } = options as any
222
+ const _args = ${argsObj}
223
+ ${customAction}return _fetchApi(${JSON.stringify(method)}, ${pathLiteral}, header ?? [], query ?? [], body ?? '', bodyFile ?? '', ${routeOptsObj}, ${commandLiteral}, _args, options as any)
224
+ })`
225
+ }
226
+
227
+ type CommandNode = {
228
+ subgroups: Map<string, CommandNode>
229
+ commands: GalbeCLICommand[]
230
+ }
231
+
232
+ function buildCommandTree(commands: GalbeCLICommand[]): CommandNode {
233
+ const root: CommandNode = { subgroups: new Map(), commands: [] }
234
+ for (const cmd of commands) {
235
+ let node = root
236
+ for (const tag of cmd.tags) {
237
+ const t = tag.toLowerCase()
238
+ if (!node.subgroups.has(t)) node.subgroups.set(t, { subgroups: new Map(), commands: [] })
239
+ node = node.subgroups.get(t)!
240
+ }
241
+ node.commands.push(cmd)
242
+ }
243
+ return root
244
+ }
245
+
246
+ function nodeToBlock(node: CommandNode, tagPath: string[], appName: string, version: string): string {
247
+ const depth = tagPath.length
248
+ const safeId = (t: string) => t.replace(/[^a-zA-Z0-9]/g, '_')
249
+ const cliVar = depth === 0 ? '_cli' : `_sub_${tagPath.map(safeId).join('_')}`
250
+ const knownVar = depth === 0 ? '_known' : `_known_${tagPath.map(safeId).join('_')}`
251
+ const cliName = [appName, ...tagPath].join(' ')
252
+ const argvExpr = `_argv[${depth}]`
253
+
254
+ const subGroupEntries = [...node.subgroups.entries()]
255
+
256
+ const subBlocks = subGroupEntries.map(([tag, subNode], i) => {
257
+ const sub = nodeToBlock(subNode, [...tagPath, tag], appName, version)
258
+ const indented = sub
259
+ .split('\n')
260
+ .map(l => ' ' + l)
261
+ .join('\n')
262
+ return `${i === 0 ? 'if' : 'else if'} (${argvExpr} === ${JSON.stringify(tag)}) {\n${indented}\n}`
263
+ })
264
+
265
+ const groupListings = subGroupEntries
266
+ .map(([tag]) => `${cliVar}.command(${JSON.stringify(tag)}, ${JSON.stringify(tag + ' commands')})`)
267
+ .join('\n')
268
+
269
+ const commandBlocks = node.commands.map(c => generateSingleCommand(c, cliVar, false)).join('\n\n')
270
+
271
+ const versionLine = depth === 0 ? `\n${cliVar}.version(${JSON.stringify(version)})` : ''
272
+ const parseArg = depth > 0 ? `['', '', ..._argv.slice(${depth})]` : ''
273
+
274
+ const cliBlockLines = [
275
+ `const ${cliVar} = cac(${JSON.stringify(cliName)})${versionLine}`,
276
+ groupListings,
277
+ commandBlocks,
278
+ `${cliVar}.help()`,
279
+ `const ${knownVar} = new Set(${cliVar}.commands.map((c: any) => c.name))`,
280
+ `if (!${argvExpr} || (!${argvExpr}.startsWith('-') && !${knownVar}.has(${argvExpr}))) {`,
281
+ ` ${cliVar}.outputHelp()`,
282
+ ` process.exit(${argvExpr} ? 1 : 0)`,
283
+ `}`,
284
+ `try { ${cliVar}.parse(${parseArg}) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }`,
285
+ ]
286
+ .filter(Boolean)
287
+ .join('\n')
288
+
289
+ if (subBlocks.length > 0) {
290
+ const indentedCli = cliBlockLines
291
+ .split('\n')
292
+ .map(l => ' ' + l)
293
+ .join('\n')
294
+ return `${subBlocks.join('\n')}\nelse {\n${indentedCli}\n}`
295
+ }
296
+ return cliBlockLines
297
+ }
298
+
299
+ function generateStandaloneBlock(commands: GalbeCLICommand[], name: string, version: string): string {
300
+ return nodeToBlock(buildCommandTree(commands), [], name, version)
301
+ }
302
+
303
+ function generateModuleCommands(commands: GalbeCLICommand[], appName: string): string {
304
+ const tree = buildCommandTree(commands)
305
+ const safeId = (t: string) => t.replace(/[^a-zA-Z0-9]/g, '_')
306
+ const lines: string[] = []
307
+
308
+ function genSubCacSetup(node: CommandNode, tagPath: string[]): string {
309
+ const subVar = `_sub_${tagPath.map(safeId).join('_')}`
310
+ const cliName = [appName, ...tagPath].join(' ')
311
+ const nodeLines: string[] = []
312
+
313
+ nodeLines.push(`const ${subVar} = cac(${JSON.stringify(cliName)})`)
314
+ for (const cmd of node.commands) nodeLines.push(generateSingleCommand(cmd, subVar, false))
315
+
316
+ for (const [tag, subNode] of node.subgroups.entries()) {
317
+ const subTagPath = [...tagPath, tag]
318
+ const subSubVar = `_sub_${subTagPath.map(safeId).join('_')}`
319
+ const grpVar = `_grp_${subTagPath.map(safeId).join('_')}`
320
+ const knownVar = `_known_${subTagPath.map(safeId).join('_')}`
321
+ nodeLines.push(genSubCacSetup(subNode, subTagPath))
322
+ nodeLines.push(
323
+ `const ${grpVar} = ${subVar}.command(${JSON.stringify(tag + ' [..._sub]')}, ${JSON.stringify(tag + ' commands')})` +
324
+ `\n .allowUnknownOptions()` +
325
+ `\n .action(() => {` +
326
+ `\n const _rawArgv = process.argv.slice(2)` +
327
+ `\n const _tagIdx = _rawArgv.findIndex((a: string) => a === ${JSON.stringify(tag)})` +
328
+ `\n const _subArgv = _tagIdx >= 0 ? _rawArgv.slice(_tagIdx + 1) : []` +
329
+ `\n const ${knownVar} = new Set(${subSubVar}.commands.map((c: any) => c.name))` +
330
+ `\n if (!_subArgv[0] || (!_subArgv[0].startsWith('-') && !${knownVar}.has(_subArgv[0]))) {` +
331
+ `\n ${subSubVar}.outputHelp()` +
332
+ `\n process.exit(_subArgv[0] ? 1 : 0)` +
333
+ `\n }` +
334
+ `\n try { ${subSubVar}.parse(['', '', ..._subArgv]) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }` +
335
+ `\n })` +
336
+ `\n;(${grpVar} as any).rawName = ${JSON.stringify(tag)}` +
337
+ `\n;(${grpVar} as any).outputHelp = () => ${subSubVar}.outputHelp()`
338
+ )
339
+ }
340
+
341
+ nodeLines.push(`${subVar}.help()`)
342
+ return nodeLines.join('\n')
343
+ }
344
+
345
+ for (const [tag, subNode] of tree.subgroups.entries()) {
346
+ const subVar = `_sub_${safeId(tag)}`
347
+ const grpVar = `_grp_${safeId(tag)}`
348
+ const knownVar = `_known_${safeId(tag)}`
349
+ lines.push(genSubCacSetup(subNode, [tag]))
350
+ lines.push(
351
+ `const ${grpVar} = parent.command(${JSON.stringify(tag + ' [..._sub]')}, ${JSON.stringify(tag + ' commands')})` +
352
+ `\n .allowUnknownOptions()` +
353
+ `\n .action(() => {` +
354
+ `\n const _rawArgv = process.argv.slice(2)` +
355
+ `\n const _tagIdx = _rawArgv.findIndex((a: string) => a === ${JSON.stringify(tag)})` +
356
+ `\n const _subArgv = _tagIdx >= 0 ? _rawArgv.slice(_tagIdx + 1) : []` +
357
+ `\n const ${knownVar} = new Set(${subVar}.commands.map((c: any) => c.name))` +
358
+ `\n if (!_subArgv[0] || (!_subArgv[0].startsWith('-') && !${knownVar}.has(_subArgv[0]))) {` +
359
+ `\n ${subVar}.outputHelp()` +
360
+ `\n process.exit(_subArgv[0] ? 1 : 0)` +
361
+ `\n }` +
362
+ `\n try { ${subVar}.parse(['', '', ..._subArgv]) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }` +
363
+ `\n })` +
364
+ `\n;(${grpVar} as any).rawName = ${JSON.stringify(tag)}` +
365
+ `\n;(${grpVar} as any).outputHelp = () => ${subVar}.outputHelp()`
366
+ )
367
+ }
368
+
369
+ for (const cmd of tree.commands) lines.push(generateSingleCommand(cmd, 'parent', false))
370
+
371
+ lines.push(
372
+ `const _parseOrig = (parent as any).parse.bind(parent)` +
373
+ `\n;(parent as any).parse = (argv?: string[]) => {` +
374
+ `\n const _a: string[] = argv ?? process.argv` +
375
+ `\n const _positionals = _a.slice(2).filter((x: string) => !x.startsWith('-'))` +
376
+ `\n if (_positionals.length === 0 && !_a.slice(2).includes('--help') && !_a.slice(2).includes('-h')) {` +
377
+ `\n parent.outputHelp()` +
378
+ `\n process.exit(0)` +
379
+ `\n }` +
380
+ `\n let _r: any` +
381
+ `\n try { _r = _parseOrig(argv) } catch (e: any) { console.error(\`error: \${e.message ?? e}\`); process.exit(1) }` +
382
+ `\n if (!(parent as any).matchedCommand && _positionals.length > 0 && !_a.slice(2).includes('--help') && !_a.slice(2).includes('-h')) {` +
383
+ `\n console.error(\`error: Unknown command "\${_positionals[0]}"\`)` +
384
+ `\n process.exit(1)` +
385
+ `\n }` +
386
+ `\n return _r` +
387
+ `\n}`
388
+ )
389
+
390
+ return lines.filter(Boolean).join('\n\n')
391
+ }
392
+
393
+ function generateUtilities(): string {
394
+ return `
395
+ const _ansi = (p: boolean, c: string, str: unknown): string =>
396
+ p ? \`\\x1b[\${c}m\${str}\\x1b[0m\` : String(str)
397
+
398
+ const _fmtObject = (o: unknown, p = false, idt = 2, iidt = 0): string => {
399
+ const _ = ' '.repeat(iidt)
400
+ const __ = ' '.repeat(iidt + idt)
401
+ const lr = idt === 0 ? '' : '\\n'
402
+ if (o === null) return _ansi(p, '38;2;255;128;0', 'null')
403
+ if (typeof o === 'boolean') return _ansi(p, '38;2;255;128;0', String(o))
404
+ if (typeof o === 'number') return _ansi(p, '38;2;10;180;220', String(o))
405
+ if (typeof o === 'string') return _ansi(p, '38;2;125;170;0', \`"\${o}"\`)
406
+ if (Array.isArray(o))
407
+ return \`[\${lr}\${o.map(e => \`\${__}\${_fmtObject(e, p, idt, iidt + idt)}\`).join(\`,\${lr}\`)}\${lr}\${_}]\`
408
+ if (typeof o === 'object' && o !== null)
409
+ return \`{\${lr}\${Object.entries(o as object)
410
+ .map(([k, v]) => v === undefined ? '' : \`\${__}\${_ansi(p, '38;2;170;120;200', \`"\${k}"\`)}: \${_fmtObject(v, p, idt, iidt + idt)}\`)
411
+ .filter(Boolean)
412
+ .join(\`,\${lr}\`)}\${lr}\${_}}\`
413
+ return String(o)
414
+ }
415
+
416
+ const _headersToObj = (h: Headers): Record<string, string> => {
417
+ const r: Record<string, string> = {}
418
+ h.forEach((v, k) => { r[k] = v })
419
+ return r
420
+ }`
421
+ }
422
+
423
+ function generateFetchApi(
424
+ nameVersion: string,
425
+ bakedBaseUrl: string,
426
+ bakedHeaders: string,
427
+ bakedRequestInterceptor: string,
428
+ bakedResponseFormatter: string,
429
+ isStandalone: boolean
430
+ ): string {
431
+ const bakedConfig = isStandalone
432
+ ? `
433
+ const _baseUrl: string | (() => string) | undefined = ${bakedBaseUrl}
434
+ const _defaultHeaders: Record<string, string> = ${bakedHeaders}
435
+ const _requestInterceptor: ((req: Request, command: any, args: Record<string, string>, options: Record<string, any>) => Promise<Request> | Request) | undefined = ${bakedRequestInterceptor}
436
+ const _responseFormatter: ((res: Response) => Promise<string> | string) | undefined = ${bakedResponseFormatter}
437
+ `
438
+ : ''
439
+
440
+ const getBaseUrl = isStandalone
441
+ ? `
442
+ const _getBaseUrl = (): string => {
443
+ if (_baseUrl) return typeof _baseUrl === 'function' ? _baseUrl() : _baseUrl
444
+ return process.env.GCLI_SERVER_URL ?? ''
445
+ }`
446
+ : `
447
+ const _getBaseUrl = (_opts: CLIOptions): string => {
448
+ if (_opts.baseUrl) return typeof _opts.baseUrl === 'function' ? _opts.baseUrl() : _opts.baseUrl
449
+ return process.env.GCLI_SERVER_URL ?? ''
450
+ }`
451
+
452
+ const fetchApiSignature = isStandalone
453
+ ? `const _fetchApi = async (
454
+ method: string,
455
+ path: string,
456
+ header: string[],
457
+ query: string[],
458
+ body: string,
459
+ bodyFile: string,
460
+ routeOpts: Record<string, unknown>,
461
+ command: any,
462
+ cliArgs: Record<string, string>,
463
+ cliOptions: Record<string, any>
464
+ ): Promise<void> => {`
465
+ : `const _makeFetchApi = (_opts: CLIOptions) => async (
466
+ method: string,
467
+ path: string,
468
+ header: string[],
469
+ query: string[],
470
+ body: string,
471
+ bodyFile: string,
472
+ routeOpts: Record<string, unknown>,
473
+ command: CommandInfo,
474
+ cliArgs: Record<string, string>,
475
+ cliOptions: Record<string, any>
476
+ ): Promise<void> => {`
477
+
478
+ const baseUrlCall = isStandalone ? '_getBaseUrl()' : '_getBaseUrl(_opts)'
479
+ const headersRef = isStandalone ? '_defaultHeaders' : '(_opts.headers ?? {})'
480
+ const interceptorRef = isStandalone ? '_requestInterceptor' : '_opts.requestInterceptor'
481
+ const formatterRef = isStandalone ? '_responseFormatter' : '_opts.responseFormatter'
482
+
483
+ return `${bakedConfig}${getBaseUrl}
484
+
485
+ ${fetchApiSignature}
486
+ const fmt = new Set('sbp'.split(''))
487
+ const _toArr = (v: any): string[] => Array.isArray(v) ? v : v ? [String(v)] : []
488
+ const extraHeaders = Object.fromEntries(_toArr(header).map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)] }))
489
+ const queryParams = Object.fromEntries(_toArr(query).map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)] }))
490
+ const queryString = Object.entries({ ...queryParams, ...routeOpts })
491
+ .filter(([_, v]) => v !== undefined && v !== '')
492
+ .map(([k, v]) => \`\${encodeURIComponent(k)}=\${encodeURIComponent(String(v))}\`)
493
+ .join('&')
494
+
495
+ const baseUrl = ${baseUrlCall}
496
+ if (!baseUrl) { console.error('error: Missing GCLI_SERVER_URL env'); process.exit(1) }
497
+
498
+ const url = \`\${baseUrl}\${path}\${queryString ? '?' + queryString : ''}\`
499
+ let bodyData: BodyInit | undefined
500
+ if (bodyFile) bodyData = await Bun.file(bodyFile).arrayBuffer()
501
+ else if (body) bodyData = body
502
+
503
+ let req = new Request(url, {
504
+ method,
505
+ headers: {
506
+ 'user-agent': ${JSON.stringify(nameVersion)},
507
+ ...(${headersRef} ?? {}),
508
+ ...(bodyFile ? { 'content-type': 'application/octet-stream' } : {}),
509
+ ...extraHeaders,
510
+ },
511
+ ...(bodyData !== undefined ? { body: bodyData } : {}),
512
+ })
513
+
514
+ if (${interceptorRef}) req = await ${interceptorRef}(req, command, cliArgs, cliOptions)
515
+
516
+ let _res: Response | undefined
517
+ let _resClone: Response | undefined
518
+ try {
519
+ const t0 = Bun.nanoseconds()
520
+ _res = await fetch(req)
521
+ _resClone = _res.clone()
522
+ const elapsed = (Bun.nanoseconds() - t0) / 1_000_000
523
+
524
+ if (${formatterRef}) {
525
+ Bun.write(Bun.stdout, await ${formatterRef}(_res, command, cliArgs, cliOptions))
526
+ } else {
527
+ if (fmt.has('s')) Bun.write(Bun.stdout, \`\${_res.status}\\n\`)
528
+ if (fmt.has('h')) Bun.write(Bun.stdout, _fmtObject(_headersToObj(_res.headers), fmt.has('p')) + '\\n')
529
+ if (fmt.has('b')) {
530
+ const ct = _res.headers.get('content-type') ?? ''
531
+ if (ct.includes('application/json')) Bun.write(Bun.stdout, _fmtObject(await _res.json(), fmt.has('p')) + '\\n')
532
+ else if (ct.match(/^text\\//)) Bun.write(Bun.stdout, (await _res.text()) + '\\n')
533
+ else Bun.write(Bun.stdout, new Uint8Array(await _res.arrayBuffer()))
534
+ }
535
+ if (fmt.has('t')) Bun.write(Bun.stdout, \`\${elapsed.toFixed(2)}ms\\n\`)
536
+ }
537
+
538
+ process.exit(_res.ok ? 0 : 1)
539
+ } catch (e: any) {
540
+ if (process.env.GCLI_DEBUG) {
541
+ console.error(\`\\n[debug] \${req.method} \${req.url}\`)
542
+ console.error(\`[debug] request headers: \${JSON.stringify(_headersToObj(req.headers), null, 2)}\`)
543
+ if (_resClone) {
544
+ try { console.error(\`[debug] response \${_resClone.status}: \${await _resClone.text()}\`) } catch {}
545
+ }
546
+ console.error(\`[debug] \${e?.stack ?? e}\`)
547
+ }
548
+ console.error(\`error: \${e?.message ?? e}\`)
549
+ process.exit(1)
550
+ }
551
+ }`
552
+ }
553
+
554
+ function generateSource(
555
+ commands: GalbeCLICommand[],
556
+ mode: 'standalone' | 'module',
557
+ pckg: any,
558
+ options?: GalbeCLIOptions,
559
+ _unused?: undefined,
560
+ // pre-bundled IIFE snippet; standalone exports all 4 config values, module exports only functions
561
+ inlinedConfigCode?: string
562
+ ): string {
563
+ const name = pckg?.name || 'galbe-cli'
564
+ const version = pckg?.version || '0.1.0'
565
+ const nameVersion = `${name}/${version}/cli`
566
+
567
+ // Standalone with config: all values come from the IIFE (avoids module-level side effects).
568
+ // Module with config: scalars serialized from options; functions come from the IIFE.
569
+ const standaloneInlined = mode === 'standalone' && !!inlinedConfigCode
570
+ const bakedBaseUrl = standaloneInlined ? '__galbe_baseUrl' : serializeValue(options?.baseUrl)
571
+ const bakedHeaders = standaloneInlined ? '__galbe_headers ?? {}' : serializeValue(options?.headers ?? {})
572
+ const bakedRequestInterceptor = !!inlinedConfigCode
573
+ ? '__galbe_requestInterceptor'
574
+ : serializeValue(options?.requestInterceptor)
575
+ const bakedResponseFormatter = !!inlinedConfigCode
576
+ ? '__galbe_responseFormatter'
577
+ : serializeValue(options?.responseFormatter)
578
+
579
+ const utils = generateUtilities()
580
+ const fetchApi = generateFetchApi(
581
+ nameVersion,
582
+ bakedBaseUrl,
583
+ bakedHeaders,
584
+ bakedRequestInterceptor,
585
+ bakedResponseFormatter,
586
+ mode === 'standalone'
587
+ )
588
+
589
+ const header = `/**
590
+ * This file was auto-generated by \`galbe generate cli\`
591
+ * Source: ${name}@${version}
592
+ */`
593
+
594
+ if (mode === 'standalone') {
595
+ const block = generateStandaloneBlock(commands, name, version)
596
+ return `#!/usr/bin/env bun
597
+ ${header}
598
+
599
+ import { cac } from 'cac'
600
+ ${inlinedConfigCode ? '\n' + inlinedConfigCode + '\n' : ''}${utils}
601
+ ${fetchApi}
602
+
603
+ const _argv = process.argv.slice(2)
604
+
605
+ ${block}
606
+ `
607
+ } else {
608
+ const cmds = generateModuleCommands(commands, name)
609
+
610
+ const defaultOptsEntries: string[] = []
611
+ if (options?.baseUrl !== undefined) defaultOptsEntries.push(` baseUrl: ${serializeValue(options.baseUrl)}`)
612
+ if (options?.headers !== undefined) defaultOptsEntries.push(` headers: ${serializeValue(options.headers)}`)
613
+ if (inlinedConfigCode) {
614
+ // Functions are defined by the inlined IIFE; always include them (undefined = no-op)
615
+ defaultOptsEntries.push(` requestInterceptor: __galbe_requestInterceptor as CLIOptions['requestInterceptor']`)
616
+ defaultOptsEntries.push(` responseFormatter: __galbe_responseFormatter as CLIOptions['responseFormatter']`)
617
+ } else {
618
+ if (options?.requestInterceptor !== undefined)
619
+ defaultOptsEntries.push(` requestInterceptor: ${serializeValue(options.requestInterceptor)}`)
620
+ if (options?.responseFormatter !== undefined)
621
+ defaultOptsEntries.push(` responseFormatter: ${serializeValue(options.responseFormatter)}`)
622
+ }
623
+ const defaultOpts = defaultOptsEntries.length ? `{\n${defaultOptsEntries.join(',\n')}\n}` : '{}'
624
+
625
+ return `${header}
626
+
627
+ import { cac, type CAC } from 'cac'
628
+
629
+ export type CommandInfo = {
630
+ name: string
631
+ tags: string[]
632
+ description?: string
633
+ pathT: string
634
+ arguments?: { name: string; type: string; description: string }[]
635
+ options?: { name: string; short: string; type: string; description: string; default: any }[]
636
+ hideOptions?: string[]
637
+ }
638
+
639
+ export type CLIOptions = {
640
+ baseUrl?: string | (() => string)
641
+ headers?: Record<string, string>
642
+ requestInterceptor?: (req: Request, command: CommandInfo, args: Record<string, string>, options: Record<string, any>) => Promise<Request> | Request
643
+ responseFormatter?: (res: Response, command: CommandInfo, args: Record<string, string>, options: Record<string, any>) => Promise<string> | string
644
+ }
645
+ ${inlinedConfigCode ? '\n' + inlinedConfigCode + '\n' : ''}${utils}
646
+ ${fetchApi}
647
+
648
+ const _defaultOptions: CLIOptions = ${defaultOpts}
649
+
650
+ export function register(parent: CAC, options?: CLIOptions): CAC {
651
+ const _opts: CLIOptions = { ..._defaultOptions, ...options }
652
+ const _fetchApi = _makeFetchApi(_opts)
653
+
654
+ ${cmds.split('\n').join('\n ')}
655
+
656
+ return parent
657
+ }
658
+ `
659
+ }
660
+ }