@simonyea/holysheep-cli 1.7.53 → 1.7.55

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.
@@ -0,0 +1,621 @@
1
+ /**
2
+ * HolySheep WebUI — HTTP 服务器 + REST API
3
+ * 仅绑定 127.0.0.1,零新依赖
4
+ */
5
+ 'use strict'
6
+
7
+ const http = require('http')
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const { execSync, spawn } = require('child_process')
11
+ const { loadConfig, saveConfig, getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY, SHOP_URL, CONFIG_FILE } = require('../utils/config')
12
+ const { removeEnvFromShell, writeEnvToShell, getShellRcFiles } = require('../utils/shell')
13
+ const { commandExists } = require('../utils/which')
14
+ const TOOLS = require('../tools')
15
+ const pkg = require('../../package.json')
16
+
17
+ // ── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ function maskKey(key) {
20
+ if (!key || key.length < 8) return '****'
21
+ return key.slice(0, 6) + '...' + key.slice(-4)
22
+ }
23
+
24
+ async function validateApiKey(apiKey) {
25
+ const fetch = require('node-fetch')
26
+ const res = await fetch(`${BASE_URL_OPENAI}/models`, {
27
+ method: 'GET',
28
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
29
+ timeout: 10000,
30
+ })
31
+ return res.status === 200
32
+ }
33
+
34
+ function getVersion(tool) {
35
+ if (typeof tool.getVersion === 'function') return tool.getVersion()
36
+ const cmds = {
37
+ 'claude-code': 'claude --version',
38
+ 'codex': 'codex --version',
39
+ 'droid': 'droid --version',
40
+ 'gemini-cli': 'gemini --version',
41
+ 'opencode': 'opencode --version',
42
+ 'openclaw': 'openclaw --version',
43
+ 'aider': 'aider --version',
44
+ }
45
+ const cmd = cmds[tool.id]
46
+ if (!cmd) return null
47
+ try {
48
+ return execSync(cmd, { stdio: 'pipe', timeout: 10000 }).toString().trim().split('\n')[0].slice(0, 30)
49
+ } catch { return null }
50
+ }
51
+
52
+ function parseBody(req) {
53
+ return new Promise((resolve, reject) => {
54
+ let body = ''
55
+ req.on('data', chunk => { body += chunk })
56
+ req.on('end', () => {
57
+ try { resolve(body ? JSON.parse(body) : {}) }
58
+ catch { reject(new Error('Invalid JSON')) }
59
+ })
60
+ req.on('error', reject)
61
+ })
62
+ }
63
+
64
+ function json(res, data, status = 200) {
65
+ res.writeHead(status, { 'Content-Type': 'application/json' })
66
+ res.end(JSON.stringify(data))
67
+ }
68
+
69
+ function sseStart(res) {
70
+ res.writeHead(200, {
71
+ 'Content-Type': 'text/event-stream',
72
+ 'Cache-Control': 'no-cache',
73
+ 'Connection': 'keep-alive',
74
+ })
75
+ }
76
+
77
+ function sseEmit(res, data) {
78
+ res.write(`data: ${JSON.stringify(data)}\n\n`)
79
+ }
80
+
81
+ // ── AUTO_INSTALL map (from setup.js) ─────────────────────────────────────────
82
+
83
+ const AUTO_INSTALL = {
84
+ 'claude-code': {
85
+ cmd: process.platform === 'win32'
86
+ ? 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
87
+ : 'curl -fsSL https://claude.ai/install.sh | bash',
88
+ },
89
+ 'codex': { cmd: 'npm install -g @openai/codex' },
90
+ 'gemini-cli': { cmd: 'npm install -g @google/gemini-cli' },
91
+ 'opencode': { cmd: 'npm install -g opencode-ai' },
92
+ 'openclaw': { cmd: 'npm install -g openclaw@latest --ignore-scripts' },
93
+ 'aider': { cmd: 'pip install aider-chat' },
94
+ }
95
+
96
+ // ── UPGRADABLE_TOOLS (from upgrade.js) ───────────────────────────────────────
97
+
98
+ const UPGRADABLE_TOOLS = [
99
+ {
100
+ name: 'Claude Code', id: 'claude-code', command: 'claude',
101
+ versionCmd: 'claude --version', npmPkg: null,
102
+ installCmd: process.platform === 'win32'
103
+ ? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
104
+ : 'curl -fsSL https://claude.ai/install.sh | bash',
105
+ },
106
+ { name: 'Codex CLI', id: 'codex', command: 'codex', versionCmd: 'codex --version', npmPkg: '@openai/codex', installCmd: 'npm install -g @openai/codex@latest' },
107
+ { name: 'Gemini CLI', id: 'gemini-cli', command: 'gemini', versionCmd: 'gemini --version', npmPkg: '@google/gemini-cli', installCmd: 'npm install -g @google/gemini-cli@latest' },
108
+ ]
109
+
110
+ // ── API Handlers ─────────────────────────────────────────────────────────────
111
+
112
+ async function handleStatus(_req, res) {
113
+ const apiKey = getApiKey()
114
+ const config = loadConfig()
115
+ json(res, {
116
+ loggedIn: !!apiKey,
117
+ apiKey: apiKey ? maskKey(apiKey) : null,
118
+ savedAt: config.savedAt || null,
119
+ version: pkg.version,
120
+ })
121
+ }
122
+
123
+ async function handleLogin(req, res) {
124
+ const body = await parseBody(req)
125
+ const apiKey = (body.apiKey || '').trim()
126
+ if (!apiKey || !apiKey.startsWith('cr_')) {
127
+ return json(res, { success: false, message: 'API Key 必须以 cr_ 开头' }, 400)
128
+ }
129
+ try {
130
+ const valid = await validateApiKey(apiKey)
131
+ if (!valid) return json(res, { success: false, message: 'API Key 无效' }, 401)
132
+ saveConfig({ apiKey, savedAt: new Date().toISOString() })
133
+ json(res, { success: true, apiKey: maskKey(apiKey) })
134
+ } catch (e) {
135
+ json(res, { success: false, message: `验证失败: ${e.message}` }, 500)
136
+ }
137
+ }
138
+
139
+ async function handleLogout(_req, res) {
140
+ const config = loadConfig()
141
+ delete config.apiKey
142
+ delete config.savedAt
143
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2))
144
+ json(res, { success: true })
145
+ }
146
+
147
+ async function handleWhoami(_req, res) {
148
+ const apiKey = getApiKey()
149
+ const config = loadConfig()
150
+ if (!apiKey) return json(res, { loggedIn: false })
151
+ let valid = null
152
+ try { valid = await validateApiKey(apiKey) } catch {}
153
+ json(res, { loggedIn: true, apiKey: maskKey(apiKey), savedAt: config.savedAt || null, valid })
154
+ }
155
+
156
+ async function handleBalance(_req, res) {
157
+ const apiKey = getApiKey()
158
+ if (!apiKey) return json(res, { error: '未登录' }, 401)
159
+ try {
160
+ const fetch = require('node-fetch')
161
+ const r = await fetch(`${SHOP_URL}/api/stats/overview`, {
162
+ headers: { Authorization: `Bearer ${apiKey}` },
163
+ timeout: 8000,
164
+ })
165
+ if (r.status === 401) return json(res, { error: 'API Key 无效或已过期' }, 401)
166
+ if (!r.ok) return json(res, { error: `HTTP ${r.status}` }, r.status)
167
+ const data = await r.json()
168
+ json(res, {
169
+ balance: Number(data.balance || 0),
170
+ todayCost: Number(data.todayCost || 0),
171
+ monthCost: Number(data.monthCost || 0),
172
+ totalCalls: Number(data.totalCalls || 0),
173
+ })
174
+ } catch (e) {
175
+ json(res, { error: e.message }, 500)
176
+ }
177
+ }
178
+
179
+ async function handleDoctor(_req, res) {
180
+ const apiKey = getApiKey()
181
+ const nodeMajor = parseInt(process.version.slice(1), 10)
182
+
183
+ // Tools
184
+ const tools = TOOLS.map(t => {
185
+ const installed = t.checkInstalled()
186
+ return {
187
+ id: t.id,
188
+ name: t.name,
189
+ installed,
190
+ configured: installed ? (t.isConfigured?.() || false) : false,
191
+ version: installed ? getVersion(t) : null,
192
+ installCmd: t.installCmd,
193
+ hint: t.hint || null,
194
+ launchCmd: t.launchCmd || null,
195
+ }
196
+ })
197
+
198
+ // Connectivity
199
+ let connectivity = { ok: false, modelCount: 0 }
200
+ if (apiKey) {
201
+ try {
202
+ const fetch = require('node-fetch')
203
+ const r = await fetch(`${BASE_URL_ANTHROPIC}/v1/models`, {
204
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
205
+ timeout: 8000,
206
+ })
207
+ if (r.ok) {
208
+ const data = await r.json()
209
+ connectivity = { ok: true, modelCount: data.data?.length || 0 }
210
+ }
211
+ } catch {}
212
+ }
213
+
214
+ json(res, {
215
+ node: { version: process.version, ok: nodeMajor >= 16 },
216
+ apiKey: { set: !!apiKey, masked: apiKey ? maskKey(apiKey) : null },
217
+ envVars: {
218
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
219
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN || null,
220
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL || null,
221
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY || null,
222
+ OPENAI_BASE_URL: process.env.OPENAI_BASE_URL || null,
223
+ },
224
+ tools,
225
+ connectivity,
226
+ })
227
+ }
228
+
229
+ async function handleTools(_req, res) {
230
+ const tools = TOOLS.map(t => {
231
+ const installed = t.checkInstalled()
232
+ return {
233
+ id: t.id,
234
+ name: t.name,
235
+ installed,
236
+ configured: installed ? (t.isConfigured?.() || false) : false,
237
+ version: installed ? getVersion(t) : null,
238
+ installCmd: t.installCmd,
239
+ hint: t.hint || null,
240
+ launchCmd: t.launchCmd || null,
241
+ canAutoInstall: !!AUTO_INSTALL[t.id],
242
+ }
243
+ })
244
+ json(res, tools)
245
+ }
246
+
247
+ async function handleSetup(req, res) {
248
+ const body = await parseBody(req)
249
+ const { apiKey, models, toolIds, autoInstall } = body
250
+
251
+ sseStart(res)
252
+
253
+ // Step 1: Validate API Key — support using saved key
254
+ let effectiveKey = apiKey
255
+ if (apiKey === '__use_saved__') {
256
+ effectiveKey = getApiKey()
257
+ if (!effectiveKey) {
258
+ sseEmit(res, { type: 'error', message: '未找到已保存的 API Key' })
259
+ return res.end()
260
+ }
261
+ }
262
+ if (!effectiveKey || !effectiveKey.startsWith('cr_')) {
263
+ sseEmit(res, { type: 'error', message: 'API Key 必须以 cr_ 开头' })
264
+ return res.end()
265
+ }
266
+
267
+ sseEmit(res, { type: 'progress', step: 'validate', message: '验证 API Key...' })
268
+ try {
269
+ const valid = await validateApiKey(effectiveKey)
270
+ if (!valid) {
271
+ sseEmit(res, { type: 'error', message: 'API Key 无效' })
272
+ return res.end()
273
+ }
274
+ sseEmit(res, { type: 'progress', step: 'validate', status: 'ok', message: 'API Key 验证成功' })
275
+ } catch (e) {
276
+ sseEmit(res, { type: 'error', message: `验证失败: ${e.message}` })
277
+ return res.end()
278
+ }
279
+
280
+ // Step 2: Save API Key
281
+ saveConfig({ apiKey: effectiveKey, savedAt: new Date().toISOString() })
282
+ sseEmit(res, { type: 'progress', step: 'save-key', status: 'ok', message: 'API Key 已保存' })
283
+
284
+ // Step 3: Determine models
285
+ const selectedModels = models && models.length > 0 ? models : ['claude-sonnet-4-6']
286
+ const primaryModel = selectedModels.find(m => m.startsWith('claude-')) || selectedModels[0] || 'claude-sonnet-4-6'
287
+
288
+ // Step 4: Process each tool
289
+ const selectedToolIds = toolIds || []
290
+ const selectedTools = TOOLS.filter(t => selectedToolIds.includes(t.id))
291
+ const results = []
292
+
293
+ for (const tool of selectedTools) {
294
+ const installed = tool.checkInstalled()
295
+
296
+ // Auto-install if needed
297
+ if (!installed && autoInstall && AUTO_INSTALL[tool.id]) {
298
+ sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, message: `正在安装 ${tool.name}...` })
299
+ const installOk = await new Promise(resolve => {
300
+ const child = spawn(AUTO_INSTALL[tool.id].cmd, [], { shell: true })
301
+ let output = ''
302
+ child.stdout?.on('data', chunk => {
303
+ output += chunk.toString()
304
+ sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
305
+ })
306
+ child.stderr?.on('data', chunk => {
307
+ output += chunk.toString()
308
+ sseEmit(res, { type: 'output', tool: tool.name, text: chunk.toString() })
309
+ })
310
+ child.on('close', code => resolve(code === 0))
311
+ child.on('error', () => resolve(false))
312
+ })
313
+ if (!installOk) {
314
+ sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'error', message: `${tool.name} 安装失败` })
315
+ results.push({ tool: tool.name, status: 'error', error: '安装失败' })
316
+ continue
317
+ }
318
+ sseEmit(res, { type: 'progress', step: 'install', tool: tool.name, status: 'ok', message: `${tool.name} 安装成功` })
319
+ } else if (!installed) {
320
+ sseEmit(res, { type: 'progress', step: 'skip', tool: tool.name, status: 'skip', message: `${tool.name} 未安装,跳过` })
321
+ results.push({ tool: tool.name, status: 'skip' })
322
+ continue
323
+ }
324
+
325
+ // Configure
326
+ sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, message: `配置 ${tool.name}...` })
327
+ try {
328
+ const result = tool.configure(effectiveKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, primaryModel, selectedModels)
329
+ if (result.manual) {
330
+ sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'manual', message: `${tool.name} 需要手动配置`, steps: result.steps })
331
+ results.push({ tool: tool.name, status: 'manual', steps: result.steps })
332
+ } else if (result.warning) {
333
+ sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'warning', message: result.warning, file: result.file })
334
+ results.push({ tool: tool.name, status: 'warning', warning: result.warning, file: result.file })
335
+ } else {
336
+ sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'ok', message: `${tool.name} 配置成功`, file: result.file, hot: result.hot })
337
+ results.push({ tool: tool.name, status: 'ok', file: result.file, hot: result.hot })
338
+ }
339
+ } catch (e) {
340
+ sseEmit(res, { type: 'progress', step: 'configure', tool: tool.name, status: 'error', message: e.message })
341
+ results.push({ tool: tool.name, status: 'error', error: e.message })
342
+ }
343
+ }
344
+
345
+ // Step 5: Clean env vars
346
+ try {
347
+ const cleaned = removeEnvFromShell(['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL'])
348
+ if (cleaned.length) {
349
+ sseEmit(res, { type: 'progress', step: 'clean-env', status: 'ok', message: `已清理环境变量: ${cleaned.join(', ')}` })
350
+ }
351
+ } catch {}
352
+
353
+ // Done
354
+ const ok = results.filter(r => r.status === 'ok').length
355
+ const errors = results.filter(r => r.status === 'error').length
356
+ sseEmit(res, { type: 'done', summary: { ok, errors, total: results.length, results } })
357
+ res.end()
358
+ }
359
+
360
+ async function handleReset(req, res) {
361
+ const body = await parseBody(req)
362
+ if (!body.confirm) return json(res, { error: '需要确认' }, 400)
363
+
364
+ const results = []
365
+ for (const tool of TOOLS) {
366
+ if (!tool.checkInstalled() && !tool.isConfigured?.()) continue
367
+ try {
368
+ tool.reset()
369
+ results.push({ tool: tool.name, status: 'ok' })
370
+ } catch (e) {
371
+ results.push({ tool: tool.name, status: 'error', error: e.message })
372
+ }
373
+ }
374
+
375
+ try { removeEnvFromShell() } catch {}
376
+ saveConfig({ apiKey: '' })
377
+
378
+ json(res, { success: true, results })
379
+ }
380
+
381
+ async function handleUpgrade(_req, res) {
382
+ sseStart(res)
383
+
384
+ for (const tool of UPGRADABLE_TOOLS) {
385
+ const installed = commandExists(tool.command)
386
+ if (!installed) {
387
+ sseEmit(res, { type: 'tool', name: tool.name, status: 'not-installed' })
388
+ continue
389
+ }
390
+
391
+ let localVer = null
392
+ try {
393
+ const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
394
+ const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
395
+ localVer = m ? m[1] : out.split('\n')[0].slice(0, 30)
396
+ } catch {}
397
+
398
+ sseEmit(res, { type: 'tool', name: tool.name, status: 'upgrading', localVer })
399
+
400
+ const ok = await new Promise(resolve => {
401
+ const child = spawn(tool.installCmd, [], { shell: true })
402
+ child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
403
+ child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', name: tool.name, text: chunk.toString() }))
404
+ child.on('close', code => resolve(code === 0))
405
+ child.on('error', () => resolve(false))
406
+ })
407
+
408
+ let newVer = null
409
+ try {
410
+ const out = execSync(tool.versionCmd, { stdio: 'pipe', timeout: 10000 }).toString().trim()
411
+ const m = out.match(/(\d+\.\d+\.\d+[\w.-]*)/)
412
+ newVer = m ? m[1] : null
413
+ } catch {}
414
+
415
+ sseEmit(res, { type: 'tool', name: tool.name, status: ok ? 'ok' : 'error', localVer, newVer })
416
+ }
417
+
418
+ sseEmit(res, { type: 'done' })
419
+ res.end()
420
+ }
421
+
422
+ async function handleToolInstall(req, res) {
423
+ const body = await parseBody(req)
424
+ const { toolId } = body
425
+ if (!toolId || !AUTO_INSTALL[toolId]) {
426
+ return json(res, { error: '不支持自动安装此工具' }, 400)
427
+ }
428
+
429
+ sseStart(res)
430
+ sseEmit(res, { type: 'progress', message: `正在安装 ${toolId}...` })
431
+
432
+ const ok = await new Promise(resolve => {
433
+ const child = spawn(AUTO_INSTALL[toolId].cmd, [], { shell: true })
434
+ child.stdout?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
435
+ child.stderr?.on('data', chunk => sseEmit(res, { type: 'output', text: chunk.toString() }))
436
+ child.on('close', code => resolve(code === 0))
437
+ child.on('error', () => resolve(false))
438
+ })
439
+
440
+ sseEmit(res, { type: 'done', success: ok })
441
+ res.end()
442
+ }
443
+
444
+ // ── Single-tool configure (SSE) ──────────────────────────────────────────────
445
+
446
+ async function handleToolConfigure(req, res) {
447
+ const body = await parseBody(req)
448
+ const { toolId } = body
449
+ const apiKey = getApiKey()
450
+ if (!apiKey) return json(res, { error: '未登录' }, 401)
451
+
452
+ const tool = TOOLS.find(t => t.id === toolId)
453
+ if (!tool) return json(res, { error: '未知工具' }, 400)
454
+
455
+ sseStart(res)
456
+
457
+ if (!tool.checkInstalled()) {
458
+ sseEmit(res, { type: 'error', message: `${tool.name} 未安装` })
459
+ sseEmit(res, { type: 'done', success: false })
460
+ return res.end()
461
+ }
462
+
463
+ const allModelIds = ['gpt-5.4', 'gpt-5.3-codex-spark', 'claude-sonnet-4-6', 'claude-opus-4-6', 'MiniMax-M2.7-highspeed', 'claude-haiku-4-5']
464
+ const primaryModel = 'claude-sonnet-4-6'
465
+
466
+ sseEmit(res, { type: 'progress', message: `正在配置 ${tool.name}...` })
467
+
468
+ try {
469
+ const result = tool.configure(apiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, primaryModel, allModelIds)
470
+
471
+ if (result.envVars && Object.keys(result.envVars).length > 0) {
472
+ writeEnvToShell(result.envVars)
473
+ sseEmit(res, { type: 'progress', message: '已写入环境变量到 shell 配置' })
474
+ }
475
+
476
+ try { removeEnvFromShell(['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL']) } catch {}
477
+
478
+ if (result.manual) {
479
+ sseEmit(res, { type: 'result', status: 'manual', message: `${tool.name} 需要手动完成配置`, steps: result.steps })
480
+ } else if (result.warning) {
481
+ sseEmit(res, { type: 'result', status: 'warning', message: result.warning, file: result.file })
482
+ } else {
483
+ sseEmit(res, { type: 'result', status: 'ok', message: `${tool.name} 配置成功`, file: result.file, hot: result.hot })
484
+ }
485
+
486
+ sseEmit(res, { type: 'done', success: true, file: result.file, hot: result.hot })
487
+ } catch (e) {
488
+ sseEmit(res, { type: 'error', message: e.message })
489
+ sseEmit(res, { type: 'done', success: false })
490
+ }
491
+ res.end()
492
+ }
493
+
494
+ // ── Single-tool reset ────────────────────────────────────────────────────────
495
+
496
+ async function handleToolReset(req, res) {
497
+ const body = await parseBody(req)
498
+ const { toolId } = body
499
+ const tool = TOOLS.find(t => t.id === toolId)
500
+ if (!tool) return json(res, { error: '未知工具' }, 400)
501
+ try {
502
+ tool.reset()
503
+ json(res, { success: true })
504
+ } catch (e) {
505
+ json(res, { success: false, error: e.message }, 500)
506
+ }
507
+ }
508
+
509
+ // ── Environment variables ────────────────────────────────────────────────────
510
+
511
+ const HS_ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'OPENAI_API_KEY', 'OPENAI_BASE_URL']
512
+ const MARKER_START = '# >>> holysheep-cli managed >>>'
513
+
514
+ function handleEnv(_req, res) {
515
+ const vars = {}
516
+ for (const k of HS_ENV_KEYS) {
517
+ const v = process.env[k]
518
+ vars[k] = v ? (k.includes('KEY') || k.includes('TOKEN') ? maskKey(v) : v) : null
519
+ }
520
+
521
+ const rcFiles = []
522
+ try {
523
+ for (const f of getShellRcFiles()) {
524
+ let has = false
525
+ try { has = fs.readFileSync(f, 'utf8').includes(MARKER_START) } catch {}
526
+ rcFiles.push({ path: f.replace(require('os').homedir(), '~'), hasManagedBlock: has })
527
+ }
528
+ } catch {}
529
+
530
+ json(res, { vars, rcFiles })
531
+ }
532
+
533
+ function handleEnvClean(_req, res) {
534
+ try {
535
+ const cleaned = removeEnvFromShell()
536
+ json(res, { success: true, cleaned })
537
+ } catch (e) {
538
+ json(res, { success: false, error: e.message }, 500)
539
+ }
540
+ }
541
+
542
+ // ── Models list ──────────────────────────────────────────────────────────────
543
+
544
+ function handleModels(_req, res) {
545
+ json(res, [
546
+ { id: 'gpt-5.4', label: 'GPT 5.4', desc: '通用编码' },
547
+ { id: 'gpt-5.3-codex-spark', label: 'GPT 5.3 Codex Spark', desc: '编码' },
548
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: '均衡推荐' },
549
+ { id: 'claude-opus-4-6', label: 'Opus 4.6', desc: '强力旗舰' },
550
+ { id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7', desc: '高速经济版' },
551
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: '轻快便宜' },
552
+ ])
553
+ }
554
+
555
+ // ── Router ───────────────────────────────────────────────────────────────────
556
+
557
+ async function handleRequest(req, res) {
558
+ res.setHeader('Access-Control-Allow-Origin', '*')
559
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
560
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
561
+
562
+ if (req.method === 'OPTIONS') { res.writeHead(204); return res.end() }
563
+
564
+ const url = new URL(req.url, `http://${req.headers.host}`)
565
+ const route = url.pathname
566
+
567
+ try {
568
+ // Static
569
+ if (route === '/' || route === '/index.html') {
570
+ const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8')
571
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
572
+ return res.end(html)
573
+ }
574
+
575
+ // API
576
+ if (route === '/api/status' && req.method === 'GET') return await handleStatus(req, res)
577
+ if (route === '/api/login' && req.method === 'POST') return await handleLogin(req, res)
578
+ if (route === '/api/logout' && req.method === 'POST') return await handleLogout(req, res)
579
+ if (route === '/api/balance' && req.method === 'GET') return await handleBalance(req, res)
580
+ if (route === '/api/doctor' && req.method === 'GET') return await handleDoctor(req, res)
581
+ if (route === '/api/tools' && req.method === 'GET') return await handleTools(req, res)
582
+ if (route === '/api/models' && req.method === 'GET') return await handleModels(req, res)
583
+ if (route === '/api/upgrade' && req.method === 'POST') return await handleUpgrade(req, res)
584
+ if (route === '/api/tool/install' && req.method === 'POST') return await handleToolInstall(req, res)
585
+ if (route === '/api/tool/configure' && req.method === 'POST') return await handleToolConfigure(req, res)
586
+ if (route === '/api/tool/reset' && req.method === 'POST') return await handleToolReset(req, res)
587
+ if (route === '/api/env' && req.method === 'GET') return handleEnv(req, res)
588
+ if (route === '/api/env/clean' && req.method === 'POST') return handleEnvClean(req, res)
589
+
590
+ res.writeHead(404)
591
+ res.end('Not Found')
592
+ } catch (e) {
593
+ if (!res.headersSent) {
594
+ json(res, { error: e.message }, 500)
595
+ }
596
+ }
597
+ }
598
+
599
+ function startServer(port) {
600
+ return new Promise((resolve, reject) => {
601
+ const server = http.createServer(handleRequest)
602
+ server.on('error', (err) => {
603
+ if (err.code === 'EADDRINUSE') {
604
+ // Try to kill stale process and retry once
605
+ try {
606
+ execSync(`lsof -ti:${port} | xargs kill -9`, { stdio: 'ignore' })
607
+ } catch {}
608
+ setTimeout(() => {
609
+ const retry = http.createServer(handleRequest)
610
+ retry.on('error', (err2) => reject(err2))
611
+ retry.listen(port, '127.0.0.1', () => resolve(retry))
612
+ }, 500)
613
+ } else {
614
+ reject(err)
615
+ }
616
+ })
617
+ server.listen(port, '127.0.0.1', () => resolve(server))
618
+ })
619
+ }
620
+
621
+ module.exports = { startServer }