zen-gitsync 2.16.33 → 2.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zen-gitsync",
3
- "version": "2.16.33",
3
+ "version": "2.16.34",
4
4
  "description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,134 +1,265 @@
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 logger from './logger.js'
16
-
17
- // 监听地址:默认只绑回环地址。
18
- // GUI 服务没有任何认证层,绑 0.0.0.0 等于把 /api/exec-stream(命令执行)、
19
- // /api/add-npm-script(写 package.json scripts)、/api/open-file(用系统
20
- // 关联程序打开任意文件)等接口裸奔在局域网上,同网段任何主机都可调用。
21
- // 确有远程访问 / WSL 跨环境需求时,用 ZEN_HOST=0.0.0.0 显式放开。
22
- const DEFAULT_HOST = '127.0.0.1'
23
-
24
- function normalizeHost(raw) {
25
- const value = typeof raw === 'string' ? raw.trim() : ''
26
- return value || DEFAULT_HOST
27
- }
28
-
29
- export async function startServerOnAvailablePort({
30
- httpServer,
31
- startPort,
32
- chalk,
33
- open,
34
- noOpen,
35
- isGitRepo,
36
- savePortToFile,
37
- fsSync,
38
- maxTries = 100,
39
- callbackExecutedRef,
40
- host
41
- }) {
42
- const bindHost = normalizeHost(host ?? process.env.ZEN_HOST)
43
- let currentPort = startPort;
44
- const maxPort = startPort + maxTries;
45
- const getCallbackExecuted = () => {
46
- if (callbackExecutedRef && typeof callbackExecutedRef === 'object' && 'value' in callbackExecutedRef) {
47
- return Boolean(callbackExecutedRef.value);
48
- }
49
- return false;
50
- };
51
-
52
- const setCallbackExecuted = (value) => {
53
- if (callbackExecutedRef && typeof callbackExecutedRef === 'object' && 'value' in callbackExecutedRef) {
54
- callbackExecutedRef.value = Boolean(value);
55
- }
56
- };
57
-
58
- while (currentPort < maxPort) {
59
- try {
60
- if (currentPort > startPort) {
61
- await new Promise(resolve => setTimeout(resolve, 800));
62
- logger.info(`尝试端口 ${currentPort}...`);
63
- }
64
-
65
- await new Promise((resolve, reject) => {
66
- const errorHandler = (err) => {
67
- httpServer.removeListener('error', errorHandler);
68
- reject(err);
69
- };
70
-
71
- httpServer.once('error', errorHandler);
72
-
73
- httpServer.listen(currentPort, bindHost, () => {
74
- if (getCallbackExecuted()) return;
75
- setCallbackExecuted(true);
76
-
77
- httpServer.removeListener('error', errorHandler);
78
-
79
- console.log(chalk.green('======================================'));
80
- console.log(chalk.green(` Zen GitSync 服务器已启动`));
81
- console.log(chalk.green(` 访问地址: http://localhost:${currentPort}`));
82
- console.log(chalk.green(` 启动时间: ${new Date().toLocaleString()}`));
83
- // 非回环地址才提示,默认情况保持安静(避免每次启动都刷警告)
84
- if (bindHost !== DEFAULT_HOST) {
85
- console.log(chalk.yellow(` 监听地址: ${bindHost}(已放开非本地访问,请确认网络环境可信)`));
86
- }
87
-
88
- if (isGitRepo) {
89
- console.log(chalk.green(` 当前目录是Git仓库`));
90
- } else {
91
- console.log(chalk.yellow(` 当前目录不是Git仓库,文件监控未启动`));
92
- }
93
-
94
- console.log(chalk.green('======================================'));
95
-
96
- savePortToFile(currentPort);
97
-
98
- // 自拉起重启通知:父进程 spawn 时设置了 ZEN_RESTART_NOTIFY_PATH
99
- // 子进程成功 bind 端口后,把端口写到该文件作为"已就绪"信号
100
- // (instanceRegistry 在 EPERM 环境下不可用,这是回退通道)
101
- const notifyPath = process.env.ZEN_RESTART_NOTIFY_PATH;
102
- if (notifyPath && fsSync) {
103
- try {
104
- fsSync.writeFileSync(notifyPath, String(currentPort), 'utf8');
105
- } catch (_) {
106
- // 通知写失败不影响主流程
107
- }
108
- }
109
-
110
- if (!noOpen) {
111
- setTimeout(() => {
112
- open(`http://localhost:${currentPort}`);
113
- }, 0);
114
- }
115
-
116
- resolve();
117
- });
118
- });
119
-
120
- return currentPort;
121
- } catch (err) {
122
- if (err.code === 'EADDRINUSE') {
123
- logger.info(`端口 ${currentPort} 被占用,尝试下一个端口...`);
124
- currentPort++;
125
- } else {
126
- logger.error('启动服务器失败:', err);
127
- process.exit(1);
128
- }
129
- }
130
- }
131
-
132
- logger.error(`无法找到可用端口 (尝试范围: ${startPort}-${maxPort - 1})`);
133
- process.exit(1);
134
- }
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 { execFileSync } from 'node:child_process'
16
+ import logger from './logger.js'
17
+
18
+ // Windows 会把成块的端口划进"排除范围"(Hyper-V / Docker / WSL 预留,或系统保留的
19
+ // 动态端口块)。落在这些段里的端口,listen() 报的是 EACCES 而不是 EADDRINUSE ——
20
+ // 端口明明没人占,系统就是不让绑。查看方式:
21
+ // netsh int ip show excludedportrange protocol=tcp
22
+ // 这些段通常连续上百个端口,靠 +1 逐个撞过去极慢(旧实现每个端口还要 sleep 800ms,
23
+ // 撞进一段 100 端口的保留区就是 80 秒)。所以这里懒加载一次并缓存:
24
+ // 只有真撞到 EACCES 时才去查 netsh,正常启动路径零开销。
25
+ let excludedRanges = null
26
+
27
+ /**
28
+ * 解析 `netsh int ip show excludedportrange protocol=tcp` 的输出。
29
+ * 纯函数,单独导出以便测试(表头随系统语言变化,解析规则必须稳)。
30
+ * @param {string} output netsh 原始输出
31
+ * @returns {Array<[number, number]>} 保留端口段 [起始, 结束] 列表
32
+ */
33
+ export function parseExcludedPortRanges(output) {
34
+ const ranges = []
35
+ if (typeof output !== 'string' || !output) return ranges
36
+
37
+ // 表头随系统语言变化(开始端口/结束端口 vs Start Port/End Port),
38
+ // 只认数据行:行尾跟着两个十进制数,行尾的 * 表示"管理的端口排除"
39
+ const re = /(\d{1,5})\s+(\d{1,5})\s*\**\s*$/gm
40
+ let match
41
+ while ((match = re.exec(output)) !== null) {
42
+ const start = Number(match[1])
43
+ const end = Number(match[2])
44
+ if (Number.isInteger(start) && Number.isInteger(end) && start > 0 && end >= start && end < 65536) {
45
+ ranges.push([start, end])
46
+ }
47
+ }
48
+ return ranges
49
+ }
50
+
51
+ function getExcludedPortRanges() {
52
+ if (excludedRanges !== null) return excludedRanges
53
+ excludedRanges = []
54
+
55
+ if (process.platform !== 'win32') return excludedRanges
56
+
57
+ try {
58
+ const output = execFileSync(
59
+ 'netsh',
60
+ ['int', 'ip', 'show', 'excludedportrange', 'protocol=tcp'],
61
+ { encoding: 'utf8', timeout: 3000, windowsHide: true }
62
+ )
63
+ excludedRanges = parseExcludedPortRanges(output)
64
+ if (excludedRanges.length) {
65
+ logger.info(`检测到 ${excludedRanges.length} 个系统保留端口段,启动时将自动跳过`)
66
+ }
67
+ } catch (err) {
68
+ // 查不到(非 Windows / netsh 不可用 / 超时)就退化成逐个 +1 试,不影响主流程
69
+ logger.info(`查询系统保留端口段失败,退化为逐端口探测: ${err?.message || err}`)
70
+ }
71
+
72
+ return excludedRanges
73
+ }
74
+
75
+ /**
76
+ * 端口是否落在系统保留段内,是则返回该段,否则返回 null。
77
+ * 注意:内部会触发 netsh 查询,只在确认需要时调用。
78
+ */
79
+ function findExcludedRange(port) {
80
+ return getExcludedPortRanges().find(([start, end]) => port >= start && port <= end) || null
81
+ }
82
+
83
+ // 监听地址:默认只绑回环地址。
84
+ // GUI 服务没有任何认证层,绑 0.0.0.0 等于把 /api/exec-stream(命令执行)、
85
+ // /api/add-npm-script(写 package.json scripts)、/api/open-file(用系统
86
+ // 关联程序打开任意文件)等接口裸奔在局域网上,同网段任何主机都可调用。
87
+ // 确有远程访问 / WSL 跨环境需求时,用 ZEN_HOST=0.0.0.0 显式放开。
88
+ const DEFAULT_HOST = '127.0.0.1'
89
+
90
+ // 浏览器访问地址不要使用 localhost:Windows 上它可能优先解析到 ::1,
91
+ // 而另一个程序恰好占用 IPv6 同端口时,页面会被打开到错误的服务。
92
+ // 默认服务明确绑定 IPv4 回环,因此浏览器也必须使用同一个地址。
93
+ function getBrowserHost(bindHost) {
94
+ return bindHost === DEFAULT_HOST ? DEFAULT_HOST : 'localhost'
95
+ }
96
+
97
+ function normalizeHost(raw) {
98
+ const value = typeof raw === 'string' ? raw.trim() : ''
99
+ return value || DEFAULT_HOST
100
+ }
101
+
102
+ export async function startServerOnAvailablePort({
103
+ httpServer,
104
+ startPort,
105
+ chalk,
106
+ open,
107
+ noOpen,
108
+ isGitRepo,
109
+ savePortToFile,
110
+ fsSync,
111
+ maxTries = 100,
112
+ callbackExecutedRef,
113
+ host
114
+ }) {
115
+ const bindHost = normalizeHost(host ?? process.env.ZEN_HOST)
116
+ let currentPort = startPort;
117
+ const maxPort = startPort + maxTries;
118
+ const getCallbackExecuted = () => {
119
+ if (callbackExecutedRef && typeof callbackExecutedRef === 'object' && 'value' in callbackExecutedRef) {
120
+ return Boolean(callbackExecutedRef.value);
121
+ }
122
+ return false;
123
+ };
124
+
125
+ const setCallbackExecuted = (value) => {
126
+ if (callbackExecutedRef && typeof callbackExecutedRef === 'object' && 'value' in callbackExecutedRef) {
127
+ callbackExecutedRef.value = Boolean(value);
128
+ }
129
+ };
130
+
131
+ let lastErrorCode = null;
132
+
133
+ while (currentPort < maxPort) {
134
+ try {
135
+ if (currentPort > startPort) {
136
+ // 系统保留端口(EACCES)不会随时间释放,没必要等;
137
+ // 只有"被占用"(EADDRINUSE)才需要给端口一点时间释放
138
+ if (lastErrorCode !== 'EACCES') {
139
+ await new Promise(resolve => setTimeout(resolve, 800));
140
+ }
141
+ logger.info(`尝试端口 ${currentPort}...`);
142
+ }
143
+
144
+ // 本次尝试要绑的端口。回调里必须用它校验,不能用外层的 currentPort:
145
+ // 闭包捕获的是同一个变量,重试时它的值早就变了
146
+ const attemptPort = currentPort;
147
+
148
+ await new Promise((resolve, reject) => {
149
+ // 每次尝试独立的 settled。失败之后即便平台补发了 listening 回调,也不再响应
150
+ let settled = false;
151
+
152
+ const cleanup = () => {
153
+ httpServer.removeListener('error', errorHandler);
154
+ httpServer.removeListener('listening', listeningHandler);
155
+ };
156
+
157
+ const errorHandler = (err) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ cleanup();
161
+ reject(err);
162
+ };
163
+
164
+ const listeningHandler = () => {
165
+ // 过期回调防护。server.listen(port, host, cb) 的 cb 会被注册成
166
+ // once('listening', cb),而 listen 失败时只 emit 'error',这个 cb
167
+ // 摘不掉。等后面某次 listen 成功 emit 'listening',历史残留的 cb
168
+ // 会和新 cb 一起被依次触发 —— 残留 cb 抢先执行会把防重入标志置位,
169
+ // 本次真正成功的回调就会直接 return,await 永久挂起,服务起不来。
170
+ // 这里用"本次是否已 settle"+"实际监听端口是否为本次目标"双重拦截。
171
+ if (settled) return;
172
+ const addr = httpServer.address();
173
+ if (addr && addr.port !== attemptPort) return;
174
+
175
+ settled = true;
176
+ cleanup();
177
+
178
+ if (getCallbackExecuted()) return;
179
+ setCallbackExecuted(true);
180
+
181
+ console.log(chalk.green('======================================'));
182
+ console.log(chalk.green(` Zen GitSync 服务器已启动`));
183
+ const browserHost = getBrowserHost(bindHost);
184
+ console.log(chalk.green(` 访问地址: http://${browserHost}:${attemptPort}`));
185
+ console.log(chalk.green(` 启动时间: ${new Date().toLocaleString()}`));
186
+ // 非回环地址才提示,默认情况保持安静(避免每次启动都刷警告)
187
+ if (bindHost !== DEFAULT_HOST) {
188
+ console.log(chalk.yellow(` 监听地址: ${bindHost}(已放开非本地访问,请确认网络环境可信)`));
189
+ }
190
+
191
+ if (isGitRepo) {
192
+ console.log(chalk.green(` 当前目录是Git仓库`));
193
+ } else {
194
+ console.log(chalk.yellow(` 当前目录不是Git仓库,文件监控未启动`));
195
+ }
196
+
197
+ console.log(chalk.green('======================================'));
198
+
199
+ savePortToFile(attemptPort);
200
+
201
+ // 自拉起重启通知:父进程 spawn 时设置了 ZEN_RESTART_NOTIFY_PATH
202
+ // 子进程成功 bind 端口后,把端口写到该文件作为"已就绪"信号
203
+ // (instanceRegistry 在 EPERM 环境下不可用,这是回退通道)
204
+ const notifyPath = process.env.ZEN_RESTART_NOTIFY_PATH;
205
+ if (notifyPath && fsSync) {
206
+ try {
207
+ fsSync.writeFileSync(notifyPath, String(attemptPort), 'utf8');
208
+ } catch (_) {
209
+ // 通知写失败不影响主流程
210
+ }
211
+ }
212
+
213
+ if (!noOpen) {
214
+ setTimeout(() => {
215
+ open(`http://${browserHost}:${attemptPort}`);
216
+ }, 0);
217
+ }
218
+
219
+ resolve();
220
+ };
221
+
222
+ // 不把成功回调挂在 listen() 的第三个参数上:那样它会被注册成
223
+ // once('listening', cb),而 listen 失败时只 emit 'error',这个 cb
224
+ // 摘不掉。显式注册后,失败路径的 cleanup() 能把它清理干净。
225
+ httpServer.once('error', errorHandler);
226
+ httpServer.once('listening', listeningHandler);
227
+ httpServer.listen(attemptPort, bindHost);
228
+ });
229
+
230
+ return currentPort;
231
+ } catch (err) {
232
+ lastErrorCode = err?.code || null;
233
+
234
+ if (err.code === 'EADDRINUSE') {
235
+ logger.info(`端口 ${currentPort} 被占用,尝试下一个端口...`);
236
+ currentPort++;
237
+ } else if (err.code === 'EACCES') {
238
+ // 端口不让绑:Windows 上绝大多数情况落在系统保留段里。
239
+ // 命中整段就直接跳到段尾之后,否则逐个撞过去会非常慢。
240
+ const range = findExcludedRange(currentPort);
241
+ if (range) {
242
+ logger.info(`端口 ${currentPort} 位于系统保留段 ${range[0]}-${range[1]},跳到 ${range[1] + 1}...`);
243
+ currentPort = range[1] + 1;
244
+ } else {
245
+ logger.info(`端口 ${currentPort} 无监听权限(可能被系统保留),尝试下一个端口...`);
246
+ currentPort++;
247
+ }
248
+ } else {
249
+ logger.error('启动服务器失败:', err);
250
+ process.exit(1);
251
+ }
252
+ }
253
+ }
254
+
255
+ logger.error(`无法找到可用端口 (尝试范围: ${startPort}-${maxPort - 1})`);
256
+ if (process.platform === 'win32' && getExcludedPortRanges().length) {
257
+ logger.error(
258
+ `已跳过的系统保留端口段: ${getExcludedPortRanges()
259
+ .map(([s, e]) => `${s}-${e}`)
260
+ .join(', ')}`
261
+ );
262
+ logger.error('可用 netsh int ip show excludedportrange protocol=tcp 复核;也可用 PORT 环境变量指定其它端口');
263
+ }
264
+ process.exit(1);
265
+ }
@@ -14,36 +14,58 @@
14
14
  //
