bingocode 1.1.62 → 1.1.64
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 -1
- package/runtime/mac_helper.py +775 -0
- package/runtime/requirements-win.txt +7 -0
- package/runtime/requirements.txt +6 -0
- package/runtime/test_helpers.py +322 -0
- package/runtime/win_helper.py +723 -0
- package/src/manager/CliMenuManager.tsx +0 -1
- package/src/server/ensureSingletonLocalServer.ts +160 -256
|
@@ -285,7 +285,6 @@ export const CliMenuManager: React.FC = () => {
|
|
|
285
285
|
let mounted = true;
|
|
286
286
|
(async () => {
|
|
287
287
|
if (apiUrl) return;
|
|
288
|
-
|
|
289
288
|
const entry = path.resolve(import.meta.dir, '../server/index.ts');
|
|
290
289
|
const MAX_RETRIES = 3;
|
|
291
290
|
const RETRY_DELAYS = [0, 2000, 5000]; // 首次无延迟,第2次2秒,第3次5秒
|
|
@@ -1,256 +1,160 @@
|
|
|
1
|
-
// server/ensureSingletonLocalServer.ts
|
|
2
|
-
import { spawn, spawnSync } from 'child_process';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import fsp from 'fs/promises';
|
|
5
|
-
import path from 'path';
|
|
6
|
-
import os from 'os';
|
|
7
|
-
import axios from 'axios';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
log(`父目录内容: ${files.join(', ')}`);
|
|
162
|
-
}
|
|
163
|
-
// 列出祖父目录
|
|
164
|
-
const grandParentDir = path.dirname(parentDir);
|
|
165
|
-
log(`祖父目录 ${grandParentDir} 存在: ${fs.existsSync(grandParentDir)}`);
|
|
166
|
-
if (fs.existsSync(grandParentDir)) {
|
|
167
|
-
const files2 = fs.readdirSync(grandParentDir);
|
|
168
|
-
log(`祖父目录内容: ${files2.join(', ')}`);
|
|
169
|
-
}
|
|
170
|
-
} catch (dirErr: any) {
|
|
171
|
-
log(`目录列举失败: ${dirErr?.message}`);
|
|
172
|
-
}
|
|
173
|
-
throw new Error(`服务器入口文件不存在: ${opts.serverEntry}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const spawnArgs = [opts.serverEntry, '--host', host, '--port', String(port)];
|
|
177
|
-
const spawnEnv = { ...process.env, SERVER_AUTH_REQUIRED: '0', ...(opts.passEnv || {}) };
|
|
178
|
-
// cwd 设为包根目录(serverEntry 的两级上级:src/server -> src -> 包根),
|
|
179
|
-
// 确保 bun 能从正确位置查找 node_modules,无论用户在哪个目录启动
|
|
180
|
-
const serverCwd = path.dirname(path.dirname(path.dirname(opts.serverEntry)));
|
|
181
|
-
log(`spawn: ${bun} ${spawnArgs.join(' ')}`);
|
|
182
|
-
log(`spawn cwd: ${serverCwd}`);
|
|
183
|
-
|
|
184
|
-
child = spawn(
|
|
185
|
-
bun,
|
|
186
|
-
spawnArgs,
|
|
187
|
-
{
|
|
188
|
-
env: spawnEnv,
|
|
189
|
-
cwd: serverCwd,
|
|
190
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
191
|
-
detached: false,
|
|
192
|
-
}
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
log(`子进程 pid: ${child.pid}`);
|
|
196
|
-
|
|
197
|
-
// 收集子进程输出用于诊断
|
|
198
|
-
let childStdout = '';
|
|
199
|
-
let childStderr = '';
|
|
200
|
-
child.stdout?.on('data', (d: Buffer) => {
|
|
201
|
-
const s = d.toString();
|
|
202
|
-
childStdout += s;
|
|
203
|
-
log(`[child stdout] ${s.trim()}`);
|
|
204
|
-
});
|
|
205
|
-
child.stderr?.on('data', (d: Buffer) => {
|
|
206
|
-
const s = d.toString();
|
|
207
|
-
childStderr += s;
|
|
208
|
-
log(`[child stderr] ${s.trim()}`);
|
|
209
|
-
});
|
|
210
|
-
child.on('exit', (code: number | null, signal: string | null) => {
|
|
211
|
-
log(`子进程退出: code=${code} signal=${signal}`);
|
|
212
|
-
log(`子进程 stdout 总计: ${childStdout.slice(0, 500)}`);
|
|
213
|
-
log(`子进程 stderr 总计: ${childStderr.slice(0, 500)}`);
|
|
214
|
-
});
|
|
215
|
-
child.on('error', (err: Error) => {
|
|
216
|
-
log(`子进程 spawn error: ${err.message}`);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
log(`开始健康检查,超时 ${HEALTH_TIMEOUT_MS}ms`);
|
|
220
|
-
await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS);
|
|
221
|
-
log(`健康检查通过`);
|
|
222
|
-
writeJson(LOCK_JSON, { pid: child.pid, port, startedAt: new Date().toISOString() });
|
|
223
|
-
} catch (e: any) {
|
|
224
|
-
log(`启动失败: ${e?.message}`);
|
|
225
|
-
if (child?.pid) {
|
|
226
|
-
log(`清理子进程 pid=${child.pid}`);
|
|
227
|
-
try {
|
|
228
|
-
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
|
|
229
|
-
else process.kill(child.pid, 'SIGTERM');
|
|
230
|
-
} catch {}
|
|
231
|
-
}
|
|
232
|
-
throw e;
|
|
233
|
-
} finally {
|
|
234
|
-
rmSafe(BOOT_LOCK);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
await acquireLease();
|
|
238
|
-
log(`启动完成 pid=${child.pid} baseUrl=${baseUrl}`);
|
|
239
|
-
return { baseUrl, stopIfLast: makeStopIfLast(child.pid, baseUrl), pid: child.pid };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function makeStopIfLast(serverPid: number, baseUrl: string) {
|
|
243
|
-
return async () => {
|
|
244
|
-
const rest = countValidLeases();
|
|
245
|
-
if (rest > 0) return;
|
|
246
|
-
let healthy = false;
|
|
247
|
-
try { await waitHealthy(baseUrl, 800); healthy = true; } catch {}
|
|
248
|
-
if (healthy && serverPid && isPidAlive(serverPid)) {
|
|
249
|
-
try {
|
|
250
|
-
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(serverPid), '/T', '/F']);
|
|
251
|
-
else process.kill(serverPid, 'SIGTERM');
|
|
252
|
-
} catch {}
|
|
253
|
-
}
|
|
254
|
-
rmSafe(LOCK_JSON);
|
|
255
|
-
};
|
|
256
|
-
}
|
|
1
|
+
// server/ensureSingletonLocalServer.ts
|
|
2
|
+
import { spawn, spawnSync } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import fsp from 'fs/promises';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import axios from 'axios';
|
|
8
|
+
|
|
9
|
+
type Handle = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
stopIfLast: () => Promise<void>;
|
|
12
|
+
pid?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
|
|
16
|
+
const LOCK_JSON = path.join(RUNTIME_DIR, 'server.lock.json');
|
|
17
|
+
const BOOT_LOCK = path.join(RUNTIME_DIR, 'server.boot.lock');
|
|
18
|
+
const LEASES_DIR = path.join(RUNTIME_DIR, 'leases');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
21
|
+
const DEFAULT_PORT = Number(process.env.SERVER_PORT || 3456);
|
|
22
|
+
const HEALTH_TIMEOUT_MS = Number(process.env.HEALTH_TIMEOUT_MS || 20000);
|
|
23
|
+
const HEALTH_RETRY_MS = 300;
|
|
24
|
+
|
|
25
|
+
function mkdirp(p: string) { fs.mkdirSync(p, { recursive: true }); }
|
|
26
|
+
function atomicCreate(p: string): boolean {
|
|
27
|
+
try { const fd = fs.openSync(p, 'wx'); fs.closeSync(fd); return true; } catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
function rmSafe(p: string) { try { fs.rmSync(p, { force: true, recursive: true }); } catch {} }
|
|
30
|
+
function readJson<T>(p: string): T | null { try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as T; } catch { return null; } }
|
|
31
|
+
function writeJson(p: string, data: any) { fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); }
|
|
32
|
+
function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
33
|
+
async function waitHealthy(baseUrl: string, timeoutMs: number) {
|
|
34
|
+
const start = Date.now(); let lastErr: any = null;
|
|
35
|
+
while (Date.now() - start < timeoutMs) {
|
|
36
|
+
try {
|
|
37
|
+
const r = await axios.get(baseUrl.replace(/\/+$/, '') + '/health', { timeout: 1500 });
|
|
38
|
+
if (r.status === 200 && r.data?.status === 'ok') return;
|
|
39
|
+
} catch (e) { lastErr = e; }
|
|
40
|
+
await new Promise(r => setTimeout(r, HEALTH_RETRY_MS));
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`健康检查超时:${lastErr?.message || 'unknown'}`);
|
|
43
|
+
}
|
|
44
|
+
function resolveBunPath() {
|
|
45
|
+
const fromEnv = process.env.BUN_PATH;
|
|
46
|
+
if (fromEnv) return fromEnv;
|
|
47
|
+
const r = spawnSync('bun', ['--version'], { stdio: 'ignore' });
|
|
48
|
+
if (r.status === 0) return 'bun';
|
|
49
|
+
throw new Error('未检测到 bun,请安装 https://bun.sh 或设置 BUN_PATH');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function acquireLease(): Promise<string> {
|
|
53
|
+
mkdirp(LEASES_DIR);
|
|
54
|
+
const lease = path.join(LEASES_DIR, `${process.pid}.json`);
|
|
55
|
+
await fsp.writeFile(lease, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
56
|
+
const cleanup = () => rmSafe(lease);
|
|
57
|
+
process.once('exit', cleanup);
|
|
58
|
+
process.once('SIGINT', () => { cleanup(); process.exit(130); });
|
|
59
|
+
process.once('SIGTERM', () => { cleanup(); process.exit(143); });
|
|
60
|
+
return lease;
|
|
61
|
+
}
|
|
62
|
+
function countValidLeases(): number {
|
|
63
|
+
try {
|
|
64
|
+
const files = fs.readdirSync(LEASES_DIR);
|
|
65
|
+
let alive = 0;
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
const p = path.join(LEASES_DIR, f);
|
|
68
|
+
const data = readJson<{pid:number, ts:number}>(p);
|
|
69
|
+
if (data?.pid && isPidAlive(data.pid)) alive++;
|
|
70
|
+
else rmSafe(p);
|
|
71
|
+
}
|
|
72
|
+
return alive;
|
|
73
|
+
} catch { return 0; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function ensureSingletonLocalServer(opts: {
|
|
77
|
+
serverEntry: string;
|
|
78
|
+
host?: string;
|
|
79
|
+
port?: number;
|
|
80
|
+
baseUrlEnv?: string;
|
|
81
|
+
passEnv?: Record<string, string>;
|
|
82
|
+
}): Promise<Handle> {
|
|
83
|
+
const preset = (opts.baseUrlEnv || process.env.BASE_API_URL || '').trim();
|
|
84
|
+
if (preset) {
|
|
85
|
+
try { await waitHealthy(preset, 3000); } catch {}
|
|
86
|
+
await acquireLease();
|
|
87
|
+
return { baseUrl: preset.replace(/\/+$/, ''), stopIfLast: async () => {} };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const host = opts.host || DEFAULT_HOST;
|
|
91
|
+
const port = Number(opts.port ?? DEFAULT_PORT);
|
|
92
|
+
const baseUrl = `http://${host}:${port}`;
|
|
93
|
+
mkdirp(RUNTIME_DIR);
|
|
94
|
+
|
|
95
|
+
const lock = readJson<{ pid:number, port:number }>(LOCK_JSON);
|
|
96
|
+
if (lock && lock.port === port) {
|
|
97
|
+
const healthy = await (async () => { try { await waitHealthy(baseUrl, 1200); return true; } catch { return false; } })();
|
|
98
|
+
if (healthy && isPidAlive(lock.pid)) {
|
|
99
|
+
await acquireLease();
|
|
100
|
+
return { baseUrl, stopIfLast: makeStopIfLast(lock.pid, baseUrl), pid: lock.pid };
|
|
101
|
+
}
|
|
102
|
+
rmSafe(LOCK_JSON);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const iAmSpawner = atomicCreate(BOOT_LOCK);
|
|
106
|
+
if (!iAmSpawner) {
|
|
107
|
+
try { await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS); } catch {
|
|
108
|
+
rmSafe(BOOT_LOCK); rmSafe(LOCK_JSON);
|
|
109
|
+
return await ensureSingletonLocalServer(opts);
|
|
110
|
+
}
|
|
111
|
+
await acquireLease();
|
|
112
|
+
const live = readJson<{pid:number}>(LOCK_JSON);
|
|
113
|
+
return { baseUrl, stopIfLast: makeStopIfLast(live?.pid || 0, baseUrl), pid: live?.pid };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let child: any = null;
|
|
117
|
+
try {
|
|
118
|
+
const bun = resolveBunPath();
|
|
119
|
+
child = spawn(
|
|
120
|
+
bun,
|
|
121
|
+
[opts.serverEntry, '--host', host, '--port', String(port)],
|
|
122
|
+
{
|
|
123
|
+
env: { ...process.env, SERVER_AUTH_REQUIRED: '0', ...(opts.passEnv || {}) },
|
|
124
|
+
stdio: 'ignore',
|
|
125
|
+
detached: false,
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS);
|
|
129
|
+
writeJson(LOCK_JSON, { pid: child.pid, port, startedAt: new Date().toISOString() });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (child?.pid) {
|
|
132
|
+
try {
|
|
133
|
+
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
|
|
134
|
+
else process.kill(child.pid, 'SIGTERM');
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
throw e;
|
|
138
|
+
} finally {
|
|
139
|
+
rmSafe(BOOT_LOCK);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await acquireLease();
|
|
143
|
+
return { baseUrl, stopIfLast: makeStopIfLast(child.pid, baseUrl), pid: child.pid };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function makeStopIfLast(serverPid: number, baseUrl: string) {
|
|
147
|
+
return async () => {
|
|
148
|
+
const rest = countValidLeases();
|
|
149
|
+
if (rest > 0) return;
|
|
150
|
+
let healthy = false;
|
|
151
|
+
try { await waitHealthy(baseUrl, 800); healthy = true; } catch {}
|
|
152
|
+
if (healthy && serverPid && isPidAlive(serverPid)) {
|
|
153
|
+
try {
|
|
154
|
+
if (process.platform === 'win32') spawn('taskkill', ['/PID', String(serverPid), '/T', '/F']);
|
|
155
|
+
else process.kill(serverPid, 'SIGTERM');
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
rmSafe(LOCK_JSON);
|
|
159
|
+
};
|
|
160
|
+
}
|