@simonyea/holysheep-cli 2.1.38 → 2.1.41

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.
Files changed (45) hide show
  1. package/dist/configure-worker.js +4491 -0
  2. package/dist/index.js +9591 -0
  3. package/dist/process-proxy-inject.js +117 -0
  4. package/package.json +19 -6
  5. package/.gitea/workflows/sanity.yml +0 -125
  6. package/scripts/check-tarball-size.js +0 -44
  7. package/src/commands/balance.js +0 -57
  8. package/src/commands/claude-proxy.js +0 -248
  9. package/src/commands/claude.js +0 -135
  10. package/src/commands/doctor.js +0 -282
  11. package/src/commands/login.js +0 -211
  12. package/src/commands/openclaw.js +0 -258
  13. package/src/commands/reset.js +0 -53
  14. package/src/commands/setup.js +0 -493
  15. package/src/commands/upgrade.js +0 -168
  16. package/src/commands/webui.js +0 -622
  17. package/src/index.js +0 -226
  18. package/src/tools/aider.js +0 -78
  19. package/src/tools/antigravity.js +0 -42
  20. package/src/tools/claude-code.js +0 -228
  21. package/src/tools/claude-process-proxy.js +0 -1030
  22. package/src/tools/codex.js +0 -254
  23. package/src/tools/continue.js +0 -146
  24. package/src/tools/cursor.js +0 -71
  25. package/src/tools/droid.js +0 -281
  26. package/src/tools/env-config.js +0 -185
  27. package/src/tools/gemini-cli.js +0 -82
  28. package/src/tools/hermes.js +0 -354
  29. package/src/tools/index.js +0 -13
  30. package/src/tools/openclaw-bridge.js +0 -987
  31. package/src/tools/openclaw.js +0 -925
  32. package/src/tools/opencode.js +0 -227
  33. package/src/tools/process-proxy-inject.js +0 -142
  34. package/src/utils/config.js +0 -54
  35. package/src/utils/shell.js +0 -342
  36. package/src/utils/which.js +0 -63
  37. package/src/webui/aionui-runtime-fetcher.js +0 -429
  38. package/src/webui/aionui-runtime.js +0 -139
  39. package/src/webui/aionui-wrapper.js +0 -734
  40. package/src/webui/configure-worker.js +0 -67
  41. package/src/webui/server.js +0 -1566
  42. package/src/webui/workspace-runtime.js +0 -288
  43. package/src/webui/workspace-store.js +0 -325
  44. /package/{src/webui → dist}/index.html +0 -0
  45. /package/{src/tools → dist}/pty-hermes-wrapper.py +0 -0