15
15
  // startServerOnAvailablePort 单元测试。
16
16
  //
17
- // 这里只锁一件事:监听地址。
18
- // GUI 服务没有认证层,listen() 不传 host 就会绑到 0.0.0.0,等于把
19
- // /api/exec-stream(命令执行)、/api/add-npm-script(写 package.json)
20
- // 等接口裸奔在局域网上。默认必须是回环地址,只有显式设置 ZEN_HOST
21
- // 或显式传 host 参数才放开。这组测试就是防止有人图省事改回 0.0.0.0。
17
+ // 这里锁两件事:
18
+ //
19
+ // 1. 监听地址。GUI 服务没有认证层,listen() 不传 host 就会绑到 0.0.0.0,等于把
20
+ // /api/exec-stream(命令执行)、/api/add-npm-script(写 package.json)等接口
21
+ // 裸奔在局域网上。默认必须是回环地址,只有显式设置 ZEN_HOST 或显式传 host
22
+ // 参数才放开。这组测试防止有人图省事改回 0.0.0.0。
23
+ //
24
+ // 2. 端口重试不能把自己挂死。server.listen(port, host, cb) 的 cb 实际被注册成
25
+ // once('listening', cb),而 listen 失败时只 emit 'error',这个 cb 摘不掉。
26
+ // 等后面某次 listen 成功 emit 'listening',历史残留的 cb 会和新 cb 一起被
27
+ // 依次触发。残留 cb 抢先跑完会把防重入标志置位,本次真正成功的回调就直接
28
+ // return,await 永久挂起 —— 服务表现为"卡住起不来"。
29
+ // Windows 上 EACCES(系统保留端口)顺延时必现。下面第 2 组测试专门盯这个。
22
30
  import { test } from 'node:test'
