zen-gitsync 2.11.30 → 2.11.32

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.
@@ -10,11 +10,11 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-LS9fheYp.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-BNd_56JT.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BM3Ffeng.js">
15
- <link rel="modulepreload" crossorigin href="/assets/vendor-BGRFri1B.js">
16
- <link rel="stylesheet" crossorigin href="/assets/vendor-CKD7ZwC_.css">
17
- <link rel="stylesheet" crossorigin href="/assets/index-BEBp0Z-C.css">
15
+ <link rel="modulepreload" crossorigin href="/assets/vendor-DTKP57oj.js">
16
+ <link rel="stylesheet" crossorigin href="/assets/vendor-7wb-m8Qu.css">
17
+ <link rel="stylesheet" crossorigin href="/assets/index-DAGcNJIG.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="app"></div>
@@ -27,6 +27,8 @@ import { registerFileOpenRoutes } from './routes/fileOpen.js';
27
27
  import { registerGitOpsRoutes } from './routes/gitOps.js';
28
28
  import { registerCodeRoutes } from './routes/code.js';
29
29
  import { registerCodeAnalysisRoutes } from './routes/codeAnalysis.js';
30
+ import { registerInstancesRoutes } from './routes/instances.js';
31
+ import { createInstanceRegistry } from './utils/instanceRegistry.js';
30
32
  import { createSavePortToFile } from './utils/createSavePortToFile.js';
31
33
  import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
32
34
  import { createFilePickerMiddleware } from 'local-file-picker';
