@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.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
+ }