23
31
  import assert from 'node:assert/strict'
24
- import { startServerOnAvailablePort } from './startServerOnAvailablePort.js'
32
+ import {
33
+ startServerOnAvailablePort,
34
+ parseExcludedPortRanges
35
+ } from './startServerOnAvailablePort.js'
25
36
 
26
37
  // 极简 chalk:只需要 .green / .yellow / .cyan 能把原串返回
27
38
  const chalk = new Proxy({}, { get: () => (s) => String(s) })
28
39
 
29
- /** 最小 httpServer mock,只记录 listen 的入参 */
30
- function createFakeHttpServer() {
40
+ /**
41
+ * 模拟真实 http.Server 的 listen 语义。
42
+ *
43
+ * 重点复刻上面提到的那个坑:listen 失败时只 emit 'error',已注册的 listening
44
+ * 回调一个都不摘;等某次 listen 成功,所有历史残留回调会和新回调一起被触发。
45
+ *
46
+ * @param {object} [opts]
47
+ * @param {number[]} [opts.failPorts] 这些端口上让 listen 失败
48
+ * @param {string} [opts.errorCode] 失败时注入的 errno
49
+ */
50
+ function createFakeHttpServer({ failPorts = [], errorCode = 'EACCES' } = {}) {
31
51
  const calls = []
32
- const handlers = new Map()
52
+ const listening = []
53
+ const errors = []
33
54
  const server = {
34
55
  calls,
35
56
  once(event, fn) {
36
- if (!handlers.has(event)) handlers.set(event, [])
37
- handlers.get(event).push(fn)
57
+ if (event === 'listening') listening.push(fn)
58
+ else if (event === 'error') errors.push(fn)
38
59
  },
39
- on(event, fn) { server.once(event, fn) },
40
60
  removeListener(event, fn) {
41
- const list = handlers.get(event) || []
61
+ const list = event === 'listening' ? listening : errors
42
62
  const i = list.indexOf(fn)
43
63
  if (i >= 0) list.splice(i, 1)
44
64
  },
65
+ address() {
66
+ return server._addr || null
67
+ },
45
68
  listen(...args) {
46
- // 同时兼容 listen(port, cb) 与 listen(port, host, cb) 两种形态
47
69
  const port = args[0]
48
70
  let host
49
71
  let cb
@@ -54,48 +76,72 @@ function createFakeHttpServer() {
54
76
  cb = args[2]
55
77
  }
56
78
  calls.push({ port, host })
57
- if (cb) setImmediate(cb)
79
+ // Node 的真实语义:回调被注册成 once('listening', cb)
80
+ if (cb) listening.push(cb)
81
+
82
+ setImmediate(() => {
83
+ if (failPorts.includes(port)) {
84
+ // 失败:只 emit 'error',listening 回调一个都不摘(关键)
85
+ const err = new Error(`${errorCode} ${port}`)
86
+ err.code = errorCode
87
+ errors.splice(0).forEach((fn) => fn(err))
88
+ return
89
+ }
90
+ server._addr = { address: host || '127.0.0.1', port }
91
+ // 成功:触发全部 listening 回调,包含历史失败残留的那些
92
+ listening.splice(0).forEach((fn) => fn())
93
+ })
58
94
  }
59
95
  }
60
96
  return server
61
97
  }
62
98
 
63
99
  /**
64
- * 跑一次启动流程,返回 listen 的调用记录。
65
- * @param {object} opts
66
- * @param {string|null} [opts.zenHost] 本次运行时 ZEN_HOST 的取值;null 表示删除该变量
67
- * @param {object} [opts.rest] 直接透传给 startServerOnAvailablePort 的参数(如 host)
100
+ * 跑一次启动流程。
101
+ *
102
+ * 端口默认取 47000 maxTries 给足:万一真撞进系统保留段触发"整段跳跃",
103
+ * 也有足够回旋空间试到成功,不至于让测试因为耗尽尝试次数而退出。
104
+ *
105
+ * @param {object} [opts]
106
+ * @param {number[]} [opts.failPorts] 让哪些端口的 listen 失败
107
+ * @param {string} [opts.errorCode] 注入的 errno
108
+ * @param {string|null} [opts.zenHost] ZEN_HOST 取值;null 表示删除该变量
109
+ * @param {object} [opts.rest] 直接透传给 startServerOnAvailablePort 的参数
68
110
  */
69
- async function listenOnce(opts = {}) {
70
- const httpServer = createFakeHttpServer()
111
+ async function startOnce({
112
+ failPorts = [],
113
+ errorCode = 'EACCES',
114
+ zenHost = null,
115
+ startPort = 47000,
116
+ maxTries = 20,
117
+ rest = {}
118
+ } = {}) {
119
+ const httpServer = createFakeHttpServer({ failPorts, errorCode })
71
120
  const envBak = process.env.ZEN_HOST
72
121
  const notifyBak = process.env.ZEN_RESTART_NOTIFY_PATH
73
- // 清掉重启通知路径,避免测试真的往 tmpdir 写文件
122
+ // 清掉重启通知路径,避免测试真的往环境里写文件
74
123
  delete process.env.ZEN_RESTART_NOTIFY_PATH
75
-
76
- if ('zenHost' in opts) {
77
- if (opts.zenHost === null) delete process.env.ZEN_HOST
78
- else process.env.ZEN_HOST = opts.zenHost
79
- }
124
+ if (zenHost === null) delete process.env.ZEN_HOST
125
+ else process.env.ZEN_HOST = zenHost
80
126
 
81
127
  // 启动成功时会 console.log 一大段横幅,静音掉以保持测试输出干净
82
128
  const logBak = console.log
83
129
  console.log = () => {}
84
130
 
85
131
  try {
86
- await startServerOnAvailablePort({
132
+ const port = await startServerOnAvailablePort({
87
133
  httpServer,
88
- startPort: 4000,
134
+ startPort,
89
135
  chalk,
90
136
  open: async () => {},
91
137
  noOpen: true,
92
138
  isGitRepo: true,
93
139
  savePortToFile: () => {},
94
- maxTries: 1,
140
+ maxTries,
95
141
  callbackExecutedRef: { value: false },
96
- ...(opts.rest || {})
142
+ ...rest
97
143
  })
98
- return httpServer.calls
144
+ return { port, calls: httpServer.calls }
99
145
  } finally {
100
146
  console.log = logBak
101
147
  if (envBak === undefined) delete process.env.ZEN_HOST
@@ -105,77 +151,131 @@ async function listenOnce(opts = {}) {
105
151
  }
106
152
  }
107
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // 1. 监听地址:默认是回环,不能退化成 0.0.0.0
156
+ // ---------------------------------------------------------------------------
157
+
108
158
  test('默认监听 127.0.0.1,不会绑到 0.0.0.0', async () => {
109
- const calls = await listenOnce({ zenHost: null })
159
+ const { calls } = await startOnce()
110
160
  assert.equal(calls.length, 1)
111
- assert.equal(calls[0].port, 4000)
161
+ assert.equal(calls[0].port, 47000)
112
162
  assert.equal(calls[0].host, '127.0.0.1')
113
163
  })
114
164
 
115
165
  test('ZEN_HOST=0.0.0.0 时放开到所有网卡', async () => {
116
- const calls = await listenOnce({ zenHost: '0.0.0.0' })
166
+ const { calls } = await startOnce({ zenHost: '0.0.0.0' })
117
167
  assert.equal(calls[0].host, '0.0.0.0')
118
168
  })
119
169
 
120
170
  test('显式传 host 参数优先于环境变量', async () => {
121
- const calls = await listenOnce({ zenHost: '0.0.0.0', rest: { host: '127.0.0.1' } })
171
+ const { calls } = await startOnce({ zenHost: '0.0.0.0', rest: { host: '127.0.0.1' } })
122
172
  assert.equal(calls[0].host, '127.0.0.1')
123
173
  })
124
174
 
125
175
  test('ZEN_HOST 为空串或纯空白时回落默认回环地址', async () => {
126
- assert.equal((await listenOnce({ zenHost: '' }))[0].host, '127.0.0.1')
127
- assert.equal((await listenOnce({ zenHost: ' ' }))[0].host, '127.0.0.1')
176
+ assert.equal((await startOnce({ zenHost: '' })).calls[0].host, '127.0.0.1')
177
+ assert.equal((await startOnce({ zenHost: ' ' })).calls[0].host, '127.0.0.1')
128
178
  })
129
179
 
130
- test('端口占用时顺延到下一个端口,且每次都带上 host', async () => {
131
- const httpServer = createFakeHttpServer()
132
- // 第一次 listen 触发 EADDRINUSE,第二次成功
133
- let first = true
134
- httpServer.listen = (...args) => {
135
- httpServer.calls.push({ port: args[0], host: args[1] })
136
- const cb = typeof args[1] === 'function' ? args[1] : args[2]
137
- const handlers = httpServer._errorHandlers || []
138
- if (first) {
139
- first = false
140
- setImmediate(() => {
141
- const err = new Error('busy')
142
- err.code = 'EADDRINUSE'
143
- handlers.forEach((fn) => fn(err))
144
- })
145
- } else if (cb) {
146
- setImmediate(cb)
147
- }
148
- }
149
- const origOnce = httpServer.once.bind(httpServer)
150
- httpServer.once = (event, fn) => {
151
- if (event === 'error') {
152
- httpServer._errorHandlers = httpServer._errorHandlers || []
153
- httpServer._errorHandlers.push(fn)
154
- }
155
- return origOnce(event, fn)
156
- }
180
+ // ---------------------------------------------------------------------------
181
+ // 2. 端口重试:不能挂死,且每次尝试都必须带 host
182
+ // ---------------------------------------------------------------------------
157
183
 
158
- const logBak = console.log
159
- console.log = () => {}
160
- try {
161
- await startServerOnAvailablePort({
162
- httpServer,
163
- startPort: 4000,
164
- chalk,
165
- open: async () => {},
166
- noOpen: true,
167
- isGitRepo: true,
168
- savePortToFile: () => {},
169
- maxTries: 5,
170
- callbackExecutedRef: { value: false }
171
- })
172
- } finally {
173
- console.log = logBak
174
- }
184
+ test('端口被占用(EADDRINUSE)时顺延到下一个端口,且每次都带上 host', async () => {
185
+ const { port, calls } = await startOnce({
186
+ failPorts: [47000],
187
+ errorCode: 'EADDRINUSE'
188
+ })
175
189
 
176
- assert.ok(httpServer.calls.length >= 2, '应该有端口重试')
190
+ assert.equal(port, 47001)
191
+ assert.ok(calls.length >= 2, '应该有端口重试')
177
192
  // 每一次尝试都必须带 host,否则重试那一次又会退化成 0.0.0.0
178
- for (const call of httpServer.calls) {
193
+ for (const call of calls) {
194
+ assert.equal(call.host, '127.0.0.1')
195
+ }
196
+ })
197
+
198
+ test('端口无监听权限(EACCES)时顺延重试,不会直接退出进程', async () => {
199
+ // 系统保留端口(Windows 排除段)上报的就是 EACCES:端口没人占,就是不让绑。
200
+ // 修复前这条路径走 else 分支 process.exit(1),服务直接起不来。
201
+ const { port, calls } = await startOnce({
202
+ failPorts: [47000, 47001],
203
+ errorCode: 'EACCES'
204
+ })
205
+
206
+ assert.equal(port, 47002)
207
+ assert.ok(calls.length >= 3, `应重试直到成功,实际只尝试了 ${calls.length} 次`)
208
+
209
+ for (const call of calls) {
179
210
  assert.equal(call.host, '127.0.0.1')
180
211
  }
212
+
213
+ // 端口必须严格递增。"整段跳过保留区"若写错(比如跳回段首)会死循环,这里兜住
214
+ const ports = calls.map((c) => c.port)
215
+ for (let i = 1; i < ports.length; i++) {
216
+ assert.ok(ports[i] > ports[i - 1], `端口应严格递增,实际: ${ports.join(' -> ')}`)
217
+ }
218
+ })
219
+
220
+ test('前一次失败留下的 listening 残留回调,不会让本次成功卡死', async (t) => {
221
+ // 这是最容易复现、也最致命的一个坑:
222
+ // listen 失败只 emit 'error',之前注册的 listening 回调摘不掉。等下一个端口
223
+ // listen 成功 emit 'listening',历史残留回调会先跑一遍 —— 它若抢先把防重入
224
+ // 标志置位,本次真正成功的回调就会直接 return,await 永久挂起。
225
+ //
226
+ // fake server 已复刻该语义(失败时不清 listening 队列)。若防护被改坏,
227
+ // 这个用例会挂起,靠 timeout 判失败而不是默默卡死整个测试进程。
228
+ t.diagnostic('防护失效时此用例会挂起,timeout 会让它失败')
229
+ const { port } = await startOnce({ failPorts: [47000], errorCode: 'EACCES' })
230
+ assert.equal(port, 47001)
231
+ }, { timeout: 5000 })
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // 3. Windows 保留端口段解析
235
+ // ---------------------------------------------------------------------------
236
+
237
+ test('解析 netsh 排除端口输出:中英文表头都要认', () => {
238
+ const zh = [
239
+ '',
240
+ '协议 tcp 端口排除范围',
241
+ '',
242
+ '开始端口 结束端口 ',
243
+ '---------- -------- ',
244
+ ' 2869 2869 ',
245
+ ' 4311 4410 ',
246
+ ' 5544 5545 *',
247
+ '',
248
+ '* - 管理的端口排除。',
249
+ ''
250
+ ].join('\n')
251
+
252
+ assert.deepEqual(parseExcludedPortRanges(zh), [
253
+ [2869, 2869],
254
+ [4311, 4410],
255
+ [5544, 5545]
256
+ ])
257
+
258
+ const en = [
259
+ 'Protocol tcp Port Exclusion Ranges',
260
+ '',
261
+ 'Start Port End Port ',
262
+ '---------- -------- ',
263
+ ' 2869 2869 ',
264
+ ' 4311 4410 ',
265
+ ''
266
+ ].join('\n')
267
+
268
+ assert.deepEqual(parseExcludedPortRanges(en), [
269
+ [2869, 2869],
270
+ [4311, 4410]
271
+ ])
272
+ })
273
+
274
+ test('解析 netsh 输出:异常输入返回空数组而不是抛错', () => {
275
+ // 表头、分隔线、说明文字都不含数字对,不该被误当成端口段
276
+ assert.deepEqual(parseExcludedPortRanges(''), [])
277
+ assert.deepEqual(parseExcludedPortRanges(null), [])
278
+ assert.deepEqual(parseExcludedPortRanges(undefined), [])
279
+ assert.deepEqual(parseExcludedPortRanges('garbage output\nno ranges here'), [])
280
+ assert.deepEqual(parseExcludedPortRanges('开始端口 结束端口'), [])
181
281
  })