@wwkit/opm 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,7 @@ pnpm opm <command> [args] [options]
67
67
  | `ftp <action>` | FTP 文件管理(上传/rsync/下载) |
68
68
  | `ensure <runtime> [version]` | 确保运行时已安装(node/php/python) |
69
69
  | `ping <host|proxy|registry>` | 验证主机、代理或所有 registry 的可访问性 |
70
+ | `env <action>` | 环境变量管理(list/get/set,跨平台:Unix profile / Windows user 级注册表) |
70
71
  | `config [section]` | 查看常用配置(proxy + 各 registry 段,段名动态生成) |
71
72
  | `opencode <action>` | opencode 运维命令(安装/升级/清理/env);裸 `opm opencode` 启动 TUI |
72
73
  | `harness <action>` | @wwkit/harness 插件管理(安装/升级/list/status/env/dir) |
@@ -532,6 +533,37 @@ opm ping registry -d # 输出完整 JSON 数组
532
533
  > `opm ping proxy` 从 `proxy.active` 解析代理 URL,提取主机名后 ping。
533
534
  > `opm ping registry` 遍历所有含 `registry` 配置的工具,并行 ping 各 active 地址的主机。
534
535
 
536
+ ### env
537
+
538
+ ```bash
539
+ opm env list # 列出 opm 管理的环境变量(含配置文件路径)
540
+ opm env list --all # 追加 process.env 全量,每条标注 managed: true/false
541
+ opm env get PATH # 根据 key 取值(先查 opm 管理源,回退 process.env)
542
+ opm env set FOO bar # 设置环境变量(替换已存在的)
543
+ opm env set MY_API_KEY secret123 # 设置 API 密钥
544
+ opm env search api # 按关键词匹配 key 或 value(忽略大小写)
545
+ opm env search api --all # 同时搜索 process.env
546
+ opm env help # 通用介绍
547
+ ```
548
+
549
+ 输出格式:默认输出**简化文本**(list/search 按来源分组:`配置文件路径` 行 + `key = value` 行;get/set 输出单行 `key = value`);加 `-j/--json` 输出完整 JSON。
550
+
551
+ ```bash
552
+ opm env list # 简化文本(按来源分组)
553
+ opm env list --json # JSON 数组
554
+ opm env get PATH # 简化:PATH = /usr/bin:/bin
555
+ opm env get PATH --json # JSON 对象(含 source/scope)
556
+ opm env set FOO bar # 简化:FOO = bar
557
+ opm env set FOO bar --json # JSON 对象(含 written/source)
558
+ opm env search api # 简化:匹配项按来源分组
559
+ opm env search api --json # JSON 数组
560
+ ```
561
+
562
+ > `list`/`search` 默认只作用于 `opm env set` 管理的变量(Unix 的 `# >>> wwkit NAME >>>` 标记块 / Windows manifest);`--all` 在其基础上追加 `process.env` 全量,并给每条标注 `managed: true/false` 与来源。
563
+ > `search <keyword>` 匹配 key 或 value 是否包含关键词(忽略大小写),可用 `-q/--query` 替代位置参数。
564
+ > 跨平台:Unix 读写 shell profile(`.zshrc`/`.bashrc`/`.profile`/fish config),用 `# >>> wwkit NAME >>>` 标记块管理;Windows 用 `setx` 写 user 级注册表(`HKCU\Environment`),`list` 用 `reg query` 读取。
565
+ > Windows 上 `setx` 不留标记,opm 通过 `~/.config/opm/env-managed.json` 记录哪些 user 级变量由 opm 管理;`set` 始终写入 user 级(无需管理员权限)。
566
+
535
567
  ### opencode
536
568
 
537
569
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wwkit/opm",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "author": "bluesliu <langcai163@163.com>",
5
5
  "description": "Unified CLI — package management (npm/pip/dnf/apt) + config view + opencode maintenance",
6
6
  "type": "module",
@@ -35,7 +35,16 @@
35
35
  "basic-ftp": "^6.2.1",
36
36
  "extract-zip": "^2.0.1",
37
37
  "systeminformation": "^5.23.0",
38
- "@wwkit/shared": "1.0.18"
38
+ "@wwkit/shared": "1.0.20"
39
+ },
40
+ "devDependencies": {
41
+ "jest": "^29.7.0"
42
+ },
43
+ "jest": {
44
+ "transform": {},
45
+ "testMatch": [
46
+ "**/tests/**/*.test.js"
47
+ ]
39
48
  },
40
49
  "publishConfig": {
41
50
  "registry": "https://registry.npmjs.org/",
@@ -45,8 +54,8 @@
45
54
  "postinstall": "node scripts/postinstall.js",
46
55
  "check": "node --check bin/index.js",
47
56
  "start": "node bin/index.js",
48
- "test": "node --test \"tests/**/*.test.js\"",
49
- "test:all": "node --test \"tests/**/*.test.js\"",
57
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests",
58
+ "test:all": "node --experimental-vm-modules node_modules/jest/bin/jest.js --no-cache tests",
50
59
  "doc": "node -e \"import('./src/cli/doc.js').then(m => m.generateDocHtml({ open: false }))\"",
51
60
  "release": "node ../../packages/shared/bin/release-pkg.js"
52
61
  }
@@ -17,7 +17,7 @@ import path from 'node:path'
17
17
  import { parseFlags } from '../cli/helpers/args.js'
18
18
  import { output } from '../formatter.js'
19
19
  import { getActiveRegistry, setActiveRegistry, getPresets, getActiveProxy, getUserConfigDir } from '../config.js'
20
- import { fetchText, downloadToFile } from '../managers/http.js'
20
+ import { fetchText, downloadToFile } from '@wwkit/shared'
21
21
  import { getOs, OS_TYPES } from './platform.js'
22
22
  import { find as findExe } from './exefinder.js'
23
23
  import { verify as verifyDeps } from './depchecker.js'