@@ -74,7 +76,16 @@ async function startUIServer(noOpen = false, savePort = false) {
74
76
 
75
77
  // 注册Socket.io实例,用于命令历史通知
76
78
  registerSocketIO(io);
77
-
79
+
80
+ // 构建实例注册表(用于跨进程共享"当前运行中的 GUI"信息)
81
+ // 注册表文件位于用户主目录,所有 g ui 进程共享写入
82
+ const instanceRegistry = createInstanceRegistry({
83
+ fs,
84
+ path,
85
+ os,
86
+ registryPath: path.join(os.homedir(), '.zen-gitsync-instances.json')
87
+ });
88
+
78
89
  // 添加全局中间件来解析JSON请求体
79
90
  app.use(express.json());
80
91
 
@@ -203,6 +214,13 @@ async function startUIServer(noOpen = false, savePort = false) {
203
214
 
204
215
  registerCodeAnalysisRoutes({ app, configManager });
205
216
 
217
+ // 实例注册表 API:列出当前所有运行中的 GUI
218
+ registerInstancesRoutes({
219
+ app,
220
+ registry: instanceRegistry,
221
+ getCurrentInstanceId: () => process.pid
222
+ });
223
+
206
224
  registerGitOpsRoutes({
207
225
  app,
208
226
  execGitCommand,
@@ -276,15 +294,26 @@ async function startUIServer(noOpen = false, savePort = false) {
276
294
 
277
295
  // 启动服务器
278
296
  const PORT = 3000;
279
-
297
+
280
298
  // 创建一个函数来保存端口号到文件和环境变量
281
299
  // 使用闭包保存端口状态,防止多次写入相同端口
282
300
  const savePortToFile = createSavePortToFile({ savePort, fs, path });
283
301
  // 使用变量标记回调是否已执行,防止多次触发
284
302
  const callbackExecutedRef = { value: false };
285
-
286
- // 尝试在可用端口上启动服务器
287
- await startServerOnAvailablePort({
303
+
304
+ // 用 'listening' 事件做注册触发:startServerOnAvailablePort 的 await
305
+ // 在端口重试场景下不一定按时返回,但 'listening' 事件只在服务器真正绑定端口时触发
306
+ let registerDone = false;
307
+ httpServer.once('listening', () => {
308
+ if (registerDone) return;
309
+ registerDone = true;
310
+ registerCurrentInstance().catch((e) => {
311
+ console.warn(chalk.yellow(`[instanceRegistry] 启动注册流程失败: ${e?.message || e}`));
312
+ });
313
+ });
314
+
315
+ // 尝试在可用端口上启动服务器(不等待;listen 事件会驱动后续逻辑)
316
+ startServerOnAvailablePort({
288
317
  httpServer,
289
318
  startPort: PORT,
290
319
  chalk,
@@ -294,8 +323,64 @@ async function startUIServer(noOpen = false, savePort = false) {
294
323
  savePortToFile,
295
324
  maxTries: 100,
296
325
  callbackExecutedRef
326
+ }).catch((e) => {
327
+ console.error('启动服务器失败:', e);
297
328
  });
298
329
 
330
+ // 把所有注册/心跳/watch 逻辑封装到独立函数,由 listening 事件触发
331
+ async function registerCurrentInstance() {
332
+ const addr = httpServer.address();
333
+ const currentPort = addr && addr.port;
334
+ if (!currentPort) {
335
+ console.warn(chalk.yellow('[instanceRegistry] 无法获取当前端口,跳过注册'));
336
+ return;
337
+ }
338
+
339
+ try {
340
+ const projectName = await instanceRegistry._resolveProjectName(currentProjectPath);
341
+ await instanceRegistry.register({
342
+ pid: process.pid,
343
+ port: currentPort,
344
+ projectPath: currentProjectPath,
345
+ projectName,
346
+ hostname: os.hostname()
347
+ });
348
+ console.log(chalk.green(`[instanceRegistry] 已注册 pid=${process.pid} port=${currentPort} name=${projectName}`));
349
+ } catch (e) {
350
+ console.warn(chalk.yellow(`[instanceRegistry] 注册失败: ${e?.message || e}`));
351
+ return;
352
+ }
353
+
354
+ // 5 秒心跳
355
+ const heartbeatTimer = setInterval(() => {
356
+ instanceRegistry.heartbeat(process.pid, { projectPath: currentProjectPath })
357
+ .catch((e) => console.warn(chalk.yellow(`[instanceRegistry] 心跳失败: ${e?.message || e}`)));
358
+ }, 5000);
359
+
360
+ // 监听注册表文件变化:任何进程写入都会触发,向本进程的所有客户端广播
361
+ // fs.watch 在 Windows 上偶有不可靠,Socket.IO 推送 + 前端轮询(15s)兜底
362
+ const stopWatcher = instanceRegistry.watch(async (fresh) => {
363
+ try {
364
+ io.emit('instances_changed', { instances: fresh });
365
+ } catch (e) {
366
+ console.warn(chalk.yellow(`[instanceRegistry] 广播失败: ${e?.message || e}`));
367
+ }
368
+ }, fsSync.watch);
369
+
370
+ // 优雅退出:SIGINT/SIGTERM 触发异步 unregister + 清心跳
371
+ const shutdown = async (signal) => {
372
+ try { clearInterval(heartbeatTimer); } catch (_) {}
373
+ try { await instanceRegistry.unregister(process.pid); } catch (_) {}
374
+ console.log(chalk.gray(`[instanceRegistry] 收到 ${signal},已清理本实例`));
375
+ };
376
+ process.on('SIGINT', () => { shutdown('SIGINT'); });
377
+ process.on('SIGTERM', () => { shutdown('SIGTERM'); });
378
+ // 'exit' 是同步钩子,无法 await:仅关闭 watcher;kill -9 走心跳超时
379
+ process.on('exit', () => {
380
+ try { stopWatcher && stopWatcher(); } catch (_) {}
381
+ });
382
+ }
383
+
299
384
  }
300
385
 
301
386
  export default startUIServer;
@@ -0,0 +1,24 @@
1
+ // 实例注册表 API 路由
2
+ // 当前只暴露只读列表;停止/启停他人实例属于 out-of-scope(v1 只做跳转导航)
3
+
4
+ export function registerInstancesRoutes({ app, registry, getCurrentInstanceId }) {
5
+ // 获取所有活跃实例(自动 prune 失效条目)
6
+ app.get('/api/instances', async (req, res) => {
7
+ try {
8
+ const instances = await registry.list({ pruneStale: true });
9
+ const currentInstanceId = typeof getCurrentInstanceId === 'function'
10
+ ? getCurrentInstanceId()
11
+ : null;
12
+ res.json({
13
+ success: true,
14
+ instances,
15
+ currentInstanceId
16
+ });
17
+ } catch (error) {
18
+ res.status(500).json({
19
+ success: false,
20
+ error: error?.message || String(error)
21
+ });
22
+ }
23
+ });
24
+ }
@@ -0,0 +1,256 @@
1
+ // 实例注册表工具
2
+ // 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
3
+ // 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
4
+ // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
5
+
6
+ const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
7
+ const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
8
+ const REGISTRY_VERSION = 1;
9
+
10
+ function isProcessAlive(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0) return false;
12
+ try {
13
+ // 信号 0 仅做存活检查,不真正发送信号
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch (err) {
17
+ if (err && (err.code === 'ESRCH' || err.code === 'ENOENT')) {
18
+ return false;
19
+ }
20
+ // EPERM 等情况:进程存在但权限不足,按 alive 处理
21
+ return true;
22
+ }
23
+ }
24
+
25
+ // 解析项目名:优先 package.json.name,兜底为目录 basename
26
+ async function resolveProjectName(projectPath, fsMod, pathMod) {
27
+ if (!projectPath) return '';
28
+ try {
29
+ const pkgPath = pathMod.join(projectPath, 'package.json');
30
+ const raw = await fsMod.readFile(pkgPath, 'utf-8');
31
+ const pkg = JSON.parse(raw);
32
+ if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
33
+ return pkg.name.trim();
34
+ }
35
+ } catch (_) {
36
+ // 读失败或解析失败,兜底
37
+ }
38
+ return pathMod.basename(projectPath);
39
+ }
40
+
41
+ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
42
+ if (!fsMod || !pathMod || !osMod || !registryPath) {
43
+ throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
44
+ }
45
+
46
+ // 进程内写串行化:所有 mutate 操作都 await 这条链
47
+ let writeChain = Promise.resolve();
48
+
49
+ function enqueueWrite(task) {
50
+ const next = writeChain.then(task, task);
51
+ // 不让单个失败阻塞后续操作
52
+ writeChain = next.catch(() => {});
53
+ return next;
54
+ }
55
+
56
+ async function readAll() {
57
+ try {
58
+ const raw = await fsMod.readFile(registryPath, 'utf-8');
59
+ const parsed = JSON.parse(raw);
60
+ if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
61
+ return parsed;
62
+ }
63
+ return { version: REGISTRY_VERSION, instances: {} };
64
+ } catch (err) {
65
+ if (err && err.code === 'ENOENT') {
66
+ return { version: REGISTRY_VERSION, instances: {} };
67
+ }
68
+ console.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
69
+ return { version: REGISTRY_VERSION, instances: {} };
70
+ }
71
+ }
72
+
73
+ async function writeAll(obj) {
74
+ const payload = {
75
+ version: REGISTRY_VERSION,
76
+ ...obj,
77
+ instances: obj.instances || {}
78
+ };
79
+ const tmpPath = `${registryPath}.tmp`;
80
+ await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
81
+ await fsMod.rename(tmpPath, registryPath);
82
+ }
83
+
84
+ // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
85
+ function pruneInPlace(instances) {
86
+ const now = Date.now();
87
+ const result = {};
88
+ for (const [pidStr, entry] of Object.entries(instances)) {
89
+ if (!entry || typeof entry !== 'object') continue;
90
+ const pid = Number(entry.pid ?? Number(pidStr));
91
+ if (!isProcessAlive(pid)) continue;
92
+ if (typeof entry.lastHeartbeat === 'number' && now - entry.lastHeartbeat > STALE_MS) continue;
93
+ result[pidStr] = entry;
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // 公开 API
99
+ async function register({ pid, port, projectPath, projectName, hostname } = {}) {
100
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
101
+ if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
102
+ if (!projectPath) throw new Error('register: projectPath 必填');
103
+
104
+ const resolvedName = projectName && String(projectName).trim()
105
+ ? String(projectName).trim()
106
+ : await resolveProjectName(projectPath, fsMod, pathMod);
107
+
108
+ const entry = {
109
+ pid,
110
+ port,
111
+ projectName: resolvedName,
112
+ projectPath,
113
+ startedAt: Date.now(),
114
+ lastHeartbeat: Date.now(),
115
+ hostname: hostname || osMod.hostname()
116
+ };
117
+
118
+ await enqueueWrite(async () => {
119
+ const obj = await readAll();
120
+ obj.instances[String(pid)] = entry;
121
+ await writeAll(obj);
122
+ });
123
+ return entry;
124
+ }
125
+
126
+ async function unregister(pid) {
127
+ if (!Number.isInteger(pid) || pid <= 0) return;
128
+ await enqueueWrite(async () => {
129
+ const obj = await readAll();
130
+ if (obj.instances && obj.instances[String(pid)]) {
131
+ delete obj.instances[String(pid)];
132
+ await writeAll(obj);
133
+ }
134
+ });
135
+ }
136
+
137
+ async function heartbeat(pid, updates = {}) {
138
+ if (!Number.isInteger(pid) || pid <= 0) return;
139
+ await enqueueWrite(async () => {
140
+ const obj = await readAll();
141
+ const key = String(pid);
142
+ const existing = obj.instances[key];
143
+ if (!existing) {
144
+ // 如果心跳时条目不存在(被裁剪或被外部清理),跳过;
145
+ // 由调用方负责周期性 re-register
146
+ return;
147
+ }
148
+ obj.instances[key] = {
149
+ ...existing,
150
+ ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
151
+ ...(updates.projectName ? { projectName: updates.projectName } : {}),
152
+ lastHeartbeat: Date.now()
153
+ };
154
+ await writeAll(obj);
155
+ });
156
+ }
157
+
158
+ async function list({ pruneStale = true } = {}) {
159
+ const obj = await readAll();
160
+ let instances = obj.instances || {};
161
+ if (pruneStale) {
162
+ instances = pruneInPlace(instances);
163
+ // 如果发生了裁剪,持久化回去
164
+ const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
165
+ if (hasChange) {
166
+ await enqueueWrite(async () => {
167
+ const fresh = await readAll();
168
+ fresh.instances = pruneInPlace(fresh.instances || {});
169
+ await writeAll(fresh);
170
+ });
171
+ }
172
+ }
173
+ const arr = Object.values(instances);
174
+ arr.sort((a, b) => (a.port || 0) - (b.port || 0));
175
+ return arr;
176
+ }
177
+
178
+ // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
179
+ // fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
180
+ function watch(callback, fsWatch) {
181
+ if (typeof callback !== 'function') {
182
+ throw new Error('watch: callback 必填');
183
+ }
184
+ if (typeof fsWatch !== 'function') {
185
+ console.warn('[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用');
186
+ return function noop() {};
187
+ }
188
+ let debounceTimer = null;
189
+ let watcher = null;
190
+ let pruneTimer = null;
191
+ let closed = false;
192
+
193
+ const fire = async () => {
194
+ if (closed) return;
195
+ try {
196
+ const fresh = await list({ pruneStale: true });
197
+ callback(fresh);
198
+ } catch (e) {
199
+ console.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);
200
+ }
201
+ };
202
+
203
+ // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
204
+ // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
205
+ pruneTimer = setInterval(() => {
206
+ if (closed) return;
207
+ list({ pruneStale: true }).catch(() => {});
208
+ }, STALE_MS / 3);
209
+
210
+ try {
211
+ watcher = fsWatch(registryPath, { persistent: false }, () => {
212
+ if (closed) return;
213
+ if (debounceTimer) clearTimeout(debounceTimer);
214
+ debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);
215
+ });
216
+ if (watcher && typeof watcher.on === 'function') {
217
+ watcher.on('error', (err) => {
218
+ console.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);
219
+ });
220
+ }
221
+ } catch (err) {
222
+ console.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);
223
+ }
224
+
225
+ return function closeWatcher() {
226
+ closed = true;
227
+ if (debounceTimer) {
228
+ clearTimeout(debounceTimer);
229
+ debounceTimer = null;
230
+ }
231
+ if (pruneTimer) {
232
+ clearInterval(pruneTimer);
233
+ pruneTimer = null;
234
+ }
235
+ if (watcher) {
236
+ try { watcher.close(); } catch (_) {}
237
+ watcher = null;
238
+ }
239
+ };
240
+ }
241
+
242
+ function close() {
243
+ // 工厂内部无 timer,仅为 API 对称
244
+ }
245
+
246
+ return {
247
+ register,
248
+ unregister,
249
+ heartbeat,
250
+ list,
251
+ watch,
252
+ close,
253
+ _resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
254
+ _STALE_MS: STALE_MS
255
+ };
256
+ }