@foundation0/api 1.1.3 → 1.1.5
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/libs/curl.test.ts +130 -0
- package/libs/curl.ts +770 -0
- package/mcp/manual.md +150 -0
- package/mcp/server.test.ts +177 -3
- package/mcp/server.ts +173 -85
- package/net.ts +170 -0
- package/package.json +4 -2
package/libs/curl.ts
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type CurlToolOptions = {
|
|
5
|
+
cwd?: string
|
|
6
|
+
env?: Record<string, string>
|
|
7
|
+
timeoutMs?: number
|
|
8
|
+
stdin?: string | Uint8Array
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CurlPerRequestResult = {
|
|
12
|
+
url: string
|
|
13
|
+
urlEffective: string
|
|
14
|
+
redirectCount: number
|
|
15
|
+
httpCode: number
|
|
16
|
+
headers: Record<string, string>
|
|
17
|
+
stdout: string
|
|
18
|
+
stdoutBase64: string
|
|
19
|
+
stderr: string
|
|
20
|
+
exitCode: number
|
|
21
|
+
timedOut: boolean
|
|
22
|
+
durationMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CurlResult = {
|
|
26
|
+
args: string[]
|
|
27
|
+
exitCode: number
|
|
28
|
+
stdout: string
|
|
29
|
+
stderr: string
|
|
30
|
+
stdoutBase64: string
|
|
31
|
+
stderrBase64: string
|
|
32
|
+
timedOut: boolean
|
|
33
|
+
results?: CurlPerRequestResult[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
37
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
38
|
+
|
|
39
|
+
const parseToolOptions = (value: unknown): CurlToolOptions => {
|
|
40
|
+
if (!isRecord(value)) return {}
|
|
41
|
+
|
|
42
|
+
const env: Record<string, string> | undefined = (() => {
|
|
43
|
+
if (!isRecord(value.env)) return undefined
|
|
44
|
+
const out: Record<string, string> = {}
|
|
45
|
+
for (const [key, entry] of Object.entries(value.env)) {
|
|
46
|
+
if (typeof key !== 'string' || key.trim().length === 0) continue
|
|
47
|
+
if (entry === undefined || entry === null) continue
|
|
48
|
+
out[key] = String(entry)
|
|
49
|
+
}
|
|
50
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
51
|
+
})()
|
|
52
|
+
|
|
53
|
+
const timeoutMs = typeof value.timeoutMs === 'number' && Number.isFinite(value.timeoutMs) && value.timeoutMs > 0
|
|
54
|
+
? value.timeoutMs
|
|
55
|
+
: undefined
|
|
56
|
+
const cwd = typeof value.cwd === 'string' && value.cwd.trim().length > 0 ? value.cwd.trim() : undefined
|
|
57
|
+
|
|
58
|
+
const stdin = (() => {
|
|
59
|
+
const raw = value.stdin
|
|
60
|
+
if (typeof raw === 'string') return raw
|
|
61
|
+
if (raw instanceof Uint8Array) return raw
|
|
62
|
+
return undefined
|
|
63
|
+
})()
|
|
64
|
+
|
|
65
|
+
return { cwd, env, timeoutMs, stdin }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type CurlConfig = {
|
|
69
|
+
method: string | null
|
|
70
|
+
headers: Headers
|
|
71
|
+
dataParts: Array<{ mode: 'raw' | 'binary' | 'urlencode'; value: string }>
|
|
72
|
+
formParts: Array<{ name: string; value: string } | { name: string; filePath: string }>
|
|
73
|
+
followRedirects: boolean
|
|
74
|
+
maxRedirects: number
|
|
75
|
+
includeHeaders: boolean
|
|
76
|
+
headOnly: boolean
|
|
77
|
+
outputPath: string | null
|
|
78
|
+
remoteName: boolean
|
|
79
|
+
silent: boolean
|
|
80
|
+
showError: boolean
|
|
81
|
+
verbose: boolean
|
|
82
|
+
fail: boolean
|
|
83
|
+
getWithData: boolean
|
|
84
|
+
user: { username: string; password: string } | null
|
|
85
|
+
writeOut: string | null
|
|
86
|
+
timeoutMs: number | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const defaultConfig = (): CurlConfig => ({
|
|
90
|
+
method: null,
|
|
91
|
+
headers: new Headers(),
|
|
92
|
+
dataParts: [],
|
|
93
|
+
formParts: [],
|
|
94
|
+
followRedirects: false,
|
|
95
|
+
maxRedirects: 50,
|
|
96
|
+
includeHeaders: false,
|
|
97
|
+
headOnly: false,
|
|
98
|
+
outputPath: null,
|
|
99
|
+
remoteName: false,
|
|
100
|
+
silent: false,
|
|
101
|
+
showError: false,
|
|
102
|
+
verbose: false,
|
|
103
|
+
fail: false,
|
|
104
|
+
getWithData: false,
|
|
105
|
+
user: null,
|
|
106
|
+
writeOut: null,
|
|
107
|
+
timeoutMs: null,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const isOptionLike = (token: string): boolean => token.startsWith('-') && token.length > 1
|
|
111
|
+
|
|
112
|
+
const splitShortCluster = (token: string): string[] => {
|
|
113
|
+
if (!token.startsWith('-') || token.startsWith('--') || token.length <= 2) return [token]
|
|
114
|
+
const flags = token.slice(1).split('')
|
|
115
|
+
return flags.map((flag) => `-${flag}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const looksLikeUrl = (token: string): boolean => {
|
|
119
|
+
if (!token) return false
|
|
120
|
+
if (token.startsWith('http://') || token.startsWith('https://') || token.startsWith('file://')) return true
|
|
121
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(token)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const normalizeUrlToken = (token: string): string => {
|
|
125
|
+
if (looksLikeUrl(token)) return token
|
|
126
|
+
|
|
127
|
+
// curl accepts scheme-less URLs like "example.com" and defaults to http://.
|
|
128
|
+
const trimmed = token.trim()
|
|
129
|
+
const looksLikeHost = trimmed === 'localhost'
|
|
130
|
+
|| /^[0-9.]+(?::\d+)?(\/|$)/.test(trimmed)
|
|
131
|
+
|| /^[a-zA-Z0-9.-]+\.[a-zA-Z0-9.-]+(?::\d+)?(\/|$)/.test(trimmed)
|
|
132
|
+
|| /^[a-zA-Z0-9.-]+(?::\d+)(\/|$)/.test(trimmed)
|
|
133
|
+
|
|
134
|
+
if (looksLikeHost) return `http://${trimmed}`
|
|
135
|
+
return token
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const resolveOutputPathForUrl = (urlText: string, cwd: string | undefined): string => {
|
|
139
|
+
const parsed = new URL(urlText)
|
|
140
|
+
const base = parsed.pathname.split('/').filter(Boolean).pop() ?? 'index.html'
|
|
141
|
+
return cwd ? path.resolve(cwd, base) : base
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const readFileAsText = async (filePath: string): Promise<string> => {
|
|
145
|
+
const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer())
|
|
146
|
+
return Buffer.from(bytes).toString('utf8')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const readFileAsBytes = async (filePath: string): Promise<Uint8Array> =>
|
|
150
|
+
new Uint8Array(await Bun.file(filePath).arrayBuffer())
|
|
151
|
+
|
|
152
|
+
const encodeUrlDataPart = (value: string): string => {
|
|
153
|
+
const index = value.indexOf('=')
|
|
154
|
+
if (index <= 0) return encodeURIComponent(value)
|
|
155
|
+
const name = value.slice(0, index)
|
|
156
|
+
const content = value.slice(index + 1)
|
|
157
|
+
return `${encodeURIComponent(name)}=${encodeURIComponent(content)}`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const applyDataToUrl = (urlText: string, data: string): string => {
|
|
161
|
+
const url = new URL(urlText)
|
|
162
|
+
const params = new URLSearchParams(url.search)
|
|
163
|
+
const pairs = data.split('&').filter((entry) => entry.length > 0)
|
|
164
|
+
for (const pair of pairs) {
|
|
165
|
+
const index = pair.indexOf('=')
|
|
166
|
+
if (index < 0) {
|
|
167
|
+
params.append(decodeURIComponent(pair), '')
|
|
168
|
+
} else {
|
|
169
|
+
const k = pair.slice(0, index)
|
|
170
|
+
const v = pair.slice(index + 1)
|
|
171
|
+
params.append(decodeURIComponent(k), decodeURIComponent(v))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
url.search = params.toString()
|
|
175
|
+
return url.toString()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const buildHeaderBlock = (status: number, statusText: string, headers: Headers): string => {
|
|
179
|
+
const lines: string[] = []
|
|
180
|
+
lines.push(`HTTP/1.1 ${status} ${statusText}`.trim())
|
|
181
|
+
for (const [key, value] of headers.entries()) {
|
|
182
|
+
lines.push(`${key}: ${value}`)
|
|
183
|
+
}
|
|
184
|
+
return `${lines.join('\r\n')}\r\n\r\n`
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const curlExitCode = {
|
|
188
|
+
ok: 0,
|
|
189
|
+
usage: 2,
|
|
190
|
+
couldNotResolveHost: 6,
|
|
191
|
+
httpReturnedError: 22,
|
|
192
|
+
operationTimeout: 28,
|
|
193
|
+
} as const
|
|
194
|
+
|
|
195
|
+
const formatWriteOut = (template: string, info: { httpCode: number; urlEffective: string; timeTotalSeconds: number }) =>
|
|
196
|
+
template
|
|
197
|
+
.replaceAll('%{http_code}', String(info.httpCode).padStart(3, '0'))
|
|
198
|
+
.replaceAll('%{url_effective}', info.urlEffective)
|
|
199
|
+
.replaceAll('%{time_total}', info.timeTotalSeconds.toFixed(3))
|
|
200
|
+
|
|
201
|
+
const parseCurlArgs = async (
|
|
202
|
+
rawArgs: string[],
|
|
203
|
+
toolOptions: CurlToolOptions,
|
|
204
|
+
): Promise<{ config: CurlConfig; urls: string[] }> => {
|
|
205
|
+
const config = defaultConfig()
|
|
206
|
+
const urls: string[] = []
|
|
207
|
+
|
|
208
|
+
const args: string[] = []
|
|
209
|
+
for (const token of rawArgs) {
|
|
210
|
+
if (token === '--') {
|
|
211
|
+
args.push(token)
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (token.startsWith('-') && !token.startsWith('--') && token.length > 2) {
|
|
215
|
+
args.push(...splitShortCluster(token))
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
args.push(token)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextValue = (index: number): string => {
|
|
222
|
+
if (index + 1 >= args.length) throw new Error(`Missing value for ${args[index]}`)
|
|
223
|
+
return args[index + 1]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
227
|
+
const token = args[i]
|
|
228
|
+
if (token === '--') {
|
|
229
|
+
urls.push(...args.slice(i + 1))
|
|
230
|
+
break
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!isOptionLike(token)) {
|
|
234
|
+
urls.push(normalizeUrlToken(token))
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (token === '-X' || token === '--request') {
|
|
239
|
+
const value = nextValue(i)
|
|
240
|
+
config.method = value.toUpperCase()
|
|
241
|
+
i += 1
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (token.startsWith('-X') && token.length > 2) {
|
|
246
|
+
config.method = token.slice(2).toUpperCase()
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (token === '-H' || token === '--header') {
|
|
251
|
+
const value = nextValue(i)
|
|
252
|
+
const idx = value.indexOf(':')
|
|
253
|
+
if (idx < 0) throw new Error(`Invalid header: ${value}`)
|
|
254
|
+
const key = value.slice(0, idx).trim()
|
|
255
|
+
const val = value.slice(idx + 1).trim()
|
|
256
|
+
config.headers.append(key, val)
|
|
257
|
+
i += 1
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (token === '-A' || token === '--user-agent') {
|
|
262
|
+
const value = nextValue(i)
|
|
263
|
+
config.headers.set('user-agent', value)
|
|
264
|
+
i += 1
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (token === '-e' || token === '--referer') {
|
|
269
|
+
const value = nextValue(i)
|
|
270
|
+
config.headers.set('referer', value)
|
|
271
|
+
i += 1
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (token === '-u' || token === '--user') {
|
|
276
|
+
const value = nextValue(i)
|
|
277
|
+
const idx = value.indexOf(':')
|
|
278
|
+
const username = idx >= 0 ? value.slice(0, idx) : value
|
|
279
|
+
const password = idx >= 0 ? value.slice(idx + 1) : ''
|
|
280
|
+
config.user = { username, password }
|
|
281
|
+
i += 1
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (token === '-L' || token === '--location') {
|
|
286
|
+
config.followRedirects = true
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (token === '--max-redirs') {
|
|
291
|
+
const value = nextValue(i)
|
|
292
|
+
const parsed = Number(value)
|
|
293
|
+
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`Invalid --max-redirs: ${value}`)
|
|
294
|
+
config.maxRedirects = parsed
|
|
295
|
+
i += 1
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (token === '-i' || token === '--include') {
|
|
300
|
+
config.includeHeaders = true
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (token === '-I' || token === '--head') {
|
|
305
|
+
config.headOnly = true
|
|
306
|
+
config.method = 'HEAD'
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (token === '-s' || token === '--silent') {
|
|
311
|
+
config.silent = true
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (token === '-S' || token === '--show-error') {
|
|
316
|
+
config.showError = true
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (token === '-v' || token === '--verbose') {
|
|
321
|
+
config.verbose = true
|
|
322
|
+
continue
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (token === '-f' || token === '--fail') {
|
|
326
|
+
config.fail = true
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (token === '-G' || token === '--get') {
|
|
331
|
+
config.getWithData = true
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (token === '-m' || token === '--max-time') {
|
|
336
|
+
const value = nextValue(i)
|
|
337
|
+
const seconds = Number(value)
|
|
338
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error(`Invalid --max-time: ${value}`)
|
|
339
|
+
config.timeoutMs = Math.floor(seconds * 1000)
|
|
340
|
+
i += 1
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (token === '-o' || token === '--output') {
|
|
345
|
+
const value = nextValue(i)
|
|
346
|
+
config.outputPath = value
|
|
347
|
+
i += 1
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (token === '-O' || token === '--remote-name') {
|
|
352
|
+
config.remoteName = true
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (token === '-w' || token === '--write-out') {
|
|
357
|
+
const value = nextValue(i)
|
|
358
|
+
config.writeOut = value
|
|
359
|
+
i += 1
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (token === '--json') {
|
|
364
|
+
const value = nextValue(i)
|
|
365
|
+
config.headers.set('content-type', 'application/json')
|
|
366
|
+
config.dataParts.push({ mode: 'raw', value })
|
|
367
|
+
if (!config.method) config.method = 'POST'
|
|
368
|
+
i += 1
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const handleData = async (mode: 'raw' | 'binary' | 'urlencode'): Promise<void> => {
|
|
373
|
+
const value = nextValue(i)
|
|
374
|
+
const resolved = (() => {
|
|
375
|
+
if (value === '@-') {
|
|
376
|
+
const stdin = toolOptions.stdin
|
|
377
|
+
if (stdin === undefined) throw new Error('curl: @- requires tool options { stdin }')
|
|
378
|
+
return typeof stdin === 'string' ? stdin : Buffer.from(stdin).toString('utf8')
|
|
379
|
+
}
|
|
380
|
+
if (value.startsWith('@')) {
|
|
381
|
+
const filePath = value.slice(1)
|
|
382
|
+
const resolvedPath = toolOptions.cwd ? path.resolve(toolOptions.cwd, filePath) : filePath
|
|
383
|
+
return readFileAsText(resolvedPath)
|
|
384
|
+
}
|
|
385
|
+
return value
|
|
386
|
+
})()
|
|
387
|
+
|
|
388
|
+
const materialized = typeof resolved === 'string' ? resolved : await resolved
|
|
389
|
+
config.dataParts.push({ mode, value: materialized })
|
|
390
|
+
if (!config.method) config.method = 'POST'
|
|
391
|
+
i += 1
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (token === '-d' || token === '--data' || token === '--data-raw') {
|
|
395
|
+
await handleData('raw')
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (token === '--data-binary') {
|
|
400
|
+
await handleData('binary')
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (token === '--data-urlencode') {
|
|
405
|
+
await handleData('urlencode')
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const handleForm = async (): Promise<void> => {
|
|
410
|
+
const value = nextValue(i)
|
|
411
|
+
const idx = value.indexOf('=')
|
|
412
|
+
if (idx <= 0) throw new Error(`Invalid form field: ${value}`)
|
|
413
|
+
const name = value.slice(0, idx)
|
|
414
|
+
const content = value.slice(idx + 1)
|
|
415
|
+
if (content.startsWith('@')) {
|
|
416
|
+
const filePath = content.slice(1)
|
|
417
|
+
const resolvedPath = toolOptions.cwd ? path.resolve(toolOptions.cwd, filePath) : filePath
|
|
418
|
+
config.formParts.push({ name, filePath: resolvedPath })
|
|
419
|
+
} else {
|
|
420
|
+
config.formParts.push({ name, value: content })
|
|
421
|
+
}
|
|
422
|
+
if (!config.method) config.method = 'POST'
|
|
423
|
+
i += 1
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (token === '-F' || token === '--form') {
|
|
427
|
+
await handleForm()
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (token === '--url') {
|
|
432
|
+
const value = nextValue(i)
|
|
433
|
+
urls.push(normalizeUrlToken(value))
|
|
434
|
+
i += 1
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
throw new Error(`Unsupported curl option: ${token}`)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// curl defaults to stdout; preserve tool option timeoutMs as a fallback.
|
|
442
|
+
if (config.timeoutMs === null && typeof toolOptions.timeoutMs === 'number') {
|
|
443
|
+
config.timeoutMs = toolOptions.timeoutMs
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { config, urls }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const toHeaderRecord = (headers: Headers): Record<string, string> => {
|
|
450
|
+
const out: Record<string, string> = {}
|
|
451
|
+
for (const [k, v] of headers.entries()) out[k] = v
|
|
452
|
+
return out
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const buildRequestBody = async (
|
|
456
|
+
config: CurlConfig,
|
|
457
|
+
): Promise<{ body: BodyInit | null; method: string }> => {
|
|
458
|
+
const method = config.method ?? (config.dataParts.length > 0 || config.formParts.length > 0 ? 'POST' : 'GET')
|
|
459
|
+
|
|
460
|
+
if (config.headOnly) return { body: null, method: 'HEAD' }
|
|
461
|
+
|
|
462
|
+
if (config.formParts.length > 0) {
|
|
463
|
+
const form = new FormData()
|
|
464
|
+
for (const part of config.formParts) {
|
|
465
|
+
if ('filePath' in part) {
|
|
466
|
+
const fileName = path.basename(part.filePath)
|
|
467
|
+
form.append(part.name, Bun.file(part.filePath), fileName)
|
|
468
|
+
} else {
|
|
469
|
+
form.append(part.name, part.value)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { body: form, method }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (config.dataParts.length > 0) {
|
|
476
|
+
const rendered = config.dataParts.map((part) => {
|
|
477
|
+
if (part.mode === 'urlencode') return encodeUrlDataPart(part.value)
|
|
478
|
+
return part.value
|
|
479
|
+
}).join('&')
|
|
480
|
+
return { body: rendered, method }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { body: null, method }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const fetchWithRedirects = async (
|
|
487
|
+
initialUrl: string,
|
|
488
|
+
init: RequestInit,
|
|
489
|
+
followRedirects: boolean,
|
|
490
|
+
maxRedirects: number,
|
|
491
|
+
controller: AbortController,
|
|
492
|
+
): Promise<{ response: Response; urlEffective: string; redirectCount: number }> => {
|
|
493
|
+
if (!followRedirects) {
|
|
494
|
+
const response = await fetch(initialUrl, { ...init, redirect: 'manual', signal: controller.signal })
|
|
495
|
+
return { response, urlEffective: initialUrl, redirectCount: 0 }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
let url = initialUrl
|
|
499
|
+
let redirectCount = 0
|
|
500
|
+
let currentInit = { ...init, redirect: 'manual' as const }
|
|
501
|
+
|
|
502
|
+
while (true) {
|
|
503
|
+
const response = await fetch(url, { ...currentInit, signal: controller.signal })
|
|
504
|
+
const status = response.status
|
|
505
|
+
const location = response.headers.get('location')
|
|
506
|
+
const isRedirect = status >= 300 && status < 400 && location
|
|
507
|
+
if (!isRedirect) {
|
|
508
|
+
return { response, urlEffective: url, redirectCount }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (redirectCount >= maxRedirects) {
|
|
512
|
+
return { response, urlEffective: url, redirectCount }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const nextUrl = new URL(location, url).toString()
|
|
516
|
+
redirectCount += 1
|
|
517
|
+
|
|
518
|
+
if (status === 303 || status === 301 || status === 302) {
|
|
519
|
+
const method = (currentInit.method ?? 'GET').toUpperCase()
|
|
520
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
521
|
+
currentInit = { ...currentInit, method: 'GET', body: undefined }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
url = nextUrl
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const buildVerboseDump = (url: string, init: RequestInit): string => {
|
|
530
|
+
const lines: string[] = []
|
|
531
|
+
lines.push(`> ${init.method ?? 'GET'} ${url}`)
|
|
532
|
+
const headers = init.headers instanceof Headers
|
|
533
|
+
? init.headers
|
|
534
|
+
: new Headers(init.headers as HeadersInit | undefined)
|
|
535
|
+
for (const [k, v] of headers.entries()) {
|
|
536
|
+
lines.push(`> ${k}: ${v}`)
|
|
537
|
+
}
|
|
538
|
+
return `${lines.join('\n')}\n`
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export const curl = async (...raw: unknown[]): Promise<CurlResult> => {
|
|
542
|
+
const trailing = raw.length > 0 ? raw[raw.length - 1] : undefined
|
|
543
|
+
const toolOptions = parseToolOptions(trailing)
|
|
544
|
+
const args = isRecord(trailing) ? raw.slice(0, -1) : raw
|
|
545
|
+
const rawArgs = args.map((arg) => String(arg))
|
|
546
|
+
|
|
547
|
+
const startedAll = performance.now()
|
|
548
|
+
|
|
549
|
+
const stderrChunks: string[] = []
|
|
550
|
+
const stdoutChunks: Uint8Array[] = []
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const { config, urls } = await parseCurlArgs(rawArgs, toolOptions)
|
|
554
|
+
const materializedUrls = urls.filter((u) => u.trim().length > 0)
|
|
555
|
+
|
|
556
|
+
if (materializedUrls.length === 0) {
|
|
557
|
+
return {
|
|
558
|
+
args: rawArgs,
|
|
559
|
+
exitCode: curlExitCode.usage,
|
|
560
|
+
stdout: '',
|
|
561
|
+
stderr: 'curl: no URL specified',
|
|
562
|
+
stdoutBase64: '',
|
|
563
|
+
stderrBase64: Buffer.from('curl: no URL specified', 'utf8').toString('base64'),
|
|
564
|
+
timedOut: false,
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const results: CurlPerRequestResult[] = []
|
|
569
|
+
let finalExitCode = curlExitCode.ok
|
|
570
|
+
let anyTimedOut = false
|
|
571
|
+
|
|
572
|
+
for (const url of materializedUrls) {
|
|
573
|
+
const started = performance.now()
|
|
574
|
+
const controller = new AbortController()
|
|
575
|
+
const timeoutMs = config.timeoutMs ?? null
|
|
576
|
+
const timeoutId = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null
|
|
577
|
+
|
|
578
|
+
let perStdoutBytes = new Uint8Array()
|
|
579
|
+
let perStderr = ''
|
|
580
|
+
let exitCode = curlExitCode.ok
|
|
581
|
+
let timedOut = false
|
|
582
|
+
let response: Response | null = null
|
|
583
|
+
let urlEffective = url
|
|
584
|
+
let redirectCount = 0
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const isFileScheme = url.startsWith('file://')
|
|
588
|
+
const isAbsolutePath = !looksLikeUrl(url) && path.isAbsolute(url)
|
|
589
|
+
|
|
590
|
+
if (isFileScheme || isAbsolutePath) {
|
|
591
|
+
const filePath = isFileScheme ? decodeURIComponent(new URL(url).pathname) : url
|
|
592
|
+
const resolvedPath = toolOptions.cwd ? path.resolve(toolOptions.cwd, filePath) : filePath
|
|
593
|
+
const bytes = await readFileAsBytes(resolvedPath)
|
|
594
|
+
perStdoutBytes = bytes
|
|
595
|
+
exitCode = curlExitCode.ok
|
|
596
|
+
urlEffective = isFileScheme ? url : `file://${resolvedPath.replaceAll('\\', '/')}`
|
|
597
|
+
} else {
|
|
598
|
+
if (!looksLikeUrl(url)) {
|
|
599
|
+
throw new Error(`URL missing scheme: ${url}`)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { body, method } = await buildRequestBody(config)
|
|
603
|
+
|
|
604
|
+
const headers = new Headers(config.headers)
|
|
605
|
+
if (config.user && !headers.has('authorization')) {
|
|
606
|
+
const token = Buffer.from(`${config.user.username}:${config.user.password}`, 'utf8').toString('base64')
|
|
607
|
+
headers.set('authorization', `Basic ${token}`)
|
|
608
|
+
}
|
|
609
|
+
if (config.dataParts.length > 0 && !headers.has('content-type')) {
|
|
610
|
+
headers.set('content-type', 'application/x-www-form-urlencoded')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const init: RequestInit = { method, headers, body: body ?? undefined }
|
|
614
|
+
|
|
615
|
+
if (config.verbose) {
|
|
616
|
+
perStderr += buildVerboseDump(url, init)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
let requestUrl = url
|
|
620
|
+
if (config.getWithData && config.dataParts.length > 0) {
|
|
621
|
+
const encoded = config.dataParts.map((part) => {
|
|
622
|
+
if (part.mode === 'urlencode') return encodeUrlDataPart(part.value)
|
|
623
|
+
return part.value
|
|
624
|
+
}).join('&')
|
|
625
|
+
requestUrl = applyDataToUrl(url, encoded)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const fetched = await fetchWithRedirects(
|
|
629
|
+
requestUrl,
|
|
630
|
+
init,
|
|
631
|
+
config.followRedirects,
|
|
632
|
+
config.maxRedirects,
|
|
633
|
+
controller,
|
|
634
|
+
)
|
|
635
|
+
response = fetched.response
|
|
636
|
+
urlEffective = fetched.urlEffective
|
|
637
|
+
redirectCount = fetched.redirectCount
|
|
638
|
+
|
|
639
|
+
const headerBlock = buildHeaderBlock(response.status, response.statusText, response.headers)
|
|
640
|
+
const headerBytes = new Uint8Array(Buffer.from(headerBlock, 'utf8'))
|
|
641
|
+
|
|
642
|
+
const bodyBytes = config.headOnly
|
|
643
|
+
? new Uint8Array()
|
|
644
|
+
: new Uint8Array(await response.arrayBuffer())
|
|
645
|
+
|
|
646
|
+
const shouldSuppressBody = config.fail && response.status >= 400
|
|
647
|
+
if (shouldSuppressBody) {
|
|
648
|
+
exitCode = curlExitCode.httpReturnedError
|
|
649
|
+
const msg = `curl: (22) The requested URL returned error: ${response.status}`
|
|
650
|
+
perStderr += `${msg}\n`
|
|
651
|
+
perStdoutBytes = config.includeHeaders ? headerBytes : new Uint8Array()
|
|
652
|
+
} else {
|
|
653
|
+
perStdoutBytes = config.includeHeaders || config.headOnly
|
|
654
|
+
? new Uint8Array([...headerBytes, ...bodyBytes])
|
|
655
|
+
: bodyBytes
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
} catch (error) {
|
|
659
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
660
|
+
if (/aborted/i.test(message) || /abort/i.test(message)) {
|
|
661
|
+
timedOut = true
|
|
662
|
+
anyTimedOut = true
|
|
663
|
+
exitCode = curlExitCode.operationTimeout
|
|
664
|
+
perStderr += `curl: (28) Operation timed out${timeoutMs ? ` after ${timeoutMs} milliseconds` : ''}\n`
|
|
665
|
+
} else if (/missing scheme/i.test(message) || /invalid url/i.test(message)) {
|
|
666
|
+
exitCode = curlExitCode.usage
|
|
667
|
+
perStderr += `curl: (2) ${message}\n`
|
|
668
|
+
} else {
|
|
669
|
+
// Best-effort mapping; fetch failures are often DNS/connection.
|
|
670
|
+
exitCode = curlExitCode.couldNotResolveHost
|
|
671
|
+
perStderr += `curl: (6) ${message}\n`
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const durationMs = performance.now() - started
|
|
678
|
+
|
|
679
|
+
const httpCode = response?.status ?? 0
|
|
680
|
+
const headers = response ? toHeaderRecord(response.headers) : {}
|
|
681
|
+
|
|
682
|
+
const timeTotalSeconds = durationMs / 1000
|
|
683
|
+
const writeOut = config.writeOut
|
|
684
|
+
? formatWriteOut(config.writeOut, { httpCode, urlEffective, timeTotalSeconds })
|
|
685
|
+
: ''
|
|
686
|
+
|
|
687
|
+
const perStdoutWithWriteOut = writeOut
|
|
688
|
+
? new Uint8Array([...perStdoutBytes, ...new Uint8Array(Buffer.from(writeOut, 'utf8'))])
|
|
689
|
+
: perStdoutBytes
|
|
690
|
+
|
|
691
|
+
if (!config.silent) {
|
|
692
|
+
// We do not implement progress meter; keep stderr clean unless verbose/error.
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!config.silent || config.showError) {
|
|
696
|
+
// For compatibility, keep stderr behavior: show errors by default, suppress under -s unless -S.
|
|
697
|
+
// If silent is set and showError is not, drop stderr.
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const effectiveStderr = config.silent && !config.showError ? '' : perStderr
|
|
701
|
+
stderrChunks.push(effectiveStderr)
|
|
702
|
+
|
|
703
|
+
if (config.outputPath || config.remoteName) {
|
|
704
|
+
const outPath = config.outputPath
|
|
705
|
+
? (toolOptions.cwd ? path.resolve(toolOptions.cwd, config.outputPath) : config.outputPath)
|
|
706
|
+
: resolveOutputPathForUrl(urlEffective, toolOptions.cwd)
|
|
707
|
+
await Bun.write(outPath, perStdoutWithWriteOut)
|
|
708
|
+
} else {
|
|
709
|
+
stdoutChunks.push(perStdoutWithWriteOut)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
results.push({
|
|
713
|
+
url,
|
|
714
|
+
urlEffective,
|
|
715
|
+
redirectCount,
|
|
716
|
+
httpCode,
|
|
717
|
+
headers,
|
|
718
|
+
stdout: Buffer.from(perStdoutWithWriteOut).toString('utf8'),
|
|
719
|
+
stdoutBase64: Buffer.from(perStdoutWithWriteOut).toString('base64'),
|
|
720
|
+
stderr: effectiveStderr,
|
|
721
|
+
exitCode,
|
|
722
|
+
timedOut,
|
|
723
|
+
durationMs,
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
finalExitCode = finalExitCode === 0 ? exitCode : finalExitCode
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const stdoutBytes = stdoutChunks.length === 0
|
|
730
|
+
? new Uint8Array()
|
|
731
|
+
: new Uint8Array(stdoutChunks.reduce((acc, chunk) => acc + chunk.length, 0))
|
|
732
|
+
|
|
733
|
+
if (stdoutChunks.length > 0) {
|
|
734
|
+
let offset = 0
|
|
735
|
+
for (const chunk of stdoutChunks) {
|
|
736
|
+
stdoutBytes.set(chunk, offset)
|
|
737
|
+
offset += chunk.length
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const stdout = Buffer.from(stdoutBytes).toString('utf8')
|
|
742
|
+
const stderr = stderrChunks.join('')
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
args: rawArgs,
|
|
746
|
+
exitCode: finalExitCode,
|
|
747
|
+
stdout,
|
|
748
|
+
stderr,
|
|
749
|
+
stdoutBase64: Buffer.from(stdoutBytes).toString('base64'),
|
|
750
|
+
stderrBase64: Buffer.from(stderr, 'utf8').toString('base64'),
|
|
751
|
+
timedOut: anyTimedOut,
|
|
752
|
+
results,
|
|
753
|
+
}
|
|
754
|
+
} catch (error) {
|
|
755
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
756
|
+
const stderr = `curl: (2) ${message}`
|
|
757
|
+
return {
|
|
758
|
+
args: rawArgs,
|
|
759
|
+
exitCode: curlExitCode.usage,
|
|
760
|
+
stdout: '',
|
|
761
|
+
stderr,
|
|
762
|
+
stdoutBase64: '',
|
|
763
|
+
stderrBase64: Buffer.from(stderr, 'utf8').toString('base64'),
|
|
764
|
+
timedOut: false,
|
|
765
|
+
}
|
|
766
|
+
} finally {
|
|
767
|
+
const elapsed = performance.now() - startedAll
|
|
768
|
+
void elapsed
|
|
769
|
+
}
|
|
770
|
+
}
|