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.
- package/README.md +18 -1
- package/bin/commands/build.ts +12 -6
- package/bin/commands/generate/cli/index.ts +163 -0
- package/bin/commands/generate/cli/targets/cac.ts +660 -0
- package/bin/commands/generate/client.ts +522 -189
- 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/bin/commands/generate/model.ts +2 -2
- package/bin/res/client.runtime.ts +185 -0
- package/bin/res/client.template.ts +1 -1
- 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 +36 -22
- package/src/types.ts +203 -61
- package/src/util.ts +10 -18
- package/src/validator.ts +62 -32
- package/bin/res/cli.template.js +0 -122
|
@@ -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
|
+
}
|