@@ -0,0 +1,451 @@
1
+ /**
2
+ * env 命令组 — 跨平台环境变量便捷管理
3
+ *
4
+ * 用法:
5
+ * opm env list [--all] 列出 opm 管理的环境变量(含配置文件路径);--all 追加 process.env 全量
6
+ * opm env get <key> 根据 key 取值(先查 opm 管理的配置源,回退 process.env)
7
+ * opm env set <key> <value> 设置值(替换已存在的)
8
+ * opm env help 通用介绍
9
+ *
10
+ * 跨平台策略(复用 @wwkit/shared 的 Shell):
11
+ * Unix: 读写 shell profile(bash/zsh/fish),用 `# >>> wwkit NAME >>>` 标记块管理
12
+ * Windows: setx 写 user 级注册表;list 用 reg query 读取;用 manifest 文件
13
+ * (~/.config/opm/env-managed.json)识别哪些 user 级变量由 opm 管理
14
+ * (setx 不留标记,无法从注册表本身区分)
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { Shell } from '@wwkit/shared'
21
+ import { parseFlags } from '../helpers/args.js'
22
+ import { output } from '../../formatter.js'
23
+ import { getUserConfigDir } from '../../config.js'
24
+
25
+ /** Windows 注册表环境变量键路径 */
26
+ const WIN_USER_KEY = 'HKCU\\Environment'
27
+
28
+ /** manifest 文件名(Windows 记录 opm 管理的 user 级变量 key) */
29
+ const MANIFEST_FILE = 'env-managed.json'
30
+
31
+ /**
32
+ * 反转 Shell.setEnvVar 的转义([$`\\"] → \\$&)
33
+ * @param {string} s
34
+ * @returns {string}
35
+ */
36
+ function unescapeEnvValue(s) {
37
+ return s.replace(/\\([$`\\"])/g, '$1')
38
+ }
39
+
40
+ /**
41
+ * 从一行 export/set 语句中提取变量值
42
+ * @param {string} line
43
+ * @param {string} key
44
+ * @returns {string|null}
45
+ */
46
+ function parseEnvLine(line, key) {
47
+ const exportQuoted = line.match(new RegExp(`^export\\s+${key}="(.*)"\\s*$`))
48
+ if (exportQuoted) return unescapeEnvValue(exportQuoted[1])
49
+ const fishQuoted = line.match(new RegExp(`^set -gx\\s+${key}\\s+"(.*)"\\s*$`))
50
+ if (fishQuoted) return unescapeEnvValue(fishQuoted[1])
51
+ const exportBare = line.match(new RegExp(`^export\\s+${key}=(\\S+)\\s*$`))
52
+ if (exportBare) return exportBare[1]
53
+ const fishBare = line.match(new RegExp(`^set -gx\\s+${key}\\s+(\\S+)\\s*$`))
54
+ if (fishBare) return fishBare[1]
55
+ return null
56
+ }
57
+
58
+ /**
59
+ * 解析 profile 文件内容中的 wwkit 标记块,提取 opm 管理的环境变量
60
+ * @param {string} content
61
+ * @returns {{ key: string, value: string }[]}
62
+ */
63
+ export function parseEnvBlocks(content) {
64
+ if (!content) return []
65
+ const lines = content.split('\n')
66
+ const result = []
67
+ let i = 0
68
+ while (i < lines.length) {
69
+ const marker = lines[i].match(/^# >>> wwkit (\S+) >>>/)
70
+ if (marker) {
71
+ const key = marker[1]
72
+ // 块体:标记行之后到下一个 wwkit 标记或 EOF 之间的非空非注释行
73
+ let j = i + 1
74
+ while (j < lines.length && lines[j].trim() === '') j++
75
+ if (j < lines.length && !lines[j].startsWith('# >>> wwkit')) {
76
+ const value = parseEnvLine(lines[j], key)
77
+ if (value !== null) result.push({ key, value })
78
+ }
79
+ i = j + 1
80
+ } else {
81
+ i++
82
+ }
83
+ }
84
+ return result
85
+ }
86
+
87
+ /**
88
+ * 解析 Windows `reg query` 输出,提取环境变量条目
89
+ * @param {string} output
90
+ * @returns {{ key: string, type: string, value: string }[]}
91
+ */
92
+ export function parseRegOutput(output) {
93
+ if (!output) return []
94
+ const result = []
95
+ for (const line of output.split(/\r?\n/)) {
96
+ const m = line.match(/^\s+(\S+)\s+(REG_\S+)\s+(.*)$/)
97
+ if (m) result.push({ key: m[1], type: m[2], value: m[3] })
98
+ }
99
+ return result
100
+ }
101
+
102
+ /**
103
+ * 校验环境变量名合法性(字母/下划线开头,后接字母数字下划线)
104
+ * @param {string} key
105
+ * @returns {boolean}
106
+ */
107
+ export function isValidEnvKey(key) {
108
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
109
+ }
110
+
111
+ export class EnvGroup {
112
+ /**
113
+ * @param {{ shell?: Shell, configDir?: string }} [opts] - shell/configDir 注入用于测试
114
+ */
115
+ constructor(opts = {}) {
116
+ this.name = 'env'
117
+ this.desc = 'Manage environment variables (list/get/set) across platforms'
118
+ this.shell = opts.shell || new Shell()
119
+ this.configDir = opts.configDir || ''
120
+ }
121
+
122
+ /** @returns {string} manifest 文件绝对路径 */
123
+ _manifestPath() {
124
+ const dir = this.configDir || getUserConfigDir()
125
+ return path.join(dir, MANIFEST_FILE)
126
+ }
127
+
128
+ /**
129
+ * 读取 manifest 中的 opm 管理 key 集合(Windows 用)
130
+ * @returns {Set<string>}
131
+ */
132
+ _readManifest() {
133
+ try {
134
+ const raw = fs.readFileSync(this._manifestPath(), 'utf8')
135
+ const data = JSON.parse(raw)
136
+ return new Set(Array.isArray(data.keys) ? data.keys : [])
137
+ } catch {
138
+ return new Set()
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 将 key 登记到 manifest(Windows set 时调用)
144
+ * @param {string} key
145
+ */
146
+ _addManagedKey(key) {
147
+ const keys = this._readManifest()
148
+ keys.add(key)
149
+ const dir = this.configDir || getUserConfigDir()
150
+ fs.mkdirSync(dir, { recursive: true })
151
+ fs.writeFileSync(this._manifestPath(), JSON.stringify({ keys: [...keys] }, null, 2), 'utf8')
152
+ }
153
+
154
+ /**
155
+ * 列出 opm 管理的环境变量(默认 list 的数据源)
156
+ * Unix: 解析所有 profile 的 wwkit 标记块
157
+ * Windows: 读 manifest → 查 user 级注册表当前值
158
+ * @returns {object[]}
159
+ */
160
+ _managedItems() {
161
+ return this.shell.isWindows ? this._listWindowsManaged() : this._listUnix()
162
+ }
163
+
164
+ _listUnix() {
165
+ const items = []
166
+ for (const { path: profilePath, syntax } of this.shell.getProfiles()) {
167
+ let content = ''
168
+ try {
169
+ content = fs.readFileSync(profilePath, 'utf8')
170
+ } catch {}
171
+ for (const { key, value } of parseEnvBlocks(content)) {
172
+ items.push({ key, value, scope: 'user', source: profilePath, syntax })
173
+ }
174
+ }
175
+ return items
176
+ }
177
+
178
+ _listWindowsManaged() {
179
+ const managedKeys = this._readManifest()
180
+ if (managedKeys.size === 0) return []
181
+ const userVars = this._regQuery(WIN_USER_KEY)
182
+ const byKey = new Map(userVars.map((v) => [v.key, v]))
183
+ const items = []
184
+ for (const key of managedKeys) {
185
+ const v = byKey.get(key)
186
+ if (v) {
187
+ items.push({ key, value: v.value, scope: 'user', source: 'registry (user)', type: v.type })
188
+ } else {
189
+ // manifest 登记过但注册表中已不存在(外部删除)——标注缺失
190
+ items.push({ key, value: null, scope: 'user', source: 'registry (user)', missing: true })
191
+ }
192
+ }
193
+ return items
194
+ }
195
+
196
+ _regQuery(keyPath) {
197
+ try {
198
+ const result = spawnSync('reg', ['query', keyPath], {
199
+ encoding: 'utf8',
200
+ stdio: ['pipe', 'pipe', 'pipe'],
201
+ })
202
+ if (result.status !== 0) return []
203
+ return parseRegOutput(result.stdout || '')
204
+ } catch {
205
+ return []
206
+ }
207
+ }
208
+
209
+ /**
210
+ * --all:opm 管理的 ∪ process.env 全量,每条标注 managed
211
+ * @returns {object[]}
212
+ */
213
+ _listAll() {
214
+ const managedItems = this._managedItems()
215
+ const seen = new Set()
216
+ const items = []
217
+ for (const m of managedItems) {
218
+ items.push({ key: m.key, value: m.value, managed: true, source: m.source, scope: m.scope })
219
+ seen.add(m.key)
220
+ }
221
+ for (const [key, value] of Object.entries(process.env)) {
222
+ if (!seen.has(key)) {
223
+ items.push({ key, value, managed: false, source: 'process.env' })
224
+ seen.add(key)
225
+ }
226
+ }
227
+ return items
228
+ }
229
+
230
+ async run(argv) {
231
+ const [action, ...rest] = argv
232
+
233
+ if (!action || action === '-h' || action === '--help' || action === 'help') {
234
+ this.printHelp()
235
+ return
236
+ }
237
+
238
+ const parsed = parseFlags(rest)
239
+
240
+ switch (action) {
241
+ case 'list':
242
+ return this._list(parsed)
243
+ case 'get':
244
+ return this._get(parsed)
245
+ case 'set':
246
+ return this._set(parsed)
247
+ case 'search':
248
+ return this._search(parsed)
249
+ default:
250
+ console.error(`Unknown action: ${action}`)
251
+ this.printHelp()
252
+ process.exit(1)
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 是否输出 JSON(--json / -j)
258
+ * @param {{ flags: object, positional: string[] }} parsed
259
+ * @returns {boolean}
260
+ */
261
+ _useJson(parsed) {
262
+ return parsed.flags.json === 'true' || parsed.flags.j === 'true'
263
+ }
264
+
265
+ /**
266
+ * 统一输出:--json 走 JSON,否则走 plain 格式化函数
267
+ * @param {{ flags: object, positional: string[] }} parsed
268
+ * @param {*} data - JSON 输出数据
269
+ * @param {() => string} plain - 简化字符串输出函数
270
+ */
271
+ _out(parsed, data, plain) {
272
+ if (this._useJson(parsed)) {
273
+ output(data)
274
+ } else {
275
+ console.log(plain())
276
+ }
277
+ }
278
+
279
+ /**
280
+ * list 简化格式:按 source 分组,每组标题为来源,下面 key = value 行
281
+ * @param {object[]} items
282
+ * @returns {string}
283
+ */
284
+ _formatListPlain(items) {
285
+ const groups = []
286
+ const groupMap = new Map()
287
+ for (const item of items) {
288
+ const source = item.source || 'unknown'
289
+ if (!groupMap.has(source)) {
290
+ const g = { source, entries: [] }
291
+ groupMap.set(source, g)
292
+ groups.push(g)
293
+ }
294
+ groupMap.get(source).entries.push(item)
295
+ }
296
+ return groups
297
+ .map((g) => [
298
+ g.source,
299
+ ...g.entries.map((e) => `${e.key} = ${e.value === null || e.value === undefined ? '(not set)' : e.value}`),
300
+ ].join('\n'))
301
+ .join('\n\n')
302
+ }
303
+
304
+ /**
305
+ * 列出环境变量
306
+ * 默认: 只显示 opm 管理的(Unix 标记块 / Windows manifest)
307
+ * --all: 追加 process.env 全量,每条标注 managed: true/false
308
+ * 输出: 默认简化文本(按 source 分组),--json 输出 JSON
309
+ * @param {{ flags: object, positional: string[] }} parsed
310
+ */
311
+ async _list(parsed) {
312
+ const all = parsed.flags.all === 'true' || parsed.flags.a === 'true'
313
+ const items = all ? this._listAll() : this._managedItems()
314
+ this._out(parsed, items, () => this._formatListPlain(items))
315
+ }
316
+
317
+ /**
318
+ * 根据 key 取值:先查 opm 管理的配置源,回退 process.env
319
+ * 输出: 默认 `key = value`(未找到 `key = (not found)`),--json 输出 JSON
320
+ * @param {{ flags: object, positional: string[] }} parsed
321
+ */
322
+ async _get(parsed) {
323
+ const key = parsed.positional[0] || parsed.flags.k || parsed.flags.key || ''
324
+ if (!key) {
325
+ console.error('Usage: opm env get <key>')
326
+ console.error('Missing required <key>')
327
+ process.exit(1)
328
+ }
329
+
330
+ const configured = this._managedItems()
331
+ const hit = configured.find((e) => e.key === key)
332
+ if (hit) {
333
+ this._out(parsed, { key, value: hit.value, found: true, source: hit.source, scope: hit.scope }, () => `${key} = ${hit.value}`)
334
+ return
335
+ }
336
+
337
+ if (process.env[key] !== undefined) {
338
+ const value = process.env[key]
339
+ this._out(parsed, { key, value, found: true, source: 'process.env' }, () => `${key} = ${value}`)
340
+ return
341
+ }
342
+
343
+ this._out(parsed, { key, value: null, found: false }, () => `${key} = (not found)`)
344
+ }
345
+
346
+ /**
347
+ * 设置环境变量(替换已存在的)
348
+ * Unix: 写入 shell profile 标记块;Windows: setx 写 user 级注册表 + manifest 登记
349
+ * @param {{ flags: object, positional: string[] }} parsed
350
+ */
351
+ async _set(parsed) {
352
+ const key = parsed.positional[0] || parsed.flags.k || parsed.flags.key || ''
353
+ const value = parsed.positional[1] ?? parsed.flags.v ?? parsed.flags.value ?? ''
354
+
355
+ if (!key) {
356
+ console.error('Usage: opm env set <key> <value>')
357
+ console.error('Missing required <key>')
358
+ process.exit(1)
359
+ }
360
+ if (!isValidEnvKey(key)) {
361
+ console.error(`Invalid env key: "${key}" (must start with letter/underscore, only letters/digits/underscore)`)
362
+ process.exit(1)
363
+ }
364
+ if (parsed.positional.length < 2 && !(parsed.flags.v || parsed.flags.value)) {
365
+ console.error('Usage: opm env set <key> <value>')
366
+ console.error('Missing required <value>')
367
+ process.exit(1)
368
+ }
369
+
370
+ const written = this.shell.setEnvVar(key, value)
371
+ if (this.shell.isWindows) {
372
+ this._addManagedKey(key)
373
+ }
374
+ const source = this.shell.isWindows
375
+ ? 'registry (user)'
376
+ : this.shell.getProfiles().map((p) => p.path)
377
+ const result = { key, value, written, source }
378
+ this._out(parsed, result, () => `${key} = ${value}${written ? '' : ' (no change)'}`)
379
+ }
380
+
381
+ /**
382
+ * 按关键词搜索环境变量(匹配 key 或 value,忽略大小写)
383
+ * 默认: 在 opm 管理的变量中搜索;--all 扩展至 process.env 全量
384
+ * 输出: 默认简化文本(按 source 分组),--json 输出 JSON
385
+ * @param {{ flags: object, positional: string[] }} parsed
386
+ */
387
+ async _search(parsed) {
388
+ const keyword = parsed.positional[0] || parsed.flags.q || parsed.flags.query || ''
389
+ if (!keyword) {
390
+ console.error('Usage: opm env search <keyword>')
391
+ console.error('Missing required <keyword>')
392
+ process.exit(1)
393
+ }
394
+
395
+ const all = parsed.flags.all === 'true' || parsed.flags.a === 'true'
396
+ const source = all ? this._listAll() : this._managedItems()
397
+ const kw = keyword.toLowerCase()
398
+ const matches = source.filter((e) => {
399
+ const k = String(e.key).toLowerCase()
400
+ const v = e.value === null || e.value === undefined ? '' : String(e.value).toLowerCase()
401
+ return k.includes(kw) || v.includes(kw)
402
+ })
403
+ this._out(parsed, matches, () => this._formatListPlain(matches))
404
+ }
405
+
406
+ printHelp() {
407
+ console.log(`
408
+ Usage: opm env <action> [args] [options]
409
+
410
+ Actions:
411
+ list [--all] List opm-managed env vars with their config file path
412
+ (default: only vars set via "opm env set";
413
+ --all appends all process.env vars, each tagged managed: true/false)
414
+ get <key> Get value by key (checks opm-managed sources first, then process.env)
415
+ set <key> <value> Set value, replacing any existing one
416
+ (Unix: shell profile marker block; Windows: setx user-level registry)
417
+ search <keyword> [--all] Search env vars whose key or value contains keyword (case-insensitive)
418
+ (default: opm-managed vars; --all also searches process.env)
419
+ help Show this help
420
+
421
+ Options:
422
+ -k, --key <name> Key (alternative to positional for get/set)
423
+ -v, --value <val> Value (alternative to positional for set)
424
+ -q, --query <kw> Keyword (alternative to positional for search)
425
+ -a, --all Also list all process.env vars (tagged managed: true/false)
426
+ -j, --json Output JSON (default: plain text)
427
+ -h, --help Show this help
428
+
429
+ Output:
430
+ default (no -j) Plain text: list groups by source then "key = value" lines;
431
+ get/set print a single "key = value" line
432
+ -j / --json JSON object/array with full metadata
433
+
434
+ Platform behavior:
435
+ Unix read/write shell profiles (.zshrc/.bashrc/.profile/fish config)
436
+ via "# >>> wwkit NAME >>>" marker blocks
437
+ Windows read/write user-level registry (HKCU\\Environment); writes via setx;
438
+ opm-managed keys tracked in ~/.config/opm/env-managed.json
439
+
440
+ Examples:
441
+ opm env list
442
+ opm env list --all # all process.env vars, tagged managed
443
+ opm env get PATH
444
+ opm env set FOO bar
445
+ opm env set MY_API_KEY secret123
446
+ opm env search api # match key or value containing "api" (case-insensitive)
447
+ opm env search api --all # also search process.env
448
+ opm env help
449
+ `)
450
+ }
451
+ }
@@ -18,6 +18,9 @@ import { getActiveProxy, getActiveRegistry, getConfig } from '../../config.js'
18
18
 
19
19
  const shell = new Shell()
20
20
 
21
+ // 跨平台 null device(curl -o 丢弃输出体)
22
+ const NULL_DEV = process.platform === 'win32' ? 'NUL' : '/dev/null'
23
+
21
24
  /**
22
25
  * 从 URL 或 deb 行中提取主机名和端口(用于 ping 目标)
23
26
  * @param {string} url
@@ -47,8 +50,9 @@ function isUrl(addr) {
47
50
  }
48
51
 
49
52
  // HTTP 状态码 → 可达性判断:2xx/3xx 视为可达,allowlist 中的状态码
50
- // 虽非成功响应但证明服务器已响应(如 404 表示主机/代理存活)
51
- const REACHABLE_ALLOWLIST = [404]
53
+ // 虽非成功响应但证明服务器已响应(如 404 表示主机/代理存活,
54
+ // 429 表示临时限流,服务器实际存活)
55
+ const REACHABLE_ALLOWLIST = [404, 429]
52
56
 
53
57
  function httpReachable(httpCode) {
54
58
  return (httpCode >= 200 && httpCode < 400) || REACHABLE_ALLOWLIST.includes(httpCode)
@@ -85,7 +89,16 @@ function checkByPing(host, count) {
85
89
  ? ['-n', String(count), '-w', '2000', host]
86
90
  : ['-c', String(count), '-W', '2', host]
87
91
 
88
- const child = spawn('ping', args, { stdio: ['pipe', 'pipe', 'pipe'] })
92
+ let child
93
+ try {
94
+ child = spawn('ping', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
95
+ } catch {
96
+ resolve({
97
+ reachable: false, sent: count, received: 0, loss: count,
98
+ lossPercent: 100, rttMin: '', rttAvg: '', rttMax: '', raw: 'ping command failed to execute',
99
+ })
100
+ return
101
+ }
89
102
  let stdout = ''
90
103
  let stderr = ''
91
104
  child.stdout.on('data', (d) => { stdout += d })
@@ -154,10 +167,19 @@ function checkByPing(host, count) {
154
167
  */
155
168
  function checkByCurl(url, proxy) {
156
169
  return new Promise((resolve) => {
157
- const args = ['-k', '-L', '-s', '-o', '/dev/null', '-w', '%{http_code} %{time_total}', '--connect-timeout', '2', '--max-time', '5']
170
+ const args = ['-k', '-L', '-s', '-o', NULL_DEV, '-w', '%{http_code} %{time_total}', '--connect-timeout', '2', '--max-time', '5']
158
171
  if (proxy) args.push('-x', proxy)
159
172
  args.push(url)
160
- const child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] })
173
+ let child
174
+ try {
175
+ child = spawn('curl', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
176
+ } catch {
177
+ resolve({
178
+ reachable: false, sent: 1, received: 0, loss: 1,
179
+ lossPercent: 100, rttMin: '', rttAvg: '', rttMax: '', raw: 'curl failed to execute',
180
+ })
181
+ return
182
+ }
161
183
  let stdout = ''
162
184
  let stderr = ''
163
185
  child.stdout.on('data', (d) => { stdout += d })
@@ -204,7 +226,16 @@ function checkByWget(url, proxy) {
204
226
  args.push('-e', `use_proxy=yes`, '-e', `https_proxy=${proxy}`, '-e', `http_proxy=${proxy}`)
205
227
  }
206
228
  args.push(url)
207
- const child = spawn('wget', args, { stdio: ['pipe', 'pipe', 'pipe'] })
229
+ let child
230
+ try {
231
+ child = spawn('wget', args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
232
+ } catch {
233
+ resolve({
234
+ reachable: false, sent: 1, received: 0, loss: 1,
235
+ lossPercent: 100, rttMin: '', rttAvg: '', rttMax: '', raw: 'wget failed to execute',
236
+ })
237
+ return
238
+ }
208
239
  let stdout = ''
209
240
  let stderr = ''
210
241
  child.stdout.on('data', (d) => { stdout += d })
@@ -271,7 +302,7 @@ async function checkReachability(address, tool, count, proxy) {
271
302
  return { reachable: false, sent: 0, received: 0, loss: 0, lossPercent: 0, rttMin: '', rttAvg: '', rttMax: '', raw: 'No tool available' }
272
303
  }
273
304
 
274
- export { checkReachability, extractHostPort }
305
+ export { checkReachability, extractHostPort, httpReachable }
275
306
 
276
307
  export class PingGroup {
277
308
  constructor() {
@@ -298,12 +329,9 @@ export class PingGroup {
298
329
  const n = (isNaN(count) || count < 1) ? 4 : count
299
330
  const detail = !!(parsed.flags.d || parsed.flags.detail)
300
331
 
301
- let proxy = ''
302
- if ('p' in parsed.flags || 'proxy' in parsed.flags) {
303
- let raw = parsed.flags.proxy || parsed.flags.p || ''
304
- if (raw === 'true') raw = ''
305
- proxy = raw || getActiveProxy()
306
- }
332
+ let proxy = parsed.flags.proxy || parsed.flags.p || ''
333
+ if (proxy === 'true') proxy = ''
334
+ if (!proxy) proxy = getActiveProxy()
307
335
 
308
336
  if (target === 'proxy') {
309
337
  return this._pingProxy(n, detail, tool)
@@ -318,7 +346,8 @@ export class PingGroup {
318
346
  }
319
347
 
320
348
  if (proxy) {
321
- const usable = await this._checkProxyUsable(proxy, tool, n)
349
+ const testUrl = isUrl(target) ? target : undefined
350
+ const usable = await this._checkProxyUsable(proxy, tool, n, testUrl)
322
351
  if (!usable.ok) {
323
352
  console.error(`[opm] Proxy not usable: ${usable.reason}`)
324
353
  process.exit(1)
@@ -331,12 +360,16 @@ export class PingGroup {
331
360
  }
332
361
 
333
362
  /**
334
- * 检查代理是否可用:通过代理访问 npm active registry 测试
363
+ * 检查代理是否可用:通过代理访问指定 URL 测试
364
+ * @param {string} proxyUrl - 代理地址
365
+ * @param {string} tool - 检测工具
366
+ * @param {number} count - ping 包数量
367
+ * @param {string} [testUrl] - 测试 URL(默认 npm active registry)
335
368
  * @returns {{ ok: boolean, reason: string }}
336
369
  */
337
- async _checkProxyUsable(proxyUrl, tool, count) {
338
- const testUrl = getActiveRegistry('npm')
339
- const result = await checkReachability(testUrl, tool, count, proxyUrl)
370
+ async _checkProxyUsable(proxyUrl, tool, count, testUrl) {
371
+ const url = testUrl || getActiveRegistry('npm')
372
+ const result = await checkReachability(url, tool, count, proxyUrl)
340
373
  if (result.proxyAuthRequired) {
341
374
  return { ok: false, reason: 'authentication required (HTTP 407)' }
342
375
  }
@@ -376,7 +409,13 @@ export class PingGroup {
376
409
  const tools = getRegistryTools()
377
410
 
378
411
  if (proxy) {
379
- const usable = await this._checkProxyUsable(proxy, tool, 1)
412
+ // 用首个可用 registry URL 作为代理可用性测试目标(而非硬编码 npm)
413
+ let testUrl = ''
414
+ for (const t of tools) {
415
+ const u = getActiveRegistry(t)
416
+ if (u) { testUrl = u; break }
417
+ }
418
+ const usable = await this._checkProxyUsable(proxy, tool, 1, testUrl)
380
419
  if (!usable.ok) {
381
420
  console.error(`[opm] Proxy not usable: ${usable.reason}`)
382
421
  process.exit(1)
@@ -459,7 +498,8 @@ Actions:
459
498
 
460
499
  Options:
461
500
  -c, --count <n> Number of packets to send (ping only; default: 4)
462
- -p, --proxy <url> Record proxy used for the check (informational)
501
+ -p, --proxy <url> Proxy address for the check (overrides config proxy.active;
502
+ routes curl/wget requests through the proxy and pre-validates usability)
463
503
  -d, --detail Output full JSON details (default: compact [{tool, address, proxy, reachable}])
464
504
  -h, --help Show this help
465
505
 
package/src/cli/index.js CHANGED
@@ -21,6 +21,7 @@ import { EnsureGroup } from './groups/ensure.js'
21
21
  import { PingGroup } from './groups/ping.js'
22
22
  import { ProcGroup } from './groups/proc.js'
23
23
  import { OverlayGroup } from './groups/overlay.js'
24
+ import { EnvGroup } from './groups/env.js'
24
25
  import { FtpGroup } from '../tools/ftp/index.js'
25
26
  import { GitGroup } from '../tools/git/index.js'
26
27
  import { ShareGroup } from '../tools/share/index.js'
@@ -66,6 +67,7 @@ const GROUPS = {
66
67
  ping: new PingGroup(),
67
68
  proc: new ProcGroup(),
68
69
  overlay: new OverlayGroup(),
70
+ env: new EnvGroup(),
69
71
  }
70
72
 
71
73
  class CLI {
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ export { DockerGroup } from './cli/groups/docker.js'
23
23
  export { PingGroup } from './cli/groups/ping.js'
24
24
  export { ProcGroup } from './cli/groups/proc.js'
25
25
  export { OverlayGroup } from './cli/groups/overlay.js'
26
+ export { EnvGroup, parseEnvBlocks, parseRegOutput, isValidEnvKey } from './cli/groups/env.js'
26
27
  export { FtpGroup } from './tools/ftp/index.js'
27
28
  export { GitGroup } from './tools/git/index.js'
28
29
  export { ShareGroup } from './tools/share/index.js'
@@ -62,7 +63,7 @@ export {
62
63
  } from './presets.js'
63
64
  export { PROXY_PRESETS } from './proxy-presets.js'
64
65
  export { output } from './formatter.js'
65
- export { downloadToFile, fetchText } from './managers/http.js'
66
+ export { downloadToFile, fetchText } from '@wwkit/shared'
66
67
  export {
67
68
  getConfig, getSection, copyBuiltinConfig, getActiveRegistry, setActiveRegistry,
68
69
  getUserConfigDir, getUserConfigFile, getActiveProxy,
@@ -166,6 +166,7 @@ export class BrewManager extends PackageManager {
166
166
  _execInherit(args, opts = {}) {
167
167
  const result = spawnSync(this._brewBin(), args, {
168
168
  stdio: 'inherit',
169
+ shell: true,
169
170
  env: this._buildEnv(opts.proxy),
170
171
  })
171
172
  if (result.error) {
@@ -2,7 +2,7 @@
2
2
  * bun 包管理器实现
3
3
  *
4
4
  * Bun 是全栈 JS 工具链(runtime + 包管理器 + 打包器)。
5
- * 包管理命令通过 bun CLI 实现,源查询通过 HTTP 直接查 npm registry。
5
+ * 包管理命令通过 bun CLI 实现,源查询通过共享 fetchText 查 npm registry。
6
6
  * 镜像通过 BUN_CONFIG_REGISTRY 环境变量传递给 bun 命令。
7
7
  */
8
8
 
@@ -11,6 +11,7 @@ import fs from 'node:fs'
11
11
  import os from 'node:os'
12
12
  import path from 'node:path'
13
13
  import { PackageManager } from './base.js'
14
+ import { fetchText } from '@wwkit/shared'
14
15
  import { getActiveRegistry, setActiveRegistry } from '../config.js'
15
16
 
16
17
  export class BunManager extends PackageManager {
@@ -108,52 +109,24 @@ export class BunManager extends PackageManager {
108
109
 
109
110
  /**
110
111
  * 通过 HTTP 查询 npm registry 获取包元数据
112
+ * 使用共享 fetchText(支持 HTTP/SOCKS5 代理隧道,与 pip/composer 一致)
111
113
  * @param {string} pkgName
112
114
  * @param {{ proxy?: string }} [opts]
113
115
  * @returns {Promise<object|null>}
114
116
  * @private
115
117
  */
116
118
  async _fetchRegistryMetadata(pkgName, opts = {}) {
117
- const registry = getActiveRegistry('bun').replace(/\/$/, '')
119
+ const registry = (getActiveRegistry('bun') || 'https://registry.npmjs.org').replace(/\/$/, '')
118
120
  const url = `${registry}/${encodeURIComponent(pkgName).replace('%40', '@')}`
119
121
  try {
120
- const { default: http } = await import('node:http')
121
- const { default: https } = await import('node:https')
122
- const client = url.startsWith('https') ? https : http
123
- const proxyAgent = opts.proxy ? await this._getProxyAgent(opts.proxy) : null
124
- return new Promise((resolve) => {
125
- const req = client.get(url, { agent: proxyAgent }, (res) => {
126
- let data = ''
127
- res.on('data', (chunk) => (data += chunk))
128
- res.on('end', () => {
129
- try {
130
- resolve(JSON.parse(data))
131
- } catch {
132
- resolve(null)
133
- }
134
- })
135
- })
136
- req.on('error', () => resolve(null))
137
- req.setTimeout(15000, () => {
138
- req.destroy()
139
- resolve(null)
140
- })
141
- })
122
+ const text = await fetchText(url, { proxy: opts.proxy })
123
+ if (!text) return null
124
+ return JSON.parse(text)
142
125
  } catch {
143
126
  return null
144
127
  }
145
128
  }
146
129
 
147
- /**
148
- * 获取 proxy agent(简化实现,仅支持 http proxy)
149
- * @param {string} proxy
150
- * @returns {Promise<undefined>}
151
- * @private
152
- */
153
- async _getProxyAgent(proxy) {
154
- return undefined
155
- }
156
-
157
130
  async getVersion() {
158
131
  try {
159
132
  const version = this._exec(['--version'], { allowNonZero: true }).trim()
@@ -14,7 +14,7 @@ import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { PackageManager } from './base.js'
17
- import { fetchText } from './http.js'
17
+ import { fetchText } from '@wwkit/shared'
18
18
  import { getActiveRegistry, setActiveRegistry } from '../config.js'
19
19
 
20
20
  export class ComposerManager extends PackageManager {
@@ -9,7 +9,7 @@ import { execSync, spawnSync } from 'node:child_process'
9
9
  import path from 'node:path'
10
10
  import { URL } from 'node:url'
11
11
  import { PackageManager } from './base.js'
12
- import { fetchText } from './http.js'
12
+ import { fetchText } from '@wwkit/shared'
13
13
  import { getActiveRegistry, setActiveRegistry } from '../config.js'
14
14
 
15
15
  export class PipManager extends PackageManager {
@@ -52,7 +52,13 @@ export async function getPortMap() {
52
52
  const args = shell.isWindows ? ['-ano'] : ['-tlnp']
53
53
 
54
54
  return new Promise((resolve) => {
55
- const child = spawn(fallback, args, { stdio: ['pipe', 'pipe', 'pipe'] })
55
+ let child
56
+ try {
57
+ child = spawn(fallback, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true })
58
+ } catch {
59
+ resolve(map)
60
+ return
61
+ }
56
62
  let stdout = ''
57
63
  child.stdout.on('data', (d) => { stdout += d })
58
64
  child.on('close', () => {
@@ -1,432 +0,0 @@
1
- /**
2
- * 共享 HTTP 拉取工具(支持代理隧道)
3
- *
4
- * 提取自 pip 管理器的 _fetchText 实现,供各包管理器复用:
5
- * 无代理时使用 native fetch;有代理时根据代理协议建立隧道:
6
- * - http(s):// HTTP 代理:HTTPS 目标走 CONNECT 隧道,HTTP 目标直接以绝对 URI 转发
7
- * - socks5(h):// Socks5 代理:握手后隧道转发(支持无认证 / username-password 认证)
8
- * 支持自动跟随重定向(最多 5 次)。
9
- */
10
-
11
- import fs from 'node:fs'
12
- import http from 'node:http'
13
- import net from 'node:net'
14
- import https from 'node:https'
15
- import tls from 'node:tls'
16
-
17
- /**
18
- * 建立裸 TCP 连接
19
- * @param {string} host
20
- * @param {number} port
21
- * @param {number} timeout
22
- * @returns {Promise<import('node:net').Socket>}
23
- * @private
24
- */
25
- function openTcp(host, port, timeout) {
26
- return new Promise((resolve, reject) => {
27
- const socket = net.connect({ host, port })
28
- socket.setTimeout(timeout)
29
- socket.setNoDelay(true)
30
- socket.once('connect', () => {
31
- socket.setTimeout(0)
32
- resolve(socket)
33
- })
34
- socket.once('error', reject)
35
- socket.once('timeout', () => {
36
- socket.destroy()
37
- reject(new Error('tcp connect timeout'))
38
- })
39
- })
40
- }
41
-
42
- /**
43
- * 对已连接的 socket 执行 TLS 握手
44
- * @param {import('node:net').Socket} socket
45
- * @param {string} host - SNI
46
- * @param {number} timeout
47
- * @returns {Promise<import('node:tls').TLSSocket>}
48
- * @private
49
- */
50
- function wrapTls(socket, host, timeout) {
51
- return new Promise((resolve, reject) => {
52
- const tlsSocket = tls.connect({ socket, servername: host, timeout }, () => {
53
- tlsSocket.setTimeout(0)
54
- resolve(tlsSocket)
55
- })
56
- tlsSocket.once('error', (err) => {
57
- socket.destroy()
58
- reject(err)
59
- })
60
- tlsSocket.once('timeout', () => {
61
- tlsSocket.destroy()
62
- reject(new Error('tls handshake timeout'))
63
- })
64
- })
65
- }
66
-
67
- /**
68
- * 通过 HTTP 代理建立 CONNECT 隧道
69
- * @param {URL} proxyUrl
70
- * @param {string} host
71
- * @param {number} port
72
- * @param {number} timeout
73
- * @returns {Promise<import('node:net').Socket>}
74
- * @private
75
- */
76
- function httpConnect(proxyUrl, host, port, timeout) {
77
- return new Promise((resolve, reject) => {
78
- const req = http.request({
79
- host: proxyUrl.hostname,
80
- port: proxyUrl.port || 80,
81
- method: 'CONNECT',
82
- path: `${host}:${port}`,
83
- timeout
84
- })
85
- req.once('connect', (res, socket) => {
86
- if (res.statusCode !== 200) {
87
- socket.destroy()
88
- reject(new Error(`CONNECT failed: ${res.statusCode}`))
89
- return
90
- }
91
- socket.setTimeout(0)
92
- resolve(socket)
93
- })
94
- req.once('error', reject)
95
- req.once('timeout', () => {
96
- req.destroy()
97
- reject(new Error('CONNECT timeout'))
98
- })
99
- req.end()
100
- })
101
- }
102
-
103
- /**
104
- * 通过 Socks5 代理建立隧道(RFC 1928)
105
- *
106
- * 支持三种地址类型(DOMAIN/IPv4/IPv6)与两种认证方式:
107
- * 无认证(0x00)、username/password(0x02,RFC 1929)。
108
- * 代理 URL 带用户名密码时自动使用 0x02 认证。
109
- *
110
- * @param {URL} proxyUrl - socks5://host:port 或 socks5://user:pass@host:port
111
- * @param {string} host - 目标主机名
112
- * @param {number} port - 目标端口
113
- * @param {number} timeout
114
- * @returns {Promise<import('node:net').Socket>}
115
- * @private
116
- */
117
- function socks5Connect(proxyUrl, host, port, timeout) {
118
- const auth = proxyUrl.username
119
- ? {
120
- user: decodeURIComponent(proxyUrl.username),
121
- pass: decodeURIComponent(proxyUrl.password || '')
122
- }
123
- : null
124
-
125
- return openTcp(proxyUrl.hostname, proxyUrl.port || 1080, timeout).then(
126
- (socket) =>
127
- new Promise((resolve, reject) => {
128
- const fail = (err) => {
129
- socket.destroy()
130
- reject(err instanceof Error ? err : new Error(String(err)))
131
- }
132
-
133
- const methods = auth ? [0x00, 0x02] : [0x00]
134
- socket.write(Buffer.from([0x05, methods.length, ...methods]))
135
-
136
- let stage = 'greeting'
137
- let buf = Buffer.alloc(0)
138
-
139
- const sendConnect = () => {
140
- stage = 'connect'
141
- const hostBuf = Buffer.from(host, 'utf8')
142
- const req = Buffer.alloc(7 + hostBuf.length)
143
- req[0] = 0x05
144
- req[1] = 0x01
145
- req[2] = 0x00
146
- req[3] = 0x03
147
- req[4] = hostBuf.length
148
- hostBuf.copy(req, 5)
149
- req.writeUInt16BE(port, 5 + hostBuf.length)
150
- socket.write(req)
151
- }
152
-
153
- const onData = (chunk) => {
154
- buf = Buffer.concat([buf, chunk])
155
- try {
156
- if (stage === 'greeting') {
157
- if (buf.length < 2) return
158
- const method = buf.readUInt8(1)
159
- buf = buf.subarray(2)
160
- if (method === 0xff) {
161
- throw new Error('socks5: no acceptable auth method')
162
- }
163
- if (method === 0x02) {
164
- if (!auth) {
165
- throw new Error('socks5: server requires auth, but none provided')
166
- }
167
- const userBuf = Buffer.from(auth.user, 'utf8')
168
- const passBuf = Buffer.from(auth.pass, 'utf8')
169
- const req = Buffer.alloc(3 + userBuf.length + passBuf.length)
170
- req[0] = 0x01
171
- req[1] = userBuf.length
172
- userBuf.copy(req, 2)
173
- req[2 + userBuf.length] = passBuf.length
174
- passBuf.copy(req, 3 + userBuf.length)
175
- socket.write(req)
176
- stage = 'auth'
177
- return
178
- }
179
- sendConnect()
180
- return
181
- }
182
- if (stage === 'auth') {
183
- if (buf.length < 2) return
184
- if (buf.readUInt8(1) !== 0x00) {
185
- throw new Error('socks5: username/password auth failed')
186
- }
187
- buf = buf.subarray(2)
188
- sendConnect()
189
- return
190
- }
191
- if (stage === 'connect') {
192
- if (buf.length < 4) return
193
- if (buf.readUInt8(0) !== 0x05) {
194
- throw new Error('socks5: invalid reply version')
195
- }
196
- const rep = buf.readUInt8(1)
197
- if (rep !== 0x00) {
198
- throw new Error(`socks5: connect failed with code ${rep}`)
199
- }
200
- const atyp = buf.readUInt8(3)
201
- let addrLen
202
- if (atyp === 0x01) addrLen = 4
203
- else if (atyp === 0x03) addrLen = buf.readUInt8(4) + 1
204
- else if (atyp === 0x04) addrLen = 16
205
- else {
206
- throw new Error('socks5: invalid address type')
207
- }
208
- const total = 4 + addrLen + 2
209
- if (buf.length < total) return
210
- cleanup()
211
- socket.setTimeout(0)
212
- resolve(socket)
213
- }
214
- } catch (err) {
215
- cleanup()
216
- fail(err)
217
- }
218
- }
219
-
220
- const cleanup = () => {
221
- socket.removeListener('data', onData)
222
- socket.removeListener('error', onError)
223
- socket.removeListener('timeout', onTimeout)
224
- }
225
-
226
- const onError = (err) => {
227
- cleanup()
228
- fail(err)
229
- }
230
-
231
- const onTimeout = () => {
232
- cleanup()
233
- fail(new Error('socks5: timeout'))
234
- }
235
-
236
- socket.setTimeout(timeout)
237
- socket.on('data', onData)
238
- socket.on('error', onError)
239
- socket.on('timeout', onTimeout)
240
- })
241
- )
242
- }
243
-
244
- /**
245
- * 建立到目标主机的隧道 socket(HTTPS 目标会完成 TLS 握手)
246
- *
247
- * @param {URL} proxyUrl - 代理 URL
248
- * @param {string} scheme - 代理协议(http/https/socks5/socks5h)
249
- * @param {string} host - 目标主机名
250
- * @param {number} port - 目标端口
251
- * @param {boolean} isHttps - 目标是否为 HTTPS
252
- * @param {number} timeout - 超时(ms)
253
- * @returns {Promise<import('node:net').Socket>}
254
- * @private
255
- */
256
- function tunnelConnect(proxyUrl, scheme, host, port, isHttps, timeout) {
257
- if (scheme === 'socks5' || scheme === 'socks5h') {
258
- return socks5Connect(proxyUrl, host, port, timeout).then((socket) =>
259
- isHttps ? wrapTls(socket, host, timeout) : socket
260
- )
261
- }
262
- if (scheme === 'http' || scheme === 'https') {
263
- if (isHttps) {
264
- return httpConnect(proxyUrl, host, port, timeout).then((socket) =>
265
- wrapTls(socket, host, timeout)
266
- )
267
- }
268
- return openTcp(proxyUrl.hostname, proxyUrl.port || 80, timeout)
269
- }
270
- return Promise.reject(new Error(`Unsupported proxy scheme: ${scheme}`))
271
- }
272
-
273
- /**
274
- * 通过代理或直连把 URL 内容流式下载到文件
275
- *
276
- * @param {string} url - 请求 URL
277
- * @param {string} destPath - 目标文件路径
278
- * @param {{ proxy?: string, timeout?: number, onProgress?: (pct: number) => void }} [opts]
279
- * @returns {Promise<void>}
280
- */
281
- export async function downloadToFile(url, destPath, { proxy, timeout = 600000, onProgress } = {}) {
282
- const target = new URL(url)
283
- const isHttps = target.protocol === 'https:'
284
- const targetPort = target.port || (isHttps ? 443 : 80)
285
-
286
- if (!proxy) {
287
- await new Promise((resolve, reject) => {
288
- const proto = isHttps ? https : http
289
- const req = proto.get(url, { timeout }, (res) => {
290
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
291
- res.resume()
292
- const next = new URL(res.headers.location, url).href
293
- downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
294
- return
295
- }
296
- if (res.statusCode !== 200) {
297
- reject(new Error(`HTTP ${res.statusCode}: ${url}`))
298
- return
299
- }
300
- pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
301
- })
302
- req.on('error', reject)
303
- req.on('timeout', () => { req.destroy(); reject(new Error(`下载超时: ${url}`)) })
304
- })
305
- return
306
- }
307
-
308
- const proxyUrl = new URL(proxy)
309
- const scheme = proxyUrl.protocol.replace(/:$/, '')
310
-
311
- await new Promise((resolve, reject) => {
312
- tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
313
- .then((socket) => {
314
- const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
315
- const req = http.request({
316
- createConnection: () => socket,
317
- method: 'GET',
318
- path: isProxiedPlainHttp ? url : target.pathname + target.search,
319
- headers: { Host: target.host },
320
- timeout,
321
- }, (res) => {
322
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
323
- res.resume()
324
- const next = new URL(res.headers.location, url).href
325
- downloadToFile(next, destPath, { proxy, timeout, onProgress }).then(resolve, reject)
326
- return
327
- }
328
- if (res.statusCode !== 200) {
329
- reject(new Error(`HTTP ${res.statusCode}: ${url}`))
330
- return
331
- }
332
- pipeToFile(res, destPath, url, onProgress).then(resolve, reject)
333
- })
334
- req.on('error', reject)
335
- req.on('timeout', () => { req.destroy(); reject(new Error(`下载超时: ${url}`)) })
336
- req.end()
337
- })
338
- .catch(reject)
339
- })
340
- }
341
-
342
- /**
343
- * 把响应流写入文件(附进度回调)
344
- * @private
345
- */
346
- function pipeToFile(res, destPath, url, onProgress) {
347
- return new Promise((resolve, reject) => {
348
- const file = fs.createWriteStream(destPath)
349
- const total = parseInt(res.headers['content-length'] || '0', 10)
350
- let written = 0
351
- let lastPct = 0
352
- res.on('data', (chunk) => {
353
- written += chunk.length
354
- if (total > 0 && onProgress) {
355
- const pct = Math.floor(written * 100 / total)
356
- if (pct >= lastPct + 10 || written === total) {
357
- lastPct = pct
358
- onProgress(pct)
359
- }
360
- }
361
- })
362
- res.pipe(file)
363
- file.on('finish', () => { file.close(); resolve() })
364
- file.on('error', reject)
365
- res.on('error', reject)
366
- })
367
- }
368
-
369
- /**
370
- * 通过代理或直连获取 URL 文本内容
371
- *
372
- * @param {string} url - 请求 URL
373
- * @param {{ proxy?: string, timeout?: number, maxRedirects?: number }} [opts] - 选项
374
- * @returns {Promise<string|null>}
375
- */
376
- export async function fetchText(url, { proxy, timeout = 15000, maxRedirects = 5 } = {}) {
377
- if (!proxy) {
378
- try {
379
- const resp = await fetch(url, { signal: AbortSignal.timeout(timeout) })
380
- if (!resp.ok) return null
381
- return await resp.text()
382
- } catch {
383
- return null
384
- }
385
- }
386
-
387
- const target = new URL(url)
388
- const proxyUrl = new URL(proxy)
389
- const isHttps = target.protocol === 'https:'
390
- const targetPort = target.port || (isHttps ? 443 : 80)
391
- const scheme = proxyUrl.protocol.replace(/:$/, '')
392
-
393
- return new Promise((resolve) => {
394
- if (maxRedirects <= 0) {
395
- resolve(null)
396
- return
397
- }
398
-
399
- tunnelConnect(proxyUrl, scheme, target.hostname, targetPort, isHttps, timeout)
400
- .then((socket) => {
401
- // HTTP 目标走 HTTP 代理时,需要发送绝对 URI(RFC 7230 5.3.2)
402
- const isProxiedPlainHttp = !isHttps && (scheme === 'http' || scheme === 'https')
403
-
404
- const req = http.request({
405
- createConnection: () => socket,
406
- method: 'GET',
407
- path: isProxiedPlainHttp ? url : target.pathname + target.search,
408
- headers: { Host: target.host },
409
- timeout
410
- }, (response) => {
411
- if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
412
- const redirectUrl = new URL(response.headers.location, url).href
413
- fetchText(redirectUrl, { proxy, timeout, maxRedirects: maxRedirects - 1 }).then(resolve)
414
- return
415
- }
416
- if (response.statusCode < 200 || response.statusCode >= 300) {
417
- resolve(null)
418
- return
419
- }
420
- let data = ''
421
- response.setEncoding('utf8')
422
- response.on('data', (chunk) => { data += chunk })
423
- response.on('end', () => resolve(data))
424
- })
425
-
426
- req.on('error', () => resolve(null))
427
- req.on('timeout', () => { req.destroy(); resolve(null) })
428
- req.end()
429
- })
430
- .catch(() => resolve(null))
431
- })
432
- }