@simonyea/holysheep-cli 2.1.37 → 2.1.40
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 +3 -3
- package/src/tools/openclaw.js +109 -3
- package/src/utils/which.js +122 -9
- package/src/webui/aionui-runtime-fetcher.js +3 -3
- package/src/webui/server.js +6 -0
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "2.1.
|
|
4
|
-
"description": "Claude Code/Cursor/Cline API relay for China
|
|
3
|
+
"version": "2.1.40",
|
|
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 && node tests/hermes.test.js && node tests/preflight.test.js && node tests/opencode-auth-purge.test.js && node tests/shell-winpath.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 && node tests/preflight.test.js && node tests/opencode-auth-purge.test.js && node tests/shell-winpath.test.js && node tests/openclaw-atomic-write.test.js",
|
|
7
7
|
"prepublishOnly": "node scripts/check-tarball-size.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
package/src/tools/openclaw.js
CHANGED
|
@@ -25,6 +25,96 @@ const OPENCLAW_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
|
|
|
25
25
|
const OPENCLAW_DEFAULT_MINIMAX_MODEL = 'MiniMax-M2.7-highspeed'
|
|
26
26
|
const OPENCLAW_PROVIDER_NAME = 'holysheep'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* [HolySheep fork v2.1.38 / hs26] Atomic JSON write.
|
|
30
|
+
*
|
|
31
|
+
* Background: `~/.openclaw/` accumulated 30+ `openclaw.json.clobbered.*`
|
|
32
|
+
* backup files (mtime span hours apart, content identical) — OpenClaw's
|
|
33
|
+
* own config layer detected that two processes raced `writeFileSync`
|
|
34
|
+
* against the same path and renamed the half-written file aside to avoid
|
|
35
|
+
* corruption. Each race produced a backup.
|
|
36
|
+
*
|
|
37
|
+
* Root cause: `fs.writeFileSync(path, data)` is NOT atomic — it opens +
|
|
38
|
+
* truncates + writes; a concurrent reader/writer can observe a partial
|
|
39
|
+
* file or overwrite between truncation and final bytes. POSIX
|
|
40
|
+
* `rename(tmp, final)` IS atomic on the same filesystem, so we:
|
|
41
|
+
* 1. Write to `${final}.tmp.${pid}.${rand}`
|
|
42
|
+
* 2. `fs.renameSync(tmp, final)`
|
|
43
|
+
* Windows: `rename` fails if target exists → retry via copyFile + unlink.
|
|
44
|
+
*
|
|
45
|
+
* Never leaves the final path in a half-written state. If two procs race,
|
|
46
|
+
* one's write wins atomically and the other's wins the next one; no
|
|
47
|
+
* .clobbered.* files get produced by OpenClaw.
|
|
48
|
+
*/
|
|
49
|
+
function atomicWriteJson(filePath, data) {
|
|
50
|
+
const dir = path.dirname(filePath)
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
52
|
+
const body = JSON.stringify(data, null, 2)
|
|
53
|
+
const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`
|
|
54
|
+
fs.writeFileSync(tmp, body, 'utf8')
|
|
55
|
+
try {
|
|
56
|
+
fs.renameSync(tmp, filePath)
|
|
57
|
+
return
|
|
58
|
+
} catch (renameErr) {
|
|
59
|
+
// Windows: rename fails if target exists. Fall back to copy+unlink.
|
|
60
|
+
// Also handles cross-device rename (EXDEV) in edge cases.
|
|
61
|
+
if (process.platform === 'win32' || renameErr.code === 'EXDEV' || renameErr.code === 'EEXIST') {
|
|
62
|
+
try {
|
|
63
|
+
fs.copyFileSync(tmp, filePath)
|
|
64
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
65
|
+
return
|
|
66
|
+
} catch (copyErr) {
|
|
67
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
68
|
+
throw copyErr
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
try { fs.unlinkSync(tmp) } catch {}
|
|
72
|
+
throw renameErr
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* [HolySheep fork v2.1.38 / hs26] Prune stale OpenClaw config backup files.
|
|
78
|
+
*
|
|
79
|
+
* Removes `~/.openclaw/openclaw.json.clobbered.*` older than 7 days. These
|
|
80
|
+
* are produced by OpenClaw itself when a racy `writeFileSync` is detected,
|
|
81
|
+
* but once our atomicWriteJson is in place no new ones should appear — this
|
|
82
|
+
* cleanup just garbage-collects the historical accumulation without touching
|
|
83
|
+
* `*.last-good` / `*.bak` / `*.pre-*` which users or other tooling may need.
|
|
84
|
+
*
|
|
85
|
+
* Safe: scoped to ONLY the exact glob `openclaw.json.clobbered.*` in the
|
|
86
|
+
* known OpenClaw dir. No-op if dir doesn't exist or user has no matching
|
|
87
|
+
* files. Best-effort (each unlink wrapped in try) so a single locked file
|
|
88
|
+
* won't block the rest of configure().
|
|
89
|
+
*/
|
|
90
|
+
function pruneClobberedBackups(maxAgeMs = 7 * 24 * 3600 * 1000) {
|
|
91
|
+
try {
|
|
92
|
+
if (!fs.existsSync(OPENCLAW_DIR)) return { scanned: 0, removed: 0 }
|
|
93
|
+
const entries = fs.readdirSync(OPENCLAW_DIR)
|
|
94
|
+
const cutoff = Date.now() - maxAgeMs
|
|
95
|
+
let scanned = 0
|
|
96
|
+
let removed = 0
|
|
97
|
+
for (const name of entries) {
|
|
98
|
+
if (!/^openclaw\.json\.clobbered\./.test(name)) continue
|
|
99
|
+
scanned++
|
|
100
|
+
const abs = path.join(OPENCLAW_DIR, name)
|
|
101
|
+
try {
|
|
102
|
+
const st = fs.statSync(abs)
|
|
103
|
+
if (st.mtimeMs < cutoff) {
|
|
104
|
+
fs.unlinkSync(abs)
|
|
105
|
+
removed++
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// locked / race — skip
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { scanned, removed }
|
|
112
|
+
} catch {
|
|
113
|
+
return { scanned: 0, removed: 0 }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
28
118
|
function getOpenClawBinaryCandidates() {
|
|
29
119
|
return isWin ? ['openclaw.cmd', 'openclaw'] : ['openclaw']
|
|
30
120
|
}
|
|
@@ -161,8 +251,8 @@ function readBridgeConfig() {
|
|
|
161
251
|
}
|
|
162
252
|
|
|
163
253
|
function writeBridgeConfig(data) {
|
|
164
|
-
|
|
165
|
-
|
|
254
|
+
// [HolySheep fork v2.1.38 / hs26] Atomic write — see atomicWriteJson.
|
|
255
|
+
atomicWriteJson(BRIDGE_CONFIG_FILE, data)
|
|
166
256
|
}
|
|
167
257
|
|
|
168
258
|
function updateBridgeConfig(patch) {
|
|
@@ -521,7 +611,9 @@ function writeManagedConfig(baseConfig, bridgeBaseUrl, apiKey, primaryModel, sel
|
|
|
521
611
|
},
|
|
522
612
|
}
|
|
523
613
|
|
|
524
|
-
|
|
614
|
+
// [HolySheep fork v2.1.38 / hs26] Atomic write — prevents the
|
|
615
|
+
// openclaw.json.clobbered.* pile-up from racing writeFileSync.
|
|
616
|
+
atomicWriteJson(CONFIG_FILE, nextConfig)
|
|
525
617
|
return plan
|
|
526
618
|
}
|
|
527
619
|
|
|
@@ -621,6 +713,17 @@ module.exports = {
|
|
|
621
713
|
const chalk = require('chalk')
|
|
622
714
|
console.log(chalk.gray('\n ⚙️ 正在配置 OpenClaw...'))
|
|
623
715
|
|
|
716
|
+
// [HolySheep fork v2.1.38 / hs26] Garbage-collect stale OpenClaw
|
|
717
|
+
// backup files from pre-atomic-write builds. Scoped to exact glob
|
|
718
|
+
// `openclaw.json.clobbered.*` older than 7 days — leaves .last-good
|
|
719
|
+
// and .bak files alone.
|
|
720
|
+
try {
|
|
721
|
+
const pruned = pruneClobberedBackups()
|
|
722
|
+
if (pruned.removed > 0) {
|
|
723
|
+
console.log(chalk.gray(` → 已清理 ${pruned.removed} 个过期的 OpenClaw 配置备份(>7 天的 .clobbered.* 文件)`))
|
|
724
|
+
}
|
|
725
|
+
} catch {}
|
|
726
|
+
|
|
624
727
|
const runtime = detectRuntime()
|
|
625
728
|
if (!runtime.available) {
|
|
626
729
|
throw new Error('未检测到 OpenClaw;请先全局安装,或确保 npx 可用')
|
|
@@ -816,4 +919,7 @@ module.exports = {
|
|
|
816
919
|
},
|
|
817
920
|
installCmd: 'npm install -g openclaw@latest',
|
|
818
921
|
docsUrl: 'https://docs.openclaw.ai',
|
|
922
|
+
// [HolySheep fork v2.1.38 / hs26] Test-only exports.
|
|
923
|
+
_atomicWriteJson: atomicWriteJson,
|
|
924
|
+
_pruneClobberedBackups: pruneClobberedBackups,
|
|
819
925
|
}
|
package/src/utils/which.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 跨平台检测命令是否存在
|
|
3
3
|
* Windows 用 where,Unix 用 which,兜底用 --version
|
|
4
|
+
*
|
|
5
|
+
* [HolySheep fork v2.1.40 / hs27] Windows-specific fallbacks.
|
|
6
|
+
*
|
|
7
|
+
* Background: some installers (notably Claude Code's Windows installer
|
|
8
|
+
* irm https://claude.ai/install.ps1 | iex) drop their binary into
|
|
9
|
+
* `%USERPROFILE%\.local\bin\` and print a warning that this directory
|
|
10
|
+
* is NOT on PATH. The *current* Node process started by hs web has a
|
|
11
|
+
* frozen process.env.PATH snapshot, so even after
|
|
12
|
+
* ensureWindowsUserPathHasLocalBin() persists the User PATH in the
|
|
13
|
+
* registry, `where claude` still reports "not found" until the next
|
|
14
|
+
* terminal is opened. The UI then shows 未安装 despite a successful
|
|
15
|
+
* install.
|
|
16
|
+
*
|
|
17
|
+
* Fix strategy (Windows only):
|
|
18
|
+
* 1. Enumerate known install locations as a fallback AFTER `where` fails:
|
|
19
|
+
* %USERPROFILE%\.local\bin\<cmd>.exe (Claude official installer)
|
|
20
|
+
* %APPDATA%\npm\<cmd>.cmd (npm global bin)
|
|
21
|
+
* %LOCALAPPDATA%\Programs\<cmd>\<cmd>.exe
|
|
22
|
+
* %LOCALAPPDATA%\<cmd>\<cmd>.exe
|
|
23
|
+
* 2. Also re-read the USER-level PATH from the registry via PowerShell
|
|
24
|
+
* so we catch updates that landed after this process started.
|
|
4
25
|
*/
|
|
26
|
+
const path = require('path')
|
|
27
|
+
const fs = require('fs')
|
|
5
28
|
const { exec, execSync } = require('child_process')
|
|
6
29
|
|
|
7
30
|
function canRun(command, options = {}) {
|
|
@@ -13,6 +36,82 @@ function canRun(command, options = {}) {
|
|
|
13
36
|
}
|
|
14
37
|
}
|
|
15
38
|
|
|
39
|
+
function canRunAsync(command, options = {}) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
exec(command, { timeout: 3000, windowsHide: true, ...options }, (error) => {
|
|
42
|
+
resolve(!error)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// [HolySheep fork v2.1.40 / hs27]
|
|
48
|
+
// Known absolute install locations per-CLI on Windows. Checked AFTER
|
|
49
|
+
// PATH lookup fails but BEFORE we report "not found". Keeping this list
|
|
50
|
+
// small on purpose — only locations that official installers actually use.
|
|
51
|
+
function getWindowsKnownPaths(cmd) {
|
|
52
|
+
if (process.platform !== 'win32') return []
|
|
53
|
+
const home = process.env.USERPROFILE || process.env.HOME || ''
|
|
54
|
+
const appData = process.env.APPDATA || ''
|
|
55
|
+
const localAppData = process.env.LOCALAPPDATA || ''
|
|
56
|
+
const paths = []
|
|
57
|
+
// Claude official installer → %USERPROFILE%\.local\bin\claude.exe
|
|
58
|
+
if (home) paths.push(path.join(home, '.local', 'bin', `${cmd}.exe`))
|
|
59
|
+
// npm global → %APPDATA%\npm\<cmd>.{cmd,exe,ps1}
|
|
60
|
+
if (appData) {
|
|
61
|
+
paths.push(path.join(appData, 'npm', `${cmd}.cmd`))
|
|
62
|
+
paths.push(path.join(appData, 'npm', `${cmd}.exe`))
|
|
63
|
+
}
|
|
64
|
+
// Programs directories → %LOCALAPPDATA%\Programs\<cmd>\<cmd>.exe
|
|
65
|
+
if (localAppData) {
|
|
66
|
+
paths.push(path.join(localAppData, 'Programs', cmd, `${cmd}.exe`))
|
|
67
|
+
paths.push(path.join(localAppData, cmd, `${cmd}.exe`))
|
|
68
|
+
paths.push(path.join(localAppData, 'factory', `${cmd}.exe`)) // droid
|
|
69
|
+
}
|
|
70
|
+
return paths
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkKnownPathsSync(cmd) {
|
|
74
|
+
for (const p of getWindowsKnownPaths(cmd)) {
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(p)) return p
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Refresh this process's PATH from the USER-level registry PATH. Cheap — only
|
|
83
|
+
// runs once per commandExists miss. Noop on non-Windows. Guarded by a module
|
|
84
|
+
// flag so we don't run it more than once per short window.
|
|
85
|
+
let _userPathRefreshedAt = 0
|
|
86
|
+
function refreshWindowsUserPath() {
|
|
87
|
+
if (process.platform !== 'win32') return
|
|
88
|
+
const now = Date.now()
|
|
89
|
+
if (now - _userPathRefreshedAt < 5000) return
|
|
90
|
+
_userPathRefreshedAt = now
|
|
91
|
+
try {
|
|
92
|
+
const out = execSync(
|
|
93
|
+
'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::GetEnvironmentVariable(\'Path\', \'User\')"',
|
|
94
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
|
|
95
|
+
).trim()
|
|
96
|
+
if (!out) return
|
|
97
|
+
const userParts = out.split(';').map((s) => s.trim()).filter(Boolean)
|
|
98
|
+
const curParts = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean)
|
|
99
|
+
const curSet = new Set(curParts.map((s) => s.toLowerCase()))
|
|
100
|
+
const added = []
|
|
101
|
+
for (const p of userParts) {
|
|
102
|
+
if (!curSet.has(p.toLowerCase())) {
|
|
103
|
+
curParts.push(p)
|
|
104
|
+
added.push(p)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (added.length) {
|
|
108
|
+
process.env.PATH = curParts.join(';')
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// noop
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
16
115
|
function commandExists(cmd) {
|
|
17
116
|
if (process.platform === 'win32') {
|
|
18
117
|
const variants = [cmd, `${cmd}.cmd`, `${cmd}.exe`, `${cmd}.bat`]
|
|
@@ -25,6 +124,15 @@ function commandExists(cmd) {
|
|
|
25
124
|
if (canRun(`cmd /d /s /c "${variant} --version"`, { timeout: 3000 })) return true
|
|
26
125
|
}
|
|
27
126
|
|
|
127
|
+
// [HolySheep fork v2.1.40 / hs27] Last-resort: re-read the USER-level
|
|
128
|
+
// registry PATH (catches recently installed binaries) and check a
|
|
129
|
+
// handful of known install locations before giving up.
|
|
130
|
+
refreshWindowsUserPath()
|
|
131
|
+
for (const variant of variants) {
|
|
132
|
+
if (canRun(`where ${variant}`)) return true
|
|
133
|
+
}
|
|
134
|
+
if (checkKnownPathsSync(cmd)) return true
|
|
135
|
+
|
|
28
136
|
return false
|
|
29
137
|
}
|
|
30
138
|
|
|
@@ -34,14 +142,6 @@ function commandExists(cmd) {
|
|
|
34
142
|
return canRun(`${cmd} --version`, { timeout: 3000 })
|
|
35
143
|
}
|
|
36
144
|
|
|
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
145
|
async function commandExistsAsync(cmd) {
|
|
46
146
|
if (process.platform === 'win32') {
|
|
47
147
|
const variants = [cmd, `${cmd}.cmd`, `${cmd}.exe`, `${cmd}.bat`]
|
|
@@ -53,6 +153,13 @@ async function commandExistsAsync(cmd) {
|
|
|
53
153
|
if (await canRunAsync(`cmd /d /s /c "${variant} --version"`)) return true
|
|
54
154
|
}
|
|
55
155
|
|
|
156
|
+
// [HolySheep fork v2.1.40 / hs27] Last-resort (see commandExists).
|
|
157
|
+
refreshWindowsUserPath()
|
|
158
|
+
for (const variant of variants) {
|
|
159
|
+
if (await canRunAsync(`where ${variant}`)) return true
|
|
160
|
+
}
|
|
161
|
+
if (checkKnownPathsSync(cmd)) return true
|
|
162
|
+
|
|
56
163
|
return false
|
|
57
164
|
}
|
|
58
165
|
|
|
@@ -60,4 +167,10 @@ async function commandExistsAsync(cmd) {
|
|
|
60
167
|
return canRunAsync(`${cmd} --version`)
|
|
61
168
|
}
|
|
62
169
|
|
|
63
|
-
module.exports = {
|
|
170
|
+
module.exports = {
|
|
171
|
+
commandExists,
|
|
172
|
+
commandExistsAsync,
|
|
173
|
+
// test-only exports
|
|
174
|
+
_getWindowsKnownPaths: getWindowsKnownPaths,
|
|
175
|
+
_refreshWindowsUserPath: refreshWindowsUserPath,
|
|
176
|
+
}
|
|
@@ -60,10 +60,10 @@ const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
|
|
|
60
60
|
// new CLI release, the next `hs web` invocation on user machines will detect
|
|
61
61
|
// the version drift and upgrade the cache in place.
|
|
62
62
|
const DEFAULT_RUNTIME_URL =
|
|
63
|
-
'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-hs27.tar.gz'
|
|
64
64
|
const DEFAULT_RUNTIME_SHA256 =
|
|
65
|
-
'
|
|
66
|
-
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-
|
|
65
|
+
'de9cb08d071d1254f8e6ad69f249d73d8b1b8fd5c4a07ca6d6009a902e0cccb1'
|
|
66
|
+
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs27'
|
|
67
67
|
|
|
68
68
|
function isValidRuntimeDir(dir) {
|
|
69
69
|
if (!dir) return false
|
package/src/webui/server.js
CHANGED
|
@@ -776,6 +776,12 @@ async function handleToolConfigure(req, res) {
|
|
|
776
776
|
await new Promise((resolve) => {
|
|
777
777
|
child.on('exit', (code) => {
|
|
778
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
|
+
}
|
|
779
785
|
sseEmit(res, {
|
|
780
786
|
type: 'done',
|
|
781
787
|
success,
|