@wwkit/shared 1.0.30 → 1.0.31

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/src/shell.js CHANGED
@@ -1,570 +1,570 @@
1
- /**
2
- * 跨平台 shell / 系统环境处理
3
- *
4
- * 封装 Windows / macOS / Linux 的 shell 与系统环境差异,供 @wwkit 各包复用:
5
- * - 平台检测(isWindows / isMac / isLinux)
6
- * - bash 解析(nvm / phpenv 等 bash 工具需要;Windows 下找 Git Bash / WSL)
7
- * - execSync / spawnSync 的 shell 选项选择
8
- * - shell profile 文件路径检测与写入(.zshrc / .bashrc / .profile)
9
- *
10
- * 用法:
11
- * import { Shell } from '@wwkit/shared'
12
- * const shell = new Shell()
13
- * execSync(cmd, { shell: shell.shellForExec(true) })
14
- * shell.writeProfile('NVM_DIR', 'nvm', ['export NVM_DIR=...'])
15
- */
16
-
17
- import fs from 'node:fs'
18
- import os from 'node:os'
19
- import path from 'node:path'
20
- import { execSync, spawnSync } from 'node:child_process'
21
-
22
- export class Shell {
23
- /**
24
- * @param {{ platform?: string, env?: Record<string,string> }} [opts]
25
- * platform / env 注入用于测试;默认取 process.platform / process.env
26
- */
27
- constructor({ platform = process.platform, env = process.env } = {}) {
28
- this.platform = platform
29
- this.env = env
30
- }
31
-
32
- /** 是否为 Windows */
33
- get isWindows() {
34
- return this.platform === 'win32'
35
- }
36
-
37
- /** 是否为 macOS */
38
- get isMac() {
39
- return this.platform === 'darwin'
40
- }
41
-
42
- /** 是否为 Linux */
43
- get isLinux() {
44
- return this.platform === 'linux'
45
- }
46
-
47
- /**
48
- * 解析可用的 bash 路径
49
- *
50
- * Unix: 优先 /bin/bash(原行为),回退 /usr/bin/bash,再回退 PATH 中的 bash
51
- * Windows: 优先 Git Bash(常见安装路径 + 由 `where git` 推导 Git 根),
52
- * 再回退 PATH 中的 bash(排除 WSL 转发器 System32\bash.exe / WindowsApps\bash.exe);
53
- * 找不到则抛错
54
- *
55
- * @returns {string} bash 可执行文件路径
56
- * @throws {Error} 找不到 bash 时抛出(含平台相关提示)
57
- */
58
- resolveBash() {
59
- if (!this.isWindows) {
60
- for (const c of ['/bin/bash', '/usr/bin/bash']) {
61
- try {
62
- fs.accessSync(c, fs.constants.X_OK)
63
- return c
64
- } catch {}
65
- }
66
- }
67
-
68
- if (this.isWindows) {
69
- const gitBash = this._findGitBash()
70
- if (gitBash) return gitBash
71
- }
72
-
73
- try {
74
- const cmd = this.isWindows ? 'where bash' : 'command -v bash'
75
- const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
76
- if (out) {
77
- const lines = out.split(/\r?\n/)
78
- if (this.isWindows) {
79
- // 排除 WSL 转发器(System32\bash.exe / WindowsApps\bash.exe)——它们不是真实 bash
80
- const real = lines.find((l) => {
81
- const lower = l.toLowerCase()
82
- return !lower.includes('system32\\bash') && !lower.includes('windowsapps\\bash')
83
- })
84
- if (real) return real
85
- }
86
- return lines[0]
87
- }
88
- } catch {}
89
-
90
- throw new Error(
91
- 'bash not found: nvm/phpenv require bash. ' +
92
- (this.isWindows ? 'Install Git Bash or run inside WSL.' : 'Install bash.')
93
- )
94
- }
95
-
96
- /**
97
- * 在 Windows 上定位 Git Bash(真实 bash,非 WSL 转发器)
98
- *
99
- * 依次尝试:常见 Git 安装路径(ProgramFiles / ProgramFiles(x86) / LOCALAPPDATA\Programs\Git)
100
- * → 由 `where git` 定位 git.exe 推导 Git 根(<root>\cmd\git.exe → <root>\bin\bash.exe)。
101
- * @returns {string} Git Bash 路径;未找到返回空串
102
- * @private
103
- */
104
- _findGitBash() {
105
- const candidates = []
106
- const pf = this.env.ProgramFiles
107
- const pf86 = this.env['ProgramFiles(x86)']
108
- const la = this.env.LOCALAPPDATA
109
- if (pf) candidates.push(path.join(pf, 'Git', 'bin', 'bash.exe'))
110
- if (pf86) candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'))
111
- if (la) candidates.push(path.join(la, 'Programs', 'Git', 'bin', 'bash.exe'))
112
- for (const c of candidates) {
113
- try {
114
- fs.accessSync(c, fs.constants.X_OK)
115
- return c
116
- } catch {}
117
- }
118
- try {
119
- const out = execSync('where git', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
120
- const gitExe = out.split(/\r?\n/)[0]
121
- if (gitExe && /\\cmd\\git\.exe$/i.test(gitExe)) {
122
- const root = path.dirname(path.dirname(gitExe))
123
- const bash = path.join(root, 'bin', 'bash.exe')
124
- try {
125
- fs.accessSync(bash, fs.constants.X_OK)
126
- return bash
127
- } catch {}
128
- }
129
- } catch {}
130
- return ''
131
- }
132
-
133
- /**
134
- * 返回 execSync / spawnSync 的 shell 选项
135
- *
136
- * needsBash=true → 解析并返回 bash 路径(nvm/phpenv 等 bash 工具)
137
- * needsBash=false → 返回 true(使用系统默认 shell:Unix /bin/sh,Windows cmd.exe)
138
- *
139
- * @param {boolean} needsBash - 命令是否需要 bash
140
- * @returns {string|boolean}
141
- */
142
- shellForExec(needsBash) {
143
- return needsBash ? this.resolveBash() : true
144
- }
145
-
146
- /**
147
- * 返回 execSync / spawnSync 在继承终端输出时的 stdio 选项
148
- *
149
- * Windows + bash 下 stdio: 'inherit' 会使 bash 继承终端 stdin,
150
- * 命令完成后 bash 仍等待 stdin EOF 不退出,导致 execSync/spawnSync 永久挂起。
151
- * 改用 ['ignore', 'inherit', 'inherit']:stdin → NUL,bash 立即收到 EOF 退出;
152
- * stdout/stderr 仍继承终端,输出可见。
153
- *
154
- * @param {boolean} needsBash - 是否使用 bash 作为 shell
155
- * @returns {'inherit'|string[]} stdio 选项
156
- */
157
- stdioForExec(needsBash) {
158
- if (needsBash && this.isWindows) {
159
- return ['ignore', 'inherit', 'inherit']
160
- }
161
- return 'inherit'
162
- }
163
-
164
- /**
165
- * 检测当前 shell 的 profile 文件路径
166
- *
167
- * Unix: $SHELL 含 zsh → ~/.zshrc;含 bash → ~/.bashrc;否则 ~/.profile
168
- * Windows: 无 $SHELL,默认 ~/.bashrc(Git Bash 约定)
169
- *
170
- * @returns {string}
171
- */
172
- getProfile() {
173
- const shell = this.env.SHELL || ''
174
- if (shell.includes('zsh')) {
175
- return path.join(os.homedir(), '.zshrc')
176
- }
177
- if (shell.includes('bash')) {
178
- return path.join(os.homedir(), '.bashrc')
179
- }
180
- if (this.isWindows) {
181
- return path.join(os.homedir(), '.bashrc')
182
- }
183
- return path.join(os.homedir(), '.profile')
184
- }
185
-
186
- /**
187
- * 检测当前用户所有需要写入的 shell profile 文件
188
- *
189
- * 返回数组以覆盖多种场景(login / non-login / 不同 shell):
190
- * - zsh: ~/.zshrc(交互式 zsh 总是读取)
191
- * - bash: ~/.bashrc(非登录交互式)+ ~/.bash_profile(登录式,仅当已存在)
192
- * - fish: ~/.config/fish/config.fish(fish 唯一配置文件)
193
- * - 未知: ~/.profile(POSIX 通用回退)
194
- *
195
- * 每项含 `path`(绝对路径)和 `syntax`('export' | 'fish'),
196
- * 调用方据此生成正确语法的变量设置行。
197
- *
198
- * @returns {{ path: string, syntax: 'export' | 'fish' }[]}
199
- */
200
- getProfiles() {
201
- const home = os.homedir()
202
- const shell = this.env.SHELL || ''
203
- const profiles = []
204
-
205
- if (shell.includes('fish')) {
206
- profiles.push({
207
- path: path.join(home, '.config', 'fish', 'config.fish'),
208
- syntax: 'fish',
209
- })
210
- } else if (shell.includes('zsh')) {
211
- profiles.push({ path: path.join(home, '.zshrc'), syntax: 'export' })
212
- } else if (shell.includes('bash')) {
213
- profiles.push({ path: path.join(home, '.bashrc'), syntax: 'export' })
214
- const bp = path.join(home, '.bash_profile')
215
- if (fs.existsSync(bp)) {
216
- profiles.push({ path: bp, syntax: 'export' })
217
- }
218
- } else {
219
- profiles.push({ path: path.join(home, '.profile'), syntax: 'export' })
220
- }
221
-
222
- return profiles
223
- }
224
-
225
- /**
226
- * 将 shell 初始化行写入 profile(按 marker 去重)
227
- *
228
- * @param {string} marker - 若 profile 已含该标记则跳过
229
- * @param {string} comment - 写入块注释
230
- * @param {string[]} lines - 要追加的行
231
- * @returns {boolean} 是否写入了新行
232
- */
233
- writeProfile(marker, comment, lines) {
234
- const profilePath = this.getProfile()
235
- let existing = ''
236
- try {
237
- existing = fs.readFileSync(profilePath, 'utf8')
238
- } catch {}
239
- if (existing.includes(marker)) {
240
- return false
241
- }
242
- const append = `\n# ${comment}\n${lines.join('\n')}\n`
243
- fs.appendFileSync(profilePath, append, 'utf8')
244
- return true
245
- }
246
-
247
- /**
248
- * 统一的 spawnSync 封装(供 Windows 注册表/setx 操作与命令检测使用)
249
- * @param {string} cmd - 可执行文件
250
- * @param {string[]} [args=[]] - 参数
251
- * @returns {Object} spawnSync 结果对象
252
- * @private
253
- */
254
- _spawnSync(cmd, args = []) {
255
- return spawnSync(cmd, args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
256
- }
257
-
258
- /**
259
- * 持久化设置系统环境变量(跨平台)
260
- *
261
- * Unix: 将变量设置行写入所有相关 shell profile(getProfiles()),使用
262
- * begin/end 标记块管理区块,支持幂等(值相同则跳过)和值更新
263
- * (路径变化时自动替换)。覆盖 bash/zsh/fish 及 login/non-login 场景。
264
- * Windows: 使用 `setx` 设置用户级环境变量(写入注册表,始终覆盖)。
265
- *
266
- * @param {string} name - 环境变量名
267
- * @param {string} value - 环境变量值
268
- * @returns {boolean} 是否写入/更新了至少一个目标(全部已存在且值相同则 false)
269
- */
270
- setEnvVar(name, value) {
271
- if (this.isWindows) {
272
- try {
273
- const result = this._spawnSync('setx', [name, String(value)])
274
- return result.status === 0
275
- } catch {
276
- return false
277
- }
278
- }
279
-
280
- const escaped = String(value).replace(/[$`\\"]/g, '\\$&')
281
- let wrote = false
282
-
283
- for (const { path: profilePath, syntax } of this.getProfiles()) {
284
- const line = syntax === 'fish'
285
- ? `set -gx ${name} "${escaped}"`
286
- : `export ${name}="${escaped}"`
287
- if (this._writeEnvBlock(profilePath, name, line)) {
288
- wrote = true
289
- }
290
- }
291
-
292
- return wrote
293
- }
294
-
295
- /**
296
- * 向指定 profile 文件写入/更新一个环境变量标记块
297
- *
298
- * 写入前先清理文件中所有同名的裸 export/set 行(非标记块内的),
299
- * 避免裸行与标记块并存导致残留误导。
300
- * 标记块用单行注释头(无尾标记),形如:
301
- * # >>> wwkit NAME >>>
302
- * export NAME="value"
303
- *
304
- * @param {string} profilePath - profile 文件绝对路径
305
- * @param {string} name - 环境变量名(用于生成标记)
306
- * @param {string} line - 变量设置行(含正确语法)
307
- * @returns {boolean} 是否写入/更新了该文件
308
- */
309
- _writeEnvBlock(profilePath, name, line) {
310
- let existing = ''
311
- try {
312
- existing = fs.readFileSync(profilePath, 'utf8')
313
- } catch {}
314
-
315
- const marker = `# >>> wwkit ${name} >>>`
316
- const oldEndMarker = `# <<< wwkit ${name} <<<`
317
-
318
- // 清理同名裸 export/set 行(不在任何标记块内的)
319
- existing = this._stripBareEnvLines(existing, name)
320
-
321
- const beginIdx = existing.indexOf(marker)
322
- if (beginIdx !== -1) {
323
- const afterMarker = beginIdx + marker.length
324
- // 块尾:优先找旧版 end marker,其次找下一个 wwkit marker,最后 EOF
325
- const oldEndIdx = existing.indexOf(oldEndMarker, afterMarker)
326
- const nextMarkerIdx = existing.indexOf('# >>> wwkit', afterMarker)
327
- let blockEnd
328
- if (oldEndIdx !== -1 && (nextMarkerIdx === -1 || oldEndIdx < nextMarkerIdx)) {
329
- blockEnd = oldEndIdx + oldEndMarker.length
330
- } else if (nextMarkerIdx !== -1) {
331
- blockEnd = nextMarkerIdx
332
- } else {
333
- blockEnd = existing.length
334
- }
335
- const currentBlock = existing.slice(beginIdx, blockEnd)
336
- const newBlock = `${marker}\n${line}\n`
337
- if (currentBlock.trim() === newBlock.trim()) return false
338
- existing = existing.slice(0, beginIdx) + newBlock + existing.slice(blockEnd)
339
- fs.writeFileSync(profilePath, existing, 'utf8')
340
- return true
341
- }
342
-
343
- const dir = path.dirname(profilePath)
344
- fs.mkdirSync(dir, { recursive: true })
345
- fs.appendFileSync(profilePath, `\n${marker}\n${line}\n`, 'utf8')
346
- return true
347
- }
348
-
349
- /**
350
- * 从 profile 内容中删除所有同名的裸 export/set 行(不在标记块内的)
351
- * @param {string} content
352
- * @param {string} name
353
- * @returns {string}
354
- * @private
355
- */
356
- _stripBareEnvLines(content, name) {
357
- const lines = content.split('\n')
358
- const barePattern = new RegExp(`^\\s*(export|set -gx)\\s+${name}\\b`)
359
- let inBlock = false
360
- return lines.filter((l) => {
361
- if (l.startsWith('# >>> wwkit ')) { inBlock = true; return true }
362
- if (l.startsWith('# <<< wwkit ')) { inBlock = false; return true }
363
- if (inBlock) return true
364
- return !barePattern.test(l)
365
- }).join('\n')
366
- }
367
-
368
- /**
369
- * 归一化路径用于 PATH 条目比较(大小写不敏感、统一分隔符、去尾斜杠)
370
- * @param {string} p - 原始路径/条目
371
- * @returns {string}
372
- * @private
373
- */
374
- _absPathKey(p) {
375
- return String(p).trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
376
- }
377
-
378
- /**
379
- * 判断路径列表(分号/冒号分隔)是否包含指定目录
380
- * @param {string} pathList - 分隔符连接的路径列表
381
- * @param {string} dir - 要查找的目录
382
- * @param {string} sep - 分隔符(Windows ';',Unix ':')
383
- * @returns {boolean}
384
- * @private
385
- */
386
- _pathListIncludes(pathList, dir, sep = ';') {
387
- if (!pathList) return false
388
- const needle = this._absPathKey(dir)
389
- if (!needle) return false
390
- return String(pathList)
391
- .split(sep)
392
- .some((p) => this._absPathKey(p) === needle)
393
- }
394
-
395
- /**
396
- * 判断目录是否已包含在当前进程 PATH(或传入的用户 PATH 字符串)中
397
- *
398
- * 进程 PATH 按平台分隔(Windows ';',Unix ':');持久化用户 PATH 字符串
399
- * (Windows 注册表读取值)恒为分号分隔。
400
- * @param {string} dir - 目录绝对路径
401
- * @param {string} [userPath=''] - 可选:持久化用户 PATH 字符串(Windows 注册表读取值)
402
- * @returns {boolean}
403
- */
404
- dirInPath(dir, userPath = '') {
405
- const needle = this._absPathKey(dir)
406
- if (!needle) return false
407
- const procSep = this.isWindows ? ';' : ':'
408
- if (this._pathListIncludes(this.env.PATH, dir, procSep)) return true
409
- return this._pathListIncludes(userPath, dir, ';')
410
- }
411
-
412
- /**
413
- * 读取持久化用户 PATH(Windows 注册表 HKCU\Environment\Path)
414
- *
415
- * Unix 无用户级 PATH 注册表,返回空串(PATH 由 shell profile 管理)。
416
- * @returns {string}
417
- */
418
- getUserPath() {
419
- if (!this.isWindows) return ''
420
- try {
421
- const result = this._spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'])
422
- if (result.status !== 0) return ''
423
- const lines = result.stdout.split(/\r?\n/)
424
- for (const line of lines) {
425
- const m = line.match(/^\s*Path\s+REG_\w+\s+(.*)$/)
426
- if (m) return m[1].trim()
427
- }
428
- return ''
429
- } catch {
430
- return ''
431
- }
432
- }
433
-
434
- /**
435
- * 写回持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
436
- *
437
- * 不用 setx 的原因:setx 会把 REG_EXPAND_SZ 降级为 REG_SZ(%VAR% 字面量失去展开)
438
- * 且值超过 1024 字符会被静默截断;reg add 可原样保留类型与完整值。
439
- * @param {string} value - 完整 PATH 值(分号分隔)
440
- * @returns {boolean} 是否写入成功
441
- * @private
442
- */
443
- _setUserPath(value) {
444
- try {
445
- const result = this._spawnSync(
446
- 'reg', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', value, '/f']
447
- )
448
- return result.status === 0
449
- } catch {
450
- return false
451
- }
452
- }
453
-
454
- /**
455
- * 追加目录到持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
456
- * @param {string} dir - 要追加的目录绝对路径
457
- * @returns {boolean} 是否写入成功
458
- * @private
459
- */
460
- _appendUserPath(dir) {
461
- const current = this.getUserPath()
462
- const sep = current && !current.endsWith(';') ? ';' : ''
463
- return this._setUserPath(current + sep + dir)
464
- }
465
-
466
- /**
467
- * 确保目录已进入 Windows 持久化用户 PATH(幂等),并同步当前进程 PATH
468
- *
469
- * 解决场景:npm 全局安装(如 ww init 插件)装到 shell 会话专属前缀(如 nvm
470
- * 的 bin/)时,该目录不在系统/用户 PATH,其他 shell 中命令不可用。此方法
471
- * 把目录追加进 HKCU\Environment\Path(REG_EXPAND_SZ),重启终端后全局生效。
472
- *
473
- * Unix:不干预(PATH 由 nvm/profile 管理,npm 全局 bin 通常已在其中),返回 false。
474
- *
475
- * @param {string} dir - 目录绝对路径
476
- * @returns {boolean} 是否新写入(已在则 false)
477
- */
478
- ensurePathEntry(dir) {
479
- const needle = this._absPathKey(dir)
480
- if (!needle || !this.isWindows) return false
481
- if (this._pathListIncludes(this.getUserPath(), dir)) return false
482
- const ok = this._appendUserPath(dir)
483
- if (ok && !this._pathListIncludes(this.env.PATH, dir)) {
484
- const sep = this.env.PATH && !this.env.PATH.endsWith(';') ? ';' : ''
485
- this.env.PATH = (this.env.PATH || '') + sep + dir
486
- }
487
- return ok
488
- }
489
-
490
- /**
491
- * 把目录前置到当前进程 PATH(process.env.PATH,幂等,不写注册表)
492
- *
493
- * 进程从旧 PATH 启动时不含注册表最新变更;需要让本进程后续的子进程立即用
494
- * 新目录中的命令(如 mise shims 的 node)时调用。已在则移到最前(不重复)。
495
- * @param {string|string[]} dirs - 目录(数组按序前置,最后一个在最前)
496
- */
497
- prependProcPath(dirs) {
498
- const list = (Array.isArray(dirs) ? dirs : [dirs]).filter(Boolean)
499
- if (!list.length) return
500
- const sep = this.isWindows ? ';' : ':'
501
- const key = (e) => this._absPathKey(e)
502
- let entries = String(this.env.PATH || '').split(sep).filter(Boolean)
503
- for (const d of list) {
504
- const needle = key(d)
505
- if (!needle) continue
506
- entries = entries.filter((e) => key(e) !== needle)
507
- entries.unshift(d)
508
- }
509
- this.env.PATH = entries.join(sep)
510
- }
511
-
512
- /**
513
- * 确保目录已前置到 Windows 持久化用户 PATH 最前(幂等),并同步当前进程 PATH
514
- *
515
- * 用于需要**优先于系统 PATH** 的场景:如 mise shims 目录——系统 PATH 里可能已有
516
- * 旧版 node(如 D:\library\nodejs),只有把 mise shims 前置才能让新 shell 优先用
517
- * mise 管理的 node。若目录已在用户 PATH 任意位置,会移到最前(幂等更新)。
518
- *
519
- * Unix:不干预,返回 false。
520
- *
521
- * @param {string} dir - 目录绝对路径
522
- * @returns {boolean} 是否新写入/更新(已在最前则 false)
523
- */
524
- prependPathEntry(dir) {
525
- const needle = this._absPathKey(dir)
526
- if (!needle || !this.isWindows) return false
527
- const current = this.getUserPath()
528
- const entries = String(current || '').split(';').map((e) => e.trim()).filter(Boolean)
529
- const key = (e) => this._absPathKey(e)
530
- const filtered = entries.filter((e) => key(e) !== needle)
531
- const newValue = [dir, ...filtered].join(';')
532
- if (newValue === current) return false
533
- const ok = this._setUserPath(newValue)
534
- if (ok) this.prependProcPath(dir)
535
- return ok
536
- }
537
-
538
- /**
539
- * 检测系统命令是否可用
540
- * Windows: where <cmd>;Unix: which <cmd>
541
- *
542
- * @param {string} cmd - 命令名
543
- * @returns {boolean}
544
- */
545
- isCommandAvailable(cmd) {
546
- const checker = this.isWindows ? 'where' : 'which'
547
- try {
548
- const result = this._spawnSync(checker, [cmd])
549
- return result.status === 0
550
- } catch {
551
- return false
552
- }
553
- }
554
-
555
- /**
556
- * 按优先级检测可用的网络探测工具
557
- * 顺序: curl > wget > ping
558
- *
559
- * curl 优先:支持代理(-x)、输出 locale 无关(HTTP 状态码)、检查 HTTP 层可达性。
560
- * ping 最后:ICMP 不支持代理、输出依赖系统语言(中文 Windows 乱码)、仅检查网络层。
561
- *
562
- * @returns {string|null} 'curl' | 'wget' | 'ping' | null
563
- */
564
- getNetProbeTool() {
565
- for (const cmd of ['curl', 'wget', 'ping']) {
566
- if (this.isCommandAvailable(cmd)) return cmd
567
- }
568
- return null
569
- }
1
+ /**
2
+ * 跨平台 shell / 系统环境处理
3
+ *
4
+ * 封装 Windows / macOS / Linux 的 shell 与系统环境差异,供 @wwkit 各包复用:
5
+ * - 平台检测(isWindows / isMac / isLinux)
6
+ * - bash 解析(nvm / phpenv 等 bash 工具需要;Windows 下找 Git Bash / WSL)
7
+ * - execSync / spawnSync 的 shell 选项选择
8
+ * - shell profile 文件路径检测与写入(.zshrc / .bashrc / .profile)
9
+ *
10
+ * 用法:
11
+ * import { Shell } from '@wwkit/shared'
12
+ * const shell = new Shell()
13
+ * execSync(cmd, { shell: shell.shellForExec(true) })
14
+ * shell.writeProfile('NVM_DIR', 'nvm', ['export NVM_DIR=...'])
15
+ */
16
+
17
+ import fs from 'node:fs'
18
+ import os from 'node:os'
19
+ import path from 'node:path'
20
+ import { execSync, spawnSync } from 'node:child_process'
21
+
22
+ export class Shell {
23
+ /**
24
+ * @param {{ platform?: string, env?: Record<string,string> }} [opts]
25
+ * platform / env 注入用于测试;默认取 process.platform / process.env
26
+ */
27
+ constructor({ platform = process.platform, env = process.env } = {}) {
28
+ this.platform = platform
29
+ this.env = env
30
+ }
31
+
32
+ /** 是否为 Windows */
33
+ get isWindows() {
34
+ return this.platform === 'win32'
35
+ }
36
+
37
+ /** 是否为 macOS */
38
+ get isMac() {
39
+ return this.platform === 'darwin'
40
+ }
41
+
42
+ /** 是否为 Linux */
43
+ get isLinux() {
44
+ return this.platform === 'linux'
45
+ }
46
+
47
+ /**
48
+ * 解析可用的 bash 路径
49
+ *
50
+ * Unix: 优先 /bin/bash(原行为),回退 /usr/bin/bash,再回退 PATH 中的 bash
51
+ * Windows: 优先 Git Bash(常见安装路径 + 由 `where git` 推导 Git 根),
52
+ * 再回退 PATH 中的 bash(排除 WSL 转发器 System32\bash.exe / WindowsApps\bash.exe);
53
+ * 找不到则抛错
54
+ *
55
+ * @returns {string} bash 可执行文件路径
56
+ * @throws {Error} 找不到 bash 时抛出(含平台相关提示)
57
+ */
58
+ resolveBash() {
59
+ if (!this.isWindows) {
60
+ for (const c of ['/bin/bash', '/usr/bin/bash']) {
61
+ try {
62
+ fs.accessSync(c, fs.constants.X_OK)
63
+ return c
64
+ } catch {}
65
+ }
66
+ }
67
+
68
+ if (this.isWindows) {
69
+ const gitBash = this._findGitBash()
70
+ if (gitBash) return gitBash
71
+ }
72
+
73
+ try {
74
+ const cmd = this.isWindows ? 'where bash' : 'command -v bash'
75
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
76
+ if (out) {
77
+ const lines = out.split(/\r?\n/)
78
+ if (this.isWindows) {
79
+ // 排除 WSL 转发器(System32\bash.exe / WindowsApps\bash.exe)——它们不是真实 bash
80
+ const real = lines.find((l) => {
81
+ const lower = l.toLowerCase()
82
+ return !lower.includes('system32\\bash') && !lower.includes('windowsapps\\bash')
83
+ })
84
+ if (real) return real
85
+ }
86
+ return lines[0]
87
+ }
88
+ } catch {}
89
+
90
+ throw new Error(
91
+ 'bash not found: nvm/phpenv require bash. ' +
92
+ (this.isWindows ? 'Install Git Bash or run inside WSL.' : 'Install bash.')
93
+ )
94
+ }
95
+
96
+ /**
97
+ * 在 Windows 上定位 Git Bash(真实 bash,非 WSL 转发器)
98
+ *
99
+ * 依次尝试:常见 Git 安装路径(ProgramFiles / ProgramFiles(x86) / LOCALAPPDATA\Programs\Git)
100
+ * → 由 `where git` 定位 git.exe 推导 Git 根(<root>\cmd\git.exe → <root>\bin\bash.exe)。
101
+ * @returns {string} Git Bash 路径;未找到返回空串
102
+ * @private
103
+ */
104
+ _findGitBash() {
105
+ const candidates = []
106
+ const pf = this.env.ProgramFiles
107
+ const pf86 = this.env['ProgramFiles(x86)']
108
+ const la = this.env.LOCALAPPDATA
109
+ if (pf) candidates.push(path.join(pf, 'Git', 'bin', 'bash.exe'))
110
+ if (pf86) candidates.push(path.join(pf86, 'Git', 'bin', 'bash.exe'))
111
+ if (la) candidates.push(path.join(la, 'Programs', 'Git', 'bin', 'bash.exe'))
112
+ for (const c of candidates) {
113
+ try {
114
+ fs.accessSync(c, fs.constants.X_OK)
115
+ return c
116
+ } catch {}
117
+ }
118
+ try {
119
+ const out = execSync('where git', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
120
+ const gitExe = out.split(/\r?\n/)[0]
121
+ if (gitExe && /\\cmd\\git\.exe$/i.test(gitExe)) {
122
+ const root = path.dirname(path.dirname(gitExe))
123
+ const bash = path.join(root, 'bin', 'bash.exe')
124
+ try {
125
+ fs.accessSync(bash, fs.constants.X_OK)
126
+ return bash
127
+ } catch {}
128
+ }
129
+ } catch {}
130
+ return ''
131
+ }
132
+
133
+ /**
134
+ * 返回 execSync / spawnSync 的 shell 选项
135
+ *
136
+ * needsBash=true → 解析并返回 bash 路径(nvm/phpenv 等 bash 工具)
137
+ * needsBash=false → 返回 true(使用系统默认 shell:Unix /bin/sh,Windows cmd.exe)
138
+ *
139
+ * @param {boolean} needsBash - 命令是否需要 bash
140
+ * @returns {string|boolean}
141
+ */
142
+ shellForExec(needsBash) {
143
+ return needsBash ? this.resolveBash() : true
144
+ }
145
+
146
+ /**
147
+ * 返回 execSync / spawnSync 在继承终端输出时的 stdio 选项
148
+ *
149
+ * Windows + bash 下 stdio: 'inherit' 会使 bash 继承终端 stdin,
150
+ * 命令完成后 bash 仍等待 stdin EOF 不退出,导致 execSync/spawnSync 永久挂起。
151
+ * 改用 ['ignore', 'inherit', 'inherit']:stdin → NUL,bash 立即收到 EOF 退出;
152
+ * stdout/stderr 仍继承终端,输出可见。
153
+ *
154
+ * @param {boolean} needsBash - 是否使用 bash 作为 shell
155
+ * @returns {'inherit'|string[]} stdio 选项
156
+ */
157
+ stdioForExec(needsBash) {
158
+ if (needsBash && this.isWindows) {
159
+ return ['ignore', 'inherit', 'inherit']
160
+ }
161
+ return 'inherit'
162
+ }
163
+
164
+ /**
165
+ * 检测当前 shell 的 profile 文件路径
166
+ *
167
+ * Unix: $SHELL 含 zsh → ~/.zshrc;含 bash → ~/.bashrc;否则 ~/.profile
168
+ * Windows: 无 $SHELL,默认 ~/.bashrc(Git Bash 约定)
169
+ *
170
+ * @returns {string}
171
+ */
172
+ getProfile() {
173
+ const shell = this.env.SHELL || ''
174
+ if (shell.includes('zsh')) {
175
+ return path.join(os.homedir(), '.zshrc')
176
+ }
177
+ if (shell.includes('bash')) {
178
+ return path.join(os.homedir(), '.bashrc')
179
+ }
180
+ if (this.isWindows) {
181
+ return path.join(os.homedir(), '.bashrc')
182
+ }
183
+ return path.join(os.homedir(), '.profile')
184
+ }
185
+
186
+ /**
187
+ * 检测当前用户所有需要写入的 shell profile 文件
188
+ *
189
+ * 返回数组以覆盖多种场景(login / non-login / 不同 shell):
190
+ * - zsh: ~/.zshrc(交互式 zsh 总是读取)
191
+ * - bash: ~/.bashrc(非登录交互式)+ ~/.bash_profile(登录式,仅当已存在)
192
+ * - fish: ~/.config/fish/config.fish(fish 唯一配置文件)
193
+ * - 未知: ~/.profile(POSIX 通用回退)
194
+ *
195
+ * 每项含 `path`(绝对路径)和 `syntax`('export' | 'fish'),
196
+ * 调用方据此生成正确语法的变量设置行。
197
+ *
198
+ * @returns {{ path: string, syntax: 'export' | 'fish' }[]}
199
+ */
200
+ getProfiles() {
201
+ const home = os.homedir()
202
+ const shell = this.env.SHELL || ''
203
+ const profiles = []
204
+
205
+ if (shell.includes('fish')) {
206
+ profiles.push({
207
+ path: path.join(home, '.config', 'fish', 'config.fish'),
208
+ syntax: 'fish',
209
+ })
210
+ } else if (shell.includes('zsh')) {
211
+ profiles.push({ path: path.join(home, '.zshrc'), syntax: 'export' })
212
+ } else if (shell.includes('bash')) {
213
+ profiles.push({ path: path.join(home, '.bashrc'), syntax: 'export' })
214
+ const bp = path.join(home, '.bash_profile')
215
+ if (fs.existsSync(bp)) {
216
+ profiles.push({ path: bp, syntax: 'export' })
217
+ }
218
+ } else {
219
+ profiles.push({ path: path.join(home, '.profile'), syntax: 'export' })
220
+ }
221
+
222
+ return profiles
223
+ }
224
+
225
+ /**
226
+ * 将 shell 初始化行写入 profile(按 marker 去重)
227
+ *
228
+ * @param {string} marker - 若 profile 已含该标记则跳过
229
+ * @param {string} comment - 写入块注释
230
+ * @param {string[]} lines - 要追加的行
231
+ * @returns {boolean} 是否写入了新行
232
+ */
233
+ writeProfile(marker, comment, lines) {
234
+ const profilePath = this.getProfile()
235
+ let existing = ''
236
+ try {
237
+ existing = fs.readFileSync(profilePath, 'utf8')
238
+ } catch {}
239
+ if (existing.includes(marker)) {
240
+ return false
241
+ }
242
+ const append = `\n# ${comment}\n${lines.join('\n')}\n`
243
+ fs.appendFileSync(profilePath, append, 'utf8')
244
+ return true
245
+ }
246
+
247
+ /**
248
+ * 统一的 spawnSync 封装(供 Windows 注册表/setx 操作与命令检测使用)
249
+ * @param {string} cmd - 可执行文件
250
+ * @param {string[]} [args=[]] - 参数
251
+ * @returns {Object} spawnSync 结果对象
252
+ * @private
253
+ */
254
+ _spawnSync(cmd, args = []) {
255
+ return spawnSync(cmd, args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
256
+ }
257
+
258
+ /**
259
+ * 持久化设置系统环境变量(跨平台)
260
+ *
261
+ * Unix: 将变量设置行写入所有相关 shell profile(getProfiles()),使用
262
+ * begin/end 标记块管理区块,支持幂等(值相同则跳过)和值更新
263
+ * (路径变化时自动替换)。覆盖 bash/zsh/fish 及 login/non-login 场景。
264
+ * Windows: 使用 `setx` 设置用户级环境变量(写入注册表,始终覆盖)。
265
+ *
266
+ * @param {string} name - 环境变量名
267
+ * @param {string} value - 环境变量值
268
+ * @returns {boolean} 是否写入/更新了至少一个目标(全部已存在且值相同则 false)
269
+ */
270
+ setEnvVar(name, value) {
271
+ if (this.isWindows) {
272
+ try {
273
+ const result = this._spawnSync('setx', [name, String(value)])
274
+ return result.status === 0
275
+ } catch {
276
+ return false
277
+ }
278
+ }
279
+
280
+ const escaped = String(value).replace(/[$`\\"]/g, '\\$&')
281
+ let wrote = false
282
+
283
+ for (const { path: profilePath, syntax } of this.getProfiles()) {
284
+ const line = syntax === 'fish'
285
+ ? `set -gx ${name} "${escaped}"`
286
+ : `export ${name}="${escaped}"`
287
+ if (this._writeEnvBlock(profilePath, name, line)) {
288
+ wrote = true
289
+ }
290
+ }
291
+
292
+ return wrote
293
+ }
294
+
295
+ /**
296
+ * 向指定 profile 文件写入/更新一个环境变量标记块
297
+ *
298
+ * 写入前先清理文件中所有同名的裸 export/set 行(非标记块内的),
299
+ * 避免裸行与标记块并存导致残留误导。
300
+ * 标记块用单行注释头(无尾标记),形如:
301
+ * # >>> wwkit NAME >>>
302
+ * export NAME="value"
303
+ *
304
+ * @param {string} profilePath - profile 文件绝对路径
305
+ * @param {string} name - 环境变量名(用于生成标记)
306
+ * @param {string} line - 变量设置行(含正确语法)
307
+ * @returns {boolean} 是否写入/更新了该文件
308
+ */
309
+ _writeEnvBlock(profilePath, name, line) {
310
+ let existing = ''
311
+ try {
312
+ existing = fs.readFileSync(profilePath, 'utf8')
313
+ } catch {}
314
+
315
+ const marker = `# >>> wwkit ${name} >>>`
316
+ const oldEndMarker = `# <<< wwkit ${name} <<<`
317
+
318
+ // 清理同名裸 export/set 行(不在任何标记块内的)
319
+ existing = this._stripBareEnvLines(existing, name)
320
+
321
+ const beginIdx = existing.indexOf(marker)
322
+ if (beginIdx !== -1) {
323
+ const afterMarker = beginIdx + marker.length
324
+ // 块尾:优先找旧版 end marker,其次找下一个 wwkit marker,最后 EOF
325
+ const oldEndIdx = existing.indexOf(oldEndMarker, afterMarker)
326
+ const nextMarkerIdx = existing.indexOf('# >>> wwkit', afterMarker)
327
+ let blockEnd
328
+ if (oldEndIdx !== -1 && (nextMarkerIdx === -1 || oldEndIdx < nextMarkerIdx)) {
329
+ blockEnd = oldEndIdx + oldEndMarker.length
330
+ } else if (nextMarkerIdx !== -1) {
331
+ blockEnd = nextMarkerIdx
332
+ } else {
333
+ blockEnd = existing.length
334
+ }
335
+ const currentBlock = existing.slice(beginIdx, blockEnd)
336
+ const newBlock = `${marker}\n${line}\n`
337
+ if (currentBlock.trim() === newBlock.trim()) return false
338
+ existing = existing.slice(0, beginIdx) + newBlock + existing.slice(blockEnd)
339
+ fs.writeFileSync(profilePath, existing, 'utf8')
340
+ return true
341
+ }
342
+
343
+ const dir = path.dirname(profilePath)
344
+ fs.mkdirSync(dir, { recursive: true })
345
+ fs.appendFileSync(profilePath, `\n${marker}\n${line}\n`, 'utf8')
346
+ return true
347
+ }
348
+
349
+ /**
350
+ * 从 profile 内容中删除所有同名的裸 export/set 行(不在标记块内的)
351
+ * @param {string} content
352
+ * @param {string} name
353
+ * @returns {string}
354
+ * @private
355
+ */
356
+ _stripBareEnvLines(content, name) {
357
+ const lines = content.split('\n')
358
+ const barePattern = new RegExp(`^\\s*(export|set -gx)\\s+${name}\\b`)
359
+ let inBlock = false
360
+ return lines.filter((l) => {
361
+ if (l.startsWith('# >>> wwkit ')) { inBlock = true; return true }
362
+ if (l.startsWith('# <<< wwkit ')) { inBlock = false; return true }
363
+ if (inBlock) return true
364
+ return !barePattern.test(l)
365
+ }).join('\n')
366
+ }
367
+
368
+ /**
369
+ * 归一化路径用于 PATH 条目比较(大小写不敏感、统一分隔符、去尾斜杠)
370
+ * @param {string} p - 原始路径/条目
371
+ * @returns {string}
372
+ * @private
373
+ */
374
+ _absPathKey(p) {
375
+ return String(p).trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
376
+ }
377
+
378
+ /**
379
+ * 判断路径列表(分号/冒号分隔)是否包含指定目录
380
+ * @param {string} pathList - 分隔符连接的路径列表
381
+ * @param {string} dir - 要查找的目录
382
+ * @param {string} sep - 分隔符(Windows ';',Unix ':')
383
+ * @returns {boolean}
384
+ * @private
385
+ */
386
+ _pathListIncludes(pathList, dir, sep = ';') {
387
+ if (!pathList) return false
388
+ const needle = this._absPathKey(dir)
389
+ if (!needle) return false
390
+ return String(pathList)
391
+ .split(sep)
392
+ .some((p) => this._absPathKey(p) === needle)
393
+ }
394
+
395
+ /**
396
+ * 判断目录是否已包含在当前进程 PATH(或传入的用户 PATH 字符串)中
397
+ *
398
+ * 进程 PATH 按平台分隔(Windows ';',Unix ':');持久化用户 PATH 字符串
399
+ * (Windows 注册表读取值)恒为分号分隔。
400
+ * @param {string} dir - 目录绝对路径
401
+ * @param {string} [userPath=''] - 可选:持久化用户 PATH 字符串(Windows 注册表读取值)
402
+ * @returns {boolean}
403
+ */
404
+ dirInPath(dir, userPath = '') {
405
+ const needle = this._absPathKey(dir)
406
+ if (!needle) return false
407
+ const procSep = this.isWindows ? ';' : ':'
408
+ if (this._pathListIncludes(this.env.PATH, dir, procSep)) return true
409
+ return this._pathListIncludes(userPath, dir, ';')
410
+ }
411
+
412
+ /**
413
+ * 读取持久化用户 PATH(Windows 注册表 HKCU\Environment\Path)
414
+ *
415
+ * Unix 无用户级 PATH 注册表,返回空串(PATH 由 shell profile 管理)。
416
+ * @returns {string}
417
+ */
418
+ getUserPath() {
419
+ if (!this.isWindows) return ''
420
+ try {
421
+ const result = this._spawnSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'])
422
+ if (result.status !== 0) return ''
423
+ const lines = result.stdout.split(/\r?\n/)
424
+ for (const line of lines) {
425
+ const m = line.match(/^\s*Path\s+REG_\w+\s+(.*)$/)
426
+ if (m) return m[1].trim()
427
+ }
428
+ return ''
429
+ } catch {
430
+ return ''
431
+ }
432
+ }
433
+
434
+ /**
435
+ * 写回持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
436
+ *
437
+ * 不用 setx 的原因:setx 会把 REG_EXPAND_SZ 降级为 REG_SZ(%VAR% 字面量失去展开)
438
+ * 且值超过 1024 字符会被静默截断;reg add 可原样保留类型与完整值。
439
+ * @param {string} value - 完整 PATH 值(分号分隔)
440
+ * @returns {boolean} 是否写入成功
441
+ * @private
442
+ */
443
+ _setUserPath(value) {
444
+ try {
445
+ const result = this._spawnSync(
446
+ 'reg', ['add', 'HKCU\\Environment', '/v', 'Path', '/t', 'REG_EXPAND_SZ', '/d', value, '/f']
447
+ )
448
+ return result.status === 0
449
+ } catch {
450
+ return false
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 追加目录到持久化用户 PATH(Windows 注册表,保留 REG_EXPAND_SZ 类型)
456
+ * @param {string} dir - 要追加的目录绝对路径
457
+ * @returns {boolean} 是否写入成功
458
+ * @private
459
+ */
460
+ _appendUserPath(dir) {
461
+ const current = this.getUserPath()
462
+ const sep = current && !current.endsWith(';') ? ';' : ''
463
+ return this._setUserPath(current + sep + dir)
464
+ }
465
+
466
+ /**
467
+ * 确保目录已进入 Windows 持久化用户 PATH(幂等),并同步当前进程 PATH
468
+ *
469
+ * 解决场景:npm 全局安装(如 ww init 插件)装到 shell 会话专属前缀(如 nvm
470
+ * 的 bin/)时,该目录不在系统/用户 PATH,其他 shell 中命令不可用。此方法
471
+ * 把目录追加进 HKCU\Environment\Path(REG_EXPAND_SZ),重启终端后全局生效。
472
+ *
473
+ * Unix:不干预(PATH 由 nvm/profile 管理,npm 全局 bin 通常已在其中),返回 false。
474
+ *
475
+ * @param {string} dir - 目录绝对路径
476
+ * @returns {boolean} 是否新写入(已在则 false)
477
+ */
478
+ ensurePathEntry(dir) {
479
+ const needle = this._absPathKey(dir)
480
+ if (!needle || !this.isWindows) return false
481
+ if (this._pathListIncludes(this.getUserPath(), dir)) return false
482
+ const ok = this._appendUserPath(dir)
483
+ if (ok && !this._pathListIncludes(this.env.PATH, dir)) {
484
+ const sep = this.env.PATH && !this.env.PATH.endsWith(';') ? ';' : ''
485
+ this.env.PATH = (this.env.PATH || '') + sep + dir
486
+ }
487
+ return ok
488
+ }
489
+
490
+ /**
491
+ * 把目录前置到当前进程 PATH(process.env.PATH,幂等,不写注册表)
492
+ *
493
+ * 进程从旧 PATH 启动时不含注册表最新变更;需要让本进程后续的子进程立即用
494
+ * 新目录中的命令(如 mise shims 的 node)时调用。已在则移到最前(不重复)。
495
+ * @param {string|string[]} dirs - 目录(数组按序前置,最后一个在最前)
496
+ */
497
+ prependProcPath(dirs) {
498
+ const list = (Array.isArray(dirs) ? dirs : [dirs]).filter(Boolean)
499
+ if (!list.length) return
500
+ const sep = this.isWindows ? ';' : ':'
501
+ const key = (e) => this._absPathKey(e)
502
+ let entries = String(this.env.PATH || '').split(sep).filter(Boolean)
503
+ for (const d of list) {
504
+ const needle = key(d)
505
+ if (!needle) continue
506
+ entries = entries.filter((e) => key(e) !== needle)
507
+ entries.unshift(d)
508
+ }
509
+ this.env.PATH = entries.join(sep)
510
+ }
511
+
512
+ /**
513
+ * 确保目录已前置到 Windows 持久化用户 PATH 最前(幂等),并同步当前进程 PATH
514
+ *
515
+ * 用于需要**优先于系统 PATH** 的场景:如 mise shims 目录——系统 PATH 里可能已有
516
+ * 旧版 node(如 D:\library\nodejs),只有把 mise shims 前置才能让新 shell 优先用
517
+ * mise 管理的 node。若目录已在用户 PATH 任意位置,会移到最前(幂等更新)。
518
+ *
519
+ * Unix:不干预,返回 false。
520
+ *
521
+ * @param {string} dir - 目录绝对路径
522
+ * @returns {boolean} 是否新写入/更新(已在最前则 false)
523
+ */
524
+ prependPathEntry(dir) {
525
+ const needle = this._absPathKey(dir)
526
+ if (!needle || !this.isWindows) return false
527
+ const current = this.getUserPath()
528
+ const entries = String(current || '').split(';').map((e) => e.trim()).filter(Boolean)
529
+ const key = (e) => this._absPathKey(e)
530
+ const filtered = entries.filter((e) => key(e) !== needle)
531
+ const newValue = [dir, ...filtered].join(';')
532
+ if (newValue === current) return false
533
+ const ok = this._setUserPath(newValue)
534
+ if (ok) this.prependProcPath(dir)
535
+ return ok
536
+ }
537
+
538
+ /**
539
+ * 检测系统命令是否可用
540
+ * Windows: where <cmd>;Unix: which <cmd>
541
+ *
542
+ * @param {string} cmd - 命令名
543
+ * @returns {boolean}
544
+ */
545
+ isCommandAvailable(cmd) {
546
+ const checker = this.isWindows ? 'where' : 'which'
547
+ try {
548
+ const result = this._spawnSync(checker, [cmd])
549
+ return result.status === 0
550
+ } catch {
551
+ return false
552
+ }
553
+ }
554
+
555
+ /**
556
+ * 按优先级检测可用的网络探测工具
557
+ * 顺序: curl > wget > ping
558
+ *
559
+ * curl 优先:支持代理(-x)、输出 locale 无关(HTTP 状态码)、检查 HTTP 层可达性。
560
+ * ping 最后:ICMP 不支持代理、输出依赖系统语言(中文 Windows 乱码)、仅检查网络层。
561
+ *
562
+ * @returns {string|null} 'curl' | 'wget' | 'ping' | null
563
+ */
564
+ getNetProbeTool() {
565
+ for (const cmd of ['curl', 'wget', 'ping']) {
566
+ if (this.isCommandAvailable(cmd)) return cmd
567
+ }
568
+ return null
569
+ }
570
570
  }