@lievclouds/dsh-cortex 0.4.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. package/src/host/reports-office.js +341 -0
@@ -0,0 +1,635 @@
1
+ /**
2
+ * curl import/export helpers for the single Repeater.
3
+ *
4
+ * Everything here is pure (no network, no DOM); the dialog only calls
5
+ * `parseCurlCommand` and `buildCurlCommand`. The parser is intentionally
6
+ * "best effort": it covers the common bash/zsh and Windows cmd forms without
7
+ * pretending to be a shell. Options that cannot be represented in a raw HTTP
8
+ * request (proxies, certificates, output files, multipart uploads...) are
9
+ * ignored and reported via `ignored`.
10
+ */
11
+ import {
12
+ serializeRawRequest,
13
+ type ParsedRawRequest
14
+ } from './repeaterRaw'
15
+
16
+ export type CurlParseErrorCode = 'empty' | 'no-url' | 'unterminated-quote'
17
+
18
+ export interface CurlParseFailure {
19
+ ok: false
20
+ code: CurlParseErrorCode
21
+ /** Raw token/option that triggered the failure, never sent to a toast. */
22
+ detail?: string
23
+ }
24
+
25
+ export interface CurlParseSuccess {
26
+ ok: true
27
+ request: ParsedRawRequest
28
+ /** Raw editor text derived from the parsed request. */
29
+ raw: string
30
+ /** Recognized but unsupported options; kept for debugging/reporting only. */
31
+ ignored: string[]
32
+ }
33
+
34
+ export type CurlParseResult = CurlParseSuccess | CurlParseFailure
35
+
36
+ type TokenizeResult =
37
+ | { ok: true; tokens: string[] }
38
+ | { ok: false; code: 'unterminated-quote' }
39
+
40
+ /** Options that take a separate value and are not part of the request we keep. */
41
+ const DISCARDED_VALUE_OPTIONS = new Set([
42
+ '-x',
43
+ '--proxy',
44
+ '--proxy-user',
45
+ '-m',
46
+ '--max-time',
47
+ '--connect-timeout',
48
+ '--retry',
49
+ '--retry-delay',
50
+ '-o',
51
+ '--output',
52
+ '--cacert',
53
+ '--capath',
54
+ '--cert',
55
+ '--key',
56
+ '--cert-type',
57
+ '--key-type',
58
+ '--pass',
59
+ '--resolve',
60
+ '--interface',
61
+ '--dns-servers',
62
+ '--limit-rate',
63
+ '--max-filesize',
64
+ '--range',
65
+ '-r',
66
+ '--write-out',
67
+ '-w',
68
+ '--upload-file',
69
+ '-T',
70
+ '--form',
71
+ '-F',
72
+ '--form-string',
73
+ '--dump-header',
74
+ '-D',
75
+ '--cookie-jar',
76
+ '-c',
77
+ '--oauth2-bearer',
78
+ '--connect-to',
79
+ '--local-port',
80
+ '--keepalive-time',
81
+ '--max-redirs',
82
+ '--proto',
83
+ '--proto-redir',
84
+ '--http-version',
85
+ '--tls-max'
86
+ ])
87
+
88
+ /** Short options that consume a value: the attached form (`-XPOST`) is common. */
89
+ const SHORT_VALUE_OPTIONS = new Set([
90
+ '-X',
91
+ '-H',
92
+ '-d',
93
+ '-b',
94
+ '-A',
95
+ '-e',
96
+ '-u',
97
+ '-x',
98
+ '-m',
99
+ '-o',
100
+ '-w',
101
+ '-r',
102
+ '-T',
103
+ '-F',
104
+ '-D',
105
+ '-c'
106
+ ])
107
+
108
+ /** Boolean short flags we can safely ignore (combined forms like `-sS` too). */
109
+ const SHORT_BOOLEAN_FLAGS = new Set('sSkLifgvNIqO462J1'.split(''))
110
+
111
+ /** Boolean long flags we can safely ignore. */
112
+ const LONG_BOOLEAN_FLAGS = new Set([
113
+ '--silent',
114
+ '--show-error',
115
+ '--insecure',
116
+ '--location',
117
+ '--location-trusted',
118
+ '--include',
119
+ '--verbose',
120
+ '--fail',
121
+ '--fail-with-body',
122
+ '--globoff',
123
+ '--compressed',
124
+ '--no-buffer',
125
+ '--ipv4',
126
+ '--ipv6',
127
+ '--http1.0',
128
+ '--http1.1',
129
+ '--http2',
130
+ '--http2-prior-knowledge',
131
+ '--http3',
132
+ '--tlsv1.0',
133
+ '--tlsv1.1',
134
+ '--tlsv1.2',
135
+ '--tlsv1.3',
136
+ '--disable',
137
+ '--path-as-is',
138
+ '--raw',
139
+ '--remote-name',
140
+ '--remote-header-name',
141
+ '--create-dirs',
142
+ '--no-keepalive',
143
+ '--no-progress-meter',
144
+ '--progress-bar',
145
+ '--trace-ascii',
146
+ '--trace-time',
147
+ '--ignore-content-length'
148
+ ])
149
+
150
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
151
+
152
+ /** Strip bash (`\`), Windows cmd (`^`) and PowerShell (`` ` ``) line continuations. */
153
+ function stripLineContinuations(text: string): string {
154
+ return text
155
+ .replace(/\\\r?\n/g, ' ')
156
+ .replace(/\^\r?\n/g, ' ')
157
+ .replace(/`\r?\n/g, ' ')
158
+ }
159
+
160
+ function isWhitespace(char: string | undefined): boolean {
161
+ return char !== undefined && /\s/.test(char)
162
+ }
163
+
164
+ function decodeAnsiEscape(
165
+ text: string,
166
+ slashIndex: number
167
+ ): { value: string; index: number } {
168
+ const next = text[slashIndex + 1]
169
+ if (next === undefined) return { value: '\\', index: slashIndex }
170
+ switch (next) {
171
+ case 'n':
172
+ return { value: '\n', index: slashIndex + 1 }
173
+ case 'r':
174
+ return { value: '\r', index: slashIndex + 1 }
175
+ case 't':
176
+ return { value: '\t', index: slashIndex + 1 }
177
+ case '\\':
178
+ return { value: '\\', index: slashIndex + 1 }
179
+ case "'":
180
+ return { value: "'", index: slashIndex + 1 }
181
+ case '"':
182
+ return { value: '"', index: slashIndex + 1 }
183
+ default:
184
+ return { value: next, index: slashIndex + 1 }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Split a curl command line into argv-like tokens.
190
+ *
191
+ * Supports POSIX single/double quotes, `$'...'` ANSI-C strings, backslash
192
+ * escapes and Windows cmd caret escapes. Backslashes inside paths (`C:\tmp`)
193
+ * stay literal unless they escape whitespace, quotes or another backslash.
194
+ */
195
+ export function tokenizeCurl(input: string): TokenizeResult {
196
+ const text = stripLineContinuations(input)
197
+ const tokens: string[] = []
198
+ let current = ''
199
+ let quote: "'" | '"' | null = null
200
+ let ansiC = false
201
+ let started = false
202
+
203
+ const flush = (): void => {
204
+ if (!started) return
205
+ tokens.push(current)
206
+ current = ''
207
+ started = false
208
+ }
209
+
210
+ for (let index = 0; index < text.length; index += 1) {
211
+ const char = text[index] ?? ''
212
+
213
+ if (ansiC) {
214
+ if (char === "'") {
215
+ ansiC = false
216
+ } else if (char === '\\') {
217
+ const decoded = decodeAnsiEscape(text, index)
218
+ current += decoded.value
219
+ index = decoded.index
220
+ } else {
221
+ current += char
222
+ }
223
+ continue
224
+ }
225
+
226
+ if (quote === "'") {
227
+ if (char === "'") quote = null
228
+ else current += char
229
+ continue
230
+ }
231
+
232
+ if (quote === '"') {
233
+ if (char === '"') {
234
+ quote = null
235
+ } else if (char === '\\') {
236
+ const next = text[index + 1]
237
+ if (next === '"' || next === '\\' || next === '$' || next === '`') {
238
+ current += next
239
+ index += 1
240
+ } else {
241
+ current += '\\'
242
+ }
243
+ } else {
244
+ current += char
245
+ }
246
+ continue
247
+ }
248
+
249
+ if (char === "'") {
250
+ quote = "'"
251
+ started = true
252
+ continue
253
+ }
254
+ if (char === '"') {
255
+ quote = '"'
256
+ started = true
257
+ continue
258
+ }
259
+ if (char === '$' && (text[index + 1] === "'" || text[index + 1] === '"')) {
260
+ if (text[index + 1] === "'") ansiC = true
261
+ else quote = '"'
262
+ started = true
263
+ index += 1
264
+ continue
265
+ }
266
+ if (char === '\\') {
267
+ const next = text[index + 1]
268
+ if (next === undefined) {
269
+ current += '\\'
270
+ started = true
271
+ continue
272
+ }
273
+ if (next === ' ' || next === '\t' || next === '"' || next === "'" || next === '\\') {
274
+ current += next
275
+ index += 1
276
+ started = true
277
+ continue
278
+ }
279
+ // Windows paths keep their literal backslash.
280
+ current += '\\'
281
+ started = true
282
+ continue
283
+ }
284
+ if (char === '^') {
285
+ const next = text[index + 1]
286
+ if (next !== undefined && !isWhitespace(next)) {
287
+ current += next
288
+ index += 1
289
+ started = true
290
+ continue
291
+ }
292
+ current += '^'
293
+ started = true
294
+ continue
295
+ }
296
+ if (isWhitespace(char)) {
297
+ flush()
298
+ continue
299
+ }
300
+ current += char
301
+ started = true
302
+ }
303
+
304
+ if (quote !== null || ansiC) return { ok: false, code: 'unterminated-quote' }
305
+ flush()
306
+ return { ok: true, tokens }
307
+ }
308
+
309
+ function baseName(token: string): string {
310
+ return token.replace(/^.*[\\/]/, '').toLowerCase()
311
+ }
312
+
313
+ function isCurlExecutable(token: string): boolean {
314
+ return /^curl(?:\.exe)?$/.test(baseName(token))
315
+ }
316
+
317
+ function looksLikeUrl(token: string): boolean {
318
+ return /^(?:[a-z][a-z0-9+.-]*:\/\/|\/\/|\/)/i.test(token) || token.includes('.') || token.includes(':')
319
+ }
320
+
321
+ function shortValueOption(token: string): string | null {
322
+ if (!token.startsWith('-') || token.startsWith('--') || token.length < 2) return null
323
+ const short = token.slice(0, 2)
324
+ return SHORT_VALUE_OPTIONS.has(short) ? short : null
325
+ }
326
+
327
+ function isCombinedBooleanShortFlags(token: string): boolean {
328
+ if (!token.startsWith('-') || token.startsWith('--') || token.length <= 2) return false
329
+ return token
330
+ .slice(1)
331
+ .split('')
332
+ .every((flag) => SHORT_BOOLEAN_FLAGS.has(flag))
333
+ }
334
+
335
+ function setHeader(headers: Record<string, string>, name: string, value: string): void {
336
+ const trimmedName = name.trim()
337
+ if (trimmedName === '') return
338
+ const existing = Object.keys(headers).find(
339
+ (key) => key.toLowerCase() === trimmedName.toLowerCase()
340
+ )
341
+ if (existing === undefined) headers[trimmedName] = value.trim()
342
+ else headers[existing] = value.trim()
343
+ }
344
+
345
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
346
+ return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase())
347
+ }
348
+
349
+ function applyHeaderLine(headers: Record<string, string>, line: string): void {
350
+ const colon = line.indexOf(':')
351
+ if (colon <= 0) return
352
+ setHeader(headers, line.slice(0, colon), line.slice(colon + 1))
353
+ }
354
+
355
+ /** UTF-8 safe standard base64 (used for `-u user:pass`). */
356
+ export function toBase64(input: string): string {
357
+ const bytes = new TextEncoder().encode(input)
358
+ let output = ''
359
+ for (let index = 0; index < bytes.length; index += 3) {
360
+ const first = bytes[index] ?? 0
361
+ const second = bytes[index + 1]
362
+ const third = bytes[index + 2]
363
+ output += BASE64_ALPHABET[first >> 2]
364
+ output += BASE64_ALPHABET[((first & 0x03) << 4) | ((second ?? 0) >> 4)]
365
+ output += second === undefined ? '=' : BASE64_ALPHABET[((second & 0x0f) << 2) | ((third ?? 0) >> 6)]
366
+ output += third === undefined ? '=' : BASE64_ALPHABET[third & 0x3f]
367
+ }
368
+ return output
369
+ }
370
+
371
+ /**
372
+ * Best-effort conversion of a pasted curl command into a raw HTTP request.
373
+ *
374
+ * The returned `raw` text is what the Repeater editor should show; sending it
375
+ * still goes through `parseRawRequest` + `stripDerivedHeaders` + the retest
376
+ * proxy. No network access happens here.
377
+ */
378
+ export function parseCurlCommand(input: string): CurlParseResult {
379
+ const tokenized = tokenizeCurl(input)
380
+ if (!tokenized.ok) return { ok: false, code: 'unterminated-quote' }
381
+ let tokens = tokenized.tokens.filter((token) => token !== '')
382
+ if (tokens.length === 0) return { ok: false, code: 'empty' }
383
+
384
+ let index = 0
385
+ const first = tokens[0] ?? ''
386
+ if (isCurlExecutable(first)) {
387
+ index = 1
388
+ } else if (!first.startsWith('-') && !looksLikeUrl(first)) {
389
+ // e.g. `proxychains curl ...`: drop a leading wrapper command.
390
+ index = 1
391
+ }
392
+ tokens = tokens.slice(index)
393
+ index = 0
394
+
395
+ const headers: Record<string, string> = {}
396
+ const specialHeaders: Record<string, string> = {}
397
+ const dataParts: string[] = []
398
+ const ignored: string[] = []
399
+ let method = ''
400
+ let url = ''
401
+ let headOnly = false
402
+ let getData = false
403
+ let basicAuth: string | null = null
404
+ let cookie: string | null = null
405
+ let jsonBody = false
406
+ let endOfOptions = false
407
+
408
+ const assignPositional = (token: string): void => {
409
+ if (url === '') {
410
+ url = token
411
+ return
412
+ }
413
+ ignored.push(token)
414
+ }
415
+
416
+ for (; index < tokens.length; index += 1) {
417
+ const token = tokens[index] ?? ''
418
+ if (token === '') continue
419
+ if (endOfOptions) {
420
+ assignPositional(token)
421
+ continue
422
+ }
423
+ if (token === '--') {
424
+ endOfOptions = true
425
+ continue
426
+ }
427
+ if (!token.startsWith('-') || token === '-') {
428
+ assignPositional(token)
429
+ continue
430
+ }
431
+
432
+ let option = token
433
+ let inlineValue: string | null = null
434
+ if (token.startsWith('--')) {
435
+ const equals = token.indexOf('=')
436
+ if (equals !== -1) {
437
+ option = token.slice(0, equals)
438
+ inlineValue = token.slice(equals + 1)
439
+ }
440
+ } else {
441
+ const valueOption = shortValueOption(token)
442
+ if (valueOption !== null) {
443
+ option = valueOption
444
+ if (token.length > 2) inlineValue = token.slice(2)
445
+ }
446
+ }
447
+
448
+ const takeValue = (): string | null => {
449
+ if (inlineValue !== null) {
450
+ const value = inlineValue
451
+ inlineValue = null
452
+ return value
453
+ }
454
+ const next = tokens[index + 1]
455
+ if (next === undefined) return null
456
+ index += 1
457
+ return next
458
+ }
459
+
460
+ switch (option) {
461
+ case '-X':
462
+ case '--request': {
463
+ const value = takeValue()
464
+ if (value !== null && /^[A-Za-z]+$/.test(value.trim())) {
465
+ method = value.trim().toUpperCase()
466
+ } else if (value !== null) {
467
+ ignored.push(option)
468
+ }
469
+ break
470
+ }
471
+ case '-H':
472
+ case '--header': {
473
+ const value = takeValue()
474
+ if (value !== null) applyHeaderLine(headers, value)
475
+ break
476
+ }
477
+ case '-d':
478
+ case '--data':
479
+ case '--data-raw':
480
+ case '--data-ascii':
481
+ case '--data-binary': {
482
+ const value = takeValue()
483
+ if (value === null) break
484
+ if (value.startsWith('@')) {
485
+ // File-backed bodies cannot be resolved from the renderer.
486
+ ignored.push(option)
487
+ break
488
+ }
489
+ dataParts.push(value)
490
+ break
491
+ }
492
+ case '--json': {
493
+ const value = takeValue()
494
+ if (value === null) break
495
+ dataParts.push(value)
496
+ jsonBody = true
497
+ break
498
+ }
499
+ case '-b':
500
+ case '--cookie': {
501
+ const value = takeValue()
502
+ if (value === null) break
503
+ if (value.startsWith('@')) {
504
+ ignored.push(option)
505
+ break
506
+ }
507
+ cookie = cookie === null ? value : `${cookie}; ${value}`
508
+ break
509
+ }
510
+ case '-A':
511
+ case '--user-agent': {
512
+ const value = takeValue()
513
+ if (value !== null) specialHeaders['User-Agent'] = value
514
+ break
515
+ }
516
+ case '-e':
517
+ case '--referer': {
518
+ const value = takeValue()
519
+ if (value !== null) specialHeaders.Referer = value
520
+ break
521
+ }
522
+ case '-u':
523
+ case '--user': {
524
+ const value = takeValue()
525
+ if (value !== null) basicAuth = value
526
+ break
527
+ }
528
+ case '--url': {
529
+ const value = takeValue()
530
+ if (value !== null && value.trim() !== '') url = value.trim()
531
+ break
532
+ }
533
+ case '-I':
534
+ case '--head':
535
+ headOnly = true
536
+ break
537
+ case '-G':
538
+ case '--get':
539
+ // `-G` moves -d payloads into the query string.
540
+ getData = true
541
+ break
542
+ default: {
543
+ if (isCombinedBooleanShortFlags(option)) {
544
+ if (option.includes('I')) headOnly = true
545
+ break
546
+ }
547
+ if (!option.startsWith('--') && option.length === 2 && SHORT_BOOLEAN_FLAGS.has(option[1] ?? '')) {
548
+ break
549
+ }
550
+ if (DISCARDED_VALUE_OPTIONS.has(option)) {
551
+ takeValue()
552
+ ignored.push(option)
553
+ break
554
+ }
555
+ if (LONG_BOOLEAN_FLAGS.has(option)) break
556
+ if (option.startsWith('--')) {
557
+ // Unknown long option: most curl long options take a value.
558
+ if (inlineValue === null) {
559
+ const next = tokens[index + 1]
560
+ if (next !== undefined && !next.startsWith('-')) index += 1
561
+ }
562
+ }
563
+ ignored.push(option)
564
+ break
565
+ }
566
+ }
567
+ }
568
+
569
+ if (url.trim() === '') return { ok: false, code: 'no-url' }
570
+ let normalizedUrl = url.trim().replace(/\s+/g, '%20')
571
+ let body = dataParts.join('&')
572
+ if (getData && body !== '') {
573
+ normalizedUrl = `${normalizedUrl}${normalizedUrl.includes('?') ? '&' : '?'}${body}`
574
+ body = ''
575
+ }
576
+ const finalMethod =
577
+ method !== ''
578
+ ? method
579
+ : headOnly
580
+ ? 'HEAD'
581
+ : body !== ''
582
+ ? 'POST'
583
+ : 'GET'
584
+
585
+ if (jsonBody && !hasHeader(headers, 'content-type')) {
586
+ setHeader(headers, 'Content-Type', 'application/json')
587
+ }
588
+ if (basicAuth !== null && !hasHeader(headers, 'authorization')) {
589
+ setHeader(headers, 'Authorization', `Basic ${toBase64(basicAuth)}`)
590
+ }
591
+ if (cookie !== null && !hasHeader(headers, 'cookie')) {
592
+ setHeader(headers, 'Cookie', cookie)
593
+ }
594
+ for (const [name, value] of Object.entries(specialHeaders)) {
595
+ if (!hasHeader(headers, name)) setHeader(headers, name, value)
596
+ }
597
+
598
+ const request: ParsedRawRequest = {
599
+ method: finalMethod,
600
+ url: normalizedUrl,
601
+ headers,
602
+ body
603
+ }
604
+ return {
605
+ ok: true,
606
+ request,
607
+ raw: serializeRawRequest(request),
608
+ ignored
609
+ }
610
+ }
611
+
612
+ /** POSIX-shell single-quote escaping; safe for URLs, headers and bodies. */
613
+ export function shellQuote(value: string): string {
614
+ return `'${value.replace(/'/g, `'\\''`)}'`
615
+ }
616
+
617
+ /**
618
+ * Build a copyable curl command equivalent to the parsed request.
619
+ *
620
+ * GET without a body stays implicit (`curl <url>`); every other method (and a
621
+ * GET that carries a body) gets an explicit `-X` so the command stays exact.
622
+ */
623
+ export function buildCurlCommand(request: ParsedRawRequest): string {
624
+ const method = request.method.trim().toUpperCase() || 'GET'
625
+ const body = request.body ?? ''
626
+ const parts: string[] = ['curl']
627
+ if (method !== 'GET' || body !== '') parts.push('-X', method)
628
+ for (const [name, value] of Object.entries(request.headers)) {
629
+ if (name.trim() === '') continue
630
+ parts.push('-H', shellQuote(`${name}: ${value}`))
631
+ }
632
+ if (body !== '') parts.push('--data-raw', shellQuote(body))
633
+ parts.push(shellQuote(request.url))
634
+ return parts.join(' ')
635
+ }