@simonyea/holysheep-cli 1.6.7 → 1.6.9

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
@@ -218,6 +218,8 @@ A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试
218
218
 
219
219
  ## Changelog
220
220
 
221
+ - **v1.6.9** — 保留 OpenClaw 的 MiniMax 配置,并为 MiniMax 使用独立 provider id,避免与 Claude provider 冲突;在 OpenClaw 2026.3.13 下改为提示精确 `/model` 切换命令,而不是停止配置 MiniMax
222
+ - **v1.6.8** — 修复 Codex 重复写入 `config.toml` 导致的 duplicate key,并修复 OpenClaw 在 Windows 下的安装检测;针对 OpenClaw 2026.3.13 的模型路由回归,临时跳过 MiniMax 避免 `model not allowed`
221
223
  - **v1.6.7** — OpenClaw 配置新增 `MiniMax-M2.7-highspeed`,并补齐节点迁移脚本中的 SSH 代理账号创建逻辑
222
224
  - **v1.6.6** — 修复 Droid CLI 的 GPT-5.4 配置残留问题,同时同步 `~/.factory/settings.json` 和 `~/.factory/config.json`,统一使用 `openai + https://api.holysheep.ai/v1`
223
225
  - **v1.6.5** — 修复 HolySheep 对 Droid Responses API 的兼容
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
4
4
  "description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
5
5
  "keywords": [
6
6
  "openai-china",
package/src/index.js CHANGED
@@ -173,8 +173,3 @@ program
173
173
  })
174
174
 
175
175
  program.parse(process.argv)
176
-
177
- // 无参数时显示默认信息
178
- if (process.argv.length === 2) {
179
- program.help()
180
- }
@@ -25,6 +25,49 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.toml')
25
25
  // 保留 JSON 兼容性(老版本 TypeScript Codex 用)
26
26
  const CONFIG_FILE_JSON = path.join(CONFIG_DIR, 'config.json')
27
27
 
28
+ function normalizeToml(content) {
29
+ return String(content || '').replace(/\r\n/g, '\n')
30
+ }
31
+
32
+ function cleanupToml(content) {
33
+ return normalizeToml(content)
34
+ .replace(/\n{3,}/g, '\n\n')
35
+ .trim()
36
+ }
37
+
38
+ function stripManagedTomlConfig(content) {
39
+ const lines = normalizeToml(content).split('\n')
40
+ const output = []
41
+ let currentSection = null
42
+ let skipHolySheepBlock = false
43
+
44
+ for (const line of lines) {
45
+ const trimmed = line.trim()
46
+
47
+ if (/^\[[^\]]+\]$/.test(trimmed)) {
48
+ if (trimmed === '[model_providers.holysheep]') {
49
+ currentSection = trimmed
50
+ skipHolySheepBlock = true
51
+ continue
52
+ }
53
+
54
+ currentSection = trimmed
55
+ skipHolySheepBlock = false
56
+ }
57
+
58
+ if (skipHolySheepBlock) continue
59
+
60
+ if (!currentSection) {
61
+ if (/^model\s*=\s*"[^"]*"\s*$/.test(trimmed)) continue
62
+ if (/^model_provider\s*=\s*"holysheep"\s*$/.test(trimmed)) continue
63
+ }
64
+
65
+ output.push(line)
66
+ }
67
+
68
+ return cleanupToml(output.join('\n'))
69
+ }
70
+
28
71
  /**
29
72
  * 读取 TOML config(简单解析,不依赖 toml 库)
30
73
  */
@@ -55,14 +98,7 @@ function writeTomlConfig(apiKey, baseUrlOpenAI, model) {
55
98
  fs.mkdirSync(CONFIG_DIR, { recursive: true })
56
99
  }
57
100
 