@@ -1,1566 +0,0 @@
1
- /**
2
- * HolySheep WebUI — HTTP 服务器 + REST API
3
- * 仅绑定 127.0.0.1,零新依赖
4
- */
5
- 'use strict'
6
-
7
- // 设置 DNS 回退:Cloudflare (1.1.1.1) + 阿里 (223.5.5.5) + 系统默认
8
- // 解决大陆服务器 DNS 解析 api.holysheep.ai 失败 (EAI_AGAIN) 的问题
9
- const dns = require('dns')
10
- dns.setServers([...new Set([...dns.getServers(), '1.1.1.1', '223.5.5.5'])])
11
-
12
- const http = require('http')
13
- const fs = require('fs')
14
- const path = require('path')
15
- const { execSync, spawn } = require('child_process')
16
- const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
17
- const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../utils/shell')
18
- const { commandExistsAsync } = require('../utils/which')
19
- const TOOLS = require('../tools')
20
- const pkg = require('../../package.json')
21
- const workspaceStore = require('./workspace-store')
22
- const workspaceRuntime = require('./workspace-runtime')
23
-
24
- const TOOL_CHECK_CACHE_TTL_MS = 10_000
25
- const toolStateCache = new Map()
26
-
27
- // ── Helpers ──────────────────────────────────────────────────────────────────
28
-
29
- function maskKey(key) {
30
- if (!key || key.length < 8) return '****'
31
- return key.slice(0, 6) + '...' + key.slice(-4)
32
- }
33
-
34
- async function fetchWithRetry(url, opts = {}, retries = 3, timeoutMs = 20000) {
35
- const fetch = require('node-fetch')
36
- for (let i = 0; i < retries; i++) {
37
- const controller = new AbortController()
38
- const timer = setTimeout(() => controller.abort(), timeoutMs)
39
- try {
40
- const res = await fetch(url, { ...opts, signal: controller.signal })
41
- clearTimeout(timer)
42
- return res
43
- } catch (e) {
44
- clearTimeout(timer)
45
- const retryable = e.code === 'EAI_AGAIN' || e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET' || e.code === 'ECONNREFUSED' || e.type === 'system' || e.name === 'AbortError'
46
- if (i < retries - 1 && retryable) {
47
- await new Promise(r => setTimeout(r, 2000 * (i + 1)))
48
- continue
49
- }
50
- throw e
51
- }
52
- }
53
- }
54
-
55
- async function validateApiKey(apiKey) {
56
- const res = await fetchWithRetry(`${BASE_URL_OPENAI}/models`, {
57
- method: 'GET',
58
- headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
59
- })
60
- return res.status === 200
61
- }
62
-
63
- const { exec } = require('child_process')
64
-
65
- function getVersionAsync(tool) {
66
- if (typeof tool.getVersion === 'function') {
67
- return Promise.resolve(tool.getVersion())
68
- }
69
- const cmds = {
70
- 'claude-code': 'claude --version',
71
- 'codex': 'codex --version',
72
- 'droid': 'droid --version',
73
- 'gemini-cli': 'gemini --version',
74
- 'opencode': 'opencode --version',
75
- 'openclaw': 'openclaw --version',
76
- 'aider': 'aider --version',
77
- 'hermes': 'hermes --version',
78
- }
79
- const cmd = cmds[tool.id]
80
- if (!cmd) return Promise.resolve(null)
81
- return new Promise(resolve => {
82
- exec(cmd, { timeout: 5000 }, (err, stdout) => {
83
- if (err) return resolve(null)
84
- resolve(stdout.trim().split('\n')[0].slice(0, 30) || null)
85
- })
86
- })
87
- }
88
-
89
- function parseBody(req) {
90
- return new Promise((resolve, reject) => {
91
- let body = ''
92
- req.on('data', chunk => { body += chunk })
93
- req.on('end', () => {
94
- try { resolve(body ? JSON.parse(body) : {}) }
95
- catch { reject(new Error('Invalid JSON')) }
96
- })
97
- req.on('error', reject)
98
- })
99
- }
100
-
101
- function getToolCommand(toolId) {
102
- const cmds = {
103
- 'claude-code': 'claude',
104
- 'codex': 'codex',
105
- 'droid': 'droid',
106
- 'gemini-cli': 'gemini',
107
- 'opencode': 'opencode',
108
- 'openclaw': 'openclaw',
109
- 'aider': 'aider',
110
- 'hermes': 'hermes',
111
- 'env-config': null,
112
- }
113
- return cmds[toolId] ?? null
114
- }
115
-
116
- async function detectToolInstalled(tool) {
117
- if (tool.id === 'env-config') return true
118
-
119
- const now = Date.now()
120
- const cached = toolStateCache.get(tool.id)
121
- if (cached && now - cached.checkedAt < TOOL_CHECK_CACHE_TTL_MS) {
122
- return cached.installed
123
- }
124
-
125
- const command = getToolCommand(tool.id)
126
- const installed = command ? await commandExistsAsync(command) : false
127
- toolStateCache.set(tool.id, { installed, checkedAt: now })
128
- return installed
129
- }
130
-
131
- async function buildToolSummary(tool) {
132
- const installed = await detectToolInstalled(tool)
133
- return {
134
- id: tool.id,
135
- name: tool.name,
136
- installed,
137
- configured: installed ? (tool.isConfigured?.() || false) : false,
138
- version: installed ? await getVersionAsync(tool) : null,
139
- installCmd: tool.installCmd,
140
- hint: tool.hint || null,
141
- launchCmd: tool.launchCmd || null,
142
- canAutoInstall: !!AUTO_INSTALL[tool.id],
143
- canUpgrade: !!UPGRADABLE_TOOLS.find((item) => item.id === tool.id),
144
- npmPkg: UPGRADABLE_TOOLS.find((item) => item.id === tool.id)?.npmPkg || null,
145
- }
146
- }
147
-
148
- function json(res, data, status = 200) {
149
- res.writeHead(status, { 'Content-Type': 'application/json' })
150
- res.end(JSON.stringify(data))
151
- }
152
-
153
- function sseStart(res) {
154
- res.writeHead(200, {
155
- 'Content-Type': 'text/event-stream',
156
- 'Cache-Control': 'no-cache',
157
- 'Connection': 'keep-alive',
158
- })
159
- }
160
-
161
- function sseEmit(res, data) {
162
- res.write(`data: ${JSON.stringify(data)}\n\n`)
163
- }
164
-
165
- // ── AUTO_INSTALL map (from setup.js) ─────────────────────────────────────────
166
-
167
- const AUTO_INSTALL = {
168
- 'claude-code': {
169
- cmd: process.platform === 'win32'
170
- ? 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
171
- : 'curl -fsSL https://claude.ai/install.sh | bash',
172
- },
173
- 'codex': { cmd: 'npm install -g @openai/codex' },
174
- 'droid': {
175
- // 2.1.14 fix: Windows/Linux now have official installers; previously
176
- // the WebUI piped the macOS-only brew command on every platform.
177
- cmd: process.platform === 'win32'
178
- ? 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
179
- : process.platform === 'darwin'
180
- ? 'brew install --cask droid'
181
- : 'curl -fsSL https://app.factory.ai/install.sh | bash',
182
- },
183
- 'gemini-cli': { cmd: 'npm install -g @google/gemini-cli' },
184
- 'opencode': { cmd: 'npm install -g opencode-ai' },
185
- 'openclaw': { cmd: 'npm install -g openclaw@latest' },
186
- 'aider': { cmd: 'pip install aider-chat' },
187
- // Hermes Agent (Nous Research). Python-based, installed via uv. Windows
188
- // requires WSL2 (the installer explicitly rejects native Windows). On
189
- // Windows the CLI's hermes tool reports manual steps instead of running
190
- // this shell command — see src/tools/hermes.js.
191
- 'hermes': {
192
- cmd: process.platform === 'win32'
193
- ? '' // unsupported — see hermes tool for manual steps
194
- : 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup',
195
- },
196
- }
197
-
198
- // ── UPGRADABLE_TOOLS (from upgrade.js) ───────────────────────────────────────
199
-
200
- const UPGRADABLE_TOOLS = [
201
- { name: 'HolySheep CLI', id: 'holysheep', command: 'hs', versionCmd: 'hs --version', npmPkg: '@simonyea/holysheep-cli', installCmd: 'npm install -g @simonyea/holysheep-cli@latest' },
202
- {
203
- name: 'Claude Code', id: 'claude-code', command: 'claude',
204
- versionCmd: 'claude --version', npmPkg: null,
205
- installCmd: process.platform === 'win32'
206
- ? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
207
- : 'curl -fsSL https://claude.ai/install.sh | bash',
208
- },
209
- { name: 'Codex CLI', id: 'codex', command: 'codex', versionCmd: 'codex --version', npmPkg: '@openai/codex', installCmd: 'npm install -g @openai/codex@latest' },
210
- {
211
- name: 'Droid CLI', id: 'droid', command: 'droid', versionCmd: 'droid --version', npmPkg: null,
212
- // 2.1.14: Correct Factory winget package id is `Factory.Droid` (not `Droid.Droid`)
213
- // and Linux/macOS get platform-native installers. Previously the Linux
214
- // fallback was the macOS brew cmd which fails.
215
- installCmd: process.platform === 'win32'
216
- ? 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
217
- : process.platform === 'darwin'
218
- ? 'brew install --cask droid'
219
- : 'curl -fsSL https://app.factory.ai/install.sh | bash',
220
- },
221
- { name: 'OpenCode', id: 'opencode', command: 'opencode', versionCmd: 'opencode --version', npmPkg: 'opencode-ai', installCmd: 'npm install -g opencode-ai@latest' },
222
- { name: 'OpenClaw', id: 'openclaw', command: 'openclaw', versionCmd: 'openclaw --version', npmPkg: 'openclaw', installCmd: 'npm install -g openclaw@latest' },
223
- { name: 'Gemini CLI', id: 'gemini-cli', command: 'gemini', versionCmd: 'gemini --version', npmPkg: '@google/gemini-cli', installCmd: 'npm install -g @google/gemini-cli@latest' },
224
- {
225
- name: 'Hermes Agent', id: 'hermes', command: 'hermes', versionCmd: 'hermes --version', npmPkg: null,
226
- installCmd: process.platform === 'win32'
227
- ? '' // unsupported on native Windows — see hermes tool hint
228
- : 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup',
229
- },
230
- ]
231
-
232
- // ── Update check (cached, refreshes every 30min) ────────────────────────────
233
-
234
- let _latestVersion = null
235
- let _lastCheckTime = 0
236
- const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000
237
-
238
- // Returns cached value immediately; fetches in background when cache is stale.
239
- function checkLatestVersion() {
240
- const now = Date.now()
241
- if (now - _lastCheckTime >= UPDATE_CHECK_INTERVAL) {
242
- _lastCheckTime = now // prevent concurrent fetches
243
- fetchWithRetry('https://registry.npmjs.org/@simonyea/holysheep-cli/latest', {}, 2)
244
- .then(r => r.ok ? r.json() : null)
245
- .then(data => { if (data?.version) _latestVersion = data.version })
246
- .catch(() => {})
247
- }
248
- return _latestVersion
249
- }
250
-
251
- // 启动时立即检查一次
252
- checkLatestVersion()
253
-
254
- // ── API Handlers ─────────────────────────────────────────────────────────────
255
-
256
- function handleStatus(_req, res) {
257
- const apiKey = getApiKey()
258
- const config = loadConfig()
259
- const latest = checkLatestVersion()
260
- json(res, {
261
- loggedIn: !!apiKey,
262
- apiKey: apiKey ? maskKey(apiKey) : null,
263
- savedAt: config.savedAt || null,
264
- version: pkg.version,
265
- latestVersion: latest || null,
266
- updateAvailable: latest && latest !== pkg.version ? latest : null,
267
- })
268
- }
269
-
270
- async function handleLogin(req, res) {
271
- const body = await parseBody(req)
272
- const apiKey = (body.apiKey || '').trim()
273
- if (!apiKey || !apiKey.startsWith('cr_')) {
274
- return json(res, { success: false, message: 'API Key 必须以 cr_ 开头' }, 400)
275
- }
276
- try {
277
- const valid = await validateApiKey(apiKey)
278
- if (!valid) return json(res, { success: false, message: 'API Key 无效 (server returned non-2xx)' }, 401)
279
- saveConfig({ apiKey, savedAt: new Date().toISOString() })
280
- workspaceStore.saveHolySheepApiConfig({
281
- apiKey,
282
- baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
283
- model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
284
- })
285
- json(res, { success: true, apiKey: maskKey(apiKey) })
286
- } catch (e) {
287
- // Translate cryptic node-fetch / DNS / proxy errors into something actionable.
288
- // Previously users just saw "链接失败" in the browser alert — now we surface
289
- // the underlying cause (DNS, timeout, proxy interference, TLS, etc).
290
- const code = e && (e.code || e.errno || e.type)
291
- const name = e && e.name
292
- let hint
293
- if (code === 'EAI_AGAIN' || code === 'ENOTFOUND') {
294
- hint = 'DNS 解析失败 — 检查网络 / 代理 / 防火墙是否拦截了 api.holysheep.ai'
295
- } else if (code === 'ETIMEDOUT' || name === 'AbortError') {
296
- hint = '连接超时 — 可能是网络慢或代理阻塞;试试开关代理再重试'
297
- } else if (code === 'ECONNREFUSED' || code === 'ECONNRESET') {
298
- hint = '连接被拒绝 — 检查本机防火墙或公司代理'
299
- } else if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
300
- hint = 'TLS 证书校验失败 — 可能是系统时间错误或中间人代理'
301
- } else {
302
- hint = e && e.message ? e.message : String(e)
303
- }
304
- const detail = `${hint}${code ? ` [${code}]` : ''}`
305
- json(res, { success: false, message: `验证失败: ${detail}` }, 500)
306
- }
307
- }
308
-
309
- async function handleLogout(_req, res) {
310
- const config = loadConfig()
311
- delete config.apiKey
312
- delete config.savedAt
313
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
314
- workspaceStore.saveHolySheepApiConfig({
315
- apiKey: '',
316
- baseUrl: workspaceStore.getHolySheepApiConfig().baseUrl || BASE_URL_OPENAI,
317
- model: workspaceStore.getHolySheepApiConfig().model || 'gpt-5.4',
318
- })
319
- json(res, { success: true })
320
- }
321
-
322
- async function handleWhoami(_req, res) {
323
- const apiKey = getApiKey()
324
- const config = loadConfig()
325
- if (!apiKey) return json(res, { loggedIn: false })
326
- let valid = null
327
- try { valid = await validateApiKey(apiKey) } catch {}
328
- json(res, { loggedIn: true, apiKey: maskKey(apiKey), savedAt: config.savedAt || null, valid })
329
- }
330
-
331
- async function handleBalance(_req, res) {
332
- const apiKey = getApiKey()
333
- if (!apiKey) return json(res, { error: '未登录' }, 401)
334
- // IMPORTANT (2.1.14): SHOP_URL is `https://holysheep.ai` which issues a
335
- // 301 to `https://www.holysheep.ai`. node-fetch's default redirect flow
336
- // drops the Authorization header on cross-origin redirects (even between
337
- // same-reg-domain hosts), so the downstream request would hit the Next.js
338
- // app unauthenticated and return 404/HTML. We hit www.* directly to keep
339
- // the Bearer header intact.
340
- //
341
- // Response schema (verified 2026-04-22):
342
- // { balance, todayCost, monthCost, totalCalls, totalCost, recentRecords:[…] }
343
- const STATS_URL = 'https://www.holysheep.ai/api/stats/overview'
344
- try {
345
- const r = await fetchWithRetry(STATS_URL, {
346
- headers: { Authorization: `Bearer ${apiKey}` },
347
- })
348
- if (r.status === 401) return json(res, { error: 'API Key 无效或已过期' }, 401)
349
- if (!r.ok) return json(res, { error: `HTTP ${r.status}` }, r.status)
350
- const data = await r.json()
351
- json(res, {
352
- balance: Number(data.balance || 0),
353
- todayCost: Number(data.todayCost || 0),
354
- monthCost: Number(data.monthCost || 0),
355
- totalCalls: Number(data.totalCalls || 0),
356
- totalCost: Number(data.totalCost || 0),
357
- plans: Array.isArray(data.plans) ? data.plans : [],
358
- })
359
- } catch (e) {
360
- const msg = e.code === 'EAI_AGAIN' ? 'DNS 解析失败,请检查网络' : e.message
361
- json(res, { error: msg }, 500)
362
- }
363
- }
364
-
365
- async function handleDoctor(_req, res) {
366
- const apiKey = getApiKey()
367
- const nodeMajor = parseInt(process.version.slice(1), 10)
368
-
369
- // Tools
370
- const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
371
-
372
- // Connectivity
373
- let connectivity = { ok: false, modelCount: 0 }
374
- if (apiKey) {
375
- try {
376
- const fetch = require('node-fetch')
377
- const r = await fetch(`${BASE_URL_ANTHROPIC}/v1/models`, {
378
- headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
379
- timeout: 8000,
380
- })
381
- if (r.ok) {
382
- const data = await r.json()
383
- connectivity = { ok: true, modelCount: data.data?.length || 0 }
384
- }
385
- } catch {}
386
- }
387
-
388
- json(res, {
389
- node: { version: process.version, ok: nodeMajor >= 16 },
390
- apiKey: { set: !!apiKey, masked: apiKey ? maskKey(apiKey) : null },
391
- envVars: {
392
- ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
393
- ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || null,
394
- ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || null,
395
- OPENAI_API_KEY: process.env.OPENAI_API_KEY || null,
396
- OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || null,
397
- },
398
- tools,
399
- connectivity,
400
- })
401
- }
402
-
403
- function isClaudeProxyRunning() {
404
- try {
405
- const pidFile = path.join(require('os').homedir(), '.holysheep', 'claude-proxy.pid')
406
- const info = JSON.parse(fs.readFileSync(pidFile, 'utf8'))
407
- process.kill(info.pid, 0) // check alive
408
- return { running: true, port: info.port, pid: info.pid }
409
- } catch {
410
- return { running: false }
411
- }
412
- }
413
-
414
- async function handleTools(_req, res) {
415
- const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
416
-
417
- // 追加 claude-proxy 虚拟工具
418
- const proxyState = isClaudeProxyRunning()
419
- tools.push({
420
- id: 'claude-proxy',
421
- name: 'Claude 代理(VS Code)',
422
- installed: true,
423
- configured: proxyState.running,
424
- version: proxyState.running ? `端口 ${proxyState.port}` : null,
425
- installCmd: null,
426
- hint: '启动后 VS Code Claude 扩展即可通过 HolySheep 使用',
427
- launchCmd: 'hs claude-proxy',
428
- canAutoInstall: false,
429
- canUpgrade: false,
430
- isProxy: true,
431
- })
432
-
433
- json(res, tools)
434
- }
435
-
436
- async function handleSetup(req, res) {
437
- const body = await parseBody(req)
438
- const { apiKey, models, toolIds, autoInstall } = body
439
-
440
- sseStart(res)
441
-
442
- // Step 1: Validate API Key — support using saved key
443
- let effectiveKey = apiKey
444
- if (apiKey === '__use_saved__') {
445
- effectiveKey = getApiKey()
446
- if (!effectiveKey) {
447
- sseEmit(res, { type: 'error', message: '未找到已保存的 API Key' })
448
- return res.end()
449
- }
450
- }
451
- if (!effectiveKey || !effectiveKey.startsWith('cr_')) {
452
- sseEmit(res, { type: 'error', message: 'API Key 必须以 cr_ 开头' })
453
- return res.end()
454
- }
455
-
456
- sseEmit(res, { type: 'progress', step: 'validate', message: '验证 API Key...' })
457
- try {
458
- const valid = await validateApiKey(effectiveKey)
459
- if (!valid) {
460
- sseEmit(res, { type: 'error', message: 'API Key 无效' })
461
- return res.end()
462
- }
463
- sseEmit(res, { type: 'progress', step: 'validate', status: 'ok', message: 'API Key 验证成功' })
464
- } catch (e) {
465
- sseEmit(res, { type: 'error', message: `验证失败: ${e.message}` })
466
- return res.end()
467
- }
468
-
469
- // Step 2: Save API Key
470
- saveConfig({ apiKey: effectiveKey, savedAt: new Date().toISOString() })
471
- sseEmit(res, { type: 'progress', step: 'save-key', status: 'ok', message: 'API Key 已保存' })
472
-
473
- // Step 3: Determine models
474
- const selectedModels = models && models.length > 0 ? models : ['claude-sonnet-4-6']
475
- const primaryModel = selectedModels.find(m => m.startsWith('claude-')) || selectedModels[0] || 'claude-sonnet-4-6'
476
-
477
- // Step 4: Process each tool
478
- const selectedToolIds = toolIds || []
479
- const selectedTools = TOOLS.filter(t => selectedToolIds.includes(t.id))
480
- const results = []
481
-
482
- for (const tool of selectedTools) {
483
- const installed = tool.checkInstalled()
484
-
485
- // Auto-install if needed
486
- if (!installed && autoInstall && AUTO_INSTALL[tool.id]) {
487
- sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, message: `正在安装 ${tool.name}...` })
488
- const installOk = await new Promise(resolve => {
489
- const child = spawn(AUTO_INSTALL[tool.id].cmd, [], { shell: true })
490
- let output = ''
491
- child.stdout?.on('data', chunk => {
492
- output += chunk.toString()
493
- sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
494
- })
495
- child.stderr?.on('data', chunk => {
496
- output += chunk.toString()
497
- sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
498
- })
499
- child.on('close', code => resolve(code === 0))
500
- child.on('error', () => resolve(false))
501
- })
502
- if (!installOk) {
503
- sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'error', message: `${tool.name} 安装失败` })
504
- results.push({ tool: tool.name, status: 'error', error: '安装失败' })
505
- continue
506
- }
507
- sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'ok', message: `${tool.name} 安装成功` })
508
- } else if (!installed) {
509
- sseEmit(res, { type: 'progress', step: 'skip', tool: tool.name, status: 'skip', message: `${tool.name} 未安装,跳过` })
510
- results.push({ tool: tool.name, status: 'skip' })
511
- continue
512
- }
513
-
514
- // Configure
515
- sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, message: `配置 ${tool.name}...` })
516
- try {
517
- const result = tool.configure(effectiveKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, primaryModel, selectedModels)
518
- if (result.manual) {
519
- sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'manual', message: `${tool.name} 需要手动配置`, steps: result.steps })
520
- results.push({ tool: tool.name, status: 'manual', steps: result.steps })
521
- } else if (result.warning) {
522
- sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'warning', message: result.warning, file: result.file })
523
- results.push({ tool: tool.name, status: 'warning', warning: result.warning, file: result.file })
524
- } else {
525
- sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'ok', message: `${tool.name} 配置成功`, file: result.file, hot: result.hot })
526
- results.push({ tool: tool.name, status: 'ok', file: result.file, hot: result.hot })
527
- }
528
- } catch (e) {
529
- sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'error', message: e.message })
530
- results.push({ tool: tool.name, status: 'error', error: e.message })
531
- }
532
- }
533
-
534
- // Step 5: Clean env vars (skip if env-config tool is selected)
535
- if (!selectedToolIds.includes('env-config')) {
536
- try {
537
- const cleaned = removeEnvFromShell(['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'])
538
- if (cleaned.length) {
539
- sseEmit(res, { type: 'progress', step: 'clean-env', status: 'ok', message: `已清理环境变量: ${cleaned.join(', ')}` })
540
- }
541
- } catch {}
542
- }
543
-
544
- // Done
545
- const ok = results.filter(r => r.status === 'ok').length
546
- const errors = results.filter(r => r.status === 'error').length
547
- sseEmit(res, { type: 'done', summary: { ok, errors, total: results.length, results } })
548
- res.end()
549
- }
550
-
551
- async function handleReset(req, res) {
552
- const body = await parseBody(req)
553
- if (!body.confirm) return json(res, { error: '需要确认' }, 400)
554
-
555
- const results = []
556
- for (const tool of TOOLS) {
557
- if (!tool.checkInstalled() && !tool.isConfigured?.()) continue
558
- try {
559
- tool.reset()
560
- results.push({ tool: tool.name, status: 'ok' })
561
- } catch (e) {
562
- results.push({ tool: tool.name, status: 'error', error: e.message })
563
- }
564
- }
565
-
566
- try { removeEnvFromShell() } catch {}
567
- saveConfig({ apiKey: '' })
568
-
569
- json(res, { success: true, results })
570
- }
571
-
572
- async function handleUpgrade(_req, res) {
573
- sseStart(res)
574
-
575
- for (const tool of UPGRADABLE_TOOLS) {
576
- const installed = commandExists(tool.command)
577
- if (!installed) {
578
- sseEmit(res, { type: 'tool', name: tool.name, status: 'not-installed' })
579
- continue
580
- }
581
-
582
- let localVer = null
583
- try {
584
- const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
585
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
586
- localVer = m ? m[1] : out.split('\n')[0].slice(0, 30)
587
- } catch {}
588
-
589
- sseEmit(res, { type: 'tool', name: tool.name, status: 'upgrading', localVer })
590
-
591
- const ok = await new Promise(resolve => {
592
- const child = spawn(tool.installCmd, [], { shell: true })
593
- child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
594
- child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
595
- child.on('close', code => resolve(code === 0))
596
- child.on('error', () => resolve(false))
597
- })
598
-
599
- let newVer = null
600
- try {
601
- const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
602
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
603
- newVer = m ? m[1] : null
604
- } catch {}
605
-
606
- // OpenClaw 升级后:Gateway → 等端口就绪 → 更新 PID → Bridge
607
- if (ok && tool.id === 'openclaw') {
608
- try { execSync('openclaw daemon stop', { stdio: 'ignore', timeout: 10000 }) } catch {}
609
- try { execSync('openclaw daemon start', { stdio: 'ignore', timeout: 30000 }) } catch {}
610
- const openclawTool = TOOLS.find(t => t.id === 'openclaw')
611
- const gPort = openclawTool?.getGatewayPort?.() || 18789
612
- let gPid = null
613
- for (let i = 0; i < 10 && !gPid; i++) {
614
- await new Promise(r => setTimeout(r, 1500))
615
- try {
616
- if (process.platform === 'win32') {
617
- const o = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
618
- gPid = Number(o.split(/\r?\n/)[0]) || null
619
- } else {
620
- const o = execSync(`lsof -iTCP:${gPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
621
- gPid = Number(o) || null
622
- }
623
- } catch {}
624
- }
625
- try {
626
- const bridgeMod = require('../tools/openclaw-bridge')
627
- const bc = bridgeMod.readBridgeConfig()
628
- bc.gatewayPid = gPid // null 也写入,清除旧死 PID
629
- bc.gatewayStartedAt = new Date().toISOString()
630
- fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8')
631
- } catch {}
632
- try { openclawTool?.ensureBridgeRunning?.() } catch {}
633
- }
634
-
635
- sseEmit(res, { type: 'tool', name: tool.name, status: ok ? 'ok' : 'error', localVer, newVer })
636
- }
637
-
638
- sseEmit(res, { type: 'done' })
639
- res.end()
640
- }
641
-
642
- async function handleToolInstall(req, res) {
643
- const body = await parseBody(req)
644
- const { toolId } = body
645
- if (!toolId || !AUTO_INSTALL[toolId]) {
646
- return json(res, { error: '不支持自动安装此工具' }, 400)
647
- }
648
- // Hermes on Windows: installer only supports Linux/macOS/WSL2. Instead of
649
- // spawning an empty cmd (which hangs SSE), short-circuit with guidance.
650
- if (toolId === 'hermes' && process.platform === 'win32') {
651
- sseStart(res)
652
- sseEmit(res, { type: 'output', text: 'Hermes Agent 官方安装脚本不支持原生 Windows。\n请先启用 WSL2(`wsl --install`)后,在 Ubuntu 终端里运行:\n curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup\n' })
653
- sseEmit(res, { type: 'done', success: false, manual: true })
654
- return res.end()
655
- }
656
-
657
- sseStart(res)
658
- sseEmit(res, { type: 'progress', message: `正在安装 ${toolId}...` })
659
-
660
- const INSTALL_TIMEOUT_MS = 10 * 60 * 1000 // 10 分钟
661
- const ok = await new Promise(resolve => {
662
- const child = spawn(AUTO_INSTALL[toolId].cmd, [], { shell: true })
663
- const timer = setTimeout(() => {
664
- sseEmit(res, { type: 'output', text: `\n⚠️ 安装超时(10分钟),请手动运行:\n${AUTO_INSTALL[toolId].cmd}\n` })
665
- try { child.kill('SIGKILL') } catch {}
666
- resolve(false)
667
- }, INSTALL_TIMEOUT_MS)
668
- child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
669
- child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
670
- child.on('close', code => { clearTimeout(timer); resolve(code === 0) })
671
- child.on('error', () => { clearTimeout(timer); resolve(false) })
672
- })
673
-
674
- // Windows: npm-global installs (codex, gemini-cli, opencode, openclaw) drop
675
- // the binary under %APPDATA%\npm which isn't in the freshly-installed user's
676
- // Path. Fix it now so the very next tool check sees the binary. No-op on
677
- // non-Windows or when already present.
678
- if (ok && process.platform === 'win32') {
679
- try {
680
- const { ensureWindowsUserPathHasNpmBin, ensureWindowsUserPathHasLocalBin } = require('../utils/shell')
681
- ensureWindowsUserPathHasNpmBin()
682
- sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 npm global bin)\n' })
683
- // [HolySheep fork v2.1.36 / hs25] Claude Code's Windows installer
684
- // (claude.ai/install.ps1) drops claude.exe under
685
- // `%USERPROFILE%\\.local\\bin` which is NOT on PATH by default — it
686
- // prints a warning and exits, leaving our `commandExists` check to
687
- // incorrectly report 未安装 even though the install succeeded.
688
- if (toolId === 'claude-code') {
689
- try {
690
- ensureWindowsUserPathHasLocalBin()
691
- sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 %USERPROFILE%\\.local\\bin for Claude Code)\n' })
692
- } catch {}
693
- }
694
- } catch {}
695
- // Resolve the real install path via `where.exe` so the user sees
696
- // exactly where the binary landed. This was a high-signal 2.1.14
697
- // feedback item: previously users saw "install succeeded" but then
698
- // `command not found` in the next terminal because PATH hadn't been
699
- // refreshed — surfacing the absolute path (a) confirms the install
700
- // really worked, (b) gives them a copy-pasteable fallback if PATH
701
- // still isn't picked up by their shell.
702
- try {
703
- const cmd = getToolCommand(toolId)
704
- if (cmd) {
705
- const out = execSync(`where.exe ${cmd}`, { stdio: 'pipe', timeout: 5000 })
706
- .toString().trim()
707
- const first = out.split(/\r?\n/).find(Boolean)
708
- if (first) {
709
- sseEmit(res, { type: 'output', text: `\n📍 ${cmd} 二进制位置: ${first}\n` })
710
- sseEmit(res, { type: 'output', text: ' 如果新终端里执行该命令仍报 not found,复制上述绝对路径直接运行。\n' })
711
- } else {
712
- sseEmit(res, { type: 'output', text: `\n⚠️ ${cmd} 安装完成但 where.exe 找不到 — 可能需要重新打开 PowerShell / CMD\n` })
713
- }
714
- }
715
- } catch {
716
- // `where.exe` can be missing in very old cmd.exe or locked-down images.
717
- // Not fatal — the ensureWindowsUserPathHasNpmBin hint above is enough.
718
- }
719
- // Bust the tool-check cache so the follow-up /api/holysheep/tools sees
720
- // the new binary without waiting 10s for the TTL.
721
- toolStateCache.delete(toolId)
722
- }
723
-
724
- sseEmit(res, { type: 'done', success: ok })
725
- res.end()
726
- }
727
-
728
- // ── Single-tool configure (SSE) ──────────────────────────────────────────────
729
-
730
- async function handleToolConfigure(req, res) {
731
- const body = await parseBody(req)
732
- const { toolId } = body
733
- const apiKey = getApiKey()
734
- if (!apiKey) return json(res, { error: '未登录' }, 401)
735
-
736
- const tool = TOOLS.find(t => t.id === toolId)
737
- if (!tool) return json(res, { error: '未知工具' }, 400)
738
-
739
- sseStart(res)
740
-
741
- if (!tool.checkInstalled()) {
742
- sseEmit(res, { type: 'error', message: `${tool.name} 未安装` })
743
- sseEmit(res, { type: 'done', success: false })
744
- return res.end()
745
- }
746
-
747
- const allModelIds = [
748
- 'gpt-5.4', 'gpt-5.3-codex-spark',
749
- 'claude-sonnet-4-6', 'claude-opus-4-6',
750
- 'MiniMax-M2.7-highspeed', 'claude-haiku-4-5',
751
- ]
752
- const primaryModel = 'claude-sonnet-4-6'
753
-
754
- sseEmit(res, { type: 'progress', message: `正在配置 ${tool.name}...` })
755
-
756
- // 用子进程运行 configure(),避免 spawnSync/busy-wait 阻塞主进程事件循环
757
- const { fork } = require('child_process')
758
- const child = fork(path.join(__dirname, 'configure-worker.js'), { silent: true })
759
-
760
- child.send({ toolId, apiKey, baseUrlAnthropic: BASE_URL_ANTHROPIC, baseUrlOpenAI: BASE_URL_OPENAI, primaryModel, allModelIds })
761
-
762
- let success = false
763
- let lastResult = null
764
-
765
- child.on('message', (msg) => {
766
- if (msg.type === 'progress') {
767
- sseEmit(res, msg)
768
- } else if (msg.type === 'result') {
769
- lastResult = msg
770
- sseEmit(res, msg)
771
- } else if (msg.type === 'error') {
772
- sseEmit(res, msg)
773
- }
774
- })
775
-
776
- await new Promise((resolve) => {
777
- child.on('exit', (code) => {
778
- success = code === 0 && lastResult?.status === 'ok'
779
- sseEmit(res, {
780
- type: 'done',
781
- success,
782
- file: lastResult?.file || null,
783
- hot: lastResult?.hot || false,
784
- dashboardUrl: lastResult?.dashboardUrl || null,
785
- })
786
- res.end()
787
- resolve()
788
- })
789
- })
790
- }
791
-
792
- // ── Single-tool upgrade (SSE) ────────────────────────────────────────────────
793
-
794
- async function handleToolUpgrade(req, res) {
795
- const body = await parseBody(req)
796
- const { toolId } = body
797
- const entry = UPGRADABLE_TOOLS.find(t => t.id === toolId)
798
- if (!entry) return json(res, { error: '不支持升级此工具' }, 400)
799
-
800
- sseStart(res)
801
-
802
- let localVer = null
803
- try {
804
- const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
805
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
806
- localVer = m ? m[1] : out.split('\n')[0].slice(0, 30)
807
- } catch {}
808
-
809
- sseEmit(res, { type: 'progress', message: `${entry.name} 当前版本: ${localVer || '未知'}` })
810
-
811
- // Windows:升级前先 kill 正在运行的进程,避免文件被占用导致安装失败
812
- if (process.platform === 'win32' && entry.command) {
813
- try {
814
- execSync(`taskkill /F /IM ${entry.command}.exe /T`, { stdio: 'ignore', shell: true })
815
- sseEmit(res, { type: 'progress', message: `已关闭正在运行的 ${entry.name} 进程` })
816
- } catch {}
817
- }
818
-
819
- sseEmit(res, { type: 'progress', message: `正在升级 ${entry.name}...` })
820
-
821
- const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000 // 10 分钟
822
- const ok = await new Promise(resolve => {
823
- const child = spawn(entry.installCmd, [], { shell: true })
824
- const timer = setTimeout(() => {
825
- sseEmit(res, { type: 'progress', message: `⚠️ 升级超时(10分钟),请手动运行: ${entry.installCmd}` })
826
- try { child.kill('SIGKILL') } catch {}
827
- resolve(false)
828
- }, UPGRADE_TIMEOUT_MS)
829
- child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
830
- child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
831
- child.on('close', code => { clearTimeout(timer); resolve(code === 0) })
832
- child.on('error', () => { clearTimeout(timer); resolve(false) })
833
- })
834
-
835
- let newVer = null
836
- try {
837
- const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
838
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
839
- newVer = m ? m[1] : null
840
- } catch {}
841
-
842
- if (ok) {
843
- sseEmit(res, { type: 'progress', message: `✓ ${entry.name} 升级成功: ${localVer || '?'} → ${newVer || 'latest'}` })
844
- // OpenClaw 升级后:先停 → 启动 Gateway(拿 PID)→ 更新 bridge config → 启动 Bridge
845
- if (entry.id === 'openclaw') {
846
- const openclawTool = TOOLS.find(t => t.id === 'openclaw')
847
- sseEmit(res, { type: 'progress', message: '正在重启 OpenClaw...' })
848
- try { execSync('openclaw daemon stop', { stdio: 'ignore', timeout: 10000 }) } catch {}
849
- try {
850
- execSync('openclaw daemon start', { stdio: 'ignore', timeout: 30000 })
851
- sseEmit(res, { type: 'progress', message: '✓ OpenClaw Gateway 已启动' })
852
- } catch {
853
- sseEmit(res, { type: 'progress', message: '⚠️ Gateway 启动失败' })
854
- }
855
- // 等 Gateway 端口就绪,获取 PID 写入 bridge config
856
- const gPort = openclawTool?.getGatewayPort?.() || 18789
857
- let gPid = null
858
- for (let i = 0; i < 10 && !gPid; i++) {
859
- await new Promise(r => setTimeout(r, 1500))
860
- try {
861
- if (process.platform === 'win32') {
862
- const o = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
863
- gPid = Number(o.split(/\r?\n/)[0]) || null
864
- } else {
865
- const o = execSync(`lsof -iTCP:${gPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
866
- gPid = Number(o) || null
867
- }
868
- } catch {}
869
- }
870
- try {
871
- const bridgeMod = require('../tools/openclaw-bridge')
872
- const bc = bridgeMod.readBridgeConfig()
873
- bc.gatewayPid = gPid // null 也写入,清除旧死 PID
874
- bc.gatewayStartedAt = new Date().toISOString()
875
- fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8')
876
- } catch {}
877
- // 启动 Bridge
878
- if (openclawTool?.ensureBridgeRunning) {
879
- try {
880
- openclawTool.ensureBridgeRunning()
881
- sseEmit(res, { type: 'progress', message: '✓ HolySheep Bridge 已启动' })
882
- } catch {
883
- sseEmit(res, { type: 'progress', message: '⚠️ Bridge 启动失败' })
884
- }
885
- }
886
- }
887
- } else {
888
- sseEmit(res, { type: 'progress', message: `✗ ${entry.name} 升级失败` })
889
- }
890
-
891
- sseEmit(res, { type: 'done', success: ok, localVer, newVer })
892
- res.end()
893
- }
894
-
895
- // ── Single-tool rollback (SSE) ────────────────────────────────────────────────
896
-
897
- async function handleToolRollback(req, res) {
898
- const body = await parseBody(req)
899
- const { toolId } = body
900
- const entry = UPGRADABLE_TOOLS.find(t => t.id === toolId)
901
- if (!entry || !entry.npmPkg) return json(res, { error: '不支持回退此工具(仅限 npm 工具)' }, 400)
902
-
903
- sseStart(res)
904
-
905
- // 1. 获取当前本地版本
906
- let localVer = null
907
- try {
908
- const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
909
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
910
- localVer = m ? m[1] : null
911
- } catch {}
912
- sseEmit(res, { type: 'progress', message: `${entry.name} 当前版本: ${localVer || '未知'}` })
913
-
914
- // 2. 从 npm registry 获取版本列表,找到倒数第二个
915
- let targetVer = null
916
- try {
917
- sseEmit(res, { type: 'progress', message: '正在查询可用版本...' })
918
- const r = await fetchWithRetry(`https://registry.npmjs.org/${entry.npmPkg}`, {}, 2, 15000)
919
- if (r.ok) {
920
- const data = await r.json()
921
- const versions = Object.keys(data.versions || {})
922
- .filter(v => !v.includes('alpha') && !v.includes('beta') && !v.includes('rc') && !v.includes('canary'))
923
- if (versions.length >= 2) {
924
- // 找到当前版本的前一个,或者倒数第二个
925
- const currentIdx = localVer ? versions.indexOf(localVer) : -1
926
- if (currentIdx > 0) {
927
- targetVer = versions[currentIdx - 1]
928
- } else {
929
- targetVer = versions[versions.length - 2]
930
- }
931
- }
932
- }
933
- } catch (e) {
934
- sseEmit(res, { type: 'progress', message: `⚠️ 查询版本失败: ${e.message}` })
935
- }
936
-
937
- if (!targetVer) {
938
- sseEmit(res, { type: 'progress', message: '✗ 无法确定回退版本' })
939
- sseEmit(res, { type: 'done', success: false })
940
- return res.end()
941
- }
942
-
943
- sseEmit(res, { type: 'progress', message: `回退目标: ${localVer || '?'} → ${targetVer}` })
944
-
945
- // 3. Windows: kill 进程 + 停止 daemon
946
- if (entry.id === 'openclaw') {
947
- try { execSync('openclaw daemon stop', { stdio: 'ignore', timeout: 10000 }) } catch {}
948
- }
949
- if (process.platform === 'win32' && entry.command) {
950
- try {
951
- execSync(`taskkill /F /IM ${entry.command}.exe /T`, { stdio: 'ignore', shell: true })
952
- sseEmit(res, { type: 'progress', message: `已关闭 ${entry.name} 进程` })
953
- } catch {}
954
- }
955
-
956
- // 4. 清理残留: npm cache + 删除 node_modules 目录
957
- sseEmit(res, { type: 'progress', message: '正在清理残留文件...' })
958
- try { execSync('npm cache clean --force', { stdio: 'ignore', timeout: 30000 }) } catch {}
959
- try { execSync(`npm uninstall -g ${entry.npmPkg}`, { stdio: 'ignore', timeout: 30000 }) } catch {}
960
- // Windows 上 npm uninstall 可能残留文件
961
- if (process.platform === 'win32') {
962
- const globalPrefix = String(execSync('npm prefix -g', { stdio: 'pipe', timeout: 5000 })).trim()
963
- const modulePath = path.join(globalPrefix, 'node_modules', ...entry.npmPkg.split('/'))
964
- try {
965
- if (fs.existsSync(modulePath)) {
966
- execSync(`rd /s /q "${modulePath}"`, { stdio: 'ignore', shell: true, timeout: 10000 })
967
- sseEmit(res, { type: 'progress', message: '已清理残留目录' })
968
- }
969
- } catch {}
970
- }
971
-
972
- // 5. 安装目标版本
973
- sseEmit(res, { type: 'progress', message: `正在安装 ${entry.npmPkg}@${targetVer}...` })
974
- const installCmd = `npm install -g ${entry.npmPkg}@${targetVer}`
975
- const ok = await new Promise(resolve => {
976
- const child = spawn(installCmd, [], { shell: true })
977
- const timer = setTimeout(() => {
978
- sseEmit(res, { type: 'progress', message: `⚠️ 安装超时` })
979
- try { child.kill('SIGKILL') } catch {}
980
- resolve(false)
981
- }, 5 * 60 * 1000)
982
- child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
983
- child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
984
- child.on('close', code => { clearTimeout(timer); resolve(code === 0) })
985
- child.on('error', () => { clearTimeout(timer); resolve(false) })
986
- })
987
-
988
- // 6. 验证版本
989
- let newVer = null
990
- try {
991
- const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
992
- const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
993
- newVer = m ? m[1] : null
994
- } catch {}
995
-
996
- if (ok) {
997
- sseEmit(res, { type: 'progress', message: `✓ ${entry.name} 已回退: ${localVer || '?'} → ${newVer || targetVer}` })
998
- // OpenClaw: 先启动 Gateway(拿 PID),更新 bridge config,再启动 Bridge
999
- if (entry.id === 'openclaw') {
1000
- sseEmit(res, { type: 'progress', message: '正在重启 OpenClaw...' })
1001
- // 1. 启动 Gateway
1002
- try {
1003
- execSync('openclaw daemon start', { stdio: 'ignore', timeout: 30000 })
1004
- sseEmit(res, { type: 'progress', message: '✓ OpenClaw Gateway 已启动' })
1005
- } catch {
1006
- sseEmit(res, { type: 'progress', message: '⚠️ Gateway 启动失败' })
1007
- }
1008
- // 2. 等 Gateway 端口就绪,获取 PID 写入 bridge config
1009
- const openclawTool = TOOLS.find(t => t.id === 'openclaw')
1010
- const gatewayPort = openclawTool?.getGatewayPort?.() || 18789
1011
- let gatewayPid = null
1012
- for (let attempt = 0; attempt < 10 && !gatewayPid; attempt++) {
1013
- await new Promise(r => setTimeout(r, 1500))
1014
- try {
1015
- if (process.platform === 'win32') {
1016
- const out = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gatewayPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
1017
- gatewayPid = Number(out.split(/\r?\n/)[0]) || null
1018
- } else {
1019
- const out = execSync(`lsof -iTCP:${gatewayPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
1020
- gatewayPid = Number(out) || null
1021
- }
1022
- } catch {}
1023
- }
1024
- // PID 检测不到时设为 null(和 hs setup 一致),watchdog 不杀 null PID 的 bridge
1025
- try {
1026
- const bridgeMod = require('../tools/openclaw-bridge')
1027
- const bc = bridgeMod.readBridgeConfig()
1028
- bc.gatewayPid = gatewayPid // null 也写入,清除旧死 PID
1029
- bc.gatewayStartedAt = new Date().toISOString()
1030
- fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8')
1031
- sseEmit(res, { type: 'progress', message: gatewayPid ? `Gateway PID: ${gatewayPid}` : 'Gateway PID cleared' })
1032
- } catch {}
1033
- // 3. 启动 Bridge
1034
- if (openclawTool?.ensureBridgeRunning) {
1035
- try {
1036
- const bridgeOk = openclawTool.ensureBridgeRunning()
1037
- sseEmit(res, { type: 'progress', message: bridgeOk ? '✓ HolySheep Bridge 已启动' : '⚠️ Bridge 未就绪' })
1038
- } catch (e) {
1039
- sseEmit(res, { type: 'progress', message: `⚠️ Bridge 启动失败: ${e.message}` })
1040
- }
1041
- }
1042
- }
1043
- } else {
1044
- sseEmit(res, { type: 'progress', message: `✗ 回退失败,请手动运行: ${installCmd}` })
1045
- }
1046
-
1047
- sseEmit(res, { type: 'done', success: ok, localVer, newVer: newVer || targetVer })
1048
- res.end()
1049
- }
1050
-
1051
- // ── Single-tool reset ────────────────────────────────────────────────────────
1052
-
1053
- async function handleToolReset(req, res) {
1054
- const body = await parseBody(req)
1055
- const { toolId } = body
1056
- const tool = TOOLS.find(t => t.id === toolId)
1057
- if (!tool) return json(res, { error: '未知工具' }, 400)
1058
- try {
1059
- tool.reset()
1060
- json(res, { success: true })
1061
- } catch (e) {
1062
- json(res, { success: false, error: e.message }, 500)
1063
- }
1064
- }
1065
-
1066
- // ── Launch tool in new terminal ───────────────────────────────────────────────
1067
-
1068
- async function handleToolLaunch(req, res) {
1069
- const body = await parseBody(req)
1070
- const { toolId } = body
1071
- const tool = TOOLS.find(t => t.id === toolId)
1072
- if (!tool) return json(res, { error: '未知工具' }, 400)
1073
-
1074
- // OpenClaw: 后台启动服务,立即打开浏览器
1075
- if (toolId === 'openclaw') {
1076
- const gatewayPort = tool.getGatewayPort?.() || 18789
1077
- const bridgePort = tool.getBridgePort?.() || 18788
1078
-
1079
- // 后台启动 Bridge + Gateway,不阻塞响应
1080
- setImmediate(() => {
1081
- try {
1082
- tool.ensureBridgeRunning?.(bridgePort)
1083
- tool.ensureGatewayRunning?.(gatewayPort)
1084
- } catch {}
1085
- })
1086
-
1087
- const url = `http://127.0.0.1:${gatewayPort}/`
1088
- if (process.platform === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' }).unref()
1089
- else if (process.platform === 'win32') spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', shell: true }).unref()
1090
- else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref()
1091
- return json(res, { ok: true, type: 'browser', url })
1092
- }
1093
-
1094
- const cmd = tool.launchCmd
1095
- if (!cmd) return json(res, { error: '无启动命令' }, 400)
1096
-
1097
- if (process.platform === 'darwin') {
1098
- spawn('osascript', ['-e', `tell app "Terminal" to do script "${cmd.replace(/"/g, '\\"')}"`], { detached: true, stdio: 'ignore' }).unref()
1099
- } else if (process.platform === 'win32') {
1100
- spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', cmd], { detached: true, stdio: 'ignore', shell: true }).unref()
1101
- } else {
1102
- const terms = ['x-terminal-emulator', 'gnome-terminal', 'xterm']
1103
- for (const term of terms) {
1104
- if (commandExists(term)) {
1105
- spawn(term, ['-e', cmd], { detached: true, stdio: 'ignore' }).unref()
1106
- break
1107
- }
1108
- }
1109
- }
1110
- json(res, { ok: true, type: 'terminal', cmd })
1111
- }
1112
-
1113
- // ── Claude Proxy start/stop ──────────────────────────────────────────────────
1114
-
1115
- async function handleClaudeProxyStart(_req, res) {
1116
- const state = isClaudeProxyRunning()
1117
- if (state.running) {
1118
- return json(res, { ok: true, message: '代理已在运行', port: state.port, pid: state.pid })
1119
- }
1120
- try {
1121
- const claudeProxy = require('../commands/claude-proxy')
1122
- await claudeProxy(['--daemon'])
1123
- // 等一下确认
1124
- await new Promise(r => setTimeout(r, 2000))
1125
- const newState = isClaudeProxyRunning()
1126
- json(res, { ok: newState.running, port: newState.port, pid: newState.pid })
1127
- } catch (e) {
1128
- json(res, { ok: false, error: e.message }, 500)
1129
- }
1130
- }
1131
-
1132
- async function handleClaudeProxyStop(_req, res) {
1133
- try {
1134
- const claudeProxy = require('../commands/claude-proxy')
1135
- await claudeProxy(['--stop'])
1136
- json(res, { ok: true })
1137
- } catch (e) {
1138
- json(res, { ok: false, error: e.message }, 500)
1139
- }
1140
- }
1141
-
1142
- // ── Environment variables ────────────────────────────────────────────────────
1143
-
1144
- const HS_ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL']
1145
- const MARKER_START = '# >>> holysheep-cli managed >>>'
1146
-
1147
- function handleEnv(_req, res) {
1148
- const vars = {}
1149
-
1150
- // Windows: process.env 看不到 setx 写入的值,从注册表读
1151
- const envConfigTool = TOOLS.find(t => t.id === 'env-config')
1152
- const registryValues = (process.platform === 'win32' && envConfigTool?.getConfiguredValues?.()) || {}
1153
-
1154
- for (const k of HS_ENV_KEYS) {
1155
- const v = process.env[k] || registryValues[k] || null
1156
- vars[k] = v ? (k.includes('KEY') || k.includes('TOKEN') ? maskKey(v) : v) : null
1157
- }
1158
-
1159
- const rcFiles = []
1160
- try {
1161
- for (const f of getShellRcFiles()) {
1162
- let has = false
1163
- try { has = fs.readFileSync(f, 'utf8').includes(MARKER_START) } catch {}
1164
- rcFiles.push({ path: f.replace(require('os').homedir(), '~'), hasManagedBlock: has })
1165
- }
1166
- } catch {}
1167
-
1168
- json(res, { vars, rcFiles })
1169
- }
1170
-
1171
- function handleEnvClean(_req, res) {
1172
- try {
1173
- const cleaned = removeEnvFromShell()
1174
- json(res, { success: true, cleaned })
1175
- } catch (e) {
1176
- json(res, { success: false, error: e.message }, 500)
1177
- }
1178
- }
1179
-
1180
- // ── Models list ──────────────────────────────────────────────────────────────
1181
-
1182
- function handleModels(_req, res) {
1183
- json(res, [
1184
- { id: 'gpt-5.4', label: 'GPT 5.4', desc: '通用编码' },
1185
- { id: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', desc: '编码' },
1186
- { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: '均衡推荐' },
1187
- { id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', desc: '均衡推荐·100万上下文' },
1188
- { id: 'claude-opus-4-6', label: 'Opus 4.6', desc: '强力旗舰' },
1189
- { id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', desc: '强力旗舰·100万上下文' },
1190
- { id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7', desc: '高速经济版' },
1191
- { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: '轻快便宜' },
1192
- ])
1193
- }
1194
-
1195
- function getWorkspacePayload() {
1196
- const config = workspaceRuntime.normalizeRuntimeConfig({})
1197
- const hasRuntimeConfig = Boolean(config.apiKey && config.baseUrl && config.model)
1198
- return {
1199
- conversations: workspaceStore.listConversations(),
1200
- scheduledTasks: workspaceStore.listTasks(),
1201
- holySheepApi: {
1202
- ...config,
1203
- apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1204
- ready: hasRuntimeConfig,
1205
- },
1206
- tools: TOOLS.map((tool) => ({
1207
- id: tool.id,
1208
- name: tool.name,
1209
- launchCmd: tool.launchCmd || null,
1210
- hint: tool.hint || null,
1211
- })),
1212
- }
1213
- }
1214
-
1215
- async function handleWorkspaceState(_req, res) {
1216
- json(res, getWorkspacePayload())
1217
- }
1218
-
1219
- async function handleWorkspaceSearch(req, res, url) {
1220
- const query = url.searchParams.get('q') || ''
1221
- json(res, workspaceStore.searchWorkspace(query))
1222
- }
1223
-
1224
- async function handleWorkspaceApiConfig(req, res) {
1225
- if (req.method === 'GET') {
1226
- const config = workspaceRuntime.normalizeRuntimeConfig({})
1227
- return json(res, {
1228
- apiKey: config.apiKey,
1229
- apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
1230
- baseUrl: config.baseUrl,
1231
- model: config.model,
1232
- ready: Boolean(config.apiKey && config.baseUrl && config.model),
1233
- })
1234
- }
1235
-
1236
- const body = await parseBody(req)
1237
- const apiKey = String(body.apiKey || '').trim()
1238
- const baseUrl = String(body.baseUrl || '').trim() || BASE_URL_OPENAI
1239
- const model = String(body.model || '').trim()
1240
- if (!apiKey || !apiKey.startsWith('cr_')) {
1241
- return json(res, { success: false, error: 'HolySheep API Key 必须以 cr_ 开头' }, 400)
1242
- }
1243
- if (!model) {
1244
- return json(res, { success: false, error: '模型不能为空' }, 400)
1245
- }
1246
-
1247
- workspaceStore.saveHolySheepApiConfig({ apiKey, baseUrl, model })
1248
- saveConfig({ apiKey, savedAt: new Date().toISOString() })
1249
- json(res, { success: true, config: getWorkspacePayload().holySheepApi })
1250
- }
1251
-
1252
- async function handleWorkspaceConversations(req, res) {
1253
- if (req.method === 'GET') {
1254
- return json(res, workspaceStore.listConversations())
1255
- }
1256
- const body = await parseBody(req)
1257
- const conversation = workspaceStore.createConversation({
1258
- title: body.title,
1259
- toolId: body.toolId,
1260
- pinned: body.pinned,
1261
- })
1262
- json(res, { success: true, conversation })
1263
- }
1264
-
1265
- async function handleWorkspaceConversationById(req, res, conversationId) {
1266
- const conversation = workspaceStore.getConversation(conversationId)
1267
- if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1268
-
1269
- if (req.method === 'GET') {
1270
- return json(res, conversation)
1271
- }
1272
-
1273
- if (req.method === 'PATCH') {
1274
- const body = await parseBody(req)
1275
- const updated = workspaceStore.updateConversation(conversationId, {
1276
- title: body.title,
1277
- toolId: body.toolId,
1278
- pinned: body.pinned,
1279
- })
1280
- return json(res, { success: true, conversation: updated })
1281
- }
1282
-
1283
- return json(res, { success: false, error: 'Method not allowed' }, 405)
1284
- }
1285
-
1286
- async function handleWorkspaceConversationMessages(req, res, conversationId) {
1287
- if (req.method === 'GET') {
1288
- const conversation = workspaceStore.getConversation(conversationId)
1289
- if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
1290
- return json(res, conversation.messages)
1291
- }
1292
-
1293
- const body = await parseBody(req)
1294
- const content = String(body.content || '').trim()
1295
- if (!content) return json(res, { success: false, error: '消息不能为空' }, 400)
1296
-
1297
- try {
1298
- const result = await workspaceRuntime.sendConversationMessage(conversationId, content, body.runtimeConfig || {})
1299
- return json(res, {
1300
- success: true,
1301
- messages: [result.userMessage, result.assistantMessage],
1302
- conversation: workspaceStore.getConversation(conversationId),
1303
- })
1304
- } catch (error) {
1305
- return json(res, {
1306
- success: false,
1307
- error: error.message,
1308
- conversation: workspaceStore.getConversation(conversationId),
1309
- }, 500)
1310
- }
1311
- }
1312
-
1313
- async function handleWorkspaceTasks(req, res) {
1314
- if (req.method === 'GET') {
1315
- return json(res, workspaceStore.listTasks())
1316
- }
1317
- const body = await parseBody(req)
1318
- const title = String(body.title || '').trim()
1319
- const prompt = String(body.prompt || '').trim()
1320
- if (!title || !prompt) return json(res, { success: false, error: '任务标题和提示词不能为空' }, 400)
1321
-
1322
- try {
1323
- workspaceRuntime.parseScheduleToMs(body.schedule || '1h')
1324
- } catch (error) {
1325
- return json(res, { success: false, error: error.message }, 400)
1326
- }
1327
-
1328
- const task = workspaceStore.saveTask({
1329
- title,
1330
- prompt,
1331
- schedule: body.schedule || '1h',
1332
- active: body.active !== false,
1333
- conversationId: body.conversationId || null,
1334
- modelOverride: body.modelOverride || '',
1335
- })
1336
- workspaceRuntime.rescheduleAllTasks()
1337
- json(res, { success: true, task })
1338
- }
1339
-
1340
- async function handleWorkspaceTaskById(req, res, taskId) {
1341
- if (req.method === 'DELETE') {
1342
- workspaceStore.deleteTask(taskId)
1343
- workspaceRuntime.rescheduleAllTasks()
1344
- return json(res, { success: true })
1345
- }
1346
-
1347
- if (req.method === 'PATCH') {
1348
- const body = await parseBody(req)
1349
- if (body.schedule) {
1350
- try {
1351
- workspaceRuntime.parseScheduleToMs(body.schedule)
1352
- } catch (error) {
1353
- return json(res, { success: false, error: error.message }, 400)
1354
- }
1355
- }
1356
- const task = workspaceStore.saveTask({
1357
- id: taskId,
1358
- title: body.title,
1359
- prompt: body.prompt,
1360
- schedule: body.schedule,
1361
- active: body.active,
1362
- conversationId: body.conversationId,
1363
- modelOverride: body.modelOverride,
1364
- })
1365
- workspaceRuntime.rescheduleAllTasks()
1366
- return json(res, { success: true, task })
1367
- }
1368
-
1369
- if (req.method === 'POST') {
1370
- try {
1371
- const result = await workspaceRuntime.runTask(taskId)
1372
- return json(res, { success: true, result })
1373
- } catch (error) {
1374
- return json(res, { success: false, error: error.message }, 500)
1375
- }
1376
- }
1377
-
1378
- return json(res, { success: false, error: 'Method not allowed' }, 405)
1379
- }
1380
-
1381
- // ── Router ───────────────────────────────────────────────────────────────────
1382
-
1383
- async function handleRequest(req, res) {
1384
- res.setHeader('Access-Control-Allow-Origin', '*')
1385
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
1386
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
1387
-
1388
- if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
1389
-
1390
- const url = new URL(req.url, `http://${req.headers.host}`)
1391
- const route = url.pathname
1392
-
1393
- try {
1394
- // Static
1395
- if (route === '/' || route === '/index.html') {
1396
- const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8')
1397
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
1398
- return res.end(html)
1399
- }
1400
-
1401
- // API
1402
- if (route === '/api/status' && req.method === 'GET') return await handleStatus(req, res)
1403
- if (route === '/api/login' && req.method === 'POST') return await handleLogin(req, res)
1404
- if (route === '/api/logout' && req.method === 'POST') return await handleLogout(req, res)
1405
- if (route === '/api/balance' && req.method === 'GET') return await handleBalance(req, res)
1406
- if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
1407
- if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
1408
- if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
1409
- if (route === '/api/workspace/state' && req.method === 'GET') return await handleWorkspaceState(req, res)
1410
- if (route === '/api/workspace/search' && req.method === 'GET') return await handleWorkspaceSearch(req, res, url)
1411
- if (route === '/api/workspace/api-config' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceApiConfig(req, res)
1412
- if (route === '/api/workspace/conversations' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceConversations(req, res)
1413
- const conversationMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)$/)
1414
- if (conversationMatch) return await handleWorkspaceConversationById(req, res, conversationMatch[1])
1415
- const messagesMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)\/messages$/)
1416
- if (messagesMatch) return await handleWorkspaceConversationMessages(req, res, messagesMatch[1])
1417
- if (route === '/api/workspace/tasks' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceTasks(req, res)
1418
- const taskMatch = route.match(/^\/api\/workspace\/tasks\/([^/]+)$/)
1419
- if (taskMatch) return await handleWorkspaceTaskById(req, res, taskMatch[1])
1420
- if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
1421
- if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
1422
- if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
1423
- if (route === '/api/tool/reset' && req.method === 'POST') return await handleToolReset(req, res)
1424
- if (route === '/api/tool/upgrade' && req.method === 'POST') return await handleToolUpgrade(req, res)
1425
- if (route === '/api/tool/rollback' && req.method === 'POST') return await handleToolRollback(req, res)
1426
- if (route === '/api/tool/launch' && req.method === 'POST') return await handleToolLaunch(req, res)
1427
- if (route === '/api/claude-proxy/start' && req.method === 'POST') return await handleClaudeProxyStart(req, res)
1428
- if (route === '/api/claude-proxy/stop' && req.method === 'POST') return await handleClaudeProxyStop(req, res)
1429
- if (route === '/api/env' && req.method === 'GET') return handleEnv(req, res)
1430
- if (route === '/api/env/clean' && req.method === 'POST') return handleEnvClean(req, res)
1431
- if (route === '/api/restart' && req.method === 'POST') {
1432
- json(res, { ok: true })
1433
- // 升级后用新版 hs web 重启自身
1434
- const port = req.headers.host?.split(':')[1] || '9876'
1435
- setTimeout(() => {
1436
- const child = spawn(process.execPath, [path.join(__dirname, '..', 'index.js'), 'web', '--port', port, '--no-open'], { detached: true, stdio: 'ignore' })
1437
- child.unref()
1438
- process.exit(0)
1439
- }, 500)
1440
- return
1441
- }
1442
-
1443
- res.writeHead(404)
1444
- res.end('Not Found')
1445
- } catch (e) {
1446
- if (!res.headersSent) {
1447
- json(res, { error: e.message }, 500)
1448
- }
1449
- }
1450
- }
1451
-
1452
- function startServer(port) {
1453
- return new Promise((resolve, reject) => {
1454
- const server = http.createServer(handleRequest)
1455
- server.on('error', (err) => {
1456
- if (err.code === 'EADDRINUSE') {
1457
- // Try to kill stale process and retry once
1458
- try {
1459
- if (process.platform === 'win32') {
1460
- // Windows: find PID by port and kill
1461
- const out = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { stdio: 'pipe', encoding: 'utf8', shell: true })
1462
- const pids = [...new Set(out.match(/\d+\s*$/gm)?.map(s => s.trim()).filter(Boolean) || [])]
1463
- for (const pid of pids) { try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }) } catch {} }
1464
- } else {
1465
- execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: 'ignore' })
1466
- }
1467
- } catch {}
1468
- setTimeout(() => {
1469
- const retry = http.createServer(handleRequest)
1470
- retry.on('error', (err2) => reject(err2))
1471
- retry.listen(port, '127.0.0.1', () => {
1472
- workspaceRuntime.startScheduler()
1473
- resolve(retry)
1474
- })
1475
- }, 500)
1476
- } else {
1477
- reject(err)
1478
- }
1479
- })
1480
- server.listen(port, '127.0.0.1', () => {
1481
- workspaceRuntime.startScheduler()
1482
- resolve(server)
1483
- })
1484
- })
1485
- }
1486
-
1487
- // ── 后台服务自动拉起 ─────────────────────────────────────────────────────────
1488
-
1489
- async function bootstrapBackgroundServices() {
1490
- const { getApiKey } = require('../utils/config')
1491
- const apiKey = getApiKey()
1492
- if (!apiKey) return // 未登录,跳过
1493
-
1494
- // 1. OpenClaw Bridge
1495
- try {
1496
- const openclawTool = TOOLS.find(t => t.id === 'openclaw')
1497
- if (openclawTool?.checkInstalled() && openclawTool?.isConfigured()) {
1498
- const bridgePort = openclawTool.getBridgePort?.() || 18788
1499
- // 检查是否已在运行
1500
- let bridgeAlive = false
1501
- try {
1502
- const http = require('http')
1503
- await new Promise((resolve, reject) => {
1504
- const req = http.get({ hostname: '127.0.0.1', port: bridgePort, path: '/health', family: 4 }, resolve)
1505
- req.setTimeout(2000, () => { req.destroy(); reject() })
1506
- req.on('error', reject)
1507
- })
1508
- bridgeAlive = true
1509
- } catch {}
1510
-
1511
- if (!bridgeAlive) {
1512
- // 更新 bridge config 里的 gateway PID,防止 watchdog 误杀
1513
- // 关键:检测不到 PID 时设为 null(和 hs setup 一致),watchdog 不会杀 null PID 的 bridge
1514
- const gatewayPort = openclawTool.getGatewayPort?.() || 18789
1515
- const bridgeMod = require('../tools/openclaw-bridge')
1516
- const bc = bridgeMod.readBridgeConfig()
1517
- let gPid = null
1518
- try {
1519
- if (process.platform === 'win32') {
1520
- const o = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gatewayPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
1521
- gPid = Number(o.split(/\r?\n/)[0]) || null
1522
- } else {
1523
- const o = execSync(`lsof -iTCP:${gatewayPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
1524
- gPid = Number(o) || null
1525
- }
1526
- } catch {}
1527
- // 无论是否检测到,都写入(null 或真实 PID),清除旧的死 PID
1528
- bc.gatewayPid = gPid
1529
- bc.gatewayStartedAt = new Date().toISOString()
1530
- try { fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8') } catch {}
1531
- openclawTool.ensureBridgeRunning?.(bridgePort)
1532
- }
1533
- }
1534
- } catch {}
1535
-
1536
- // 2. Claude Proxy
1537
- try {
1538
- const claudeTool = TOOLS.find(t => t.id === 'claude-code')
1539
- if (claudeTool?.checkInstalled() && claudeTool?.isConfigured()) {
1540
- if (!isClaudeProxyRunning().running) {
1541
- const claudeProxy = require('../commands/claude-proxy')
1542
- await claudeProxy(['--daemon'])
1543
- }
1544
- }
1545
- } catch {}
1546
- }
1547
-
1548
- // Exported for in-process reuse by the AionUi wrapper.
1549
- // Each handler is a pure `(req, res) => Promise<void>` and writes the HTTP response itself.
1550
- module.exports = {
1551
- startServer,
1552
- bootstrapBackgroundServices,
1553
- // Handlers reused by src/webui/aionui-wrapper.js
1554
- handleTools,
1555
- handleSetup,
1556
- handleToolInstall,
1557
- handleToolConfigure,
1558
- handleToolReset,
1559
- handleToolLaunch,
1560
- handleBalance,
1561
- handleDoctor,
1562
- handleWhoami,
1563
- handleStatus,
1564
- handleModels,
1565
- handleEnv,
1566
- }