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.
- package/README.md +18 -1
- package/bin/commands/build.ts +10 -4
- package/bin/commands/generate/cli/index.ts +156 -0
- package/bin/commands/generate/cli/targets/cac.ts +535 -0
- package/bin/commands/generate/client.ts +32 -109
- package/bin/commands/generate/code/openapi.parser.ts +428 -133
- package/bin/commands/generate/code/route-merge.ts +248 -0
- package/bin/commands/generate/code.ts +110 -23
- package/bin/commands/generate/index.ts +2 -0
- package/package.json +4 -1
- package/src/cookies.ts +87 -0
- package/src/extras/spec/openapi.serializer.ts +236 -101
- package/src/extras.ts +1 -0
- package/src/index.ts +4 -7
- package/src/parser.ts +88 -65
- package/src/router.ts +34 -20
- package/src/routes.ts +14 -10
- package/src/schema.ts +82 -95
- package/src/server.ts +35 -21
- package/src/types.ts +179 -61
- package/src/util.ts +10 -18
- package/src/validator.ts +62 -32
- package/bin/res/cli.template.js +0 -122
|
@@ -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
|
+
}
|