@simonyea/holysheep-cli 2.1.40 → 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.
- package/dist/configure-worker.js +4491 -0
- package/dist/index.js +9591 -0
- package/dist/process-proxy-inject.js +117 -0
- package/package.json +20 -7
- package/.gitea/workflows/sanity.yml +0 -125
- package/scripts/check-tarball-size.js +0 -44
- package/src/commands/balance.js +0 -57
- package/src/commands/claude-proxy.js +0 -248
- package/src/commands/claude.js +0 -135
- package/src/commands/doctor.js +0 -282
- package/src/commands/login.js +0 -211
- package/src/commands/openclaw.js +0 -258
- package/src/commands/reset.js +0 -53
- package/src/commands/setup.js +0 -493
- package/src/commands/upgrade.js +0 -168
- package/src/commands/webui.js +0 -622
- package/src/index.js +0 -226
- package/src/tools/aider.js +0 -78
- package/src/tools/antigravity.js +0 -42
- package/src/tools/claude-code.js +0 -228
- package/src/tools/claude-process-proxy.js +0 -1030
- package/src/tools/codex.js +0 -254
- package/src/tools/continue.js +0 -146
- package/src/tools/cursor.js +0 -71
- package/src/tools/droid.js +0 -281
- package/src/tools/env-config.js +0 -185
- package/src/tools/gemini-cli.js +0 -82
- package/src/tools/hermes.js +0 -354
- package/src/tools/index.js +0 -13
- package/src/tools/openclaw-bridge.js +0 -987
- package/src/tools/openclaw.js +0 -925
- package/src/tools/opencode.js +0 -227
- package/src/tools/process-proxy-inject.js +0 -142
- package/src/utils/config.js +0 -54
- package/src/utils/shell.js +0 -342
- package/src/utils/which.js +0 -176
- package/src/webui/aionui-runtime-fetcher.js +0 -429
- package/src/webui/aionui-runtime.js +0 -139
- package/src/webui/aionui-wrapper.js +0 -734
- package/src/webui/configure-worker.js +0 -67
- package/src/webui/server.js +0 -1572
- package/src/webui/workspace-runtime.js +0 -288
- package/src/webui/workspace-store.js +0 -325
- /package/{src/webui → dist}/index.html +0 -0
- /package/{src/tools → dist}/pty-hermes-wrapper.py +0 -0
package/src/webui/server.js
DELETED
|
@@ -1,1572 +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
|
-
// [HolySheep fork v2.1.40 / hs27] Bust tool-check cache on success so
|
|
780
|
-
// CLI Manager's follow-up `/api/holysheep/tools` immediately sees the
|
|
781
|
-
// refreshed configured/installed state without waiting 10s for TTL.
|
|
782
|
-
if (success) {
|
|
783
|
-
toolStateCache.delete(toolId)
|
|
784
|
-
}
|
|
785
|
-
sseEmit(res, {
|
|
786
|
-
type: 'done',
|
|
787
|
-
success,
|
|
788
|
-
file: lastResult?.file || null,
|
|
789
|
-
hot: lastResult?.hot || false,
|
|
790
|
-
dashboardUrl: lastResult?.dashboardUrl || null,
|
|
791
|
-
})
|
|
792
|
-
res.end()
|
|
793
|
-
resolve()
|
|
794
|
-
})
|
|
795
|
-
})
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// ── Single-tool upgrade (SSE) ────────────────────────────────────────────────
|
|
799
|
-
|
|
800
|
-
async function handleToolUpgrade(req, res) {
|
|
801
|
-
const body = await parseBody(req)
|
|
802
|
-
const { toolId } = body
|
|
803
|
-
const entry = UPGRADABLE_TOOLS.find(t => t.id === toolId)
|
|
804
|
-
if (!entry) return json(res, { error: '不支持升级此工具' }, 400)
|
|
805
|
-
|
|
806
|
-
sseStart(res)
|
|
807
|
-
|
|
808
|
-
let localVer = null
|
|
809
|
-
try {
|
|
810
|
-
const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
811
|
-
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
812
|
-
localVer = m ? m[1] : out.split('\n')[0].slice(0, 30)
|
|
813
|
-
} catch {}
|
|
814
|
-
|
|
815
|
-
sseEmit(res, { type: 'progress', message: `${entry.name} 当前版本: ${localVer || '未知'}` })
|
|
816
|
-
|
|
817
|
-
// Windows:升级前先 kill 正在运行的进程,避免文件被占用导致安装失败
|
|
818
|
-
if (process.platform === 'win32' && entry.command) {
|
|
819
|
-
try {
|
|
820
|
-
execSync(`taskkill /F /IM ${entry.command}.exe /T`, { stdio: 'ignore', shell: true })
|
|
821
|
-
sseEmit(res, { type: 'progress', message: `已关闭正在运行的 ${entry.name} 进程` })
|
|
822
|
-
} catch {}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
sseEmit(res, { type: 'progress', message: `正在升级 ${entry.name}...` })
|
|
826
|
-
|
|
827
|
-
const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000 // 10 分钟
|
|
828
|
-
const ok = await new Promise(resolve => {
|
|
829
|
-
const child = spawn(entry.installCmd, [], { shell: true })
|
|
830
|
-
const timer = setTimeout(() => {
|
|
831
|
-
sseEmit(res, { type: 'progress', message: `⚠️ 升级超时(10分钟),请手动运行: ${entry.installCmd}` })
|
|
832
|
-
try { child.kill('SIGKILL') } catch {}
|
|
833
|
-
resolve(false)
|
|
834
|
-
}, UPGRADE_TIMEOUT_MS)
|
|
835
|
-
child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
836
|
-
child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
837
|
-
child.on('close', code => { clearTimeout(timer); resolve(code === 0) })
|
|
838
|
-
child.on('error', () => { clearTimeout(timer); resolve(false) })
|
|
839
|
-
})
|
|
840
|
-
|
|
841
|
-
let newVer = null
|
|
842
|
-
try {
|
|
843
|
-
const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
844
|
-
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
845
|
-
newVer = m ? m[1] : null
|
|
846
|
-
} catch {}
|
|
847
|
-
|
|
848
|
-
if (ok) {
|
|
849
|
-
sseEmit(res, { type: 'progress', message: `✓ ${entry.name} 升级成功: ${localVer || '?'} → ${newVer || 'latest'}` })
|
|
850
|
-
// OpenClaw 升级后:先停 → 启动 Gateway(拿 PID)→ 更新 bridge config → 启动 Bridge
|
|
851
|
-
if (entry.id === 'openclaw') {
|
|
852
|
-
const openclawTool = TOOLS.find(t => t.id === 'openclaw')
|
|
853
|
-
sseEmit(res, { type: 'progress', message: '正在重启 OpenClaw...' })
|
|
854
|
-
try { execSync('openclaw daemon stop', { stdio: 'ignore', timeout: 10000 }) } catch {}
|
|
855
|
-
try {
|
|
856
|
-
execSync('openclaw daemon start', { stdio: 'ignore', timeout: 30000 })
|
|
857
|
-
sseEmit(res, { type: 'progress', message: '✓ OpenClaw Gateway 已启动' })
|
|
858
|
-
} catch {
|
|
859
|
-
sseEmit(res, { type: 'progress', message: '⚠️ Gateway 启动失败' })
|
|
860
|
-
}
|
|
861
|
-
// 等 Gateway 端口就绪,获取 PID 写入 bridge config
|
|
862
|
-
const gPort = openclawTool?.getGatewayPort?.() || 18789
|
|
863
|
-
let gPid = null
|
|
864
|
-
for (let i = 0; i < 10 && !gPid; i++) {
|
|
865
|
-
await new Promise(r => setTimeout(r, 1500))
|
|
866
|
-
try {
|
|
867
|
-
if (process.platform === 'win32') {
|
|
868
|
-
const o = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
869
|
-
gPid = Number(o.split(/\r?\n/)[0]) || null
|
|
870
|
-
} else {
|
|
871
|
-
const o = execSync(`lsof -iTCP:${gPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
872
|
-
gPid = Number(o) || null
|
|
873
|
-
}
|
|
874
|
-
} catch {}
|
|
875
|
-
}
|
|
876
|
-
try {
|
|
877
|
-
const bridgeMod = require('../tools/openclaw-bridge')
|
|
878
|
-
const bc = bridgeMod.readBridgeConfig()
|
|
879
|
-
bc.gatewayPid = gPid // null 也写入,清除旧死 PID
|
|
880
|
-
bc.gatewayStartedAt = new Date().toISOString()
|
|
881
|
-
fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8')
|
|
882
|
-
} catch {}
|
|
883
|
-
// 启动 Bridge
|
|
884
|
-
if (openclawTool?.ensureBridgeRunning) {
|
|
885
|
-
try {
|
|
886
|
-
openclawTool.ensureBridgeRunning()
|
|
887
|
-
sseEmit(res, { type: 'progress', message: '✓ HolySheep Bridge 已启动' })
|
|
888
|
-
} catch {
|
|
889
|
-
sseEmit(res, { type: 'progress', message: '⚠️ Bridge 启动失败' })
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
} else {
|
|
894
|
-
sseEmit(res, { type: 'progress', message: `✗ ${entry.name} 升级失败` })
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
sseEmit(res, { type: 'done', success: ok, localVer, newVer })
|
|
898
|
-
res.end()
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// ── Single-tool rollback (SSE) ────────────────────────────────────────────────
|
|
902
|
-
|
|
903
|
-
async function handleToolRollback(req, res) {
|
|
904
|
-
const body = await parseBody(req)
|
|
905
|
-
const { toolId } = body
|
|
906
|
-
const entry = UPGRADABLE_TOOLS.find(t => t.id === toolId)
|
|
907
|
-
if (!entry || !entry.npmPkg) return json(res, { error: '不支持回退此工具(仅限 npm 工具)' }, 400)
|
|
908
|
-
|
|
909
|
-
sseStart(res)
|
|
910
|
-
|
|
911
|
-
// 1. 获取当前本地版本
|
|
912
|
-
let localVer = null
|
|
913
|
-
try {
|
|
914
|
-
const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
915
|
-
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
916
|
-
localVer = m ? m[1] : null
|
|
917
|
-
} catch {}
|
|
918
|
-
sseEmit(res, { type: 'progress', message: `${entry.name} 当前版本: ${localVer || '未知'}` })
|
|
919
|
-
|
|
920
|
-
// 2. 从 npm registry 获取版本列表,找到倒数第二个
|
|
921
|
-
let targetVer = null
|
|
922
|
-
try {
|
|
923
|
-
sseEmit(res, { type: 'progress', message: '正在查询可用版本...' })
|
|
924
|
-
const r = await fetchWithRetry(`https://registry.npmjs.org/${entry.npmPkg}`, {}, 2, 15000)
|
|
925
|
-
if (r.ok) {
|
|
926
|
-
const data = await r.json()
|
|
927
|
-
const versions = Object.keys(data.versions || {})
|
|
928
|
-
.filter(v => !v.includes('alpha') && !v.includes('beta') && !v.includes('rc') && !v.includes('canary'))
|
|
929
|
-
if (versions.length >= 2) {
|
|
930
|
-
// 找到当前版本的前一个,或者倒数第二个
|
|
931
|
-
const currentIdx = localVer ? versions.indexOf(localVer) : -1
|
|
932
|
-
if (currentIdx > 0) {
|
|
933
|
-
targetVer = versions[currentIdx - 1]
|
|
934
|
-
} else {
|
|
935
|
-
targetVer = versions[versions.length - 2]
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
} catch (e) {
|
|
940
|
-
sseEmit(res, { type: 'progress', message: `⚠️ 查询版本失败: ${e.message}` })
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
if (!targetVer) {
|
|
944
|
-
sseEmit(res, { type: 'progress', message: '✗ 无法确定回退版本' })
|
|
945
|
-
sseEmit(res, { type: 'done', success: false })
|
|
946
|
-
return res.end()
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
sseEmit(res, { type: 'progress', message: `回退目标: ${localVer || '?'} → ${targetVer}` })
|
|
950
|
-
|
|
951
|
-
// 3. Windows: kill 进程 + 停止 daemon
|
|
952
|
-
if (entry.id === 'openclaw') {
|
|
953
|
-
try { execSync('openclaw daemon stop', { stdio: 'ignore', timeout: 10000 }) } catch {}
|
|
954
|
-
}
|
|
955
|
-
if (process.platform === 'win32' && entry.command) {
|
|
956
|
-
try {
|
|
957
|
-
execSync(`taskkill /F /IM ${entry.command}.exe /T`, { stdio: 'ignore', shell: true })
|
|
958
|
-
sseEmit(res, { type: 'progress', message: `已关闭 ${entry.name} 进程` })
|
|
959
|
-
} catch {}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
// 4. 清理残留: npm cache + 删除 node_modules 目录
|
|
963
|
-
sseEmit(res, { type: 'progress', message: '正在清理残留文件...' })
|
|
964
|
-
try { execSync('npm cache clean --force', { stdio: 'ignore', timeout: 30000 }) } catch {}
|
|
965
|
-
try { execSync(`npm uninstall -g ${entry.npmPkg}`, { stdio: 'ignore', timeout: 30000 }) } catch {}
|
|
966
|
-
// Windows 上 npm uninstall 可能残留文件
|
|
967
|
-
if (process.platform === 'win32') {
|
|
968
|
-
const globalPrefix = String(execSync('npm prefix -g', { stdio: 'pipe', timeout: 5000 })).trim()
|
|
969
|
-
const modulePath = path.join(globalPrefix, 'node_modules', ...entry.npmPkg.split('/'))
|
|
970
|
-
try {
|
|
971
|
-
if (fs.existsSync(modulePath)) {
|
|
972
|
-
execSync(`rd /s /q "${modulePath}"`, { stdio: 'ignore', shell: true, timeout: 10000 })
|
|
973
|
-
sseEmit(res, { type: 'progress', message: '已清理残留目录' })
|
|
974
|
-
}
|
|
975
|
-
} catch {}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
// 5. 安装目标版本
|
|
979
|
-
sseEmit(res, { type: 'progress', message: `正在安装 ${entry.npmPkg}@${targetVer}...` })
|
|
980
|
-
const installCmd = `npm install -g ${entry.npmPkg}@${targetVer}`
|
|
981
|
-
const ok = await new Promise(resolve => {
|
|
982
|
-
const child = spawn(installCmd, [], { shell: true })
|
|
983
|
-
const timer = setTimeout(() => {
|
|
984
|
-
sseEmit(res, { type: 'progress', message: `⚠️ 安装超时` })
|
|
985
|
-
try { child.kill('SIGKILL') } catch {}
|
|
986
|
-
resolve(false)
|
|
987
|
-
}, 5 * 60 * 1000)
|
|
988
|
-
child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
989
|
-
child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
|
|
990
|
-
child.on('close', code => { clearTimeout(timer); resolve(code === 0) })
|
|
991
|
-
child.on('error', () => { clearTimeout(timer); resolve(false) })
|
|
992
|
-
})
|
|
993
|
-
|
|
994
|
-
// 6. 验证版本
|
|
995
|
-
let newVer = null
|
|
996
|
-
try {
|
|
997
|
-
const out = execSync(entry.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
|
|
998
|
-
const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
|
|
999
|
-
newVer = m ? m[1] : null
|
|
1000
|
-
} catch {}
|
|
1001
|
-
|
|
1002
|
-
if (ok) {
|
|
1003
|
-
sseEmit(res, { type: 'progress', message: `✓ ${entry.name} 已回退: ${localVer || '?'} → ${newVer || targetVer}` })
|
|
1004
|
-
// OpenClaw: 先启动 Gateway(拿 PID),更新 bridge config,再启动 Bridge
|
|
1005
|
-
if (entry.id === 'openclaw') {
|
|
1006
|
-
sseEmit(res, { type: 'progress', message: '正在重启 OpenClaw...' })
|
|
1007
|
-
// 1. 启动 Gateway
|
|
1008
|
-
try {
|
|
1009
|
-
execSync('openclaw daemon start', { stdio: 'ignore', timeout: 30000 })
|
|
1010
|
-
sseEmit(res, { type: 'progress', message: '✓ OpenClaw Gateway 已启动' })
|
|
1011
|
-
} catch {
|
|
1012
|
-
sseEmit(res, { type: 'progress', message: '⚠️ Gateway 启动失败' })
|
|
1013
|
-
}
|
|
1014
|
-
// 2. 等 Gateway 端口就绪,获取 PID 写入 bridge config
|
|
1015
|
-
const openclawTool = TOOLS.find(t => t.id === 'openclaw')
|
|
1016
|
-
const gatewayPort = openclawTool?.getGatewayPort?.() || 18789
|
|
1017
|
-
let gatewayPid = null
|
|
1018
|
-
for (let attempt = 0; attempt < 10 && !gatewayPid; attempt++) {
|
|
1019
|
-
await new Promise(r => setTimeout(r, 1500))
|
|
1020
|
-
try {
|
|
1021
|
-
if (process.platform === 'win32') {
|
|
1022
|
-
const out = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gatewayPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
1023
|
-
gatewayPid = Number(out.split(/\r?\n/)[0]) || null
|
|
1024
|
-
} else {
|
|
1025
|
-
const out = execSync(`lsof -iTCP:${gatewayPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
1026
|
-
gatewayPid = Number(out) || null
|
|
1027
|
-
}
|
|
1028
|
-
} catch {}
|
|
1029
|
-
}
|
|
1030
|
-
// PID 检测不到时设为 null(和 hs setup 一致),watchdog 不杀 null PID 的 bridge
|
|
1031
|
-
try {
|
|
1032
|
-
const bridgeMod = require('../tools/openclaw-bridge')
|
|
1033
|
-
const bc = bridgeMod.readBridgeConfig()
|
|
1034
|
-
bc.gatewayPid = gatewayPid // null 也写入,清除旧死 PID
|
|
1035
|
-
bc.gatewayStartedAt = new Date().toISOString()
|
|
1036
|
-
fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8')
|
|
1037
|
-
sseEmit(res, { type: 'progress', message: gatewayPid ? `Gateway PID: ${gatewayPid}` : 'Gateway PID cleared' })
|
|
1038
|
-
} catch {}
|
|
1039
|
-
// 3. 启动 Bridge
|
|
1040
|
-
if (openclawTool?.ensureBridgeRunning) {
|
|
1041
|
-
try {
|
|
1042
|
-
const bridgeOk = openclawTool.ensureBridgeRunning()
|
|
1043
|
-
sseEmit(res, { type: 'progress', message: bridgeOk ? '✓ HolySheep Bridge 已启动' : '⚠️ Bridge 未就绪' })
|
|
1044
|
-
} catch (e) {
|
|
1045
|
-
sseEmit(res, { type: 'progress', message: `⚠️ Bridge 启动失败: ${e.message}` })
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
} else {
|
|
1050
|
-
sseEmit(res, { type: 'progress', message: `✗ 回退失败,请手动运行: ${installCmd}` })
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
sseEmit(res, { type: 'done', success: ok, localVer, newVer: newVer || targetVer })
|
|
1054
|
-
res.end()
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
// ── Single-tool reset ────────────────────────────────────────────────────────
|
|
1058
|
-
|
|
1059
|
-
async function handleToolReset(req, res) {
|
|
1060
|
-
const body = await parseBody(req)
|
|
1061
|
-
const { toolId } = body
|
|
1062
|
-
const tool = TOOLS.find(t => t.id === toolId)
|
|
1063
|
-
if (!tool) return json(res, { error: '未知工具' }, 400)
|
|
1064
|
-
try {
|
|
1065
|
-
tool.reset()
|
|
1066
|
-
json(res, { success: true })
|
|
1067
|
-
} catch (e) {
|
|
1068
|
-
json(res, { success: false, error: e.message }, 500)
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// ── Launch tool in new terminal ───────────────────────────────────────────────
|
|
1073
|
-
|
|
1074
|
-
async function handleToolLaunch(req, res) {
|
|
1075
|
-
const body = await parseBody(req)
|
|
1076
|
-
const { toolId } = body
|
|
1077
|
-
const tool = TOOLS.find(t => t.id === toolId)
|
|
1078
|
-
if (!tool) return json(res, { error: '未知工具' }, 400)
|
|
1079
|
-
|
|
1080
|
-
// OpenClaw: 后台启动服务,立即打开浏览器
|
|
1081
|
-
if (toolId === 'openclaw') {
|
|
1082
|
-
const gatewayPort = tool.getGatewayPort?.() || 18789
|
|
1083
|
-
const bridgePort = tool.getBridgePort?.() || 18788
|
|
1084
|
-
|
|
1085
|
-
// 后台启动 Bridge + Gateway,不阻塞响应
|
|
1086
|
-
setImmediate(() => {
|
|
1087
|
-
try {
|
|
1088
|
-
tool.ensureBridgeRunning?.(bridgePort)
|
|
1089
|
-
tool.ensureGatewayRunning?.(gatewayPort)
|
|
1090
|
-
} catch {}
|
|
1091
|
-
})
|
|
1092
|
-
|
|
1093
|
-
const url = `http://127.0.0.1:${gatewayPort}/`
|
|
1094
|
-
if (process.platform === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' }).unref()
|
|
1095
|
-
else if (process.platform === 'win32') spawn('cmd.exe', ['/c', 'start', '', url], { detached: true, stdio: 'ignore', shell: true }).unref()
|
|
1096
|
-
else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref()
|
|
1097
|
-
return json(res, { ok: true, type: 'browser', url })
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const cmd = tool.launchCmd
|
|
1101
|
-
if (!cmd) return json(res, { error: '无启动命令' }, 400)
|
|
1102
|
-
|
|
1103
|
-
if (process.platform === 'darwin') {
|
|
1104
|
-
spawn('osascript', ['-e', `tell app "Terminal" to do script "${cmd.replace(/"/g, '\\"')}"`], { detached: true, stdio: 'ignore' }).unref()
|
|
1105
|
-
} else if (process.platform === 'win32') {
|
|
1106
|
-
spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', cmd], { detached: true, stdio: 'ignore', shell: true }).unref()
|
|
1107
|
-
} else {
|
|
1108
|
-
const terms = ['x-terminal-emulator', 'gnome-terminal', 'xterm']
|
|
1109
|
-
for (const term of terms) {
|
|
1110
|
-
if (commandExists(term)) {
|
|
1111
|
-
spawn(term, ['-e', cmd], { detached: true, stdio: 'ignore' }).unref()
|
|
1112
|
-
break
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
json(res, { ok: true, type: 'terminal', cmd })
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
// ── Claude Proxy start/stop ──────────────────────────────────────────────────
|
|
1120
|
-
|
|
1121
|
-
async function handleClaudeProxyStart(_req, res) {
|
|
1122
|
-
const state = isClaudeProxyRunning()
|
|
1123
|
-
if (state.running) {
|
|
1124
|
-
return json(res, { ok: true, message: '代理已在运行', port: state.port, pid: state.pid })
|
|
1125
|
-
}
|
|
1126
|
-
try {
|
|
1127
|
-
const claudeProxy = require('../commands/claude-proxy')
|
|
1128
|
-
await claudeProxy(['--daemon'])
|
|
1129
|
-
// 等一下确认
|
|
1130
|
-
await new Promise(r => setTimeout(r, 2000))
|
|
1131
|
-
const newState = isClaudeProxyRunning()
|
|
1132
|
-
json(res, { ok: newState.running, port: newState.port, pid: newState.pid })
|
|
1133
|
-
} catch (e) {
|
|
1134
|
-
json(res, { ok: false, error: e.message }, 500)
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
async function handleClaudeProxyStop(_req, res) {
|
|
1139
|
-
try {
|
|
1140
|
-
const claudeProxy = require('../commands/claude-proxy')
|
|
1141
|
-
await claudeProxy(['--stop'])
|
|
1142
|
-
json(res, { ok: true })
|
|
1143
|
-
} catch (e) {
|
|
1144
|
-
json(res, { ok: false, error: e.message }, 500)
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// ── Environment variables ────────────────────────────────────────────────────
|
|
1149
|
-
|
|
1150
|
-
const HS_ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL']
|
|
1151
|
-
const MARKER_START = '# >>> holysheep-cli managed >>>'
|
|
1152
|
-
|
|
1153
|
-
function handleEnv(_req, res) {
|
|
1154
|
-
const vars = {}
|
|
1155
|
-
|
|
1156
|
-
// Windows: process.env 看不到 setx 写入的值,从注册表读
|
|
1157
|
-
const envConfigTool = TOOLS.find(t => t.id === 'env-config')
|
|
1158
|
-
const registryValues = (process.platform === 'win32' && envConfigTool?.getConfiguredValues?.()) || {}
|
|
1159
|
-
|
|
1160
|
-
for (const k of HS_ENV_KEYS) {
|
|
1161
|
-
const v = process.env[k] || registryValues[k] || null
|
|
1162
|
-
vars[k] = v ? (k.includes('KEY') || k.includes('TOKEN') ? maskKey(v) : v) : null
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
const rcFiles = []
|
|
1166
|
-
try {
|
|
1167
|
-
for (const f of getShellRcFiles()) {
|
|
1168
|
-
let has = false
|
|
1169
|
-
try { has = fs.readFileSync(f, 'utf8').includes(MARKER_START) } catch {}
|
|
1170
|
-
rcFiles.push({ path: f.replace(require('os').homedir(), '~'), hasManagedBlock: has })
|
|
1171
|
-
}
|
|
1172
|
-
} catch {}
|
|
1173
|
-
|
|
1174
|
-
json(res, { vars, rcFiles })
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
function handleEnvClean(_req, res) {
|
|
1178
|
-
try {
|
|
1179
|
-
const cleaned = removeEnvFromShell()
|
|
1180
|
-
json(res, { success: true, cleaned })
|
|
1181
|
-
} catch (e) {
|
|
1182
|
-
json(res, { success: false, error: e.message }, 500)
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
// ── Models list ──────────────────────────────────────────────────────────────
|
|
1187
|
-
|
|
1188
|
-
function handleModels(_req, res) {
|
|
1189
|
-
json(res, [
|
|
1190
|
-
{ id: 'gpt-5.4', label: 'GPT 5.4', desc: '通用编码' },
|
|
1191
|
-
{ id: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', desc: '编码' },
|
|
1192
|
-
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: '均衡推荐' },
|
|
1193
|
-
{ id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', desc: '均衡推荐·100万上下文' },
|
|
1194
|
-
{ id: 'claude-opus-4-6', label: 'Opus 4.6', desc: '强力旗舰' },
|
|
1195
|
-
{ id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', desc: '强力旗舰·100万上下文' },
|
|
1196
|
-
{ id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7', desc: '高速经济版' },
|
|
1197
|
-
{ id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: '轻快便宜' },
|
|
1198
|
-
])
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
function getWorkspacePayload() {
|
|
1202
|
-
const config = workspaceRuntime.normalizeRuntimeConfig({})
|
|
1203
|
-
const hasRuntimeConfig = Boolean(config.apiKey && config.baseUrl && config.model)
|
|
1204
|
-
return {
|
|
1205
|
-
conversations: workspaceStore.listConversations(),
|
|
1206
|
-
scheduledTasks: workspaceStore.listTasks(),
|
|
1207
|
-
holySheepApi: {
|
|
1208
|
-
...config,
|
|
1209
|
-
apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
|
|
1210
|
-
ready: hasRuntimeConfig,
|
|
1211
|
-
},
|
|
1212
|
-
tools: TOOLS.map((tool) => ({
|
|
1213
|
-
id: tool.id,
|
|
1214
|
-
name: tool.name,
|
|
1215
|
-
launchCmd: tool.launchCmd || null,
|
|
1216
|
-
hint: tool.hint || null,
|
|
1217
|
-
})),
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
async function handleWorkspaceState(_req, res) {
|
|
1222
|
-
json(res, getWorkspacePayload())
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
async function handleWorkspaceSearch(req, res, url) {
|
|
1226
|
-
const query = url.searchParams.get('q') || ''
|
|
1227
|
-
json(res, workspaceStore.searchWorkspace(query))
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
async function handleWorkspaceApiConfig(req, res) {
|
|
1231
|
-
if (req.method === 'GET') {
|
|
1232
|
-
const config = workspaceRuntime.normalizeRuntimeConfig({})
|
|
1233
|
-
return json(res, {
|
|
1234
|
-
apiKey: config.apiKey,
|
|
1235
|
-
apiKeyMasked: config.apiKey ? maskKey(config.apiKey) : null,
|
|
1236
|
-
baseUrl: config.baseUrl,
|
|
1237
|
-
model: config.model,
|
|
1238
|
-
ready: Boolean(config.apiKey && config.baseUrl && config.model),
|
|
1239
|
-
})
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
const body = await parseBody(req)
|
|
1243
|
-
const apiKey = String(body.apiKey || '').trim()
|
|
1244
|
-
const baseUrl = String(body.baseUrl || '').trim() || BASE_URL_OPENAI
|
|
1245
|
-
const model = String(body.model || '').trim()
|
|
1246
|
-
if (!apiKey || !apiKey.startsWith('cr_')) {
|
|
1247
|
-
return json(res, { success: false, error: 'HolySheep API Key 必须以 cr_ 开头' }, 400)
|
|
1248
|
-
}
|
|
1249
|
-
if (!model) {
|
|
1250
|
-
return json(res, { success: false, error: '模型不能为空' }, 400)
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
workspaceStore.saveHolySheepApiConfig({ apiKey, baseUrl, model })
|
|
1254
|
-
saveConfig({ apiKey, savedAt: new Date().toISOString() })
|
|
1255
|
-
json(res, { success: true, config: getWorkspacePayload().holySheepApi })
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
async function handleWorkspaceConversations(req, res) {
|
|
1259
|
-
if (req.method === 'GET') {
|
|
1260
|
-
return json(res, workspaceStore.listConversations())
|
|
1261
|
-
}
|
|
1262
|
-
const body = await parseBody(req)
|
|
1263
|
-
const conversation = workspaceStore.createConversation({
|
|
1264
|
-
title: body.title,
|
|
1265
|
-
toolId: body.toolId,
|
|
1266
|
-
pinned: body.pinned,
|
|
1267
|
-
})
|
|
1268
|
-
json(res, { success: true, conversation })
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
async function handleWorkspaceConversationById(req, res, conversationId) {
|
|
1272
|
-
const conversation = workspaceStore.getConversation(conversationId)
|
|
1273
|
-
if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
|
|
1274
|
-
|
|
1275
|
-
if (req.method === 'GET') {
|
|
1276
|
-
return json(res, conversation)
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
if (req.method === 'PATCH') {
|
|
1280
|
-
const body = await parseBody(req)
|
|
1281
|
-
const updated = workspaceStore.updateConversation(conversationId, {
|
|
1282
|
-
title: body.title,
|
|
1283
|
-
toolId: body.toolId,
|
|
1284
|
-
pinned: body.pinned,
|
|
1285
|
-
})
|
|
1286
|
-
return json(res, { success: true, conversation: updated })
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
return json(res, { success: false, error: 'Method not allowed' }, 405)
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
async function handleWorkspaceConversationMessages(req, res, conversationId) {
|
|
1293
|
-
if (req.method === 'GET') {
|
|
1294
|
-
const conversation = workspaceStore.getConversation(conversationId)
|
|
1295
|
-
if (!conversation) return json(res, { success: false, error: '会话不存在' }, 404)
|
|
1296
|
-
return json(res, conversation.messages)
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
const body = await parseBody(req)
|
|
1300
|
-
const content = String(body.content || '').trim()
|
|
1301
|
-
if (!content) return json(res, { success: false, error: '消息不能为空' }, 400)
|
|
1302
|
-
|
|
1303
|
-
try {
|
|
1304
|
-
const result = await workspaceRuntime.sendConversationMessage(conversationId, content, body.runtimeConfig || {})
|
|
1305
|
-
return json(res, {
|
|
1306
|
-
success: true,
|
|
1307
|
-
messages: [result.userMessage, result.assistantMessage],
|
|
1308
|
-
conversation: workspaceStore.getConversation(conversationId),
|
|
1309
|
-
})
|
|
1310
|
-
} catch (error) {
|
|
1311
|
-
return json(res, {
|
|
1312
|
-
success: false,
|
|
1313
|
-
error: error.message,
|
|
1314
|
-
conversation: workspaceStore.getConversation(conversationId),
|
|
1315
|
-
}, 500)
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
async function handleWorkspaceTasks(req, res) {
|
|
1320
|
-
if (req.method === 'GET') {
|
|
1321
|
-
return json(res, workspaceStore.listTasks())
|
|
1322
|
-
}
|
|
1323
|
-
const body = await parseBody(req)
|
|
1324
|
-
const title = String(body.title || '').trim()
|
|
1325
|
-
const prompt = String(body.prompt || '').trim()
|
|
1326
|
-
if (!title || !prompt) return json(res, { success: false, error: '任务标题和提示词不能为空' }, 400)
|
|
1327
|
-
|
|
1328
|
-
try {
|
|
1329
|
-
workspaceRuntime.parseScheduleToMs(body.schedule || '1h')
|
|
1330
|
-
} catch (error) {
|
|
1331
|
-
return json(res, { success: false, error: error.message }, 400)
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
const task = workspaceStore.saveTask({
|
|
1335
|
-
title,
|
|
1336
|
-
prompt,
|
|
1337
|
-
schedule: body.schedule || '1h',
|
|
1338
|
-
active: body.active !== false,
|
|
1339
|
-
conversationId: body.conversationId || null,
|
|
1340
|
-
modelOverride: body.modelOverride || '',
|
|
1341
|
-
})
|
|
1342
|
-
workspaceRuntime.rescheduleAllTasks()
|
|
1343
|
-
json(res, { success: true, task })
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
async function handleWorkspaceTaskById(req, res, taskId) {
|
|
1347
|
-
if (req.method === 'DELETE') {
|
|
1348
|
-
workspaceStore.deleteTask(taskId)
|
|
1349
|
-
workspaceRuntime.rescheduleAllTasks()
|
|
1350
|
-
return json(res, { success: true })
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
if (req.method === 'PATCH') {
|
|
1354
|
-
const body = await parseBody(req)
|
|
1355
|
-
if (body.schedule) {
|
|
1356
|
-
try {
|
|
1357
|
-
workspaceRuntime.parseScheduleToMs(body.schedule)
|
|
1358
|
-
} catch (error) {
|
|
1359
|
-
return json(res, { success: false, error: error.message }, 400)
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
const task = workspaceStore.saveTask({
|
|
1363
|
-
id: taskId,
|
|
1364
|
-
title: body.title,
|
|
1365
|
-
prompt: body.prompt,
|
|
1366
|
-
schedule: body.schedule,
|
|
1367
|
-
active: body.active,
|
|
1368
|
-
conversationId: body.conversationId,
|
|
1369
|
-
modelOverride: body.modelOverride,
|
|
1370
|
-
})
|
|
1371
|
-
workspaceRuntime.rescheduleAllTasks()
|
|
1372
|
-
return json(res, { success: true, task })
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
if (req.method === 'POST') {
|
|
1376
|
-
try {
|
|
1377
|
-
const result = await workspaceRuntime.runTask(taskId)
|
|
1378
|
-
return json(res, { success: true, result })
|
|
1379
|
-
} catch (error) {
|
|
1380
|
-
return json(res, { success: false, error: error.message }, 500)
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
return json(res, { success: false, error: 'Method not allowed' }, 405)
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
// ── Router ───────────────────────────────────────────────────────────────────
|
|
1388
|
-
|
|
1389
|
-
async function handleRequest(req, res) {
|
|
1390
|
-
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
1391
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
1392
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
1393
|
-
|
|
1394
|
-
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
|
|
1395
|
-
|
|
1396
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
1397
|
-
const route = url.pathname
|
|
1398
|
-
|
|
1399
|
-
try {
|
|
1400
|
-
// Static
|
|
1401
|
-
if (route === '/' || route === '/index.html') {
|
|
1402
|
-
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8')
|
|
1403
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
1404
|
-
return res.end(html)
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
// API
|
|
1408
|
-
if (route === '/api/status' && req.method === 'GET') return await handleStatus(req, res)
|
|
1409
|
-
if (route === '/api/login' && req.method === 'POST') return await handleLogin(req, res)
|
|
1410
|
-
if (route === '/api/logout' && req.method === 'POST') return await handleLogout(req, res)
|
|
1411
|
-
if (route === '/api/balance' && req.method === 'GET') return await handleBalance(req, res)
|
|
1412
|
-
if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
|
|
1413
|
-
if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
|
|
1414
|
-
if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
|
|
1415
|
-
if (route === '/api/workspace/state' && req.method === 'GET') return await handleWorkspaceState(req, res)
|
|
1416
|
-
if (route === '/api/workspace/search' && req.method === 'GET') return await handleWorkspaceSearch(req, res, url)
|
|
1417
|
-
if (route === '/api/workspace/api-config' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceApiConfig(req, res)
|
|
1418
|
-
if (route === '/api/workspace/conversations' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceConversations(req, res)
|
|
1419
|
-
const conversationMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)$/)
|
|
1420
|
-
if (conversationMatch) return await handleWorkspaceConversationById(req, res, conversationMatch[1])
|
|
1421
|
-
const messagesMatch = route.match(/^\/api\/workspace\/conversations\/([^/]+)\/messages$/)
|
|
1422
|
-
if (messagesMatch) return await handleWorkspaceConversationMessages(req, res, messagesMatch[1])
|
|
1423
|
-
if (route === '/api/workspace/tasks' && (req.method === 'GET' || req.method === 'POST')) return await handleWorkspaceTasks(req, res)
|
|
1424
|
-
const taskMatch = route.match(/^\/api\/workspace\/tasks\/([^/]+)$/)
|
|
1425
|
-
if (taskMatch) return await handleWorkspaceTaskById(req, res, taskMatch[1])
|
|
1426
|
-
if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
|
|
1427
|
-
if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
|
|
1428
|
-
if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
|
|
1429
|
-
if (route === '/api/tool/reset' && req.method === 'POST') return await handleToolReset(req, res)
|
|
1430
|
-
if (route === '/api/tool/upgrade' && req.method === 'POST') return await handleToolUpgrade(req, res)
|
|
1431
|
-
if (route === '/api/tool/rollback' && req.method === 'POST') return await handleToolRollback(req, res)
|
|
1432
|
-
if (route === '/api/tool/launch' && req.method === 'POST') return await handleToolLaunch(req, res)
|
|
1433
|
-
if (route === '/api/claude-proxy/start' && req.method === 'POST') return await handleClaudeProxyStart(req, res)
|
|
1434
|
-
if (route === '/api/claude-proxy/stop' && req.method === 'POST') return await handleClaudeProxyStop(req, res)
|
|
1435
|
-
if (route === '/api/env' && req.method === 'GET') return handleEnv(req, res)
|
|
1436
|
-
if (route === '/api/env/clean' && req.method === 'POST') return handleEnvClean(req, res)
|
|
1437
|
-
if (route === '/api/restart' && req.method === 'POST') {
|
|
1438
|
-
json(res, { ok: true })
|
|
1439
|
-
// 升级后用新版 hs web 重启自身
|
|
1440
|
-
const port = req.headers.host?.split(':')[1] || '9876'
|
|
1441
|
-
setTimeout(() => {
|
|
1442
|
-
const child = spawn(process.execPath, [path.join(__dirname, '..', 'index.js'), 'web', '--port', port, '--no-open'], { detached: true, stdio: 'ignore' })
|
|
1443
|
-
child.unref()
|
|
1444
|
-
process.exit(0)
|
|
1445
|
-
}, 500)
|
|
1446
|
-
return
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
res.writeHead(404)
|
|
1450
|
-
res.end('Not Found')
|
|
1451
|
-
} catch (e) {
|
|
1452
|
-
if (!res.headersSent) {
|
|
1453
|
-
json(res, { error: e.message }, 500)
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
function startServer(port) {
|
|
1459
|
-
return new Promise((resolve, reject) => {
|
|
1460
|
-
const server = http.createServer(handleRequest)
|
|
1461
|
-
server.on('error', (err) => {
|
|
1462
|
-
if (err.code === 'EADDRINUSE') {
|
|
1463
|
-
// Try to kill stale process and retry once
|
|
1464
|
-
try {
|
|
1465
|
-
if (process.platform === 'win32') {
|
|
1466
|
-
// Windows: find PID by port and kill
|
|
1467
|
-
const out = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { stdio: 'pipe', encoding: 'utf8', shell: true })
|
|
1468
|
-
const pids = [...new Set(out.match(/\d+\s*$/gm)?.map(s => s.trim()).filter(Boolean) || [])]
|
|
1469
|
-
for (const pid of pids) { try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }) } catch {} }
|
|
1470
|
-
} else {
|
|
1471
|
-
execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: 'ignore' })
|
|
1472
|
-
}
|
|
1473
|
-
} catch {}
|
|
1474
|
-
setTimeout(() => {
|
|
1475
|
-
const retry = http.createServer(handleRequest)
|
|
1476
|
-
retry.on('error', (err2) => reject(err2))
|
|
1477
|
-
retry.listen(port, '127.0.0.1', () => {
|
|
1478
|
-
workspaceRuntime.startScheduler()
|
|
1479
|
-
resolve(retry)
|
|
1480
|
-
})
|
|
1481
|
-
}, 500)
|
|
1482
|
-
} else {
|
|
1483
|
-
reject(err)
|
|
1484
|
-
}
|
|
1485
|
-
})
|
|
1486
|
-
server.listen(port, '127.0.0.1', () => {
|
|
1487
|
-
workspaceRuntime.startScheduler()
|
|
1488
|
-
resolve(server)
|
|
1489
|
-
})
|
|
1490
|
-
})
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
// ── 后台服务自动拉起 ─────────────────────────────────────────────────────────
|
|
1494
|
-
|
|
1495
|
-
async function bootstrapBackgroundServices() {
|
|
1496
|
-
const { getApiKey } = require('../utils/config')
|
|
1497
|
-
const apiKey = getApiKey()
|
|
1498
|
-
if (!apiKey) return // 未登录,跳过
|
|
1499
|
-
|
|
1500
|
-
// 1. OpenClaw Bridge
|
|
1501
|
-
try {
|
|
1502
|
-
const openclawTool = TOOLS.find(t => t.id === 'openclaw')
|
|
1503
|
-
if (openclawTool?.checkInstalled() && openclawTool?.isConfigured()) {
|
|
1504
|
-
const bridgePort = openclawTool.getBridgePort?.() || 18788
|
|
1505
|
-
// 检查是否已在运行
|
|
1506
|
-
let bridgeAlive = false
|
|
1507
|
-
try {
|
|
1508
|
-
const http = require('http')
|
|
1509
|
-
await new Promise((resolve, reject) => {
|
|
1510
|
-
const req = http.get({ hostname: '127.0.0.1', port: bridgePort, path: '/health', family: 4 }, resolve)
|
|
1511
|
-
req.setTimeout(2000, () => { req.destroy(); reject() })
|
|
1512
|
-
req.on('error', reject)
|
|
1513
|
-
})
|
|
1514
|
-
bridgeAlive = true
|
|
1515
|
-
} catch {}
|
|
1516
|
-
|
|
1517
|
-
if (!bridgeAlive) {
|
|
1518
|
-
// 更新 bridge config 里的 gateway PID,防止 watchdog 误杀
|
|
1519
|
-
// 关键:检测不到 PID 时设为 null(和 hs setup 一致),watchdog 不会杀 null PID 的 bridge
|
|
1520
|
-
const gatewayPort = openclawTool.getGatewayPort?.() || 18789
|
|
1521
|
-
const bridgeMod = require('../tools/openclaw-bridge')
|
|
1522
|
-
const bc = bridgeMod.readBridgeConfig()
|
|
1523
|
-
let gPid = null
|
|
1524
|
-
try {
|
|
1525
|
-
if (process.platform === 'win32') {
|
|
1526
|
-
const o = execSync(`powershell -NonInteractive -Command "(Get-NetTCPConnection -LocalPort ${gatewayPort} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
1527
|
-
gPid = Number(o.split(/\r?\n/)[0]) || null
|
|
1528
|
-
} else {
|
|
1529
|
-
const o = execSync(`lsof -iTCP:${gatewayPort} -sTCP:LISTEN -t 2>/dev/null | head -1`, { stdio: 'pipe', timeout: 5000 }).toString().trim()
|
|
1530
|
-
gPid = Number(o) || null
|
|
1531
|
-
}
|
|
1532
|
-
} catch {}
|
|
1533
|
-
// 无论是否检测到,都写入(null 或真实 PID),清除旧的死 PID
|
|
1534
|
-
bc.gatewayPid = gPid
|
|
1535
|
-
bc.gatewayStartedAt = new Date().toISOString()
|
|
1536
|
-
try { fs.writeFileSync(bridgeMod.BRIDGE_CONFIG_FILE, JSON.stringify(bc, null, 2), 'utf8') } catch {}
|
|
1537
|
-
openclawTool.ensureBridgeRunning?.(bridgePort)
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
} catch {}
|
|
1541
|
-
|
|
1542
|
-
// 2. Claude Proxy
|
|
1543
|
-
try {
|
|
1544
|
-
const claudeTool = TOOLS.find(t => t.id === 'claude-code')
|
|
1545
|
-
if (claudeTool?.checkInstalled() && claudeTool?.isConfigured()) {
|
|
1546
|
-
if (!isClaudeProxyRunning().running) {
|
|
1547
|
-
const claudeProxy = require('../commands/claude-proxy')
|
|
1548
|
-
await claudeProxy(['--daemon'])
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
} catch {}
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
// Exported for in-process reuse by the AionUi wrapper.
|
|
1555
|
-
// Each handler is a pure `(req, res) => Promise<void>` and writes the HTTP response itself.
|
|
1556
|
-
module.exports = {
|
|
1557
|
-
startServer,
|
|
1558
|
-
bootstrapBackgroundServices,
|
|
1559
|
-
// Handlers reused by src/webui/aionui-wrapper.js
|
|
1560
|
-
handleTools,
|
|
1561
|
-
handleSetup,
|
|
1562
|
-
handleToolInstall,
|
|
1563
|
-
handleToolConfigure,
|
|
1564
|
-
handleToolReset,
|
|
1565
|
-
handleToolLaunch,
|
|
1566
|
-
handleBalance,
|
|
1567
|
-
handleDoctor,
|
|
1568
|
-
handleWhoami,
|
|
1569
|
-
handleStatus,
|
|
1570
|
-
handleModels,
|
|
1571
|
-
handleEnv,
|
|
1572
|
-
}
|