@simonyea/holysheep-cli 1.7.136 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/commands/webui.js +33 -2
- package/src/utils/which.js +28 -2
- package/src/webui/aionui-runtime.js +120 -0
- package/src/webui/server.js +52 -30
- package/src/webui/workspace-runtime.js +25 -12
package/README.md
CHANGED
|
@@ -225,6 +225,8 @@ A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试
|
|
|
225
225
|
|
|
226
226
|
## Changelog
|
|
227
227
|
|
|
228
|
+
- **v2.0.1** — `hs web` 默认切到 HolySheep 登录版 AionUi runtime;本机检测到 AionUi + bun 时直接接管 WebUI,起不来才回退旧版 shell
|
|
229
|
+
- **v2.0.0** — 修复 `hs web` 的关键可用性问题:工具探测改为异步缓存,避免 WebUI 首屏请求卡死;workspace 对话上游请求增加超时保护,防止发送消息时接口悬挂
|
|
228
230
|
- **v1.7.135** — Droid CLI 的 GPT-5.4 配置切回官方要求的 `provider=openai + https://api.holysheep.ai/v1`;同时服务端兼容桥接 `gpt-5.4` 的 `/responses` 请求到 `/v1/chat/completions`
|
|
229
231
|
- **v1.7.134** — 修复并发配置/Worker 路径下 Droid CLI 的 GPT-5.4 BYOK 配置:GPT 走 `generic-chat-completion-api + https://api.holysheep.ai/v1`,避免误走 Anthropic `/v1/messages`
|
|
230
232
|
- **v1.7.53** — 修复 `hs claude` 在绝对 URL 代理路径下未透传 `x-hs-node-proxied` 的问题,避免同一会话里部分 Claude 请求被后端误判为非可信代理请求并随机触发 403
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node tests/droid.test.js && node tests/workspace-store.test.js"
|
package/src/commands/webui.js
CHANGED
|
@@ -8,7 +8,6 @@ const { execSync } = require('child_process')
|
|
|
8
8
|
|
|
9
9
|
async function webui(opts) {
|
|
10
10
|
const port = Number(opts.port) || 9876
|
|
11
|
-
const { startServer } = require('../webui/server')
|
|
12
11
|
|
|
13
12
|
console.log()
|
|
14
13
|
console.log(chalk.bold('🌐 HolySheep WebUI'))
|
|
@@ -16,9 +15,31 @@ async function webui(opts) {
|
|
|
16
15
|
console.log()
|
|
17
16
|
|
|
18
17
|
try {
|
|
19
|
-
|
|
18
|
+
let child = null
|
|
19
|
+
let mode = 'legacy'
|
|
20
|
+
|
|
21
|
+
if (process.env.HOLYSHEEP_WEBUI_LEGACY !== '1') {
|
|
22
|
+
try {
|
|
23
|
+
const { startAionUiRuntime } = require('../webui/aionui-runtime')
|
|
24
|
+
const result = await startAionUiRuntime(port)
|
|
25
|
+
child = result.child
|
|
26
|
+
mode = 'aionui'
|
|
27
|
+
console.log(chalk.green(`✓ AionUi runtime 已接管 hs web`))
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.log(chalk.yellow(`! 未切到 AionUi runtime,回退旧版 WebUI: ${error.message}`))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!child) {
|
|
34
|
+
const { startServer } = require('../webui/server')
|
|
35
|
+
await startServer(port)
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
const url = `http://127.0.0.1:${port}`
|
|
21
39
|
console.log(chalk.green(`✓ WebUI 已启动: ${chalk.cyan.bold(url)}`))
|
|
40
|
+
if (mode === 'aionui') {
|
|
41
|
+
console.log(chalk.gray(' 当前模式: AionUi runtime'))
|
|
42
|
+
}
|
|
22
43
|
console.log(chalk.gray(' 按 Ctrl+C 停止'))
|
|
23
44
|
console.log()
|
|
24
45
|
|
|
@@ -31,6 +52,16 @@ async function webui(opts) {
|
|
|
31
52
|
} catch {}
|
|
32
53
|
}
|
|
33
54
|
|
|
55
|
+
if (child) {
|
|
56
|
+
const stopChild = () => {
|
|
57
|
+
if (!child.killed) child.kill('SIGTERM')
|
|
58
|
+
}
|
|
59
|
+
process.on('SIGINT', stopChild)
|
|
60
|
+
process.on('SIGTERM', stopChild)
|
|
61
|
+
child.on('exit', (code) => {
|
|
62
|
+
process.exit(code || 0)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
34
65
|
|
|
35
66
|
// Keep alive
|
|
36
67
|
await new Promise(() => {})
|
package/src/utils/which.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 跨平台检测命令是否存在
|
|
3
3
|
* Windows 用 where,Unix 用 which,兜底用 --version
|
|
4
4
|
*/
|
|
5
|
-
const { execSync } = require('child_process')
|
|
5
|
+
const { exec, execSync } = require('child_process')
|
|
6
6
|
|
|
7
7
|
function canRun(command, options = {}) {
|
|
8
8
|
try {
|
|
@@ -34,4 +34,30 @@ function commandExists(cmd) {
|
|
|
34
34
|
return canRun(`${cmd} --version`, { timeout: 3000 })
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
function canRunAsync(command, options = {}) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
exec(command, { timeout: 3000, windowsHide: true, ...options }, (error) => {
|
|
40
|
+
resolve(!error)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function commandExistsAsync(cmd) {
|
|
46
|
+
if (process.platform === 'win32') {
|
|
47
|
+
const variants = [cmd, `${cmd}.cmd`, `${cmd}.exe`, `${cmd}.bat`]
|
|
48
|
+
for (const variant of variants) {
|
|
49
|
+
if (await canRunAsync(`where ${variant}`)) return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const variant of variants) {
|
|
53
|
+
if (await canRunAsync(`cmd /d /s /c "${variant} --version"`)) return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (await canRunAsync(`which ${cmd}`)) return true
|
|
60
|
+
return canRunAsync(`${cmd} --version`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { commandExists, commandExistsAsync }
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const http = require('http')
|
|
7
|
+
const { execSync, spawn } = require('child_process')
|
|
8
|
+
|
|
9
|
+
function resolveBunPath() {
|
|
10
|
+
if (process.env.BUN && fs.existsSync(process.env.BUN)) return process.env.BUN
|
|
11
|
+
try {
|
|
12
|
+
const resolved = execSync('which bun', {
|
|
13
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
timeout: 2000,
|
|
16
|
+
}).trim()
|
|
17
|
+
return resolved || null
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getRuntimeCandidates() {
|
|
24
|
+
const home = os.homedir()
|
|
25
|
+
return [
|
|
26
|
+
process.env.HOLYSHEEP_AIONUI_DIR,
|
|
27
|
+
path.join(home, 'AionUi'),
|
|
28
|
+
path.join(home, 'Projects', 'AionUi'),
|
|
29
|
+
path.join(__dirname, '..', '..', 'vendor', 'aionui'),
|
|
30
|
+
].filter(Boolean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isValidRuntimeDir(dir) {
|
|
34
|
+
if (!dir) return false
|
|
35
|
+
return fs.existsSync(path.join(dir, 'dist-server', 'server.mjs')) &&
|
|
36
|
+
fs.existsSync(path.join(dir, 'out', 'renderer', 'index.html'))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveAionUiRuntimeDir() {
|
|
40
|
+
return getRuntimeCandidates().find(isValidRuntimeDir) || null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function waitForReady(port, timeoutMs = 15000) {
|
|
44
|
+
const startedAt = Date.now()
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const tick = () => {
|
|
47
|
+
const req = http.get({
|
|
48
|
+
hostname: '127.0.0.1',
|
|
49
|
+
port,
|
|
50
|
+
path: '/',
|
|
51
|
+
family: 4,
|
|
52
|
+
timeout: 1500,
|
|
53
|
+
}, (res) => {
|
|
54
|
+
res.resume()
|
|
55
|
+
if (res.statusCode && res.statusCode < 500) {
|
|
56
|
+
resolve(true)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
retry()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
req.on('timeout', () => {
|
|
63
|
+
req.destroy()
|
|
64
|
+
retry()
|
|
65
|
+
})
|
|
66
|
+
req.on('error', retry)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const retry = () => {
|
|
70
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
71
|
+
reject(new Error('AionUi runtime did not become ready in time'))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
setTimeout(tick, 500)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
tick()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startAionUiRuntime(port) {
|
|
82
|
+
const runtimeDir = resolveAionUiRuntimeDir()
|
|
83
|
+
if (!runtimeDir) {
|
|
84
|
+
throw new Error('AionUi runtime not found')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const bunPath = resolveBunPath()
|
|
88
|
+
if (!bunPath) {
|
|
89
|
+
throw new Error('bun is required to start the AionUi runtime')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const child = spawn(bunPath, ['dist-server/server.mjs'], {
|
|
93
|
+
cwd: runtimeDir,
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
PORT: String(port),
|
|
97
|
+
NODE_ENV: 'production',
|
|
98
|
+
},
|
|
99
|
+
stdio: 'inherit',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await waitForReady(port)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
child.kill('SIGTERM')
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
child,
|
|
111
|
+
runtimeDir,
|
|
112
|
+
bunPath,
|
|
113
|
+
mode: 'aionui',
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
resolveAionUiRuntimeDir,
|
|
119
|
+
startAionUiRuntime,
|
|
120
|
+
}
|
package/src/webui/server.js
CHANGED
|
@@ -15,12 +15,15 @@ const path = require('path')
|
|
|
15
15
|
const { execSync, spawn } = require('child_process')
|
|
16
16
|
const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
|
|
17
17
|
const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../utils/shell')
|
|
18
|
-
const {
|
|
18
|
+
const { commandExistsAsync } = require('../utils/which')
|
|
19
19
|
const TOOLS = require('../tools')
|
|
20
20
|
const pkg = require('../../package.json')
|
|
21
21
|
const workspaceStore = require('./workspace-store')
|
|
22
22
|
const workspaceRuntime = require('./workspace-runtime')
|
|
23
23
|
|
|
24
|
+
const TOOL_CHECK_CACHE_TTL_MS = 10_000
|
|
25
|
+
const toolStateCache = new Map()
|
|
26
|
+
|
|
24
27
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
25
28
|
|
|
26
29
|
function maskKey(key) {
|
|
@@ -94,6 +97,52 @@ function parseBody(req) {
|
|
|
94
97
|
})
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
function getToolCommand(toolId) {
|
|
101
|
+
const cmds = {
|
|
102
|
+
'claude-code': 'claude',
|
|
103
|
+
'codex': 'codex',
|
|
104
|
+
'droid': 'droid',
|
|
105
|
+
'gemini-cli': 'gemini',
|
|
106
|
+
'opencode': 'opencode',
|
|
107
|
+
'openclaw': 'openclaw',
|
|
108
|
+
'aider': 'aider',
|
|
109
|
+
'env-config': null,
|
|
110
|
+
}
|
|
111
|
+
return cmds[toolId] ?? null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function detectToolInstalled(tool) {
|
|
115
|
+
if (tool.id === 'env-config') return true
|
|
116
|
+
|
|
117
|
+
const now = Date.now()
|
|
118
|
+
const cached = toolStateCache.get(tool.id)
|
|
119
|
+
if (cached && now - cached.checkedAt < TOOL_CHECK_CACHE_TTL_MS) {
|
|
120
|
+
return cached.installed
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const command = getToolCommand(tool.id)
|
|
124
|
+
const installed = command ? await commandExistsAsync(command) : false
|
|
125
|
+
toolStateCache.set(tool.id, { installed, checkedAt: now })
|
|
126
|
+
return installed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function buildToolSummary(tool) {
|
|
130
|
+
const installed = await detectToolInstalled(tool)
|
|
131
|
+
return {
|
|
132
|
+
id: tool.id,
|
|
133
|
+
name: tool.name,
|
|
134
|
+
installed,
|
|
135
|
+
configured: installed ? (tool.isConfigured?.() || false) : false,
|
|
136
|
+
version: installed ? await getVersionAsync(tool) : null,
|
|
137
|
+
installCmd: tool.installCmd,
|
|
138
|
+
hint: tool.hint || null,
|
|
139
|
+
launchCmd: tool.launchCmd || null,
|
|
140
|
+
canAutoInstall: !!AUTO_INSTALL[tool.id],
|
|
141
|
+
canUpgrade: !!UPGRADABLE_TOOLS.find((item) => item.id === tool.id),
|
|
142
|
+
npmPkg: UPGRADABLE_TOOLS.find((item) => item.id === tool.id)?.npmPkg || null,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
function json(res, data, status = 200) {
|
|
98
147
|
res.writeHead(status, { 'Content-Type': 'application/json' })
|
|
99
148
|
res.end(JSON.stringify(data))
|
|
@@ -252,19 +301,7 @@ async function handleDoctor(_req, res) {
|
|
|
252
301
|
const nodeMajor = parseInt(process.version.slice(1), 10)
|
|
253
302
|
|
|
254
303
|
// Tools
|
|
255
|
-
const tools = TOOLS.map(
|
|
256
|
-
const installed = t.checkInstalled()
|
|
257
|
-
return {
|
|
258
|
-
id: t.id,
|
|
259
|
-
name: t.name,
|
|
260
|
-
installed,
|
|
261
|
-
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
262
|
-
version: installed ? getVersion(t) : null,
|
|
263
|
-
installCmd: t.installCmd,
|
|
264
|
-
hint: t.hint || null,
|
|
265
|
-
launchCmd: t.launchCmd || null,
|
|
266
|
-
}
|
|
267
|
-
})
|
|
304
|
+
const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
|
|
268
305
|
|
|
269
306
|
// Connectivity
|
|
270
307
|
let connectivity = { ok: false, modelCount: 0 }
|
|
@@ -309,22 +346,7 @@ function isClaudeProxyRunning() {
|
|
|
309
346
|
}
|
|
310
347
|
|
|
311
348
|
async function handleTools(_req, res) {
|
|
312
|
-
const tools = await Promise.all(TOOLS.map(
|
|
313
|
-
const installed = t.checkInstalled()
|
|
314
|
-
return {
|
|
315
|
-
id: t.id,
|
|
316
|
-
name: t.name,
|
|
317
|
-
installed,
|
|
318
|
-
configured: installed ? (t.isConfigured?.() || false) : false,
|
|
319
|
-
version: installed ? await getVersionAsync(t) : null,
|
|
320
|
-
installCmd: t.installCmd,
|
|
321
|
-
hint: t.hint || null,
|
|
322
|
-
launchCmd: t.launchCmd || null,
|
|
323
|
-
canAutoInstall: !!AUTO_INSTALL[t.id],
|
|
324
|
-
canUpgrade: !!UPGRADABLE_TOOLS.find(u => u.id === t.id),
|
|
325
|
-
npmPkg: UPGRADABLE_TOOLS.find(u => u.id === t.id)?.npmPkg || null,
|
|
326
|
-
}
|
|
327
|
-
}))
|
|
349
|
+
const tools = await Promise.all(TOOLS.map((tool) => buildToolSummary(tool)))
|
|
328
350
|
|
|
329
351
|
// 追加 claude-proxy 虚拟工具
|
|
330
352
|
const proxyState = isClaudeProxyRunning()
|
|
@@ -46,18 +46,31 @@ function buildSystemPrompt(toolId) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function requestCompletion(messages, config) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
49
|
+
const controller = new AbortController()
|
|
50
|
+
const timeout = setTimeout(() => controller.abort(), 20_000)
|
|
51
|
+
let response
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
model: config.model,
|
|
61
|
+
temperature: 0.2,
|
|
62
|
+
messages,
|
|
63
|
+
}),
|
|
64
|
+
signal: controller.signal,
|
|
65
|
+
})
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.name === 'AbortError') {
|
|
68
|
+
throw new Error('HolySheep API request timed out')
|
|
69
|
+
}
|
|
70
|
+
throw error
|
|
71
|
+
} finally {
|
|
72
|
+
clearTimeout(timeout)
|
|
73
|
+
}
|
|
61
74
|
|
|
62
75
|
const payload = await response.json().catch(() => null)
|
|
63
76
|
if (!response.ok) {
|