@foundation0/api 1.1.3 → 1.1.4
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/net.ts +170 -0
- package/package.json +4 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { curl } from './curl'
|
|
3
|
+
|
|
4
|
+
describe('api/libs/curl', () => {
|
|
5
|
+
const withServer = async <T>(fn: (baseUrl: string) => Promise<T>): Promise<T> => {
|
|
6
|
+
const server = Bun.serve({
|
|
7
|
+
port: 0,
|
|
8
|
+
fetch: async (req) => {
|
|
9
|
+
const url = new URL(req.url)
|
|
10
|
+
|
|
11
|
+
if (url.pathname === '/hello') {
|
|
12
|
+
return new Response('hello', { headers: { 'x-test': '1' } })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (url.pathname === '/echo') {
|
|
16
|
+
const body = await req.text()
|
|
17
|
+
return Response.json({
|
|
18
|
+
method: req.method,
|
|
19
|
+
body,
|
|
20
|
+
contentType: req.headers.get('content-type'),
|
|
21
|
+
ua: req.headers.get('user-agent'),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (url.pathname === '/redirect') {
|
|
26
|
+
return new Response('nope', { status: 302, headers: { location: '/hello' } })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return new Response('not found', { status: 404 })
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return await fn(`http://127.0.0.1:${server.port}`)
|
|
35
|
+
} finally {
|
|
36
|
+
server.stop(true)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('fetches a URL and returns body on stdout', async () => {
|
|
41
|
+
await withServer(async (baseUrl) => {
|
|
42
|
+
const result = await curl(`${baseUrl}/hello`)
|
|
43
|
+
expect(result.exitCode).toBe(0)
|
|
44
|
+
expect(result.timedOut).toBe(false)
|
|
45
|
+
expect(result.stdout).toBe('hello')
|
|
46
|
+
expect(result.results?.[0]?.httpCode).toBe(200)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('defaults scheme-less host URLs to http://', async () => {
|
|
51
|
+
await withServer(async (baseUrl) => {
|
|
52
|
+
const url = baseUrl.replace('http://', '')
|
|
53
|
+
const result = await curl(`${url}/hello`)
|
|
54
|
+
expect(result.exitCode).toBe(0)
|
|
55
|
+
expect(result.stdout).toBe('hello')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('supports -i to include response headers', async () => {
|
|
60
|
+
await withServer(async (baseUrl) => {
|
|
61
|
+
const result = await curl('-i', `${baseUrl}/hello`)
|
|
62
|
+
expect(result.exitCode).toBe(0)
|
|
63
|
+
expect(result.stdout).toContain('HTTP/1.1 200')
|
|
64
|
+
expect(result.stdout.toLowerCase()).toContain('x-test: 1')
|
|
65
|
+
expect(result.stdout).toContain('hello')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('supports -d for POST body and defaults method to POST', async () => {
|
|
70
|
+
await withServer(async (baseUrl) => {
|
|
71
|
+
const result = await curl('-d', 'a=1', `${baseUrl}/echo`)
|
|
72
|
+
expect(result.exitCode).toBe(0)
|
|
73
|
+
const payload = JSON.parse(result.stdout)
|
|
74
|
+
expect(payload.method).toBe('POST')
|
|
75
|
+
expect(payload.body).toBe('a=1')
|
|
76
|
+
expect(payload.contentType).toContain('application/x-www-form-urlencoded')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('supports -G with -d to apply data as query params', async () => {
|
|
81
|
+
await withServer(async (baseUrl) => {
|
|
82
|
+
const result = await curl('-G', '-d', 'a=1', `${baseUrl}/hello`)
|
|
83
|
+
expect(result.exitCode).toBe(0)
|
|
84
|
+
expect(result.stdout).toBe('hello')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('supports -L to follow redirects', async () => {
|
|
89
|
+
await withServer(async (baseUrl) => {
|
|
90
|
+
const result = await curl('-L', `${baseUrl}/redirect`)
|
|
91
|
+
expect(result.exitCode).toBe(0)
|
|
92
|
+
expect(result.results?.[0]?.redirectCount).toBe(1)
|
|
93
|
+
expect(result.results?.[0]?.urlEffective).toContain('/hello')
|
|
94
|
+
expect(result.stdout).toBe('hello')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('supports -f to fail on HTTP >= 400 and suppress body', async () => {
|
|
99
|
+
await withServer(async (baseUrl) => {
|
|
100
|
+
const result = await curl('-f', `${baseUrl}/missing`)
|
|
101
|
+
expect(result.exitCode).toBe(22)
|
|
102
|
+
expect(result.stdout).toBe('')
|
|
103
|
+
expect(result.stderr).toContain('returned error')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('supports -w to write out %{http_code}', async () => {
|
|
108
|
+
await withServer(async (baseUrl) => {
|
|
109
|
+
const result = await curl('-w', '%{http_code}', `${baseUrl}/hello`)
|
|
110
|
+
expect(result.exitCode).toBe(0)
|
|
111
|
+
expect(result.stdout).toBe('hello200')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('accepts trailing tool options object without turning it into a curl arg', async () => {
|
|
116
|
+
await withServer(async (baseUrl) => {
|
|
117
|
+
const result = await curl(`${baseUrl}/hello`, { timeoutMs: 10_000 })
|
|
118
|
+
expect(result.exitCode).toBe(0)
|
|
119
|
+
expect(result.args).toEqual([`${baseUrl}/hello`])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('returns usage exit code for unsupported flags', async () => {
|
|
124
|
+
await withServer(async (baseUrl) => {
|
|
125
|
+
const result = await curl('--version', `${baseUrl}/hello`)
|
|
126
|
+
expect(result.exitCode).toBe(2)
|
|
127
|
+
expect(result.stderr).toContain('Unsupported curl option')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
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
|
+
}
|
package/net.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { curl as curlImpl, type CurlResult, type CurlToolOptions } from './libs/curl'
|
|
2
|
+
|
|
3
|
+
export type NetCurlResult = CurlResult
|
|
4
|
+
|
|
5
|
+
type NetCurlRequest = {
|
|
6
|
+
url?: string | string[]
|
|
7
|
+
urls?: string[]
|
|
8
|
+
method?: string
|
|
9
|
+
headers?: Record<string, string>
|
|
10
|
+
body?: string
|
|
11
|
+
data?: string | string[]
|
|
12
|
+
json?: unknown
|
|
13
|
+
form?: Record<string, string>
|
|
14
|
+
followRedirects?: boolean
|
|
15
|
+
location?: boolean
|
|
16
|
+
maxRedirects?: number
|
|
17
|
+
includeHeaders?: boolean
|
|
18
|
+
head?: boolean
|
|
19
|
+
user?: string
|
|
20
|
+
output?: string
|
|
21
|
+
remoteName?: boolean
|
|
22
|
+
fail?: boolean
|
|
23
|
+
silent?: boolean
|
|
24
|
+
showError?: boolean
|
|
25
|
+
verbose?: boolean
|
|
26
|
+
writeOut?: string
|
|
27
|
+
maxTimeSeconds?: number
|
|
28
|
+
get?: boolean
|
|
29
|
+
timeoutMs?: number
|
|
30
|
+
cwd?: string
|
|
31
|
+
env?: Record<string, string>
|
|
32
|
+
stdin?: string | Uint8Array | number[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
36
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
37
|
+
|
|
38
|
+
const toStringRecord = (value: unknown): Record<string, string> | null => {
|
|
39
|
+
if (!isRecord(value)) return null
|
|
40
|
+
const out: Record<string, string> = {}
|
|
41
|
+
for (const [k, v] of Object.entries(value)) {
|
|
42
|
+
if (v === undefined || v === null) continue
|
|
43
|
+
out[k] = String(v)
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parseRequest = (value: unknown): NetCurlRequest | null => {
|
|
49
|
+
if (!isRecord(value)) return null
|
|
50
|
+
if ('args' in value) return null
|
|
51
|
+
if (!('url' in value) && !('urls' in value)) return null
|
|
52
|
+
return value as NetCurlRequest
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const toUint8Array = (value: unknown): Uint8Array | undefined => {
|
|
56
|
+
if (value instanceof Uint8Array) return value
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
const bytes = value.map((entry) => Number(entry)).filter((n) => Number.isFinite(n) && n >= 0 && n <= 255)
|
|
59
|
+
return new Uint8Array(bytes)
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const buildCurlArgsFromRequest = (request: NetCurlRequest): { args: string[]; toolOptions: CurlToolOptions } => {
|
|
65
|
+
const args: string[] = []
|
|
66
|
+
|
|
67
|
+
const urls = (() => {
|
|
68
|
+
if (Array.isArray(request.urls)) return request.urls.map(String)
|
|
69
|
+
if (Array.isArray(request.url)) return request.url.map(String)
|
|
70
|
+
if (typeof request.url === 'string') return [request.url]
|
|
71
|
+
return []
|
|
72
|
+
})()
|
|
73
|
+
|
|
74
|
+
const method = typeof request.method === 'string' && request.method.trim().length > 0
|
|
75
|
+
? request.method.trim().toUpperCase()
|
|
76
|
+
: null
|
|
77
|
+
|
|
78
|
+
if (request.includeHeaders === true) args.push('-i')
|
|
79
|
+
if (request.head === true || method === 'HEAD') args.push('-I')
|
|
80
|
+
|
|
81
|
+
if (request.followRedirects === true || request.location === true) args.push('-L')
|
|
82
|
+
if (typeof request.maxRedirects === 'number' && Number.isInteger(request.maxRedirects) && request.maxRedirects >= 0) {
|
|
83
|
+
args.push('--max-redirs', String(request.maxRedirects))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (request.fail === true) args.push('-f')
|
|
87
|
+
if (request.silent === true) args.push('-s')
|
|
88
|
+
if (request.showError === true) args.push('-S')
|
|
89
|
+
if (request.verbose === true) args.push('-v')
|
|
90
|
+
|
|
91
|
+
if (request.get === true) args.push('-G')
|
|
92
|
+
|
|
93
|
+
if (typeof request.user === 'string' && request.user.trim().length > 0) {
|
|
94
|
+
args.push('-u', request.user.trim())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const headers = toStringRecord(request.headers)
|
|
98
|
+
if (headers) {
|
|
99
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
100
|
+
args.push('-H', `${k}: ${v}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof request.writeOut === 'string') args.push('-w', request.writeOut)
|
|
105
|
+
if (typeof request.output === 'string' && request.output.trim().length > 0) args.push('-o', request.output.trim())
|
|
106
|
+
if (request.remoteName === true) args.push('-O')
|
|
107
|
+
|
|
108
|
+
if (typeof request.maxTimeSeconds === 'number' && Number.isFinite(request.maxTimeSeconds) && request.maxTimeSeconds > 0) {
|
|
109
|
+
args.push('--max-time', String(request.maxTimeSeconds))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (method && method !== 'HEAD' && request.head !== true) {
|
|
113
|
+
args.push('-X', method)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof request.body === 'string') {
|
|
117
|
+
args.push('--data-raw', request.body)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = request.data
|
|
121
|
+
if (typeof data === 'string') {
|
|
122
|
+
args.push('-d', data)
|
|
123
|
+
} else if (Array.isArray(data)) {
|
|
124
|
+
for (const entry of data) args.push('-d', String(entry))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (request.json !== undefined) {
|
|
128
|
+
const rendered = typeof request.json === 'string' ? request.json : JSON.stringify(request.json)
|
|
129
|
+
args.push('--json', rendered)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isRecord(request.form)) {
|
|
133
|
+
for (const [k, v] of Object.entries(request.form)) {
|
|
134
|
+
if (v === undefined || v === null) continue
|
|
135
|
+
args.push('-F', `${k}=${String(v)}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
args.push(...urls)
|
|
140
|
+
|
|
141
|
+
const toolOptions: CurlToolOptions = {
|
|
142
|
+
timeoutMs: typeof request.timeoutMs === 'number' && Number.isFinite(request.timeoutMs) && request.timeoutMs > 0
|
|
143
|
+
? request.timeoutMs
|
|
144
|
+
: undefined,
|
|
145
|
+
cwd: typeof request.cwd === 'string' && request.cwd.trim().length > 0 ? request.cwd.trim() : undefined,
|
|
146
|
+
env: toStringRecord(request.env) ?? undefined,
|
|
147
|
+
stdin: typeof request.stdin === 'string' ? request.stdin : toUint8Array(request.stdin),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { args, toolOptions }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const curl = async (...rawArgs: unknown[]): Promise<CurlResult> => {
|
|
154
|
+
const request = rawArgs.length === 1 ? parseRequest(rawArgs[0]) : null
|
|
155
|
+
if (request) {
|
|
156
|
+
const { args, toolOptions } = buildCurlArgsFromRequest(request)
|
|
157
|
+
return curlImpl(...args, toolOptions)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stringArgCount = rawArgs.filter((arg) => typeof arg === 'string').length
|
|
161
|
+
if (stringArgCount === 0 && rawArgs.length === 1 && isRecord(rawArgs[0])) {
|
|
162
|
+
const maybe = rawArgs[0] as Record<string, unknown>
|
|
163
|
+
if ('method' in maybe || 'headers' in maybe || 'body' in maybe || 'data' in maybe || 'json' in maybe) {
|
|
164
|
+
const { args, toolOptions } = buildCurlArgsFromRequest(maybe as NetCurlRequest)
|
|
165
|
+
return curlImpl(...args, toolOptions)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return curlImpl(...rawArgs)
|
|
170
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foundation0/api",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Foundation 0 API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"agents.ts",
|
|
20
20
|
"git.ts",
|
|
21
|
+
"net.ts",
|
|
21
22
|
"taskgraph-parser.ts",
|
|
22
23
|
"projects.ts",
|
|
24
|
+
"libs",
|
|
23
25
|
"mcp"
|
|
24
26
|
],
|
|
25
27
|
"publishConfig": {
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
},
|
|
33
35
|
"scripts": {
|
|
34
36
|
"mcp": "bun run mcp/cli.ts",
|
|
35
|
-
"test": "
|
|
37
|
+
"test": "bun test",
|
|
36
38
|
"deploy": "pnpm publish --access public",
|
|
37
39
|
"version:patch": "pnpm version patch && git commit -am \"Bump version to %s\"",
|
|
38
40
|
"version:minor": "pnpm version minor && git commit -am \"Bump version to %s\"",
|