zen-gitsync 2.14.2 → 2.15.1

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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-BoAhI0Kt.js} +2 -2
  3. package/src/ui/public/assets/BranchSelector--8Qfb75A.js +1 -0
  4. package/src/ui/public/assets/CommitForm-CobjwXah.js +6 -0
  5. package/src/ui/public/assets/ConsoleView-B2WZRKsP.css +1 -0
  6. package/src/ui/public/assets/ConsoleView-HcfiYLnM.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-DQG4wRU7.js +1 -0
  8. package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-CAjuz7yY.js} +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-C8H9uoZL.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-C2RMFB-p.js +21 -0
  11. package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-8qSNrgT7.js} +4 -4
  12. package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-C5pLBOs_.js} +1 -1
  13. package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-Bijvi8pi.js} +1 -1
  14. package/src/ui/public/assets/ProjectStartupButton-Cb-RAZqA.js +1 -0
  15. package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-DjfI188H.js} +1 -1
  16. package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-DbdbojUR.js} +2 -2
  17. package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-BVBpj6nK.js} +1 -1
  18. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
  19. package/src/ui/public/assets/TemplateManager-CSI1wQZY.js +1 -0
  20. package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-8ocSzj-6.js} +1 -1
  21. package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
  22. package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-DAXAzJTK.js} +4 -4
  23. package/src/ui/public/assets/_plugin-vue_export-helper-Uwc4Esmp.js +15 -0
  24. package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
  25. package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-BHFJlgWp.js} +1 -1
  26. package/src/ui/public/assets/element-plus-R1nMzM9j.css +1 -0
  27. package/src/ui/public/assets/{flow-mindmap-CAkGY2ir.js → flow-mindmap-C0IUPqjN.js} +1 -1
  28. package/src/ui/public/assets/index-BZk-ldXr.css +1 -0
  29. package/src/ui/public/assets/index-Bl8fyL3X.js +23 -0
  30. package/src/ui/public/assets/{vendor-BuiZXcjj.js → vendor-0hkrGFDb.js} +1 -1
  31. package/src/ui/public/assets/{vue-flow-C6RXt-U3.js → vue-flow-CkwNeuGO.js} +1 -1
  32. package/src/ui/public/index.html +9 -11
  33. package/src/ui/server/index.js +632 -625
  34. package/src/ui/server/middleware/errorHandler.js +84 -0
  35. package/src/ui/server/middleware/errorHandler.test.js +114 -0
  36. package/src/ui/server/routes/branchStatus.test.js +37 -0
  37. package/src/ui/server/routes/exec.js +0 -21
  38. package/src/ui/server/routes/gitOps.js +62 -0
  39. package/src/ui/server/routes/monitor.js +42 -26
  40. package/src/ui/server/routes/npm.js +1387 -1327
  41. package/src/ui/server/routes/terminal.js +318 -320
  42. package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
  43. package/src/ui/server/routes/workbench/index.js +1680 -0
  44. package/src/ui/server/routes/workbench/instructionStore.js +175 -0
  45. package/src/ui/server/routes/workbench/jobStore.js +225 -0
  46. package/src/ui/server/routes/workbench/jsonParse.js +219 -0
  47. package/src/ui/server/routes/workbench/llmClient.js +214 -0
  48. package/src/ui/server/routes/workbench/projectScan.js +191 -0
  49. package/src/ui/server/routes/workbench/sessionStore.js +126 -0
  50. package/src/ui/server/routes/workbench/shared.js +115 -0
  51. package/src/ui/server/routes/workbench/taskRunner.js +493 -0
  52. package/src/ui/server/routes/workbench.js +16 -3523
  53. package/src/utils/index.js +10 -3
  54. package/src/ui/public/assets/BranchSelector-scIk6OeM.js +0 -1
  55. package/src/ui/public/assets/CommandConsole-BikQuarw.js +0 -17
  56. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +0 -1
  57. package/src/ui/public/assets/CommitForm-DuvNxUdP.js +0 -6
  58. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  59. package/src/ui/public/assets/CommonDialog-G17HYMln.js +0 -1
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-C3l3Jco7.js +0 -21
  61. package/src/ui/public/assets/ProjectStartupButton-CgS14qyg.js +0 -1
  62. package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
  63. package/src/ui/public/assets/_plugin-vue_export-helper-BMXWW0fG.js +0 -15
  64. package/src/ui/public/assets/element-plus-D06ZgZjR.css +0 -1
  65. package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
  66. package/src/ui/public/assets/index-gbRjS0sL.css +0 -1
  67. /package/src/ui/public/assets/{element-plus-B6Owr5jY.js → element-plus-tbZnDtyd.js} +0 -0
