@simonyea/holysheep-cli 2.1.35 → 2.1.37
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.
|
@@ -2,11 +2,6 @@ name: CI sanity
|
|
|
2
2
|
|
|
3
3
|
# [HolySheep fork] Minimal Gitea-side CI for holysheep-cli.
|
|
4
4
|
# Mirrors the sanity check pattern from holysheep-webui.
|
|
5
|
-
# Runs on every push to main, guards against regressions in:
|
|
6
|
-
# - package.json version format
|
|
7
|
-
# - runtime fetcher URL/SHA/VERSION consistency
|
|
8
|
-
# - key source files present (claude-process-proxy, pty-hermes-wrapper, droid)
|
|
9
|
-
# - aionui-wrapper brand strings (must not contain [aionui-wrapper] prefix)
|
|
10
5
|
|
|
11
6
|
on:
|
|
12
7
|
push:
|
|
@@ -73,19 +68,26 @@ jobs:
|
|
|
73
68
|
run: |
|
|
74
69
|
set -e
|
|
75
70
|
cd /opt/act_runner/deploy-cache/holysheep-cli
|
|
76
|
-
URL
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
# The URL value is on the line AFTER 'const DEFAULT_RUNTIME_URL ='
|
|
72
|
+
# Use node to read the values reliably
|
|
73
|
+
URL_TAG=$(node -e "
|
|
74
|
+
const fs = require('fs');
|
|
75
|
+
const content = fs.readFileSync('src/webui/aionui-runtime-fetcher.js', 'utf8');
|
|
76
|
+
const urlMatch = content.match(/DEFAULT_RUNTIME_URL\s*=\s*[\r\n\s]*'[^']*holysheep-(hs\d+)[^']*'/);
|
|
77
|
+
const verMatch = content.match(/DEFAULT_RUNTIME_VERSION\s*=\s*'[^']*holysheep-(hs\d+)[^']*'/);
|
|
78
|
+
if (!urlMatch || !verMatch) { process.stderr.write('PARSE_FAIL'); process.exit(1); }
|
|
79
|
+
if (urlMatch[1] !== verMatch[1]) {
|
|
80
|
+
process.stderr.write('MISMATCH: URL=' + urlMatch[1] + ' VER=' + verMatch[1]);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log(urlMatch[1]);
|
|
84
|
+
" 2>&1)
|
|
85
|
+
echo "URL/VERSION tag: $URL_TAG"
|
|
86
|
+
if [ -z "$URL_TAG" ] || echo "$URL_TAG" | grep -q "FAIL\|MISMATCH"; then
|
|
87
|
+
echo "::error::DEFAULT_RUNTIME_URL and DEFAULT_RUNTIME_VERSION hs-tags mismatch: $URL_TAG"
|
|
82
88
|
exit 1
|
|
83
89
|
fi
|
|
84
|
-
|
|
85
|
-
echo "::error::URL tag ($URL) != VERSION tag ($VER) — run pack-runtime.mjs and update fetcher"
|
|
86
|
-
exit 1
|
|
87
|
-
fi
|
|
88
|
-
echo " ok: URL and VERSION both reference $URL"
|
|
90
|
+
echo " ok: URL and VERSION both reference $URL_TAG"
|
|
89
91
|
|
|
90
92
|
- name: Invariant grep (brand + proxy)
|
|
91
93
|
run: |
|
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.37",
|
|
4
|
+
"description": "Claude Code/Cursor/Cline API relay for China \u2014 \u00a51=$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",
|
|
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",
|
|
7
7
|
"prepublishOnly": "node scripts/check-tarball-size.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
@@ -100,12 +100,34 @@ function createClientValidationErrorBody(message) {
|
|
|
100
100
|
})
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// [HolySheep fork v2.1.36 / hs24] This is the ONLY trigger for switching to a
|
|
104
|
+
// different node (via fetchFreshLease({forceReassign:true}) → control plane
|
|
105
|
+
// drops Redis stickiness → chooseNode round-robins). It is NOT a generic retry
|
|
106
|
+
// trigger — same-node retries don't need this.
|
|
107
|
+
//
|
|
108
|
+
// Previous versions returned true for any 403/503, which made the CLI tear
|
|
109
|
+
// down 5-min session stickiness on every transient upstream hiccup (busy
|
|
110
|
+
// account, momentary scheduler capacity, ticket timing) and bounced the
|
|
111
|
+
// session node3↔node4↔node3 repeatedly. Bug surfaced as repeated lease.close
|
|
112
|
+
// log lines and ~5s stalls.
|
|
113
|
+
//
|
|
114
|
+
// Fix: only force-reassign when upstream sends an EXPLICIT "switch node"
|
|
115
|
+
// signal in the body. Other errors (403/503/ECONNREFUSED) stay on the same
|
|
116
|
+
// node — the retry loop will either succeed after upstream recovers, or fall
|
|
117
|
+
// through to direct-https fallback.
|
|
118
|
+
//
|
|
119
|
+
// IMPORTANT: we deliberately do NOT match bare `client_validation_error` —
|
|
120
|
+
// forwardViaNodeProxy wraps local forward errors (ECONNREFUSED etc.) in a
|
|
121
|
+
// client_validation_error body (see createClientValidationErrorBody), so
|
|
122
|
+
// matching that type alone would fire false positives on local-level errors
|
|
123
|
+
// that have nothing to do with node health. We only match specific messages
|
|
124
|
+
// that upstream emits when it explicitly wants the CLI to pick a new node:
|
|
125
|
+
// - homi-crs internalRoutes.js:1025 ("当前代理节点 X 不可用")
|
|
126
|
+
// - homi-crs auth.js / internalRoutes.js ("Claude Code 必须使用 hs claude 指令启动")
|
|
127
|
+
// - control plane server.js ("No active Claude relay nodes are available")
|
|
103
128
|
function shouldRefreshLeaseAfterError(err) {
|
|
104
|
-
const statusCode = Number(err?.statusCode || 0)
|
|
105
129
|
const body = String(err?.body || err?.message || '')
|
|
106
|
-
if (statusCode === 403 || statusCode === 503) return true
|
|
107
130
|
return (
|
|
108
|
-
body.includes('client_validation_error') ||
|
|
109
131
|
body.includes('Claude Code 必须使用 hs claude 指令启动') ||
|
|
110
132
|
body.includes('当前代理节点') ||
|
|
111
133
|
body.includes('No active Claude relay nodes are available')
|
|
@@ -748,12 +770,27 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH, allowAn
|
|
|
748
770
|
await doForward(getCachedLease(sessionId), attempt)
|
|
749
771
|
} else {
|
|
750
772
|
const config = readConfig(configPath)
|
|
773
|
+
const prevLease = (() => { try { return leaseCache.get(sessionId) } catch { return null } })()
|
|
774
|
+
const forceReassign = shouldRefreshLeaseAfterError(lastError)
|
|
775
|
+
const retryReason = String(lastError?.body || lastError?.message || '').slice(0, 120)
|
|
776
|
+
// [HolySheep fork v2.1.36 / hs24] Emit structured breadcrumb so we
|
|
777
|
+
// can see whether the retry stayed on same node (sticky) or forced
|
|
778
|
+
// reassign. Previously invisible — made diagnosing node-bounce hard.
|
|
779
|
+
if (forceReassign) {
|
|
780
|
+
console.error(`[hs-claude-proxy] lease.force-reassign ${JSON.stringify({
|
|
781
|
+
sessionId, nodeId: prevLease?.nodeId || '', attempt, reason: retryReason,
|
|
782
|
+
})}`)
|
|
783
|
+
} else {
|
|
784
|
+
console.error(`[hs-claude-proxy] lease.sticky-hit ${JSON.stringify({
|
|
785
|
+
sessionId, nodeId: prevLease?.nodeId || '', attempt, retryReason,
|
|
786
|
+
})}`)
|
|
787
|
+
}
|
|
751
788
|
leaseCache.delete(sessionId)
|
|
752
|
-
if (
|
|
789
|
+
if (forceReassign) {
|
|
753
790
|
await closeSession(configPath, sessionId)
|
|
754
791
|
}
|
|
755
792
|
const freshLease = await fetchFreshLease(config, sessionId, {
|
|
756
|
-
forceReassign
|
|
793
|
+
forceReassign,
|
|
757
794
|
})
|
|
758
795
|
await doForward(freshLease, attempt)
|
|
759
796
|
}
|
|
@@ -875,12 +912,25 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH, allowAn
|
|
|
875
912
|
await doConnect(getCachedLease(sessionId))
|
|
876
913
|
} else {
|
|
877
914
|
const config = readConfig(configPath)
|
|
915
|
+
const prevLease = (() => { try { return leaseCache.get(sessionId) } catch { return null } })()
|
|
916
|
+
const forceReassign = shouldRefreshLeaseAfterError(lastError)
|
|
917
|
+
const retryReason = String(lastError?.body || lastError?.message || '').slice(0, 120)
|
|
918
|
+
// [HolySheep fork v2.1.36 / hs24] Same breadcrumb as HTTP retry loop.
|
|
919
|
+
if (forceReassign) {
|
|
920
|
+
console.error(`[hs-claude-proxy] lease.force-reassign ${JSON.stringify({
|
|
921
|
+
sessionId, nodeId: prevLease?.nodeId || '', attempt, reason: retryReason, path: 'CONNECT',
|
|
922
|
+
})}`)
|
|
923
|
+
} else {
|
|
924
|
+
console.error(`[hs-claude-proxy] lease.sticky-hit ${JSON.stringify({
|
|
925
|
+
sessionId, nodeId: prevLease?.nodeId || '', attempt, retryReason, path: 'CONNECT',
|
|
926
|
+
})}`)
|
|
927
|
+
}
|
|
878
928
|
leaseCache.delete(sessionId)
|
|
879
|
-
if (
|
|
929
|
+
if (forceReassign) {
|
|
880
930
|
await closeSession(configPath, sessionId)
|
|
881
931
|
}
|
|
882
932
|
const freshLease = await fetchFreshLease(config, sessionId, {
|
|
883
|
-
forceReassign
|
|
933
|
+
forceReassign,
|
|
884
934
|
})
|
|
885
935
|
await doConnect(freshLease)
|
|
886
936
|
}
|
package/src/tools/opencode.js
CHANGED
|
@@ -70,6 +70,73 @@ function writeConfig(data) {
|
|
|
70
70
|
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* [HolySheep fork v2.1.36 / hs25] OpenCode OAuth credential store paths.
|
|
75
|
+
* OpenCode >=1.3.0 keeps `opencode auth login` tokens in auth.json. If an
|
|
76
|
+
* Anthropic OAuth access token lives here, it OVERRIDES the config-file
|
|
77
|
+
* `baseURL`, routing requests straight to api.anthropic.com — bypassing the
|
|
78
|
+
* HolySheep relay. Symptom in the wild:
|
|
79
|
+
* "Third-party apps now draw from your extra usage, not your plan limits."
|
|
80
|
+
* which is Anthropic's plan-limit string, i.e. the request never hit us.
|
|
81
|
+
*/
|
|
82
|
+
function getAuthFileCandidates() {
|
|
83
|
+
const home = os.homedir()
|
|
84
|
+
if (process.platform === 'win32') {
|
|
85
|
+
return [
|
|
86
|
+
path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'opencode', 'auth.json'),
|
|
87
|
+
path.join(home, '.local', 'share', 'opencode', 'auth.json'),
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
const xdg = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
|
|
91
|
+
return [
|
|
92
|
+
path.join(xdg, 'opencode', 'auth.json'),
|
|
93
|
+
path.join(home, '.local', 'share', 'opencode', 'auth.json'),
|
|
94
|
+
path.join(home, 'Library', 'Application Support', 'opencode', 'auth.json'),
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Strip `anthropic` and `openai` OAuth tokens from OpenCode's auth.json.
|
|
100
|
+
* Uses `delete` on individual keys so other provider logins (github,
|
|
101
|
+
* deepseek, zhipu, …) survive. Deletes the whole file if empty after purge.
|
|
102
|
+
* Returns { touched: string[], keysAfter: string[] } for test assertions.
|
|
103
|
+
*/
|
|
104
|
+
function purgeOauthAuth() {
|
|
105
|
+
const touched = []
|
|
106
|
+
let keysAfter = []
|
|
107
|
+
for (const file of getAuthFileCandidates()) {
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(file)) continue
|
|
110
|
+
const raw = fs.readFileSync(file, 'utf8')
|
|
111
|
+
let data
|
|
112
|
+
try { data = JSON.parse(raw) } catch { continue }
|
|
113
|
+
if (!data || typeof data !== 'object') continue
|
|
114
|
+
|
|
115
|
+
let changed = false
|
|
116
|
+
for (const key of ['anthropic', 'openai']) {
|
|
117
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
118
|
+
delete data[key]
|
|
119
|
+
changed = true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!changed) continue
|
|
123
|
+
|
|
124
|
+
touched.push(file)
|
|
125
|
+
const remaining = Object.keys(data)
|
|
126
|
+
keysAfter = remaining
|
|
127
|
+
if (remaining.length === 0) {
|
|
128
|
+
try { fs.unlinkSync(file) } catch {}
|
|
129
|
+
} else {
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8')
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// best-effort
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { touched, keysAfter }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
73
140
|
module.exports = {
|
|
74
141
|
name: 'OpenCode',
|
|
75
142
|
id: 'opencode',
|
|
@@ -84,6 +151,10 @@ module.exports = {
|
|
|
84
151
|
)
|
|
85
152
|
},
|
|
86
153
|
configure(apiKey, baseUrlAnthropicNoV1, baseUrlOpenAI, primaryModel) {
|
|
154
|
+
// [HolySheep fork v2.1.36 / hs25] Strip any stale Anthropic/OpenAI OAuth
|
|
155
|
+
// tokens from OpenCode's auth.json BEFORE writing the config file, so
|
|
156
|
+
// the relay baseURL in opencode.json actually takes effect.
|
|
157
|
+
try { purgeOauthAuth() } catch {}
|
|
87
158
|
const config = readConfig()
|
|
88
159
|
if (!config.provider) config.provider = {}
|
|
89
160
|
|
|
@@ -141,10 +212,16 @@ module.exports = {
|
|
|
141
212
|
delete config.provider.openai
|
|
142
213
|
}
|
|
143
214
|
writeConfig(config)
|
|
215
|
+
// [HolySheep fork v2.1.36 / hs25] Also purge OAuth tokens so a subsequent
|
|
216
|
+
// reconfigure doesn't silently fall back to direct-to-Anthropic.
|
|
217
|
+
try { purgeOauthAuth() } catch {}
|
|
144
218
|
},
|
|
145
219
|
getConfigPath() { return getConfigFile() },
|
|
146
220
|
hint: '切换后重启 OpenCode 生效;配置文件: ~/.config/opencode/opencode.json',
|
|
147
221
|
launchCmd: 'opencode',
|
|
148
222
|
installCmd: 'brew install anomalyco/tap/opencode # 或: npm i -g opencode-ai@latest',
|
|
149
223
|
docsUrl: 'https://opencode.ai',
|
|
224
|
+
// [HolySheep fork v2.1.36 / hs25] Exposed for tests — see tests/opencode-auth-purge.test.js
|
|
225
|
+
_purgeOauthAuth: purgeOauthAuth,
|
|
226
|
+
_getAuthFileCandidates: getAuthFileCandidates,
|
|
150
227
|
}
|
package/src/utils/shell.js
CHANGED
|
@@ -116,6 +116,83 @@ function ensureWindowsUserPathHasNpmBin() {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* [HolySheep fork v2.1.36 / hs25] Same mechanism as
|
|
121
|
+
* `ensureWindowsUserPathHasNpmBin`, but targets `%USERPROFILE%\.local\bin`.
|
|
122
|
+
*
|
|
123
|
+
* Background: Claude Code's official Windows installer (irm
|
|
124
|
+
* https://claude.ai/install.ps1 | iex) drops `claude.exe` under
|
|
125
|
+
* `C:\Users\<user>\.local\bin` and prints a warning that this directory
|
|
126
|
+
* is NOT on PATH. Our CLI Manager page then calls `commandExists('claude')`
|
|
127
|
+
* which runs `where claude` — finds nothing — and reports 未安装, even
|
|
128
|
+
* though the install succeeded.
|
|
129
|
+
*
|
|
130
|
+
* This helper:
|
|
131
|
+
* 1. Persists `.local\bin` into the USER-level Path (so new terminals
|
|
132
|
+
* see it too).
|
|
133
|
+
* 2. Appends it to `process.env.PATH` of the current process, so that
|
|
134
|
+
* the very next `commandExists('claude')` call in the same Node
|
|
135
|
+
* instance resolves correctly — no restart required.
|
|
136
|
+
*
|
|
137
|
+
* Returns a list of human-readable lines describing what was touched.
|
|
138
|
+
* No-op on non-Windows (returns []).
|
|
139
|
+
*/
|
|
140
|
+
function ensureWindowsUserPathHasLocalBin() {
|
|
141
|
+
if (process.platform !== 'win32') return []
|
|
142
|
+
|
|
143
|
+
const userProfile = process.env.USERPROFILE || os.homedir()
|
|
144
|
+
if (!userProfile) return []
|
|
145
|
+
|
|
146
|
+
const localBin = path.join(userProfile, '.local', 'bin')
|
|
147
|
+
|
|
148
|
+
let currentPath = ''
|
|
149
|
+
try {
|
|
150
|
+
currentPath = execSync(
|
|
151
|
+
'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::GetEnvironmentVariable(\'Path\', \'User\')"',
|
|
152
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
153
|
+
).trim()
|
|
154
|
+
} catch {
|
|
155
|
+
currentPath = process.env.PATH || ''
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const parts = currentPath
|
|
159
|
+
.split(';')
|
|
160
|
+
.map((item) => item.trim())
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
|
|
163
|
+
const hasLocalBin = parts.some((item) => item.toLowerCase() === localBin.toLowerCase())
|
|
164
|
+
|
|
165
|
+
// Always also hot-patch the in-process PATH so the immediate follow-up
|
|
166
|
+
// `commandExists('claude')` check sees the new directory. This covers
|
|
167
|
+
// the (common) case where the persisted user PATH already has it but
|
|
168
|
+
// our own process was spawned before the PowerShell installer ran.
|
|
169
|
+
const procParts = (process.env.PATH || '').split(';').map((p) => p.trim()).filter(Boolean)
|
|
170
|
+
const inProcessHasIt = procParts.some((item) => item.toLowerCase() === localBin.toLowerCase())
|
|
171
|
+
if (!inProcessHasIt) {
|
|
172
|
+
process.env.PATH = [...procParts, localBin].join(';')
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (hasLocalBin) return inProcessHasIt ? [] : ['[当前进程 PATH] %USERPROFILE%\\.local\\bin']
|
|
176
|
+
|
|
177
|
+
const nextPath = [...parts, localBin].join(';')
|
|
178
|
+
try {
|
|
179
|
+
const escapedPath = nextPath.replace(/'/g, "''")
|
|
180
|
+
execSync(
|
|
181
|
+
`powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::SetEnvironmentVariable('Path', '${escapedPath}', 'User')"`,
|
|
182
|
+
{ stdio: 'ignore' }
|
|
183
|
+
)
|
|
184
|
+
return ['[用户 PATH] %USERPROFILE%\\.local\\bin']
|
|
185
|
+
} catch {
|
|
186
|
+
try {
|
|
187
|
+
const chalk = require('chalk')
|
|
188
|
+
console.warn(chalk.yellow(
|
|
189
|
+
` ⚠️ 无法自动更新 PATH,请手动将以下路径加入系统环境变量 PATH:\n ${localBin}`
|
|
190
|
+
))
|
|
191
|
+
} catch {}
|
|
192
|
+
return []
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
119
196
|
function installWindowsCliShims() {
|
|
120
197
|
if (process.platform !== 'win32') return []
|
|
121
198
|
|
|
@@ -259,6 +336,7 @@ module.exports = {
|
|
|
259
336
|
writeEnvToShell,
|
|
260
337
|
removeEnvFromShell,
|
|
261
338
|
ensureWindowsUserPathHasNpmBin,
|
|
339
|
+
ensureWindowsUserPathHasLocalBin,
|
|
262
340
|
installWindowsCliShims,
|
|
263
341
|
removeWindowsUserEnvVars
|
|
264
342
|
}
|
|
@@ -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-hs25.tar.gz'
|
|
64
64
|
const DEFAULT_RUNTIME_SHA256 =
|
|
65
|
-
'
|
|
66
|
-
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-
|
|
65
|
+
'9e2a47adf8a151c9ab6916d046ee9f268aa5f1589a961d1b0374b15508ed527b'
|
|
66
|
+
const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs25'
|
|
67
67
|
|
|
68
68
|
function isValidRuntimeDir(dir) {
|
|
69
69
|
if (!dir) return false
|
package/src/webui/server.js
CHANGED
|
@@ -677,9 +677,20 @@ async function handleToolInstall(req, res) {
|
|
|
677
677
|
// non-Windows or when already present.
|
|
678
678
|
if (ok && process.platform === 'win32') {
|
|
679
679
|
try {
|
|
680
|
-
const { ensureWindowsUserPathHasNpmBin } = require('../utils/shell')
|
|
680
|
+
const { ensureWindowsUserPathHasNpmBin, ensureWindowsUserPathHasLocalBin } = require('../utils/shell')
|
|
681
681
|
ensureWindowsUserPathHasNpmBin()
|
|
682
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
|
+
}
|
|
683
694
|
} catch {}
|
|
684
695
|
// Resolve the real install path via `where.exe` so the user sees
|
|
685
696
|
// exactly where the binary landed. This was a high-signal 2.1.14
|