zen-gitsync 2.11.32 → 2.11.34

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-BNd_56JT.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-e3eHiWHO.js"></script>
14
14
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BM3Ffeng.js">
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">
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-CuS7ofwF.css">
18
18
  </head>
19
19
  <body>
20
20
  <div id="app"></div>
@@ -31,6 +31,7 @@ import { registerInstancesRoutes } from './routes/instances.js';
31
31
  import { createInstanceRegistry } from './utils/instanceRegistry.js';
32
32
  import { createSavePortToFile } from './utils/createSavePortToFile.js';
33
33
  import { startServerOnAvailablePort } from './utils/startServerOnAvailablePort.js';
34
+ import { resolveStartPort } from './utils/randomStartPort.js';
34
35
  import { createFilePickerMiddleware } from 'local-file-picker';
35
36
  import { createAiModelMiddleware } from 'ai-model-form';
36
37
 
@@ -293,7 +294,14 @@ async function startUIServer(noOpen = false, savePort = false) {
293
294
  }
294
295
 
295
296
  // 启动服务器
296
- const PORT = 3000;
297
+ // 端口策略:默认从 [4000, 6000) 随机挑起点,再顺序扫描 EADDRINUSE;
298
+ // 可通过 PORT 环境变量强制使用固定端口(向后兼容 + 便于书签/调试)
299
+ const portStrategy = resolveStartPort();
300
+ if (portStrategy.source === 'env') {
301
+ console.log(chalk.cyan(`[端口] 使用环境变量 PORT=${portStrategy.startPort}`));
302
+ } else {
303
+ console.log(chalk.cyan(`[端口] 随机起点 ${portStrategy.startPort}(范围 ${portStrategy.min}-${portStrategy.max},遇到占用会顺延)`));
304
+ }
297
305
 
298
306
  // 创建一个函数来保存端口号到文件和环境变量
299
307
  // 使用闭包保存端口状态,防止多次写入相同端口
@@ -315,7 +323,7 @@ async function startUIServer(noOpen = false, savePort = false) {
315
323
  // 尝试在可用端口上启动服务器(不等待;listen 事件会驱动后续逻辑)
316
324
  startServerOnAvailablePort({
317
325
  httpServer,
318
- startPort: PORT,
326
+ startPort: portStrategy.startPort,
319
327
  chalk,
320
328
  open,
321
329
  noOpen,
@@ -19,14 +19,22 @@ function spawnDetached(command, args, options = {}) {
19
19
  });
20
20
  }
21
21
 
22
- async function launchClaudeCode(dirPath) {
22
+ async function launchClaudeCode(dirPath, { permissionMode } = {}) {
23
+ // 透传可选的权限模式参数到 claude CLI(如 acceptEdits)
24
+ // 注意:permissionMode 必须是一个 token 字符串,避免 shell 注入
25
+ const SAFE_MODE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
26
+ const cliArgs = [];
27
+ if (permissionMode && typeof permissionMode === 'string' && SAFE_MODE.test(permissionMode)) {
28
+ cliArgs.push('--permission-mode', permissionMode);
29
+ }
30
+
23
31
  if (process.platform === 'win32') {
24
- return spawnDetached('cmd.exe', ['/c', 'start', '""', 'claude'], {
32
+ return spawnDetached('cmd.exe', ['/c', 'start', '""', 'claude', ...cliArgs], {
25
33
  cwd: dirPath
26
34
  });
27
35
  }
28
36
 
29
- return spawnDetached('claude', [], {
37
+ return spawnDetached('claude', cliArgs, {
30
38
  cwd: dirPath
31
39
  });
32
40
  }
@@ -227,7 +235,7 @@ export function registerFileOpenRoutes({
227
235
  // 用 Claude Code 打开目录
228
236
  app.post('/api/open-directory-with-claude-code', async (req, res) => {
229
237
  try {
230
- const { path: dirPath } = req.body;
238
+ const { path: dirPath, permissionMode } = req.body || {};
231
239
  if (!dirPath) {
232
240
  return res.status(400).json({ success: false, error: '目录路径不能为空' });
233
241
  }
@@ -239,8 +247,11 @@ export function registerFileOpenRoutes({
239
247
  }
240
248
 
241
249
  try {
242
- await launchClaudeCode(dirPath);
243
- res.json({ success: true, message: '已用 Claude Code 打开目录' });
250
+ await launchClaudeCode(dirPath, { permissionMode });
251
+ const message = permissionMode
252
+ ? `已用 Claude Code 打开目录(permission-mode=${permissionMode})`
253
+ : '已用 Claude Code 打开目录';
254
+ res.json({ success: true, message });
244
255
  } catch (error) {
245
256
  res.status(400).json({
246
257
  success: false,
@@ -1,24 +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
- }
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
+ }
@@ -1,256 +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
- }
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
+ }