@@ -1,625 +1,632 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import express from 'express';
16
- import logger from './utils/logger.js'
17
- import { createServer } from 'http';
18
- import { fileURLToPath } from 'url';
19
- import path from 'path';
20
- import { execGitCommand, getCommandHistory, addCommandToHistory, clearCommandHistory, registerSocketIO, execGitAddWithLockFilter, checkAndClearGitLock } from '../../utils/index.js';
21
- import open from 'open';
22
- import config from '../../config.js';
23
- import chalk from 'chalk';
24
- import fs from 'fs/promises';
25
- import fsSync from 'fs';
26
- import os from 'os';
27
- import { Server } from 'socket.io';
28
- import { spawn, exec } from 'child_process';
29
- import iconv from 'iconv-lite';
30
- import { createRequestLogger } from './middleware/requestLogger.js';
31
- import { registerUiSocketHandlers } from './socket/registerUiSocketHandlers.js';
32
- import { registerExecRoutes } from './routes/exec.js';
33
- import { registerTerminalRoutes } from './routes/terminal.js';
34
- import { registerProcessRoutes } from './routes/process.js';
35
- import { registerStatusRoutes } from './routes/status.js';
36
- import { registerBranchStatusRoutes } from './routes/branchStatus.js';
37
- import { registerConfigRoutes } from './routes/config.js';
38
- import { registerGitRoutes } from './routes/git.js';
39
- import { registerFsRoutes } from './routes/fs.js';
40
- import { registerNpmRoutes } from './routes/npm.js';
41
- import { registerFileOpenRoutes } from './routes/fileOpen.js';
42
- import { registerWorkbenchRoutes } from './routes/workbench.js';
43
- import { registerGitOpsRoutes } from './routes/gitOps.js';
44
- import { registerCodeRoutes } from './routes/code.js';
45
- import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
46
- import { registerInstancesRoutes } from './routes/instances.js';
47
- import { registerMonitorRoutes } from './routes/monitor.js';
48
- import { registerMindmapRoutes } from './routes/mindmap.js';
49
- import { createInstanceRegistry } from './utils/instanceRegistry.js';
50
- import { createSavePortToFile } from './utils/createSavePortToFile.js';
51
- import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
52
- import { resolveStartPort } from './utils/randomStartPort.js';
53
- import { createFilePickerMiddleware } from 'local-file-picker';
54
- import { createAiModelMiddleware } from 'ai-model-form';
55
-
56
- const __filename = fileURLToPath(import.meta.url);
57
- const __dirname = path.dirname(__filename);
58
- const configManager = config; // 确保 configManager 可用
59
- // 存储正在运行的进程(用于停止功能)
60
- const runningProcesses = new Map(); // key: processId, value: { childProcess, command, startTime }
61
- let processIdCounter = 0;
62
-
63
- const terminalSessions = new Map(); // key: terminalSessionId, value: { id, command, workingDirectory, pid, createdAt, lastStartedAt }
64
- let terminalSessionIdCounter = 0;
65
-
66
- /**
67
- * 终止并等待单个子进程退出
68
- * @param {import('child_process').ChildProcess} child
69
- * @param {number} timeoutMs 等待 SIGTERM 生效的最大时间,超时后发 SIGKILL
70
- * @returns {Promise<void>}
71
- */
72
- function killChildProcess(child, timeoutMs = 3000) {
73
- return new Promise((resolve) => {
74
- if (!child || (child.exitCode !== null && child.exitCode !== undefined)) {
75
- return resolve();
76
- }
77
- let done = false;
78
- const finish = () => {
79
- if (done) return;
80
- done = true;
81
- clearTimeout(timer);
82
- child.removeListener('exit', finish);
83
- resolve();
84
- };
85
- child.once('exit', finish);
86
- try {
87
- // SIGTERM 优雅退出,被 Node 在 Windows 上转 TerminateProcess
88
- child.kill('SIGTERM');
89
- } catch (_) {
90
- finish();
91
- return;
92
- }
93
- // 超时未退就 SIGKILL 强杀
94
- const timer = setTimeout(() => {
95
- try { child.kill('SIGKILL'); } catch (_) {}
96
- // SIGKILL 200ms 兜底,再不退也不等了
97
- setTimeout(finish, 200);
98
- }, timeoutMs);
99
- });
100
- }
101
-
102
- /**
103
- * 并行终止 runningProcesses 内所有子进程,总等待时间不超过 timeoutMs。
104
- * 防止 SIGINT/SIGTERM 关闭 server 时,正在跑的 claude/npm/git 子进程变孤儿。
105
- *
106
- * 注意:返回值是"尝试终止的进程数",不等于"已实际退出数"。内部用
107
- * Promise.race 卡总时长,timeoutMs 到了就返回,此时可能仍有子进程
108
- * 处于 SIGTERM → exit 的过渡中(尤其 Windows 上 TerminateProcess 是
109
- * 异步的)。调用方日志应使用"已尝试终止"措辞,避免排查孤儿进程时被误导。
110
- *
111
- * @param {number} timeoutMs
112
- * @returns {Promise<number>} 被尝试终止的进程数(entries.length)
113
- */
114
- async function drainRunningProcesses(timeoutMs = 5000) {
115
- const entries = Array.from(runningProcesses.entries());
116
- if (entries.length === 0) return 0;
117
- // 先并行发 SIGTERM,再并行等;整批用 Promise.race 卡最大时长
118
- const killPromises = entries.map(([, info]) => killChildProcess(info.childProcess, timeoutMs).catch(() => {}));
119
- await Promise.race([
120
- Promise.all(killPromises),
121
- new Promise((r) => setTimeout(r, timeoutMs))
122
- ]);
123
- return entries.length;
124
- }
125
-
126
- // 全局错误处理 — 幂等,只允许进入 fatalExit 一次
127
- let _fatalExitInProgress = false;
128
-
129
- /**
130
- * 注册 process.on('uncaughtException') / unhandledRejection 处理器
131
- *
132
- * 触发时的清理链路(防止留下孤儿子进程 / 假死 registry / 端口文件残留):
133
- * 1) drainRunningProcesses(3s) 并行 SIGTERM 杀掉所有 claude/npm/git 子进程
134
- * 2) instanceRegistry.unregister把自己从实例表里摘掉
135
- * 3) setTimeout 250ms 退 给 logger/console 一点时间把日志刷盘
136
- *
137
- * @param {Object} opts
138
- * @param {ReturnType<typeof createInstanceRegistry>} opts.instanceRegistry
139
- */
140
- function setupGlobalErrorHandlers({ instanceRegistry }) {
141
- // 幂等:多个错误同时触发时只走一次清理,避免清理动作自身再次触发 fatalExit
142
- const fatalExit = async (reason, err) => {
143
- if (_fatalExitInProgress) return;
144
- _fatalExitInProgress = true;
145
- try {
146
- logger.error(`[FATAL] ${reason}: ${err?.message || err}`);
147
- if (err?.stack) logger.error(err.stack);
148
- } catch (_) { /* logger 自身炸了,放弃 */ }
149
- // 1) 杀光所有子进程
150
- try {
151
- const killed = await drainRunningProcesses(3000);
152
- if (killed > 0) logger.warn(`[FATAL] 已尝试终止 ${killed} 个运行中的子进程`);
153
- } catch (_) {}
154
- // 2) 反注册实例
155
- if (instanceRegistry) {
156
- try { await instanceRegistry.unregister(process.pid); } catch (_) {}
157
- }
158
- // 3) 给 OS 250ms 刷盘日志,然后非 0 退出
159
- setTimeout(() => process.exit(1), 250);
160
- };
161
-
162
- process.on('uncaughtException', (err, origin) => {
163
- // 同步处理路径:直接调 fatalExit,内部 await 会进入 microtask
164
- fatalExit(`uncaughtException (${origin || 'uncaught'})`, err);
165
- });
166
- process.on('unhandledRejection', (reason) => {
167
- fatalExit('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
168
- });
169
- }
170
-
171
- // 分支状态缓存
172
- let branchStatusCache = {
173
- currentBranch: null,
174
- upstreamBranch: null,
175
- lastUpdate: 0,
176
- cacheTimeout: 5000 // 5秒缓存
177
- };
178
-
179
- // 推送状态标记 - 用于优化推送后的分支状态查询
180
- let recentPushStatus = {
181
- justPushed: false,
182
- pushTime: 0,
183
- validDuration: 10000 // 推送后10秒内认为分支状态是同步的
184
- };
185
-
186
- const showConsole = true;
187
- async function startUIServer(noOpen = false, savePort = false) {
188
- const app = express();
189
- const httpServer = createServer(app);
190
-
191
- // ─────────────────────────────────────────────────────────────────────────────
192
- // Socket.IO CORS / origin 收紧
193
- //
194
- // 默认 Socket.IO 接受任意 origin,跨域页面也能连上 → CSRF / 跨站攻击面
195
- // 暴露给恶意页面。这里只允许同源 + 127.0.0.1 / localhost。
196
- // 用环境变量 ZEN_ALLOWED_ORIGINS 可以追加自定义来源(逗号分隔),
197
- // CI / 远程调试场景用。
198
- // ─────────────────────────────────────────────────────────────────────────────
199
- const DEFAULT_ALLOWED_ORIGINS = [
200
- 'http://localhost',
201
- 'https://localhost',
202
- 'http://127.0.0.1',
203
- 'https://127.0.0.1',
204
- 'http://[::1]',
205
- 'null', // 同源 file:// 等场景
206
- ];
207
- const extraOrigins = String(process.env.ZEN_ALLOWED_ORIGINS || '')
208
- .split(',').map(s => s.trim()).filter(Boolean);
209
- const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, ...extraOrigins]);
210
-
211
- function isOriginAllowed(origin) {
212
- if (!origin) return true; // 同源 / 非浏览器客户端不传 Origin
213
- if (allowedOrigins.has(origin)) return true;
214
- // 允许 localhost/127.0.0.1/[::1] 上的任意端口(开发常换端口)
215
- try {
216
- const u = new URL(origin);
217
- if (['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return true;
218
- } catch { /* ignore */ }
219
- return false;
220
- }
221
-
222
- const io = new Server(httpServer, {
223
- cors: {
224
- origin: (origin, cb) => {
225
- if (isOriginAllowed(origin)) return cb(null, true);
226
- logger.warn(`[Socket.IO] 拒绝跨域 origin: ${origin}`);
227
- return cb(new Error('Origin not allowed'), false);
228
- },
229
- methods: ['GET', 'POST'],
230
- credentials: false,
231
- },
232
- });
233
- if (showConsole) logger.info(`创建服务成功`)
234
-
235
- // 获取当前项目的唯一标识(使用工作目录路径)
236
- // 需要在切换目录时更新,故使用 let
237
- let currentProjectPath = process.cwd();
238
- let projectRoomId = `project:${currentProjectPath.replace(/[\\/:\s]/g, '_')}`;
239
-
240
- console.log(chalk.blue(`项目房间ID: ${projectRoomId}`));
241
- console.log(chalk.blue(`项目路径: ${currentProjectPath}`));
242
-
243
- // 注册Socket.io实例,用于命令历史通知
244
- registerSocketIO(io);
245
-
246
- // 构建实例注册表(用于跨进程共享"当前运行中的 GUI"信息)
247
- // 注册表文件位于用户主目录,所有 g ui 进程共享写入
248
- const instanceRegistry = createInstanceRegistry({
249
- fs,
250
- path,
251
- os,
252
- registryPath: path.join(os.homedir(), '.zen-gitsync-instances.json')
253
- });
254
-
255
- // 注册全局错误处理器:任意未捕获异常/拒绝都会触发 fatalExit,
256
- // drainRunningProcesses + unregister + 延迟 250ms 退出的链路。
257
- // instanceRegistry 创建之后立即注册,这样后续 route 注册阶段抛出
258
- // 也能被同一个处理器捕获(否则会无声崩,留下子进程孤儿与 .port 残留)。
259
- setupGlobalErrorHandlers({ instanceRegistry });
260
-
261
- // 添加全局中间件来解析JSON请求体
262
- app.use(express.json());
263
-
264
- // 记录最近打开的目录(优先 Git 根目录,其次当前工作目录)
265
- try {
266
- let dirPath = process.cwd();
267
- try {
268
- if(showConsole) logger.info(`记录最近打开目录`)
269
- const { stdout } = await execGitCommand(['rev-parse', '--show-toplevel']);
270
- const root = stdout?.trim();
271
- if (root) dirPath = root;
272
- } catch (_) {
273
- // 非Git仓库或命令失败,使用 CWD 即可
274
- }
275
- if (showConsole) logger.info(`记录最近打开目录: ${dirPath}`)
276
- await configManager.saveRecentDirectory(dirPath);
277
- if (showConsole) console.log(chalk.gray(`已记录最近打开目录: ${dirPath}`));
278
- } catch (e) {
279
- console.warn(chalk.yellow(`记录最近目录失败: ${e?.message || e}`));
280
- }
281
-
282
- // 添加请求日志中间件
283
- app.use(createRequestLogger({ chalk }));
284
-
285
- // 静态文件服务
286
- app.use(express.static(path.join(__dirname, '../public')));
287
-
288
- registerExecRoutes({
289
- app,
290
- execGitCommand,
291
- addCommandToHistory,
292
- getCurrentProjectPath: () => currentProjectPath,
293
- // 用 pre-increment 从 1 起,避免首进程拿到 id=0(0 在 `if (processId)` 这类真值检查里会被吞)
294
- nextProcessId: () => ++processIdCounter,
295
- runningProcesses
296
- });
297
-
298
- registerCodeRoutes({
299
- app
300
- });
301
-
302
- registerTerminalRoutes({
303
- app,
304
- getCurrentProjectPath: () => currentProjectPath,
305
- nextTerminalSessionId: () => terminalSessionIdCounter++,
306
- terminalSessions
307
- });
308
-
309
- registerProcessRoutes({
310
- app,
311
- runningProcesses,
312
- });
313
-
314
- // API路由
315
- // 移除了 /api/status 端点,因为前端只使用 porcelain 格式
316
- registerStatusRoutes({
317
- app,
318
- getCommandHistory,
319
- execGitCommand
320
- });
321
-
322
- registerBranchStatusRoutes({
323
- app,
324
- execGitCommand,
325
- getIsGitRepo: () => isGitRepo,
326
- getBranchStatusCache: () => branchStatusCache,
327
- setBranchStatusCache: (v) => { branchStatusCache = v; },
328
- getRecentPushStatus: () => recentPushStatus,
329
- setRecentPushStatus: (v) => { recentPushStatus = v; }
330
- });
331
-
332
- // 清除分支缓存的函数(在分支切换时调用)
333
- function clearBranchCache() {
334
- logger.info('清除分支缓存');
335
- // 清除5秒分支状态缓存
336
- branchStatusCache = {
337
- currentBranch: null,
338
- upstreamBranch: null,
339
- lastUpdate: 0,
340
- cacheTimeout: 5000
341
- };
342
- // 清除推送状态标记
343
- recentPushStatus = {
344
- justPushed: false,
345
- pushTime: 0,
346
- validDuration: 10000
347
- };
348
- }
349
-
350
- registerGitRoutes({
351
- app,
352
- execGitCommand,
353
- clearBranchCache
354
- });
355
-
356
- registerFsRoutes({
357
- app,
358
- execGitCommand,
359
- configManager,
360
- io,
361
- getCurrentProjectPath: () => currentProjectPath,
362
- setCurrentProjectPath: (v) => {
363
- currentProjectPath = v;
364
- // project 切换后清掉 _lastBroadcastedPorcelain 缓存,
365
- // 否则两个 project 都是 clean 时(porcelain 都是 '')会误判
366
- // 内容未变 漏推新 projectPath
367
- _lastBroadcastedPorcelain = null;
368
- },
369
- getProjectRoomId: () => projectRoomId,
370
- setProjectRoomId: (v) => { projectRoomId = v; },
371
- setIsGitRepo: (v) => { isGitRepo = v; }
372
- });
373
-
374
- registerConfigRoutes({
375
- app,
376
- configManager,
377
- execGitCommand,
378
- getCurrentProjectPath: () => currentProjectPath
379
- });
380
-
381
- registerNpmRoutes({
382
- app,
383
- getCurrentProjectPath: () => currentProjectPath
384
- });
385
-
386
- registerFileOpenRoutes({
387
- app
388
- });
389
-
390
- registerWorkbenchRoutes({
391
- app,
392
- getCurrentProjectPath: () => currentProjectPath,
393
- getProjectRoomId: () => projectRoomId,
394
- io,
395
- configManager
396
- });
397
-
398
- // local-file-picker 中间件,提供 /api/fs/* 文件浏览路由
399
- app.use('/api', createFilePickerMiddleware());
400
-
401
- // ai-model-form 中间件:提供 /api/ai-model/* 路由(模型列表/测试/暂存保存)
402
- app.use('/api', createAiModelMiddleware());
403
-
404
- registerCodeAnalysisRoutes({ app, configManager, getCurrentProjectPath: () => currentProjectPath });
405
-
406
- // 实例注册表 API:列出当前所有运行中的 GUI
407
- registerInstancesRoutes({
408
- app,
409
- registry: instanceRegistry,
410
- getCurrentInstanceId: () => process.pid
411
- });
412
-
413
- registerGitOpsRoutes({
414
- app,
415
- execGitCommand,
416
- configManager,
417
- execGitAddWithLockFilter,
418
- addCommandToHistory,
419
- clearCommandHistory,
420
- checkAndClearGitLock,
421
- getIsGitRepo: () => isGitRepo,
422
- setRecentPushStatus: (v) => { recentPushStatus = v; }
423
- });
424
-
425
- // 系统监控:CPU / 内存 / 端口占用 + kill 进程
426
- registerMonitorRoutes({ app });
427
-
428
- // 思维导图:.mindmap.json 文件的列/读/写/建/删/重命名
429
- registerMindmapRoutes({ app });
430
- registerUiSocketHandlers({
431
- io,
432
- getProjectRoomId: () => projectRoomId,
433
- getCurrentProjectPath: () => currentProjectPath,
434
- getAndBroadcastStatus,
435
- getCommandHistory,
436
- clearCommandHistory,
437
- addCommandToHistory,
438
- runningProcesses,
439
- nextProcessId: () => ++processIdCounter,
440
- spawn,
441
- exec,
442
- path,
443
- iconv
444
- });
445
-
446
- // 获取并广播Git状态 (优化版本 - 只获取porcelain格式)
447
- // 去重:50ms 内的多次调用合并为一次 git status + 一次广播。
448
- // 触发场景:页面刷新风暴(打开 GUI 后浏览器自动重连 3-5 次),
449
- // 或前端多 tab 同时连接。N 个客户端连进来 = N 次 git status 子进程,
450
- // 每次 ~30-80ms,合并后只跑 1 次。
451
- let _statusBroadcastPending = false;
452
- let _lastBroadcastedPorcelain = null;
453
- async function getAndBroadcastStatus() {
454
- if (_statusBroadcastPending) return;
455
- _statusBroadcastPending = true;
456
- // setImmediate 让同一 tick 内的多次调用合并(更短的等待)
457
- setImmediate(async () => {
458
- _statusBroadcastPending = false;
459
- try {
460
- // 如果不是Git仓库,发送特殊状态
461
- if (!isGitRepo) {
462
- io.to(projectRoomId).emit('git_status_update', {
463
- isGitRepo: false,
464
- porcelain: '',
465
- timestamp: new Date().toISOString(),
466
- projectPath: currentProjectPath
467
- });
468
- return;
469
- }
470
-
471
- // 只获取porcelain格式状态,不再获取完整的git status
472
- const { stdout: porcelainOutput } = await execGitCommand(['status', '--porcelain', '--untracked-files=all']);
473
-
474
- // 内容未变就不广播 — 防止每次前端 reconnect 都推一份完全一样的字符串
475
- // 跨项目切换时 isGitRepo/projectPath 会变,这里只按内容去重
476
- if (porcelainOutput === _lastBroadcastedPorcelain) {
477
- return;
478
- }
479
- _lastBroadcastedPorcelain = porcelainOutput;
480
-
481
- // 广播到当前项目房间的所有客户端
482
- io.to(projectRoomId).emit('git_status_update', {
483
- isGitRepo: true,
484
- porcelain: porcelainOutput,
485
- timestamp: new Date().toISOString(),
486
- projectPath: currentProjectPath
487
- });
488
-
489
- logger.info(`已广播Git状态更新到房间: ${projectRoomId}`);
490
- } catch (error) {
491
- logger.error('获取或广播Git状态失败:', error);
492
- }
493
- });
494
- }
495
-
496
- // 检查当前目录是否是Git仓库
497
- let isGitRepo = false;
498
- try {
499
- const { stdout } = await execGitCommand(['rev-parse', '--is-inside-work-tree'], { log: false });
500
- isGitRepo = stdout.trim() === 'true';
501
- } catch (error) {
502
- isGitRepo = false;
503
- console.log(chalk.yellow('======================================'));
504
- console.log(chalk.yellow(` 提示: 当前目录不是Git仓库`));
505
- console.log(chalk.yellow(` 目录: ${process.cwd()}`));
506
- console.log(chalk.yellow('======================================'));
507
- }
508
-
509
- // 启动服务器
510
- // 端口策略:默认从 [4000, 6000) 随机挑起点,再顺序扫描 EADDRINUSE;
511
- // 可通过 PORT 环境变量强制使用固定端口(向后兼容 + 便于书签/调试)
512
- const portStrategy = resolveStartPort();
513
- if (portStrategy.source === 'env') {
514
- console.log(chalk.cyan(`[端口] 使用环境变量 PORT=${portStrategy.startPort}`));
515
- } else {
516
- console.log(chalk.cyan(`[端口] 随机起点 ${portStrategy.startPort}(范围 ${portStrategy.min}-${portStrategy.max},遇到占用会顺延)`));
517
- }
518
-
519
- // 创建一个函数来保存端口号到文件和环境变量
520
- // 使用闭包保存端口状态,防止多次写入相同端口
521
- const savePortToFile = createSavePortToFile({ savePort, fs, path });
522
- // 使用变量标记回调是否已执行,防止多次触发
523
- const callbackExecutedRef = { value: false };
524
-
525
- // 用 'listening' 事件做注册触发:startServerOnAvailablePort 的 await
526
- // 在端口重试场景下不一定按时返回,但 'listening' 事件只在服务器真正绑定端口时触发
527
- let registerDone = false;
528
- httpServer.once('listening', () => {
529
- if (registerDone) return;
530
- registerDone = true;
531
- registerCurrentInstance().catch((e) => {
532
- console.warn(chalk.yellow(`[instanceRegistry] 启动注册流程失败: ${e?.message || e}`));
533
- });
534
- });
535
-
536
- // 尝试在可用端口上启动服务器(不等待;listen 事件会驱动后续逻辑)
537
- startServerOnAvailablePort({
538
- httpServer,
539
- startPort: portStrategy.startPort,
540
- chalk,
541
- open,
542
- noOpen,
543
- isGitRepo,
544
- savePortToFile,
545
- fsSync,
546
- maxTries: 100,
547
- callbackExecutedRef
548
- }).catch((e) => {
549
- logger.error('启动服务器失败:', e);
550
- });
551
-
552
- // 把所有注册/心跳/watch 逻辑封装到独立函数,由 listening 事件触发
553
- async function registerCurrentInstance() {
554
- const addr = httpServer.address();
555
- const currentPort = addr && addr.port;
556
- if (!currentPort) {
557
- console.warn(chalk.yellow('[instanceRegistry] 无法获取当前端口,跳过注册'));
558
- return;
559
- }
560
-
561
- try {
562
- const projectName = await instanceRegistry._resolveProjectName(currentProjectPath);
563
- await instanceRegistry.register({
564
- pid: process.pid,
565
- port: currentPort,
566
- projectPath: currentProjectPath,
567
- projectName,
568
- hostname: os.hostname()
569
- });
570
- console.log(chalk.green(`[instanceRegistry] 已注册 pid=${process.pid} port=${currentPort} name=${projectName}`));
571
- } catch (e) {
572
- console.warn(chalk.yellow(`[instanceRegistry] 注册失败: ${e?.message || e}`));
573
- return;
574
- }
575
-
576
- // 5 秒心跳
577
- const heartbeatTimer = setInterval(() => {
578
- instanceRegistry.heartbeat(process.pid, { projectPath: currentProjectPath })
579
- .catch((e) => console.warn(chalk.yellow(`[instanceRegistry] 心跳失败: ${e?.message || e}`)));
580
- }, 5000);
581
-
582
- // 监听注册表文件变化:任何进程写入都会触发,向本进程的所有客户端广播
583
- // fs.watch 在 Windows 上偶有不可靠,Socket.IO 推送 + 前端轮询(15s)兜底
584
- const stopWatcher = instanceRegistry.watch(async (fresh) => {
585
- try {
586
- io.emit('instances_changed', { instances: fresh });
587
- } catch (e) {
588
- console.warn(chalk.yellow(`[instanceRegistry] 广播失败: ${e?.message || e}`));
589
- }
590
- }, fsSync.watch);
591
-
592
- // 优雅退出:SIGINT/SIGTERM 触发 drain 子进程 + unregister + 清心跳
593
- // 幂等保护:连按 Ctrl+C SIGINT+SIGTERM 同来时只走一次清理,
594
- // 避免重复 drain(3s)、重复 unregister、排多个 setTimeout(exit)。
595
- // 与上方 _fatalExitInProgress 同思路,但作用域在本次 startUIServer 调用内。
596
- let _shuttingDown = false;
597
- const shutdown = async (signal) => {
598
- if (_shuttingDown) return;
599
- _shuttingDown = true;
600
- console.log(chalk.gray(`[shutdown] 收到 ${signal},开始清理…`));
601
- // 1) 先并行 SIGTERM 所有正在跑的子进程(限时 3s),防止 claude/npm/git 变孤儿。
602
- // 注意:返回值是"尝试终止数"而非"实际终止数" drain 内部用
603
- // Promise.race 卡总时长,3s 到了就返回,可能仍有进程在 SIGTERM 退出中。
604
- try {
605
- const killed = await drainRunningProcesses(3000);
606
- if (killed > 0) console.log(chalk.gray(`[shutdown] 已尝试终止 ${killed} 个子进程(实际终止情况以 exit 事件为准)`));
607
- } catch (_) {}
608
- // 2) 清心跳 + 反注册实例
609
- try { clearInterval(heartbeatTimer); } catch (_) {}
610
- try { await instanceRegistry.unregister(process.pid); } catch (_) {}
611
- console.log(chalk.gray(`[shutdown] 收到 ${signal},已清理本实例`));
612
- // 3) OS 100ms 刷盘日志
613
- setTimeout(() => process.exit(0), 100);
614
- };
615
- process.on('SIGINT', () => { shutdown('SIGINT'); });
616
- process.on('SIGTERM', () => { shutdown('SIGTERM'); });
617
- // 'exit' 是同步钩子,无法 await:仅关闭 watcher;kill -9 走心跳超时
618
- process.on('exit', () => {
619
- try { stopWatcher && stopWatcher(); } catch (_) {}
620
- });
621
- }
622
-
623
- }
624
-
625
- export default startUIServer;
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import express from 'express';
16
+ import logger from './utils/logger.js'
17
+ import { createServer } from 'http';
18
+ import { fileURLToPath } from 'url';
19
+ import path from 'path';
20
+ import { execGitCommand, getCommandHistory, addCommandToHistory, clearCommandHistory, registerSocketIO, execGitAddWithLockFilter, checkAndClearGitLock } from '../../utils/index.js';
21
+ import open from 'open';
22
+ import config from '../../config.js';
23
+ import chalk from 'chalk';
24
+ import fs from 'fs/promises';
25
+ import fsSync from 'fs';
26
+ import os from 'os';
27
+ import { Server } from 'socket.io';
28
+ import { spawn, exec } from 'child_process';
29
+ import iconv from 'iconv-lite';
30
+ import { createRequestLogger } from './middleware/requestLogger.js';
31
+ import { createErrorHandler } from './middleware/errorHandler.js';
32
+ import { registerUiSocketHandlers } from './socket/registerUiSocketHandlers.js';
33
+ import { registerExecRoutes } from './routes/exec.js';
34
+ import { registerTerminalRoutes } from './routes/terminal.js';
35
+ import { registerProcessRoutes } from './routes/process.js';
36
+ import { registerStatusRoutes } from './routes/status.js';
37
+ import { registerBranchStatusRoutes } from './routes/branchStatus.js';
38
+ import { registerConfigRoutes } from './routes/config.js';
39
+ import { registerGitRoutes } from './routes/git.js';
40
+ import { registerFsRoutes } from './routes/fs.js';
41
+ import { registerNpmRoutes } from './routes/npm.js';
42
+ import { registerFileOpenRoutes } from './routes/fileOpen.js';
43
+ import { registerWorkbenchRoutes } from './routes/workbench.js';
44
+ import { registerGitOpsRoutes } from './routes/gitOps.js';
45
+ import { registerCodeRoutes } from './routes/code.js';
46
+ import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
47
+ import { registerInstancesRoutes } from './routes/instances.js';
48
+ import { registerMonitorRoutes } from './routes/monitor.js';
49
+ import { registerMindmapRoutes } from './routes/mindmap.js';
50
+ import { createInstanceRegistry } from './utils/instanceRegistry.js';
51
+ import { createSavePortToFile } from './utils/createSavePortToFile.js';
52
+ import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
53
+ import { resolveStartPort } from './utils/randomStartPort.js';
54
+ import { createFilePickerMiddleware } from 'local-file-picker';
55
+ import { createAiModelMiddleware } from 'ai-model-form';
56
+
57
+ const __filename = fileURLToPath(import.meta.url);
58
+ const __dirname = path.dirname(__filename);
59
+ const configManager = config; // 确保 configManager 可用
60
+ // 存储正在运行的进程(用于停止功能)
61
+ const runningProcesses = new Map(); // key: processId, value: { childProcess, command, startTime }
62
+ let processIdCounter = 0;
63
+
64
+ const terminalSessions = new Map(); // key: terminalSessionId, value: { id, command, workingDirectory, pid, createdAt, lastStartedAt }
65
+ let terminalSessionIdCounter = 0;
66
+
67
+ /**
68
+ * 终止并等待单个子进程退出
69
+ * @param {import('child_process').ChildProcess} child
70
+ * @param {number} timeoutMs 等待 SIGTERM 生效的最大时间,超时后发 SIGKILL
71
+ * @returns {Promise<void>}
72
+ */
73
+ function killChildProcess(child, timeoutMs = 3000) {
74
+ return new Promise((resolve) => {
75
+ if (!child || (child.exitCode !== null && child.exitCode !== undefined)) {
76
+ return resolve();
77
+ }
78
+ let done = false;
79
+ const finish = () => {
80
+ if (done) return;
81
+ done = true;
82
+ clearTimeout(timer);
83
+ child.removeListener('exit', finish);
84
+ resolve();
85
+ };
86
+ child.once('exit', finish);
87
+ try {
88
+ // SIGTERM 优雅退出,被 Node 在 Windows 上转 TerminateProcess
89
+ child.kill('SIGTERM');
90
+ } catch (_) {
91
+ finish();
92
+ return;
93
+ }
94
+ // 超时未退就 SIGKILL 强杀
95
+ const timer = setTimeout(() => {
96
+ try { child.kill('SIGKILL'); } catch (_) {}
97
+ // 给 SIGKILL 200ms 兜底,再不退也不等了
98
+ setTimeout(finish, 200);
99
+ }, timeoutMs);
100
+ });
101
+ }
102
+
103
+ /**
104
+ * 并行终止 runningProcesses 内所有子进程,总等待时间不超过 timeoutMs。
105
+ * 防止 SIGINT/SIGTERM 关闭 server 时,正在跑的 claude/npm/git 子进程变孤儿。
106
+ *
107
+ * 注意:返回值是"尝试终止的进程数",不等于"已实际退出数"。内部用
108
+ * Promise.race 卡总时长,timeoutMs 到了就返回,此时可能仍有子进程
109
+ * 处于 SIGTERM → exit 的过渡中(尤其 Windows 上 TerminateProcess 是
110
+ * 异步的)。调用方日志应使用"已尝试终止"措辞,避免排查孤儿进程时被误导。
111
+ *
112
+ * @param {number} timeoutMs
113
+ * @returns {Promise<number>} 被尝试终止的进程数(entries.length)
114
+ */
115
+ async function drainRunningProcesses(timeoutMs = 5000) {
116
+ const entries = Array.from(runningProcesses.entries());
117
+ if (entries.length === 0) return 0;
118
+ // 先并行发 SIGTERM,再并行等;整批用 Promise.race 卡最大时长
119
+ const killPromises = entries.map(([, info]) => killChildProcess(info.childProcess, timeoutMs).catch(() => {}));
120
+ await Promise.race([
121
+ Promise.all(killPromises),
122
+ new Promise((r) => setTimeout(r, timeoutMs))
123
+ ]);
124
+ return entries.length;
125
+ }
126
+
127
+ // 全局错误处理 幂等,只允许进入 fatalExit 一次
128
+ let _fatalExitInProgress = false;
129
+
130
+ /**
131
+ * 注册 process.on('uncaughtException') / unhandledRejection 处理器
132
+ *
133
+ * 触发时的清理链路(防止留下孤儿子进程 / 假死 registry / 端口文件残留):
134
+ * 1) drainRunningProcesses(3s)并行 SIGTERM 杀掉所有 claude/npm/git 子进程
135
+ * 2) instanceRegistry.unregister把自己从实例表里摘掉
136
+ * 3) setTimeout 250ms 退 — 给 logger/console 一点时间把日志刷盘
137
+ *
138
+ * @param {Object} opts
139
+ * @param {ReturnType<typeof createInstanceRegistry>} opts.instanceRegistry
140
+ */
141
+ function setupGlobalErrorHandlers({ instanceRegistry }) {
142
+ // 幂等:多个错误同时触发时只走一次清理,避免清理动作自身再次触发 fatalExit
143
+ const fatalExit = async (reason, err) => {
144
+ if (_fatalExitInProgress) return;
145
+ _fatalExitInProgress = true;
146
+ try {
147
+ logger.error(`[FATAL] ${reason}: ${err?.message || err}`);
148
+ if (err?.stack) logger.error(err.stack);
149
+ } catch (_) { /* logger 自身炸了,放弃 */ }
150
+ // 1) 杀光所有子进程
151
+ try {
152
+ const killed = await drainRunningProcesses(3000);
153
+ if (killed > 0) logger.warn(`[FATAL] 已尝试终止 ${killed} 个运行中的子进程`);
154
+ } catch (_) {}
155
+ // 2) 反注册实例
156
+ if (instanceRegistry) {
157
+ try { await instanceRegistry.unregister(process.pid); } catch (_) {}
158
+ }
159
+ // 3) OS 250ms 刷盘日志,然后非 0 退出
160
+ setTimeout(() => process.exit(1), 250);
161
+ };
162
+
163
+ process.on('uncaughtException', (err, origin) => {
164
+ // 同步处理路径:直接调 fatalExit,内部 await 会进入 microtask
165
+ fatalExit(`uncaughtException (${origin || 'uncaught'})`, err);
166
+ });
167
+ process.on('unhandledRejection', (reason) => {
168
+ fatalExit('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
169
+ });
170
+ }
171
+
172
+ // 分支状态缓存
173
+ let branchStatusCache = {
174
+ currentBranch: null,
175
+ upstreamBranch: null,
176
+ lastUpdate: 0,
177
+ cacheTimeout: 5000 // 5秒缓存
178
+ };
179
+
180
+ // 推送状态标记 - 用于优化推送后的分支状态查询
181
+ let recentPushStatus = {
182
+ justPushed: false,
183
+ pushTime: 0,
184
+ validDuration: 10000 // 推送后10秒内认为分支状态是同步的
185
+ };
186
+
187
+ const showConsole = true;
188
+ async function startUIServer(noOpen = false, savePort = false) {
189
+ const app = express();
190
+ const httpServer = createServer(app);
191
+
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ // Socket.IO CORS / origin 收紧
194
+ //
195
+ // 默认 Socket.IO 接受任意 origin,跨域页面也能连上 → CSRF / 跨站攻击面
196
+ // 暴露给恶意页面。这里只允许同源 + 127.0.0.1 / localhost。
197
+ // 用环境变量 ZEN_ALLOWED_ORIGINS 可以追加自定义来源(逗号分隔),
198
+ // CI / 远程调试场景用。
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ const DEFAULT_ALLOWED_ORIGINS = [
201
+ 'http://localhost',
202
+ 'https://localhost',
203
+ 'http://127.0.0.1',
204
+ 'https://127.0.0.1',
205
+ 'http://[::1]',
206
+ 'null', // 同源 file:// 等场景
207
+ ];
208
+ const extraOrigins = String(process.env.ZEN_ALLOWED_ORIGINS || '')
209
+ .split(',').map(s => s.trim()).filter(Boolean);
210
+ const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, ...extraOrigins]);
211
+
212
+ function isOriginAllowed(origin) {
213
+ if (!origin) return true; // 同源 / 非浏览器客户端不传 Origin
214
+ if (allowedOrigins.has(origin)) return true;
215
+ // 允许 localhost/127.0.0.1/[::1] 上的任意端口(开发常换端口)
216
+ try {
217
+ const u = new URL(origin);
218
+ if (['localhost', '127.0.0.1', '[::1]'].includes(u.hostname)) return true;
219
+ } catch { /* ignore */ }
220
+ return false;
221
+ }
222
+
223
+ const io = new Server(httpServer, {
224
+ cors: {
225
+ origin: (origin, cb) => {
226
+ if (isOriginAllowed(origin)) return cb(null, true);
227
+ logger.warn(`[Socket.IO] 拒绝跨域 origin: ${origin}`);
228
+ return cb(new Error('Origin not allowed'), false);
229
+ },
230
+ methods: ['GET', 'POST'],
231
+ credentials: false,
232
+ },
233
+ });
234
+ if (showConsole) logger.info(`创建服务成功`)
235
+
236
+ // 获取当前项目的唯一标识(使用工作目录路径)
237
+ // 需要在切换目录时更新,故使用 let
238
+ let currentProjectPath = process.cwd();
239
+ let projectRoomId = `project:${currentProjectPath.replace(/[\\/:\s]/g, '_')}`;
240
+
241
+ console.log(chalk.blue(`项目房间ID: ${projectRoomId}`));
242
+ console.log(chalk.blue(`项目路径: ${currentProjectPath}`));
243
+
244
+ // 注册Socket.io实例,用于命令历史通知
245
+ registerSocketIO(io);
246
+
247
+ // 构建实例注册表(用于跨进程共享"当前运行中的 GUI"信息)
248
+ // 注册表文件位于用户主目录,所有 g ui 进程共享写入
249
+ const instanceRegistry = createInstanceRegistry({
250
+ fs,
251
+ path,
252
+ os,
253
+ registryPath: path.join(os.homedir(), '.zen-gitsync-instances.json')
254
+ });
255
+
256
+ // 注册全局错误处理器:任意未捕获异常/拒绝都会触发 fatalExit,
257
+ // drainRunningProcesses + unregister + 延迟 250ms 退出的链路。
258
+ // instanceRegistry 创建之后立即注册,这样后续 route 注册阶段抛出
259
+ // 也能被同一个处理器捕获(否则会无声崩,留下子进程孤儿与 .port 残留)
260
+ setupGlobalErrorHandlers({ instanceRegistry });
261
+
262
+ // 添加全局中间件来解析JSON请求体
263
+ app.use(express.json());
264
+
265
+ // 记录最近打开的目录(优先 Git 根目录,其次当前工作目录)
266
+ try {
267
+ let dirPath = process.cwd();
268
+ try {
269
+ if(showConsole) logger.info(`记录最近打开目录`)
270
+ const { stdout } = await execGitCommand(['rev-parse', '--show-toplevel']);
271
+ const root = stdout?.trim();
272
+ if (root) dirPath = root;
273
+ } catch (_) {
274
+ // 非Git仓库或命令失败,使用 CWD 即可
275
+ }
276
+ if (showConsole) logger.info(`记录最近打开目录: ${dirPath}`)
277
+ await configManager.saveRecentDirectory(dirPath);
278
+ if (showConsole) console.log(chalk.gray(`已记录最近打开目录: ${dirPath}`));
279
+ } catch (e) {
280
+ console.warn(chalk.yellow(`记录最近目录失败: ${e?.message || e}`));
281
+ }
282
+
283
+ // 添加请求日志中间件
284
+ app.use(createRequestLogger({ chalk }));
285
+
286
+ // 静态文件服务
287
+ app.use(express.static(path.join(__dirname, '../public')));
288
+
289
+ registerExecRoutes({
290
+ app,
291
+ execGitCommand,
292
+ addCommandToHistory,
293
+ getCurrentProjectPath: () => currentProjectPath,
294
+ // 用 pre-increment 从 1 起,避免首进程拿到 id=0(0 在 `if (processId)` 这类真值检查里会被吞)
295
+ nextProcessId: () => ++processIdCounter,
296
+ runningProcesses
297
+ });
298
+
299
+ registerCodeRoutes({
300
+ app
301
+ });
302
+
303
+ registerTerminalRoutes({
304
+ app,
305
+ getCurrentProjectPath: () => currentProjectPath,
306
+ nextTerminalSessionId: () => terminalSessionIdCounter++,
307
+ terminalSessions
308
+ });
309
+
310
+ registerProcessRoutes({
311
+ app,
312
+ runningProcesses,
313
+ });
314
+
315
+ // API路由
316
+ // 移除了 /api/status 端点,因为前端只使用 porcelain 格式
317
+ registerStatusRoutes({
318
+ app,
319
+ getCommandHistory,
320
+ execGitCommand
321
+ });
322
+
323
+ registerBranchStatusRoutes({
324
+ app,
325
+ execGitCommand,
326
+ getIsGitRepo: () => isGitRepo,
327
+ getBranchStatusCache: () => branchStatusCache,
328
+ setBranchStatusCache: (v) => { branchStatusCache = v; },
329
+ getRecentPushStatus: () => recentPushStatus,
330
+ setRecentPushStatus: (v) => { recentPushStatus = v; }
331
+ });
332
+
333
+ // 清除分支缓存的函数(在分支切换时调用)
334
+ function clearBranchCache() {
335
+ logger.info('清除分支缓存');
336
+ // 清除5秒分支状态缓存
337
+ branchStatusCache = {
338
+ currentBranch: null,
339
+ upstreamBranch: null,
340
+ lastUpdate: 0,
341
+ cacheTimeout: 5000
342
+ };
343
+ // 清除推送状态标记
344
+ recentPushStatus = {
345
+ justPushed: false,
346
+ pushTime: 0,
347
+ validDuration: 10000
348
+ };
349
+ }
350
+
351
+ registerGitRoutes({
352
+ app,
353
+ execGitCommand,
354
+ clearBranchCache
355
+ });
356
+
357
+ registerFsRoutes({
358
+ app,
359
+ execGitCommand,
360
+ configManager,
361
+ io,
362
+ getCurrentProjectPath: () => currentProjectPath,
363
+ setCurrentProjectPath: (v) => {
364
+ currentProjectPath = v;
365
+ // project 切换后清掉 _lastBroadcastedPorcelain 缓存,
366
+ // 否则两个 project 都是 clean 时(porcelain 都是 '')会误判
367
+ // 内容未变 → 漏推新 projectPath
368
+ _lastBroadcastedPorcelain = null;
369
+ },
370
+ getProjectRoomId: () => projectRoomId,
371
+ setProjectRoomId: (v) => { projectRoomId = v; },
372
+ setIsGitRepo: (v) => { isGitRepo = v; }
373
+ });
374
+
375
+ registerConfigRoutes({
376
+ app,
377
+ configManager,
378
+ execGitCommand,
379
+ getCurrentProjectPath: () => currentProjectPath
380
+ });
381
+
382
+ registerNpmRoutes({
383
+ app,
384
+ getCurrentProjectPath: () => currentProjectPath
385
+ });
386
+
387
+ registerFileOpenRoutes({
388
+ app
389
+ });
390
+
391
+ registerWorkbenchRoutes({
392
+ app,
393
+ getCurrentProjectPath: () => currentProjectPath,
394
+ getProjectRoomId: () => projectRoomId,
395
+ io,
396
+ configManager
397
+ });
398
+
399
+ // local-file-picker 中间件,提供 /api/fs/* 文件浏览路由
400
+ app.use('/api', createFilePickerMiddleware());
401
+
402
+ // ai-model-form 中间件:提供 /api/ai-model/* 路由(模型列表/测试/暂存保存)
403
+ app.use('/api', createAiModelMiddleware());
404
+
405
+ registerCodeAnalysisRoutes({ app, configManager, getCurrentProjectPath: () => currentProjectPath });
406
+
407
+ // 实例注册表 API:列出当前所有运行中的 GUI
408
+ registerInstancesRoutes({
409
+ app,
410
+ registry: instanceRegistry,
411
+ getCurrentInstanceId: () => process.pid
412
+ });
413
+
414
+ registerGitOpsRoutes({
415
+ app,
416
+ execGitCommand,
417
+ configManager,
418
+ execGitAddWithLockFilter,
419
+ addCommandToHistory,
420
+ clearCommandHistory,
421
+ checkAndClearGitLock,
422
+ getIsGitRepo: () => isGitRepo,
423
+ setRecentPushStatus: (v) => { recentPushStatus = v; }
424
+ });
425
+
426
+ // 系统监控:CPU / 内存 / 端口占用 + kill 进程
427
+ registerMonitorRoutes({ app });
428
+
429
+ // 思维导图:.mindmap.json 文件的列/读/写/建/删/重命名
430
+ registerMindmapRoutes({ app });
431
+
432
+ // 全局错误处理中间件:必须放在所有 register*Routes 之后注册,
433
+ // 作为最后一道兜底捕获 asyncRoute 之外抛出的异常(headersSent 走 express 默认关闭)。
434
+ // 与 asyncRoute 共享同一套错误响应格式 {success:false, error}。
435
+ app.use(createErrorHandler());
436
+
437
+ registerUiSocketHandlers({
438
+ io,
439
+ getProjectRoomId: () => projectRoomId,
440
+ getCurrentProjectPath: () => currentProjectPath,
441
+ getAndBroadcastStatus,
442
+ getCommandHistory,
443
+ clearCommandHistory,
444
+ addCommandToHistory,
445
+ runningProcesses,
446
+ nextProcessId: () => ++processIdCounter,
447
+ spawn,
448
+ exec,
449
+ path,
450
+ iconv
451
+ });
452
+
453
+ // 获取并广播Git状态 (优化版本 - 只获取porcelain格式)
454
+ // 去重:50ms 内的多次调用合并为一次 git status + 一次广播。
455
+ // 触发场景:页面刷新风暴(打开 GUI 后浏览器自动重连 3-5 次),
456
+ // 或前端多 tab 同时连接。N 个客户端连进来 = N 次 git status 子进程,
457
+ // 每次 ~30-80ms,合并后只跑 1 次。
458
+ let _statusBroadcastPending = false;
459
+ let _lastBroadcastedPorcelain = null;
460
+ async function getAndBroadcastStatus() {
461
+ if (_statusBroadcastPending) return;
462
+ _statusBroadcastPending = true;
463
+ // 用 setImmediate 让同一 tick 内的多次调用合并(更短的等待)
464
+ setImmediate(async () => {
465
+ _statusBroadcastPending = false;
466
+ try {
467
+ // 如果不是Git仓库,发送特殊状态
468
+ if (!isGitRepo) {
469
+ io.to(projectRoomId).emit('git_status_update', {
470
+ isGitRepo: false,
471
+ porcelain: '',
472
+ timestamp: new Date().toISOString(),
473
+ projectPath: currentProjectPath
474
+ });
475
+ return;
476
+ }
477
+
478
+ // 只获取porcelain格式状态,不再获取完整的git status
479
+ const { stdout: porcelainOutput } = await execGitCommand(['status', '--porcelain', '--untracked-files=all']);
480
+
481
+ // 内容未变就不广播 — 防止每次前端 reconnect 都推一份完全一样的字符串
482
+ // 跨项目切换时 isGitRepo/projectPath 会变,这里只按内容去重
483
+ if (porcelainOutput === _lastBroadcastedPorcelain) {
484
+ return;
485
+ }
486
+ _lastBroadcastedPorcelain = porcelainOutput;
487
+
488
+ // 广播到当前项目房间的所有客户端
489
+ io.to(projectRoomId).emit('git_status_update', {
490
+ isGitRepo: true,
491
+ porcelain: porcelainOutput,
492
+ timestamp: new Date().toISOString(),
493
+ projectPath: currentProjectPath
494
+ });
495
+
496
+ logger.info(`已广播Git状态更新到房间: ${projectRoomId}`);
497
+ } catch (error) {
498
+ logger.error('获取或广播Git状态失败:', error);
499
+ }
500
+ });
501
+ }
502
+
503
+ // 检查当前目录是否是Git仓库
504
+ let isGitRepo = false;
505
+ try {
506
+ const { stdout } = await execGitCommand(['rev-parse', '--is-inside-work-tree'], { log: false });
507
+ isGitRepo = stdout.trim() === 'true';
508
+ } catch (error) {
509
+ isGitRepo = false;
510
+ console.log(chalk.yellow('======================================'));
511
+ console.log(chalk.yellow(` 提示: 当前目录不是Git仓库`));
512
+ console.log(chalk.yellow(` 目录: ${process.cwd()}`));
513
+ console.log(chalk.yellow('======================================'));
514
+ }
515
+
516
+ // 启动服务器
517
+ // 端口策略:默认从 [4000, 6000) 随机挑起点,再顺序扫描 EADDRINUSE;
518
+ // 可通过 PORT 环境变量强制使用固定端口(向后兼容 + 便于书签/调试)
519
+ const portStrategy = resolveStartPort();
520
+ if (portStrategy.source === 'env') {
521
+ console.log(chalk.cyan(`[端口] 使用环境变量 PORT=${portStrategy.startPort}`));
522
+ } else {
523
+ console.log(chalk.cyan(`[端口] 随机起点 ${portStrategy.startPort}(范围 ${portStrategy.min}-${portStrategy.max},遇到占用会顺延)`));
524
+ }
525
+
526
+ // 创建一个函数来保存端口号到文件和环境变量
527
+ // 使用闭包保存端口状态,防止多次写入相同端口
528
+ const savePortToFile = createSavePortToFile({ savePort, fs, path });
529
+ // 使用变量标记回调是否已执行,防止多次触发
530
+ const callbackExecutedRef = { value: false };
531
+
532
+ // 'listening' 事件做注册触发:startServerOnAvailablePort 的 await
533
+ // 在端口重试场景下不一定按时返回,但 'listening' 事件只在服务器真正绑定端口时触发
534
+ let registerDone = false;
535
+ httpServer.once('listening', () => {
536
+ if (registerDone) return;
537
+ registerDone = true;
538
+ registerCurrentInstance().catch((e) => {
539
+ console.warn(chalk.yellow(`[instanceRegistry] 启动注册流程失败: ${e?.message || e}`));
540
+ });
541
+ });
542
+
543
+ // 尝试在可用端口上启动服务器(不等待;listen 事件会驱动后续逻辑)
544
+ startServerOnAvailablePort({
545
+ httpServer,
546
+ startPort: portStrategy.startPort,
547
+ chalk,
548
+ open,
549
+ noOpen,
550
+ isGitRepo,
551
+ savePortToFile,
552
+ fsSync,
553
+ maxTries: 100,
554
+ callbackExecutedRef
555
+ }).catch((e) => {
556
+ logger.error('启动服务器失败:', e);
557
+ });
558
+
559
+ // 把所有注册/心跳/watch 逻辑封装到独立函数,由 listening 事件触发
560
+ async function registerCurrentInstance() {
561
+ const addr = httpServer.address();
562
+ const currentPort = addr && addr.port;
563
+ if (!currentPort) {
564
+ console.warn(chalk.yellow('[instanceRegistry] 无法获取当前端口,跳过注册'));
565
+ return;
566
+ }
567
+
568
+ try {
569
+ const projectName = await instanceRegistry._resolveProjectName(currentProjectPath);
570
+ await instanceRegistry.register({
571
+ pid: process.pid,
572
+ port: currentPort,
573
+ projectPath: currentProjectPath,
574
+ projectName,
575
+ hostname: os.hostname()
576
+ });
577
+ console.log(chalk.green(`[instanceRegistry] 已注册 pid=${process.pid} port=${currentPort} name=${projectName}`));
578
+ } catch (e) {
579
+ console.warn(chalk.yellow(`[instanceRegistry] 注册失败: ${e?.message || e}`));
580
+ return;
581
+ }
582
+
583
+ // 5 秒心跳
584
+ const heartbeatTimer = setInterval(() => {
585
+ instanceRegistry.heartbeat(process.pid, { projectPath: currentProjectPath })
586
+ .catch((e) => console.warn(chalk.yellow(`[instanceRegistry] 心跳失败: ${e?.message || e}`)));
587
+ }, 5000);
588
+
589
+ // 监听注册表文件变化:任何进程写入都会触发,向本进程的所有客户端广播
590
+ // fs.watch 在 Windows 上偶有不可靠,Socket.IO 推送 + 前端轮询(15s)兜底
591
+ const stopWatcher = instanceRegistry.watch(async (fresh) => {
592
+ try {
593
+ io.emit('instances_changed', { instances: fresh });
594
+ } catch (e) {
595
+ console.warn(chalk.yellow(`[instanceRegistry] 广播失败: ${e?.message || e}`));
596
+ }
597
+ }, fsSync.watch);
598
+
599
+ // 优雅退出:SIGINT/SIGTERM 触发 drain 子进程 + unregister + 清心跳
600
+ // 幂等保护:连按 Ctrl+C 或 SIGINT+SIGTERM 同来时只走一次清理,
601
+ // 避免重复 drain(3s)、重复 unregister、排多个 setTimeout(exit)。
602
+ // 与上方 _fatalExitInProgress 同思路,但作用域在本次 startUIServer 调用内。
603
+ let _shuttingDown = false;
604
+ const shutdown = async (signal) => {
605
+ if (_shuttingDown) return;
606
+ _shuttingDown = true;
607
+ console.log(chalk.gray(`[shutdown] 收到 ${signal},开始清理…`));
608
+ // 1) 先并行 SIGTERM 所有正在跑的子进程(限时 3s),防止 claude/npm/git 变孤儿。
609
+ // 注意:返回值是"尝试终止数"而非"实际终止数" drain 内部用
610
+ // Promise.race 卡总时长,3s 到了就返回,可能仍有进程在 SIGTERM 退出中。
611
+ try {
612
+ const killed = await drainRunningProcesses(3000);
613
+ if (killed > 0) console.log(chalk.gray(`[shutdown] 已尝试终止 ${killed} 个子进程(实际终止情况以 exit 事件为准)`));
614
+ } catch (_) {}
615
+ // 2) 清心跳 + 反注册实例
616
+ try { clearInterval(heartbeatTimer); } catch (_) {}
617
+ try { await instanceRegistry.unregister(process.pid); } catch (_) {}
618
+ console.log(chalk.gray(`[shutdown] 收到 ${signal},已清理本实例`));
619
+ // 3) OS 100ms 刷盘日志
620
+ setTimeout(() => process.exit(0), 100);
621
+ };
622
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
623
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
624
+ // 'exit' 是同步钩子,无法 await:仅关闭 watcher;kill -9 走心跳超时
625
+ process.on('exit', () => {
626
+ try { stopWatcher && stopWatcher(); } catch (_) {}
627
+ });
628
+ }
629
+
630
+ }
631
+
632
+ export default startUIServer;