58
- let content = readTomlConfig()
59
-
60
- // 移除旧的 holysheep 相关配置
61
- content = content
62
- .replace(/\nmodel\s*=\s*"[^"]*"\n/g, '\n')
63
- .replace(/\nmodel_provider\s*=\s*"holysheep"\n/g, '\n')
64
- .replace(/\[model_providers\.holysheep\][^\[]*(\[|$)/gs, (m, end) => end === '[' ? '[' : '')
65
- .trim()
101
+ const content = stripManagedTomlConfig(readTomlConfig())
66
102
 
67
103
  // 在开头插入 holysheep 配置
68
104
  const newConfig = [
@@ -76,9 +112,9 @@ function writeTomlConfig(apiKey, baseUrlOpenAI, model) {
76
112
  `base_url = "${baseUrlOpenAI}"`,
77
113
  `env_key = "OPENAI_API_KEY"`,
78
114
  '',
79
- ].join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'
115
+ ].join('\n')
80
116
 
81
- fs.writeFileSync(CONFIG_FILE, newConfig, 'utf8')
117
+ fs.writeFileSync(CONFIG_FILE, cleanupToml(newConfig) + '\n', 'utf8')
82
118
  }
83
119
 
84
120
  /**
@@ -91,8 +127,15 @@ function writeJsonConfigIfNeeded(apiKey, baseUrlOpenAI, model) {
91
127
  jsonConfig = JSON.parse(fs.readFileSync(CONFIG_FILE_JSON, 'utf8'))
92
128
  }
93
129
  jsonConfig.model = model || 'gpt-5.4'
130
+ jsonConfig.model_provider = 'holysheep'
94
131
  jsonConfig.provider = 'holysheep'
132
+ if (!jsonConfig.model_providers) jsonConfig.model_providers = {}
95
133
  if (!jsonConfig.providers) jsonConfig.providers = {}
134
+ jsonConfig.model_providers.holysheep = {
135
+ name: 'HolySheep',
136
+ base_url: baseUrlOpenAI,
137
+ env_key: 'OPENAI_API_KEY',
138
+ }
96
139
  jsonConfig.providers.holysheep = {
97
140
  name: 'HolySheep',
98
141
  baseURL: baseUrlOpenAI,
@@ -132,22 +175,23 @@ module.exports = {
132
175
  reset() {
133
176
  // 清理 TOML
134
177
  if (fs.existsSync(CONFIG_FILE)) {
135
- let content = readTomlConfig()
136
- content = content
137
- .replace(/^model\s*=\s*"[^"]*"\n/m, '')
138
- .replace(/^model_provider\s*=\s*"holysheep"\n/m, '')
139
- .replace(/\[model_providers\.holysheep\][^\[]*(\[|$)/gs, (m, end) => end === '[' ? '[' : '')
140
- .trim() + '\n'
141
- fs.writeFileSync(CONFIG_FILE, content, 'utf8')
178
+ const content = stripManagedTomlConfig(readTomlConfig())
179
+ fs.writeFileSync(CONFIG_FILE, (content ? content + '\n' : ''), 'utf8')
142
180
  }
143
181
  // 清理 JSON
144
182
  if (fs.existsSync(CONFIG_FILE_JSON)) {
145
183
  try {
146
184
  const c = JSON.parse(fs.readFileSync(CONFIG_FILE_JSON, 'utf8'))
185
+ if (c.model_provider === 'holysheep') {
186
+ delete c.model_provider
187
+ }
147
188
  if (c.provider === 'holysheep') {
148
189
  delete c.provider
149
- delete c.providers?.holysheep
150
190
  }
191
+ delete c.model_providers?.holysheep
192
+ delete c.providers?.holysheep
193
+ if (c.model_providers && Object.keys(c.model_providers).length === 0) delete c.model_providers
194
+ if (c.providers && Object.keys(c.providers).length === 0) delete c.providers
151
195
  fs.writeFileSync(CONFIG_FILE_JSON, JSON.stringify(c, null, 2), 'utf8')
152
196
  } catch {}
153
197
  }
@@ -3,7 +3,7 @@
3
3
  * 配置文件: ~/.factory/settings.json
4
4
  *
5
5
  * 使用 Droid 原生 customModels 配置 HolySheep 的多个模型入口:
6
- * - GPT 走 OpenAI 兼容入口: https://api.holysheep.ai/v1
6
+ * - GPT 走 OpenAI 兼容入口: https://api.holysheep.ai/openai
7
7
  * - Claude 走 Anthropic 入口: https://api.holysheep.ai
8
8
  * - MiniMax 走 Anthropic 入口: https://api.holysheep.ai/minimax
9
9
  */
@@ -13,13 +13,12 @@ const os = require('os')
13
13
 
14
14
  const CONFIG_DIR = path.join(os.homedir(), '.factory')
15
15
  const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json')
16
- const LEGACY_CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
17
16
 
18
17
  const DEFAULT_MODELS = [
19
18
  {
20
19
  model: 'gpt-5.4',
21
20
  id: 'custom:gpt-5.4-0',
22
- baseUrlSuffix: '',
21
+ baseUrlSuffix: '/openai',
23
22
  displayName: 'GPT-5.4',
24
23
  provider: 'openai',
25
24
  },
@@ -67,24 +66,6 @@ function writeSettings(data) {
67
66
  fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2), 'utf8')
68
67
  }
