@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 +32 -0
- package/package.json +13 -4
- package/src/binaries/base.js +1 -1
- package/src/cli/groups/env.js +451 -0
- package/src/cli/groups/ping.js +60 -20
- package/src/cli/index.js +2 -0
- package/src/index.js +2 -1
- package/src/managers/brew.js +1 -0
- package/src/managers/bun.js +7 -34
- package/src/managers/composer.js +1 -1
- package/src/managers/pip.js +1 -1
- package/src/managers/proc.js +7 -1
- package/src/managers/http.js +0 -432
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.
|
|
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.
|
|
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 --
|
|
49
|
-
"test:all": "node --
|
|
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
|
}
|
package/src/binaries/base.js
CHANGED
|
@@ -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 '
|
|
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
|
+
}
|
package/src/cli/groups/ping.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 ('
|
|
303
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
339
|
-
const result = await checkReachability(
|
|
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
|
-
|
|
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>
|
|
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 '
|
|
66
|
+
export { downloadToFile, fetchText } from '@wwkit/shared'
|
|
66
67
|
export {
|
|
67
68
|
getConfig, getSection, copyBuiltinConfig, getActiveRegistry, setActiveRegistry,
|
|
68
69
|
getUserConfigDir, getUserConfigFile, getActiveProxy,
|
package/src/managers/brew.js
CHANGED
package/src/managers/bun.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* bun 包管理器实现
|
|
3
3
|
*
|
|
4
4
|
* Bun 是全栈 JS 工具链(runtime + 包管理器 + 打包器)。
|
|
5
|
-
* 包管理命令通过 bun CLI
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
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()
|
package/src/managers/composer.js
CHANGED
|
@@ -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 '
|
|
17
|
+
import { fetchText } from '@wwkit/shared'
|
|
18
18
|
import { getActiveRegistry, setActiveRegistry } from '../config.js'
|
|
19
19
|
|
|
20
20
|
export class ComposerManager extends PackageManager {
|
package/src/managers/pip.js
CHANGED
|
@@ -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 '
|
|
12
|
+
import { fetchText } from '@wwkit/shared'
|
|
13
13
|
import { getActiveRegistry, setActiveRegistry } from '../config.js'
|
|
14
14
|
|
|
15
15
|
export class PipManager extends PackageManager {
|
package/src/managers/proc.js
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
package/src/managers/http.js
DELETED
|
@@ -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
|
-
}
|