@simonyea/holysheep-cli 2.1.13 → 2.1.14
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/package.json +2 -2
- package/src/commands/balance.js +4 -3
- package/src/commands/webui.js +37 -9
- package/src/tools/codex.js +30 -2
- package/src/tools/droid.js +31 -1
- package/src/tools/hermes.js +157 -0
- package/src/tools/index.js +2 -0
- package/src/webui/aionui-runtime-fetcher.js +20 -13
- package/src/webui/aionui-wrapper.js +46 -4
- package/src/webui/server.js +73 -2
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.14",
|
|
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
|
-
"test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js",
|
|
6
|
+
"test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js && node tests/hermes.test.js",
|
|
7
7
|
"prepublishOnly": "node scripts/check-tarball-size.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
package/src/commands/balance.js
CHANGED
|
@@ -15,9 +15,10 @@ async function balance() {
|
|
|
15
15
|
const spinner = ora('获取账户信息...').start()
|
|
16
16
|
try {
|
|
17
17
|
const fetch = require('node-fetch')
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
18
|
+
// Use the canonical www host directly. `${SHOP_URL}` returns a 301 redirect,
|
|
19
|
+
// and node-fetch drops the Authorization header on cross-origin redirects,
|
|
20
|
+
// which surfaced as "HTTP 404" on every `hs balance` call (2.1.13 bug).
|
|
21
|
+
const res = await fetch('https://www.holysheep.ai/api/stats/overview', {
|
|
21
22
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
22
23
|
timeout: 8000,
|
|
23
24
|
})
|
package/src/commands/webui.js
CHANGED
|
@@ -138,8 +138,14 @@ function probeAionUiRuntimeLabel() {
|
|
|
138
138
|
isVersionCurrent,
|
|
139
139
|
} = require('../webui/aionui-runtime-fetcher')
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
// aionui-fork/ label only appears when dev opted-in via HOLYSHEEP_DEV=1 —
|
|
142
|
+
// must match the resolver gating in aionui-runtime-fetcher.js, otherwise
|
|
143
|
+
// the status line would lie ("runtime=aionui-fork" when the actual resolver
|
|
144
|
+
// is about to return user-cache).
|
|
145
|
+
if (process.env.HOLYSHEEP_DEV === '1') {
|
|
146
|
+
const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
|
|
147
|
+
if (isValidRuntimeDir(forkDir)) return 'aionui-fork'
|
|
148
|
+
}
|
|
143
149
|
if (isValidRuntimeDir(VENDOR_DIR)) return 'vendor'
|
|
144
150
|
if (isValidRuntimeDir(USER_CACHE_DIR)) {
|
|
145
151
|
return isVersionCurrent(USER_CACHE_DIR) ? 'installed' : 'installed-stale'
|
|
@@ -464,14 +470,33 @@ async function startAionUiMode(opts) {
|
|
|
464
470
|
|
|
465
471
|
console.log(chalk.cyan(`▶ Starting AionUi v1.9.18 (HolySheep fork, source: ${runtime.source})`))
|
|
466
472
|
|
|
467
|
-
// 3. Spawn
|
|
473
|
+
// 3. Spawn AionUi on an internal loopback port and wrap it with
|
|
474
|
+
// `aionui-wrapper.js` which listens on the user-visible port. This is the
|
|
475
|
+
// critical architectural piece that makes `/api/holysheep/balance`,
|
|
476
|
+
// `/api/holysheep/tools`, `/api/holysheep/setup`, etc. actually reachable
|
|
477
|
+
// from the browser: the wrapper in-process dispatches those routes to
|
|
478
|
+
// `src/webui/server.js`'s exported handlers, and proxies everything else
|
|
479
|
+
// (including /login, /api/auth/*, WebSocket /ws) to AionUi.
|
|
480
|
+
//
|
|
481
|
+
// Before 2.1.14 hs web spawned AionUi directly on :9876 and the wrapper
|
|
482
|
+
// was dead code — so the Dashboard couldn't fetch balance/tools at all.
|
|
483
|
+
const { startWrapper } = require('../webui/aionui-wrapper')
|
|
484
|
+
|
|
485
|
+
// Pick an internal port. We rely on the wrapper's own picker to avoid
|
|
486
|
+
// duplicating logic, but we need to know the public port upfront for the
|
|
487
|
+
// user-visible URL message.
|
|
488
|
+
let wrapper
|
|
468
489
|
let aionuiProc
|
|
469
490
|
try {
|
|
470
|
-
|
|
491
|
+
wrapper = await startWrapper({
|
|
492
|
+
port, // user-visible port (default 9876)
|
|
493
|
+
runtimeDir: runtime.dir,
|
|
494
|
+
runtimeVersion: runtime.version,
|
|
495
|
+
runtimeSource: runtime.source,
|
|
496
|
+
bunPath,
|
|
497
|
+
})
|
|
498
|
+
aionuiProc = wrapper.aionui
|
|
471
499
|
} catch (e) {
|
|
472
|
-
// `e.message` may now be multi-line (includes bun/AionUi stderr tail).
|
|
473
|
-
// Print the first line in red (the reason), then any extra lines verbatim
|
|
474
|
-
// so the user can see the real bun/AionUi error and paste it back to us.
|
|
475
500
|
const [firstLine, ...rest] = String(e.message).split(/\r?\n/)
|
|
476
501
|
console.log(chalk.red(`✗ AionUi server failed to start: ${firstLine}`))
|
|
477
502
|
for (const line of rest) {
|
|
@@ -487,13 +512,16 @@ async function startAionUiMode(opts) {
|
|
|
487
512
|
}
|
|
488
513
|
|
|
489
514
|
const baseUrl = `http://127.0.0.1:${port}`
|
|
515
|
+
const internalPort = wrapper.internalPort
|
|
490
516
|
|
|
491
|
-
// 4. Auto-login via HolySheep API key if available
|
|
517
|
+
// 4. Auto-login via HolySheep API key if available — talks to the INTERNAL
|
|
518
|
+
// AionUi port (bypasses the wrapper's rate limits / middleware), because
|
|
519
|
+
// this is just a cookie-warmer for the browser launch URL.
|
|
492
520
|
const apiKey = readHolySheepApiKey()
|
|
493
521
|
let launchUrl = baseUrl
|
|
494
522
|
if (apiKey) {
|
|
495
523
|
try {
|
|
496
|
-
const { cookieLine } = await loginWithApiKey(
|
|
524
|
+
const { cookieLine } = await loginWithApiKey(internalPort, apiKey)
|
|
497
525
|
// We can't programmatically seed the browser with cookies easily. Best UX:
|
|
498
526
|
// - AionUi /login already set the cookie in our HTTP client, not the browser
|
|
499
527
|
// - We ask user to paste key once in UI, OR we display a one-shot magic link
|
package/src/tools/codex.js
CHANGED
|
@@ -160,10 +160,38 @@ function writeJsonConfigIfNeeded(apiKey, baseUrlOpenAI, model) {
|
|
|
160
160
|
/**
|
|
161
161
|
* 清除 auth.json 中的 ChatGPT OAuth 认证,避免干扰 holysheep provider
|
|
162
162
|
* Codex RS bug: auth_mode=chatgpt 时 OAuth token 会覆盖自定义 provider 的 api_key
|
|
163
|
+
*
|
|
164
|
+
* Windows: Defender occasionally holds an open handle to auth.json during the
|
|
165
|
+
* first post-install scan, so an immediate `unlinkSync` returns EBUSY. Retry
|
|
166
|
+
* up to 5 times with a short backoff. On non-Windows a single pass suffices.
|
|
163
167
|
*/
|
|
164
168
|
function neutralizeAuthJson() {
|
|
165
|
-
|
|
166
|
-
|
|
169
|
+
const MAX_ATTEMPTS = process.platform === 'win32' ? 5 : 1
|
|
170
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(AUTH_FILE)
|
|
173
|
+
return
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (e && e.code === 'ENOENT') return // already gone — success
|
|
176
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
177
|
+
// Last try failed — on Windows fall back to truncating the file to
|
|
178
|
+
// an empty JSON object. Codex RS treats `{}` as "no chatgpt session",
|
|
179
|
+
// so the config.toml's `http_headers.Authorization` takes over.
|
|
180
|
+
try { fs.writeFileSync(AUTH_FILE, '{}\n', 'utf8') } catch {}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
// Sleep ~200ms via a synchronous shell command. We avoid pulling in a
|
|
184
|
+
// new dep or using Atomics.wait here to keep the tool footprint small.
|
|
185
|
+
try {
|
|
186
|
+
require('child_process').execSync(
|
|
187
|
+
process.platform === 'win32'
|
|
188
|
+
? 'powershell -NoProfile -Command "Start-Sleep -Milliseconds 200"'
|
|
189
|
+
: 'sleep 0.2',
|
|
190
|
+
{ stdio: 'ignore' }
|
|
191
|
+
)
|
|
192
|
+
} catch {}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
module.exports = {
|
package/src/tools/droid.js
CHANGED
|
@@ -20,6 +20,21 @@ const CONFIG_DIR = path.join(os.homedir(), '.factory')
|
|
|
20
20
|
const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json')
|
|
21
21
|
const LEGACY_CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
|
|
22
22
|
|
|
23
|
+
// Platform-specific install command. Windows previously read the macOS-only
|
|
24
|
+
// `brew install --cask droid` which always fails on fresh Win10/11 boxes —
|
|
25
|
+
// users reported this explicitly in 2.1.12/13. Factory ships an official
|
|
26
|
+
// winget package; Linux gets the official shell installer. macOS keeps brew.
|
|
27
|
+
function installCmdForPlatform() {
|
|
28
|
+
if (process.platform === 'win32') {
|
|
29
|
+
return 'winget install --id Factory.Droid -e --accept-source-agreements --accept-package-agreements'
|
|
30
|
+
}
|
|
31
|
+
if (process.platform === 'darwin') {
|
|
32
|
+
return 'brew install --cask droid'
|
|
33
|
+
}
|
|
34
|
+
// Linux — Factory's official installer supports Linux amd64/arm64.
|
|
35
|
+
return 'curl -fsSL https://app.factory.ai/install.sh | bash'
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
const DEFAULT_MODELS = [
|
|
24
39
|
{
|
|
25
40
|
model: 'gpt-5.4',
|
|
@@ -173,6 +188,21 @@ module.exports = {
|
|
|
173
188
|
legacy.logoAnimation = 'off'
|
|
174
189
|
writeLegacyConfig(legacy)
|
|
175
190
|
|
|
191
|
+
// Windows: also write FACTORY_API_KEY to user env via setx so a freshly
|
|
192
|
+
// opened PowerShell / cmd.exe picks it up without requiring the user to
|
|
193
|
+
// manually edit env vars. Non-Windows shells don't need this because the
|
|
194
|
+
// config.json → settings.json path already wires the key through Factory's
|
|
195
|
+
// own auth resolution.
|
|
196
|
+
if (process.platform === 'win32') {
|
|
197
|
+
try {
|
|
198
|
+
const { execSync } = require('child_process')
|
|
199
|
+
execSync(`setx FACTORY_API_KEY "${apiKey}"`, { stdio: 'ignore', timeout: 10000 })
|
|
200
|
+
} catch {
|
|
201
|
+
// setx can fail in locked-down corp images; best-effort only.
|
|
202
|
+
// The settings.json / config.json path still works inside Droid CLI itself.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
176
206
|
return {
|
|
177
207
|
file: SETTINGS_FILE,
|
|
178
208
|
hot: true,
|
|
@@ -194,6 +224,6 @@ module.exports = {
|
|
|
194
224
|
getConfigPath() { return SETTINGS_FILE },
|
|
195
225
|
hint: '已写入 ~/.factory/settings.json;重启 Droid 后可见 HolySheep 模型列表',
|
|
196
226
|
launchCmd: 'droid',
|
|
197
|
-
installCmd:
|
|
227
|
+
installCmd: installCmdForPlatform(),
|
|
198
228
|
docsUrl: 'https://docs.factory.ai/cli/getting-started/overview',
|
|
199
229
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes Agent (Nous Research) 适配器
|
|
3
|
+
* Project: https://github.com/NousResearch/hermes-agent
|
|
4
|
+
*
|
|
5
|
+
* Hermes 是 Python 项目,上游推荐用 uv 安装:
|
|
6
|
+
* curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup
|
|
7
|
+
*
|
|
8
|
+
* 配置文件:~/.hermes/config.toml(Python 的 TOML 风格)。我们注入一个
|
|
9
|
+
* [providers.holysheep] block 并把 default_provider 设成 holysheep,让
|
|
10
|
+
* `hermes` 启动后默认使用 HolySheep。
|
|
11
|
+
*
|
|
12
|
+
* 写 TOML 是侵入式操作,宁可保守:
|
|
13
|
+
* 1. 如果文件不存在,整个写一个最小可用的 HolySheep-only 配置。
|
|
14
|
+
* 2. 如果已存在,只替换 `[providers.holysheep]` 段和一个 `default_provider = "..."`
|
|
15
|
+
* 行,其余内容(用户自己加的 other providers / tools / memory 配置)原样保留。
|
|
16
|
+
*
|
|
17
|
+
* Windows:官方安装脚本(bash + uv)不支持原生 Windows。hs setup 里我们把
|
|
18
|
+
* `installCmd` 返回 manual 标记,引导用户到 WSL2。
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs')
|
|
21
|
+
const path = require('path')
|
|
22
|
+
const os = require('os')
|
|
23
|
+
const { commandExists } = require('../utils/which')
|
|
24
|
+
|
|
25
|
+
const CONFIG_DIR = path.join(os.homedir(), '.hermes')
|
|
26
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.toml')
|
|
27
|
+
|
|
28
|
+
const PROVIDER_SECTION_RE = /^\[providers\.holysheep\]\s*$([\s\S]*?)(?=^\[|\Z)/m
|
|
29
|
+
const DEFAULT_PROVIDER_RE = /^default_provider\s*=\s*"[^"]*"\s*$/m
|
|
30
|
+
|
|
31
|
+
function readConfig() {
|
|
32
|
+
try {
|
|
33
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
34
|
+
return fs.readFileSync(CONFIG_FILE, 'utf8')
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeConfig(content) {
|
|
41
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
42
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(CONFIG_FILE, content, { encoding: 'utf8', mode: 0o600 })
|
|
45
|
+
if (process.platform !== 'win32') {
|
|
46
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600) } catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Build the [providers.holysheep] block + default_provider pointing at it. */
|
|
51
|
+
function buildHolySheepBlock(apiKey, baseUrlOpenAI, primaryModel) {
|
|
52
|
+
const cleanBase = String(baseUrlOpenAI || 'https://api.holysheep.ai/v1').replace(/\/+$/, '')
|
|
53
|
+
const model = primaryModel || 'gpt-5.4'
|
|
54
|
+
return [
|
|
55
|
+
'[providers.holysheep]',
|
|
56
|
+
'# Managed by @simonyea/holysheep-cli. Do not edit by hand — run `hs setup`.',
|
|
57
|
+
'type = "openai"',
|
|
58
|
+
`base_url = "${cleanBase}"`,
|
|
59
|
+
`api_key = "${apiKey}"`,
|
|
60
|
+
`default_model = "${model}"`,
|
|
61
|
+
'',
|
|
62
|
+
].join('\n')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Strip a previous HolySheep-managed block from the TOML content. Everything
|
|
67
|
+
* else the user wrote is preserved verbatim. Non-TOML-aware line splicing —
|
|
68
|
+
* the hermes config uses standard TOML sections which are blank-line separated,
|
|
69
|
+
* so detecting a section header that starts with `[…]` is sufficient.
|
|
70
|
+
*/
|
|
71
|
+
function stripExisting(content) {
|
|
72
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
|
73
|
+
const out = []
|
|
74
|
+
let inBlock = false
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
if (/^\[providers\.holysheep\]\s*$/.test(line)) {
|
|
77
|
+
inBlock = true
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (inBlock) {
|
|
81
|
+
if (/^\[[^\]]+\]\s*$/.test(line)) {
|
|
82
|
+
inBlock = false
|
|
83
|
+
out.push(line)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
out.push(line)
|
|
89
|
+
}
|
|
90
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeConfig(apiKey, baseUrlOpenAI, primaryModel) {
|
|
94
|
+
const existing = stripExisting(readConfig())
|
|
95
|
+
const block = buildHolySheepBlock(apiKey, baseUrlOpenAI, primaryModel)
|
|
96
|
+
|
|
97
|
+
// Replace an existing `default_provider` line, or insert one near the top.
|
|
98
|
+
let withDefault = existing
|
|
99
|
+
if (DEFAULT_PROVIDER_RE.test(withDefault)) {
|
|
100
|
+
withDefault = withDefault.replace(DEFAULT_PROVIDER_RE, 'default_provider = "holysheep"')
|
|
101
|
+
} else {
|
|
102
|
+
withDefault = `default_provider = "holysheep"\n` + withDefault
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const final = [withDefault.trim(), '', block].filter(Boolean).join('\n')
|
|
106
|
+
return final.endsWith('\n') ? final : final + '\n'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isConfiguredInToml(content) {
|
|
110
|
+
if (!content) return false
|
|
111
|
+
return /\[providers\.holysheep\]/.test(content) &&
|
|
112
|
+
/api\.holysheep\.ai/.test(content) &&
|
|
113
|
+
/api_key\s*=\s*"cr_/.test(content)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function installCmdForPlatform() {
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
return '' // handled as manual in server.js
|
|
119
|
+
}
|
|
120
|
+
return 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
name: 'Hermes Agent',
|
|
125
|
+
id: 'hermes',
|
|
126
|
+
checkInstalled() {
|
|
127
|
+
return commandExists('hermes')
|
|
128
|
+
},
|
|
129
|
+
isConfigured() {
|
|
130
|
+
return isConfiguredInToml(readConfig())
|
|
131
|
+
},
|
|
132
|
+
configure(apiKey, _baseUrlAnthropic, baseUrlOpenAI, primaryModel /*, _selectedModels */) {
|
|
133
|
+
const merged = mergeConfig(apiKey, baseUrlOpenAI, primaryModel)
|
|
134
|
+
writeConfig(merged)
|
|
135
|
+
return {
|
|
136
|
+
file: CONFIG_FILE,
|
|
137
|
+
hot: true, // next `hermes` run picks up the TOML on load
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
reset() {
|
|
141
|
+
const existing = readConfig()
|
|
142
|
+
if (!existing) return
|
|
143
|
+
const stripped = stripExisting(existing).replace(DEFAULT_PROVIDER_RE, '').replace(/\n{3,}/g, '\n\n').trim()
|
|
144
|
+
writeConfig(stripped ? stripped + '\n' : '')
|
|
145
|
+
},
|
|
146
|
+
getConfigPath() { return CONFIG_FILE },
|
|
147
|
+
hint: process.platform === 'win32'
|
|
148
|
+
? 'Hermes 官方安装脚本仅支持 macOS/Linux/WSL2,Windows 请先启用 WSL2。'
|
|
149
|
+
: '已写入 ~/.hermes/config.toml;运行 `hermes` 默认使用 HolySheep。',
|
|
150
|
+
launchCmd: 'hermes',
|
|
151
|
+
installCmd: installCmdForPlatform(),
|
|
152
|
+
docsUrl: 'https://hermes-agent.nousresearch.com/docs/',
|
|
153
|
+
// Internal helpers — re-exported for tests / inspection.
|
|
154
|
+
_stripExisting: stripExisting,
|
|
155
|
+
_mergeConfig: mergeConfig,
|
|
156
|
+
_buildHolySheepBlock: buildHolySheepBlock,
|
|
157
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* cache is atomically replaced with the current version on next launch.
|
|
9
9
|
* 2. <cli>/src/webui/vendor/aionui/ (dev checkout — not shipped to npm,
|
|
10
10
|
* no version gate)
|
|
11
|
-
* 3. <cli>/../aionui-fork/ (
|
|
12
|
-
*
|
|
13
|
-
* any cached runtime
|
|
11
|
+
* 3. <cli>/../aionui-fork/ (ONLY when `HOLYSHEEP_DEV=1` is set — when you
|
|
12
|
+
* are actively iterating on the fork and want `bun run build` to take
|
|
13
|
+
* precedence over any cached runtime). OFF BY DEFAULT so that even
|
|
14
|
+
* when the CLI is run from the source repo, it behaves exactly like a
|
|
15
|
+
* user install (user-cache → download). This removed a massive
|
|
16
|
+
* silent-drift risk: previously a dev commit that broke the fork would
|
|
17
|
+
* silently ship to developers' `hs web` sessions but not to end users,
|
|
18
|
+
* so bugs were invisible locally.
|
|
14
19
|
* 4. DEFAULT_RUNTIME_URL + DEFAULT_RUNTIME_SHA256 (baked in; auto-download
|
|
15
20
|
* when --setup-runtime is passed OR when cache is stale). Users can
|
|
16
21
|
* override via env.
|
|
@@ -55,10 +60,10 @@ const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
|
|
|
55
60
|
// new CLI release, the next `hs web` invocation on user machines will detect
|
|
56
61
|
// the version drift and upgrade the cache in place.
|
|
57
62
|
const DEFAULT_RUNTIME_URL =
|
|
58
|
-
'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-
|
|
63
|
+
'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs10.tar.gz'
|
|
59
64
|
const DEFAULT_RUNTIME_SHA256 =
|
|
60
|
-
'
|
|
61
|
-
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-
|
|
65
|
+
'45dfa23db819ea57f445a5db7652b0e4455d822e07f60458e6713c7d3a28baec'
|
|
66
|
+
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs10'
|
|
62
67
|
|
|
63
68
|
function isValidRuntimeDir(dir) {
|
|
64
69
|
if (!dir) return false
|
|
@@ -194,13 +199,15 @@ async function resolveRuntime({ allowDownload = false, logger = () => {} } = {})
|
|
|
194
199
|
return { dir: VENDOR_DIR, version: readVersion(VENDOR_DIR), source: 'vendor' }
|
|
195
200
|
}
|
|
196
201
|
|
|
197
|
-
// 3. Dev repo aionui-fork/
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
// 3. Dev repo aionui-fork/ — GATED BEHIND HOLYSHEEP_DEV=1. See resolver
|
|
203
|
+
// header docstring (order step 3) for rationale. Users (even devs running
|
|
204
|
+
// from the repo checkout) go through user-cache → download, so the
|
|
205
|
+
// production behavior is always what they see locally.
|
|
206
|
+
if (process.env.HOLYSHEEP_DEV === '1') {
|
|
207
|
+
const forkDir = path.resolve(__dirname, '..', '..', 'aionui-fork')
|
|
208
|
+
if (isValidRuntimeDir(forkDir)) {
|
|
209
|
+
return { dir: forkDir, version: readVersion(forkDir), source: 'aionui-fork' }
|
|
210
|
+
}
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
// 4. Fresh install — download from env override OR baked-in default
|
|
@@ -45,8 +45,11 @@ const BRIDGE_CRED_FILE = path.join(BRIDGE_DIR, 'aionui-bridge.json')
|
|
|
45
45
|
const TOKEN_TTL_MS = 30_000
|
|
46
46
|
const INTERNAL_PORT_START = 9877
|
|
47
47
|
const INTERNAL_PORT_TRIES = 10
|
|
48
|
-
|
|
48
|
+
// First launch on Windows spends 30-40s in Defender + bun JIT + sqlite init,
|
|
49
|
+
// so 25s was flaking on cold boxes. Align with the old standalone spawn path.
|
|
50
|
+
const UPSTREAM_STARTUP_TIMEOUT_MS = Number(process.env.HS_WEB_STARTUP_TIMEOUT_MS) || 60_000
|
|
49
51
|
const UPSTREAM_CONNECT_TIMEOUT_MS = 30_000
|
|
52
|
+
const AIONUI_LOG_TAIL_BYTES = 4096
|
|
50
53
|
|
|
51
54
|
// Bootstrap token store — Map<token, { createdAt, used }>
|
|
52
55
|
const bootstrapTokens = new Map()
|
|
@@ -525,6 +528,14 @@ function buildRouter(ctx) {
|
|
|
525
528
|
if (route === '/api/holysheep/whoami' && req.method === 'GET') {
|
|
526
529
|
return await legacy().handleWhoami(req, res)
|
|
527
530
|
}
|
|
531
|
+
// One-click-all (SSE) — configure every installed tool in a single stream.
|
|
532
|
+
// Must sit next to `/api/holysheep/tool/*` so the Dashboard's
|
|
533
|
+
// "一键配置所有 CLI" button hits a real route instead of falling
|
|
534
|
+
// through to the default AionUi proxy (which returns 404/502 and
|
|
535
|
+
// surfaces as "Failed to load resource" in the console).
|
|
536
|
+
if (route === '/api/holysheep/setup' && req.method === 'POST') {
|
|
537
|
+
return await legacy().handleSetup(req, res)
|
|
538
|
+
}
|
|
528
539
|
// POST handlers: install, configure, reset, launch for a named tool
|
|
529
540
|
if (route === '/api/holysheep/tool/install' && req.method === 'POST') {
|
|
530
541
|
return await legacy().handleToolInstall(req, res)
|
|
@@ -586,7 +597,12 @@ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, b
|
|
|
586
597
|
const internalPort = await pickInternalPort()
|
|
587
598
|
log(`internal AionUi port: ${internalPort}`)
|
|
588
599
|
|
|
589
|
-
// Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set)
|
|
600
|
+
// Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set).
|
|
601
|
+
// stdio is piped (not inherited) so that:
|
|
602
|
+
// 1. When startup fails we can surface the last ~4KB of bun/AionUi
|
|
603
|
+
// output via the rejected `startWrapper` promise (previously silent).
|
|
604
|
+
// 2. In HS_WEB_DEBUG=1 mode we stream live logs prefixed with [aionui].
|
|
605
|
+
const debug = process.env.HS_WEB_DEBUG === '1'
|
|
590
606
|
const aionui = spawn(bunPath, ['dist-server/server.mjs'], {
|
|
591
607
|
cwd: runtimeDir,
|
|
592
608
|
env: {
|
|
@@ -596,14 +612,40 @@ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, b
|
|
|
596
612
|
ALLOW_REMOTE: '',
|
|
597
613
|
NODE_ENV: 'production',
|
|
598
614
|
},
|
|
599
|
-
stdio: ['ignore', '
|
|
615
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
600
616
|
})
|
|
617
|
+
|
|
618
|
+
let logTail = ''
|
|
619
|
+
const appendLog = (stream, chunk) => {
|
|
620
|
+
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
621
|
+
logTail += s
|
|
622
|
+
if (logTail.length > AIONUI_LOG_TAIL_BYTES) {
|
|
623
|
+
logTail = logTail.slice(logTail.length - AIONUI_LOG_TAIL_BYTES)
|
|
624
|
+
}
|
|
625
|
+
if (debug) {
|
|
626
|
+
const target = stream === 'err' ? process.stderr : process.stdout
|
|
627
|
+
target.write(`[aionui] ${s}`)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
aionui.stdout.on('data', (d) => appendLog('out', d))
|
|
631
|
+
aionui.stderr.on('data', (d) => appendLog('err', d))
|
|
601
632
|
aionui.on('exit', (code) => {
|
|
602
633
|
log(`AionUi upstream exited (code=${code})`)
|
|
603
634
|
process.exit(code || 1)
|
|
604
635
|
})
|
|
605
636
|
|
|
606
|
-
|
|
637
|
+
try {
|
|
638
|
+
await waitForUpstreamReady(internalPort)
|
|
639
|
+
} catch (e) {
|
|
640
|
+
const tail = logTail.trim()
|
|
641
|
+
const msg = tail
|
|
642
|
+
? `${e.message}\n\n --- last AionUi output (tail) ---\n${tail
|
|
643
|
+
.split(/\r?\n/)
|
|
644
|
+
.map((ln) => ` ${ln}`)
|
|
645
|
+
.join('\n')}\n ------------------------------------`
|
|
646
|
+
: e.message
|
|
647
|
+
throw new Error(msg)
|
|
648
|
+
}
|
|
607
649
|
log(`AionUi upstream ready (version=${runtimeVersion}, source=${runtimeSource})`)
|
|
608
650
|
|
|
609
651
|
const ctx = { internalPort, runtimeDir, runtimeVersion, runtimeSource, bunPath }
|
package/src/webui/server.js
CHANGED
|
@@ -74,6 +74,7 @@ function getVersionAsync(tool) {
|
|
|
74
74
|
'opencode': 'opencode --version',
|
|
75
75
|
'openclaw': 'openclaw --version',
|
|
76
76
|
'aider': 'aider --version',
|
|
77
|
+
'hermes': 'hermes --version',
|
|
77
78
|
}
|
|
78
79
|
const cmd = cmds[tool.id]
|
|
79
80
|
if (!cmd) return Promise.resolve(null)
|
|
@@ -106,6 +107,7 @@ function getToolCommand(toolId) {
|
|
|
106
107
|
'opencode': 'opencode',
|
|
107
108
|
'openclaw': 'openclaw',
|
|
108
109
|
'aider': 'aider',
|
|
110
|
+
'hermes': 'hermes',
|
|
109
111
|
'env-config': null,
|
|
110
112
|
}
|
|
111
113
|
return cmds[toolId] ?? null
|
|
@@ -169,10 +171,28 @@ const AUTO_INSTALL = {
|
|
|
169
171
|
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
170
172
|
},
|
|
171
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
|
+
},
|
|
172
183
|
'gemini-cli': { cmd: 'npm install -g @google/gemini-cli' },
|
|
173
184
|
'opencode': { cmd: 'npm install -g opencode-ai' },
|
|
174
185
|
'openclaw': { cmd: 'npm install -g openclaw@latest' },
|
|
175
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
|
+
},
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
// ── UPGRADABLE_TOOLS (from upgrade.js) ───────────────────────────────────────
|
|
@@ -187,10 +207,26 @@ const UPGRADABLE_TOOLS = [
|
|
|
187
207
|
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
188
208
|
},
|
|
189
209
|
{ name: 'Codex CLI', id: 'codex', command: 'codex', versionCmd: 'codex --version', npmPkg: '@openai/codex', installCmd: 'npm install -g @openai/codex@latest' },
|
|
190
|
-
{
|
|
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
|
+
},
|
|
191
221
|
{ name: 'OpenCode', id: 'opencode', command: 'opencode', versionCmd: 'opencode --version', npmPkg: 'opencode-ai', installCmd: 'npm install -g opencode-ai@latest' },
|
|
192
222
|
{ name: 'OpenClaw', id: 'openclaw', command: 'openclaw', versionCmd: 'openclaw --version', npmPkg: 'openclaw', installCmd: 'npm install -g openclaw@latest' },
|
|
193
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
|
+
},
|
|
194
230
|
]
|
|
195
231
|
|
|
196
232
|
// ── Update check (cached, refreshes every 30min) ────────────────────────────
|
|
@@ -295,8 +331,18 @@ async function handleWhoami(_req, res) {
|
|
|
295
331
|
async function handleBalance(_req, res) {
|
|
296
332
|
const apiKey = getApiKey()
|
|
297
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'
|
|
298
344
|
try {
|
|
299
|
-
const r = await fetchWithRetry(
|
|
345
|
+
const r = await fetchWithRetry(STATS_URL, {
|
|
300
346
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
301
347
|
})
|
|
302
348
|
if (r.status === 401) return json(res, { error: 'API Key 无效或已过期' }, 401)
|
|
@@ -307,6 +353,8 @@ async function handleBalance(_req, res) {
|
|
|
307
353
|
todayCost: Number(data.todayCost || 0),
|
|
308
354
|
monthCost: Number(data.monthCost || 0),
|
|
309
355
|
totalCalls: Number(data.totalCalls || 0),
|
|
356
|
+
totalCost: Number(data.totalCost || 0),
|
|
357
|
+
plans: Array.isArray(data.plans) ? data.plans : [],
|
|
310
358
|
})
|
|
311
359
|
} catch (e) {
|
|
312
360
|
const msg = e.code === 'EAI_AGAIN' ? 'DNS 解析失败,请检查网络' : e.message
|
|
@@ -597,6 +645,14 @@ async function handleToolInstall(req, res) {
|
|
|
597
645
|
if (!toolId || !AUTO_INSTALL[toolId]) {
|
|
598
646
|
return json(res, { error: '不支持自动安装此工具' }, 400)
|
|
599
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
|
+
}
|
|
600
656
|
|
|
601
657
|
sseStart(res)
|
|
602
658
|
sseEmit(res, { type: 'progress', message: `正在安装 ${toolId}...` })
|
|
@@ -615,6 +671,21 @@ async function handleToolInstall(req, res) {
|
|
|
615
671
|
child.on('error', () => { clearTimeout(timer); resolve(false) })
|
|
616
672
|
})
|
|
617
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 } = require('../utils/shell')
|
|
681
|
+
ensureWindowsUserPathHasNpmBin()
|
|
682
|
+
sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 npm global bin)\n' })
|
|
683
|
+
} catch {}
|
|
684
|
+
// Bust the tool-check cache so the follow-up /api/holysheep/tools sees
|
|
685
|
+
// the new binary without waiting 10s for the TTL.
|
|
686
|
+
toolStateCache.delete(toolId)
|
|
687
|
+
}
|
|
688
|
+
|
|
618
689
|
sseEmit(res, { type: 'done', success: ok })
|
|
619
690
|
res.end()
|
|
620
691
|
}
|