69
68
 
70
- function readLegacyConfig() {
71
- try {
72
- if (fs.existsSync(LEGACY_CONFIG_FILE)) {
73
- return JSON.parse(fs.readFileSync(LEGACY_CONFIG_FILE, 'utf8'))
74
- }
75
- } catch {}
76
- return {}
77
- }
78
-
79
- function writeLegacyConfig(data) {
80
- fs.mkdirSync(CONFIG_DIR, { recursive: true })
81
- fs.writeFileSync(LEGACY_CONFIG_FILE, JSON.stringify(data, null, 2), 'utf8')
82
- }
83
-
84
- function isHolySheepModel(item) {
85
- return typeof item?.baseUrl === 'string' && item.baseUrl.includes('api.holysheep.ai')
86
- }
87
-
88
69
  function normalizeSelectedModels(selectedModels) {
89
70
  const selected = new Set(
90
71
  Array.isArray(selectedModels) && selectedModels.length > 0
@@ -103,17 +84,13 @@ function normalizeSelectedModels(selectedModels) {
103
84
  return models.length > 0 ? models : DEFAULT_MODELS.map((item, index) => ({ ...item, index }))
104
85
  }
105
86
 
106
- function buildCustomModels(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModels) {
107
- const anthropicRootUrl = String(baseUrlAnthropic || '').replace(/\/+$/, '')
108
- const openaiRootUrl = String(baseUrlOpenAI || '').replace(/\/+$/, '')
87
+ function buildCustomModels(apiKey, baseUrlAnthropic, selectedModels) {
88
+ const rootUrl = String(baseUrlAnthropic || '').replace(/\/+$/, '')
109
89
  return normalizeSelectedModels(selectedModels).map((item) => ({
110
90
  model: item.model,
111
91
  id: item.id,
112
92
  index: item.index,
113
- baseUrl:
114
- item.provider === 'openai'
115
- ? `${openaiRootUrl}${item.baseUrlSuffix}`
116
- : `${anthropicRootUrl}${item.baseUrlSuffix}`,
93
+ baseUrl: `${rootUrl}${item.baseUrlSuffix}`,
117
94
  apiKey,
118
95
  displayName: item.displayName,
119
96
  maxOutputTokens: 64000,
@@ -131,37 +108,25 @@ module.exports = {
131
108
  isConfigured() {
132
109
  const settings = readSettings()
133
110
  const customModels = Array.isArray(settings.customModels) ? settings.customModels : []
134
- if (customModels.some(isHolySheepModel)) return true
135
-
136
- const legacy = readLegacyConfig()
137
- const legacyModels = Array.isArray(legacy.customModels) ? legacy.customModels : []
138
- return legacyModels.some(isHolySheepModel)
111
+ return customModels.some((item) =>
112
+ typeof item.baseUrl === 'string' && item.baseUrl.includes('api.holysheep.ai')
113
+ )
139
114
  },
140
- configure(apiKey, baseUrlAnthropic, baseUrlOpenAI, _primaryModel, selectedModels) {
141
- const nextModels = buildCustomModels(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModels)
142
-
115
+ configure(apiKey, baseUrlAnthropic, _baseUrlOpenAI, _primaryModel, selectedModels) {
143
116
  const settings = readSettings()
144
117
  const preservedModels = Array.isArray(settings.customModels)
145
- ? settings.customModels.filter((item) => !isHolySheepModel(item))
118
+ ? settings.customModels.filter(
119
+ (item) => !(typeof item.baseUrl === 'string' && item.baseUrl.includes('api.holysheep.ai'))
120
+ )
146
121
  : []
122
+
147
123
  settings.customModels = [
148
- ...nextModels,
124
+ ...buildCustomModels(apiKey, baseUrlAnthropic, selectedModels),
149
125
  ...preservedModels,
150
126
  ]
151
127
  settings.logoAnimation = 'off'
152
128
  writeSettings(settings)
153
129
 
154
- const legacy = readLegacyConfig()
155
- const preservedLegacyModels = Array.isArray(legacy.customModels)
156
- ? legacy.customModels.filter((item) => !isHolySheepModel(item))
157
- : []
158
- legacy.customModels = [
159
- ...nextModels,
160
- ...preservedLegacyModels,
161
- ]
162
- legacy.logoAnimation = 'off'
163
- writeLegacyConfig(legacy)
164
-
165
130
  return {
166
131
  file: SETTINGS_FILE,
167
132
  hot: true,
@@ -170,15 +135,11 @@ module.exports = {
170
135
  reset() {
171
136
  const settings = readSettings()
172
137
  if (Array.isArray(settings.customModels)) {
173
- settings.customModels = settings.customModels.filter((item) => !isHolySheepModel(item))
138
+ settings.customModels = settings.customModels.filter(
139
+ (item) => !(typeof item.baseUrl === 'string' && item.baseUrl.includes('api.holysheep.ai'))
140
+ )
174
141
  }
175
142
  writeSettings(settings)
176
-
177
- const legacy = readLegacyConfig()
178
- if (Array.isArray(legacy.customModels)) {
179
- legacy.customModels = legacy.customModels.filter((item) => !isHolySheepModel(item))
180
- }
181
- writeLegacyConfig(legacy)
182
143
  },
183
144
  getConfigPath() { return SETTINGS_FILE },
184
145
  hint: '已写入 ~/.factory/settings.json;重启 Droid 后可见 HolySheep 模型列表',
@@ -18,9 +18,20 @@ const MAX_PORT_SCAN = 20
18
18
  const OPENCLAW_DEFAULT_MODEL = 'gpt-5.4'
19
19
  const OPENCLAW_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
20
20
  const OPENCLAW_DEFAULT_MINIMAX_MODEL = 'MiniMax-M2.7-highspeed'
21
+ const OPENCLAW_ROUTING_REGRESSION_VERSION = /^2026\.3\.13(?:\D|$)/
22
+
23
+ function getOpenClawBinaryCandidates() {
24
+ return isWin ? ['openclaw.cmd', 'openclaw'] : ['openclaw']
25
+ }
26
+
27
+ function getBinaryRunner() {
28
+ return isWin
29
+ ? { cmd: 'openclaw.cmd', argsPrefix: [], shell: true, label: 'openclaw', via: 'binary' }
30
+ : { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
31
+ }
21
32
 
22
33
  function hasOpenClawBinary() {
23
- return commandExists('openclaw')
34
+ return getOpenClawBinaryCandidates().some((cmd) => commandExists(cmd))
24
35
  }
25
36
 
26
37
  function hasNpx() {
@@ -28,18 +39,41 @@ function hasNpx() {
28
39
  }
29
40
 
30
41
  function getRunner(preferNpx = false) {
42
+ const binaryRunner = hasOpenClawBinary() ? getBinaryRunner() : null
43
+
31
44
  if (!preferNpx && hasOpenClawBinary()) {
32
- return { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
45
+ return binaryRunner
33
46
  }
34
47
  if (hasNpx()) {
35
48
  return { cmd: 'npx', argsPrefix: ['openclaw'], shell: isWin, label: 'npx openclaw', via: 'npx' }
36
49
  }
37
- if (hasOpenClawBinary()) {
38
- return { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
50
+ if (binaryRunner) {
51
+ return binaryRunner
39
52
  }
40
53
  return null
41
54
  }
42
55
 
56
+ function runWithRunner(runner, args, opts = {}) {
57
+ return spawnSync(runner.cmd, [...runner.argsPrefix, ...args], {
58
+ shell: runner.shell,
59
+ timeout: opts.timeout || 30000,
60
+ stdio: opts.stdio || 'pipe',
61
+ encoding: 'utf8',
62
+ })
63
+ }
64
+
65
+ function normalizeVersionOutput(text) {
66
+ return firstLine(text).replace(/^openclaw\s+/i, '').trim()
67
+ }
68
+
69
+ function probeRunner(runner, timeout) {
70
+ const result = runWithRunner(runner, ['--version'], { timeout })
71
+ if (result.error || result.status !== 0) return null
72
+
73
+ const version = normalizeVersionOutput(result.stdout || result.stderr || '')
74
+ return version || null
75
+ }
76
+
43
77
  /** 运行 openclaw CLI(优先全局命令,可切换到 npx 回退) */
44
78
  function runOpenClaw(args, opts = {}) {
45
79
  const runner = getRunner(Boolean(opts.preferNpx))
@@ -47,12 +81,7 @@ function runOpenClaw(args, opts = {}) {
47
81
  return { status: 1, stdout: '', stderr: 'OpenClaw CLI not found' }
48
82
  }
49
83
 
50
- return spawnSync(runner.cmd, [...runner.argsPrefix, ...args], {
51
- shell: runner.shell,
52
- timeout: opts.timeout || 30000,
53
- stdio: opts.stdio || 'pipe',
54
- encoding: 'utf8',
55
- })
84
+ return runWithRunner(runner, args, opts)
56
85
  }
57
86
 
58
87
  function spawnOpenClaw(args, opts = {}) {
@@ -75,37 +104,58 @@ function firstLine(text) {
75
104
  }
76
105
 
77
106
  function getOpenClawVersion(preferNpx = false) {
78
- const result = runOpenClaw(['--version'], { preferNpx, timeout: preferNpx ? 60000 : 15000 })
79
- if (result.status !== 0) return null
80
- return firstLine(result.stdout)
107
+ const runner = getRunner(preferNpx)
108
+ if (!runner) return null
109
+ return probeRunner(runner, preferNpx ? 60000 : 15000)
81
110
  }
82
111
 
83
112
  function detectRuntime() {
84
113
  const preferNpx = getPreferredRuntime()
85
- const preferredRunner = getRunner(preferNpx)
86
-
87
- if (preferredRunner) {
88
- return {
89
- available: true,
90
- via: preferredRunner.via,
91
- command: preferredRunner.label,
92
- version: getOpenClawVersion(preferNpx),
114
+ const runnerOrder = preferNpx ? [getRunner(true), getRunner(false)] : [getRunner(false), getRunner(true)]
115
+ const seen = new Set()
116
+
117
+ for (const runner of runnerOrder) {
118
+ if (!runner) continue
119
+ const key = `${runner.via}:${runner.cmd}:${runner.argsPrefix.join(' ')}`
120
+ if (seen.has(key)) continue
121
+ seen.add(key)
122
+
123
+ const version = probeRunner(runner, runner.via === 'npx' ? 60000 : 15000)
124
+ if (version) {
125
+ return {
126
+ available: true,
127
+ via: runner.via,
128
+ command: runner.label,
129
+ version,
130
+ }
93
131
  }
94
132
  }
95
133
 
96
- const fallbackRunner = getRunner(true)
134
+ const fallbackRunner = getRunner(preferNpx)
97
135
  if (fallbackRunner) {
98
136
  return {
99
- available: true,
137
+ available: false,
100
138
  via: fallbackRunner.via,
101
139
  command: fallbackRunner.label,
102
- version: getOpenClawVersion(true),
140
+ version: null,
103
141
  }
104
142
  }
105
143
 
106
144
  return { available: false, via: null, command: null, version: null }
107
145
  }
108
146
 
147
+ function isRoutingRegressionVersion(version) {
148
+ return OPENCLAW_ROUTING_REGRESSION_VERSION.test(String(version || '').trim())
149
+ }
150
+
151
+ function getRoutingRegressionWarning(runtimeVersion, minimaxModelRef) {
152
+ if (!isRoutingRegressionVersion(runtimeVersion) || !minimaxModelRef) {
153
+ return ''
154
+ }
155
+
156
+ return `当前 OpenClaw 2026.3.13 存在 provider 路由回归,但 HolySheep 仍会保留 MiniMax 配置。若网页模型切换失败,请直接输入 /model ${minimaxModelRef},或升级 OpenClaw 后再试。`
157
+ }
158
+
109
159
  function readConfig() {
110
160
  try {
111
161
  if (fs.existsSync(CONFIG_FILE)) {
@@ -233,7 +283,7 @@ function buildManagedPlan(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModel
233
283
 
234
284
  const openaiProviderName = buildProviderName(baseUrlOpenAI, 'custom-openai')
235
285
  const anthropicProviderName = buildProviderName(baseUrlAnthropic, 'custom-anthropic')
236
- const minimaxProviderName = buildProviderName(`${baseUrlAnthropic.replace(/\/+$/, '')}/minimax`, 'custom-anthropic')
286
+ const minimaxProviderName = buildProviderName(`${baseUrlAnthropic.replace(/\/+$/, '')}/minimax`, 'custom-minimax')
237
287
 
238
288
  const providers = {
239
289
  [openaiProviderName]: {
@@ -269,6 +319,7 @@ function buildManagedPlan(apiKey, baseUrlAnthropic, baseUrlOpenAI, selectedModel
269
319
  providers,
270
320
  managedModelRefs,
271
321
  primaryRef: `${openaiProviderName}/${OPENCLAW_DEFAULT_MODEL}`,
322
+ minimaxRef: minimaxModels[0] ? `${minimaxProviderName}/${minimaxModels[0]}` : '',
272
323
  }
273
324
  }
274
325
 
@@ -465,7 +516,7 @@ module.exports = {
465
516
  console.log(chalk.yellow(' ⚠️ onboard 失败,使用备用配置...'))
466
517
  }
467
518
 
468
- writeManagedConfig(
519
+ const plan = writeManagedConfig(
469
520
  result.status === 0 ? readConfig() : {},
470
521
  apiKey,
471
522
  baseUrlAnthropic,
@@ -474,6 +525,11 @@ module.exports = {
474
525
  gatewayPort,
475
526
  )
476
527
 
528
+ const routingRegressionWarning = getRoutingRegressionWarning(runtime.version, plan.minimaxRef)
529
+ if (routingRegressionWarning) {
530
+ console.log(chalk.yellow(` ⚠️ ${routingRegressionWarning}`))
531
+ }
532
+
477
533
  _disableGatewayAuth(runtime.via === 'npx')
478
534
  const serviceReady = _installGatewayService(gatewayPort, runtime.via === 'npx')
479
535
 
@@ -4,18 +4,34 @@
4
4
  */
5
5
  const { execSync } = require('child_process')
6
6
 
7
- function commandExists(cmd) {
8
- const finder = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`
7
+ function canRun(command, options = {}) {
9
8
  try {
10
- execSync(finder, { stdio: 'ignore' })
9
+ execSync(command, { stdio: 'ignore', ...options })
11
10
  return true
12
11
  } catch {
13
- // 兜底:直接跑 --version
14
- try {
15
- execSync(`${cmd} --version`, { stdio: 'ignore', timeout: 3000 })
16
- return true
17
- } catch { return false }
12
+ return false
18
13
  }
19
14
  }
20
15
 
16
+ function commandExists(cmd) {
17
+ if (process.platform === 'win32') {
18
+ const variants = [cmd, `${cmd}.cmd`, `${cmd}.exe`, `${cmd}.bat`]
19
+ for (const variant of variants) {
20
+ if (canRun(`where ${variant}`)) return true
21
+ }
22
+
23
+ // Windows 上很多 npm 全局命令实际是 .cmd 包装器,需要交给 cmd.exe 执行。
24
+ for (const variant of variants) {
25
+ if (canRun(`cmd /d /s /c "${variant} --version"`, { timeout: 3000 })) return true
26
+ }
27
+
28
+ return false
29
+ }
30
+
31
+ if (canRun(`which ${cmd}`)) return true
32
+
33
+ // 兜底:直接跑 --version
34
+ return canRun(`${cmd} --version`, { timeout: 3000 })
35
+ }
36
+
21
37
  module.exports = { commandExists }