nx-sk 0.15.2 → 0.15.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## 0.15.3 (2026-10-20)
8
+
9
+ ### Changed
10
+
11
+ - **端口被占用时不再报错退出:自动复用或顺延,并把浏览器指过去**。`serve` 绑定端口
12
+ 遇到 `EADDRINUSE` 时,先探测该端口上的 `/api/bootstrap`:占用者**就是本项目的面板**
13
+ (`app.name` 相同)就直接复用它——不再起第二个进程——并打开浏览器指向它;占用者是
14
+ 别的服务(或探测不出来)则顺延到**下一个空闲端口**起自己的面板,并打开浏览器指向新
15
+ 面板,不会把浏览器指向别人的服务。
16
+ - **跨项目统一的关闭开关 `NX_SERVE_NO_OPEN`**:设成 `1` / `true` / `yes` / `on` 时
17
+ `serve` 完全不开浏览器(所有 nx-xx 项目共用这一个变量名);`--no-open` 仍然只关
18
+ 当前这一次。
19
+
7
20
  ## 0.15.2 (2026-10-20)
8
21
 
9
22
  ### Changed
package/README.md CHANGED
@@ -130,10 +130,14 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
130
130
 
131
131
  | 命令 | 作用 |
132
132
  | --- | --- |
133
- | `nx-sk serve [--port N] [--no-open]` | 起 Web 面板(唯一常驻命令;默认只绑 `127.0.0.1`) |
133
+ | `nx-sk serve [--port N] [--no-open]` | 起 Web 面板(唯一常驻命令;默认只绑 `127.0.0.1`;端口上已有本面板就直接打开它,被别的服务占了就顺延到下一个空闲端口) |
134
134
  | `nx-sk help [主题]` | 命令表(由 action 声明生成,不是手写) |
135
135
  | `nx-sk version` | 裸输出版本号 |
136
136
 
137
+ 端口被占不会报错退出:占用者就是**本项目自己的面板** → 不重复启动,直接把浏览器指向它;
138
+ 占用者是别的服务 → 顺延到下一个空闲端口起自己的面板,并把浏览器指向新面板。
139
+ 不想开浏览器:`--no-open`(只影响这一次)或 `NX_SERVE_NO_OPEN=1`(所有 nx-xx 项目通用的开关,对整个 shell 生效)。
140
+
137
141
  模块命令(`src/modules/*/index.js` 声明,`nx-sk routes` 可双向查):
138
142
 
139
143
  | 命令 | 作用 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,139 @@
1
+ // serve 的端口协商 + 浏览器打开策略。
2
+ //
3
+ // 零业务语义:所有 nx-xx 面板共用这一份,靠环境变量 NX_SERVE_NO_OPEN 统一关闭自动开浏览器。
4
+ // 放 core/ 是因为它只依赖 Node 内置模块与「本项目的 app 名」,不涉及任何功能域。
5
+ //
6
+ // 这份文件在所有 nx-xx 项目之间**逐字节相同**——改一处就要全量同步,
7
+ // 否则「口径统一」这件事本身会悄悄分叉。
8
+ import net from 'node:net';
9
+
10
+ /**
11
+ * 所有 nx-xx 项目共享的关闭开关。
12
+ * 设成 1 / true / yes / on 即禁止 `serve` 自动打开浏览器(正常启动与端口复用都算)。
13
+ * 命令行 `--no-open` 只影响这一次;这个环境变量对整个 shell 生效。
14
+ */
15
+ export const NO_OPEN_ENV = 'NX_SERVE_NO_OPEN';
16
+
17
+ /**
18
+ * 探测面板时给的应答窗口。
19
+ *
20
+ * 1500ms 而不是更短:面板的 bootstrap 不一定是纯本机计算——nx-kv 登录后要去后端
21
+ * 拉分组,链路一慢就超过 800ms。窗口太窄的后果不是「探测失败」这么轻,
22
+ * 而是**把自己的面板误判成别人的服务**,于是白起一个实例、占掉第二个端口——
23
+ * 恰好是这套机制要消灭的那种体验。
24
+ */
25
+ export const PROBE_TIMEOUT_MS = 1500;
26
+
27
+ /** 真值判定:1 / true / yes / on(大小写不敏感)算开,其他一律算关。 */
28
+ export function isEnvTruthy(value) {
29
+ if (value === undefined || value === null) return false;
30
+ const s = String(value).trim().toLowerCase();
31
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on';
32
+ }
33
+
34
+ /**
35
+ * 是否允许自动打开浏览器。
36
+ * 优先级:`--no-open` 命令行开关 > NX_SERVE_NO_OPEN 环境变量 > 默认允许。
37
+ */
38
+ export function autoOpenEnabled(flagNoOpen) {
39
+ if (flagNoOpen) return false;
40
+ return !isEnvTruthy(process.env[NO_OPEN_ENV]);
41
+ }
42
+
43
+ /**
44
+ * 探一次端口,返回 { data, timedOut }。
45
+ *
46
+ * 把「超时」和「不是面板」分开,是因为两者的后续动作不同:
47
+ * 超时说明端口上可能正是一个应答慢的面板,值得再等一次;
48
+ * 明确不是面板就没有再等的必要。
49
+ */
50
+ async function probePanelOnce(port, host, timeoutMs, headers) {
51
+ try {
52
+ const controller = new AbortController();
53
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
54
+ let res;
55
+ try {
56
+ res = await fetch(`http://${host}:${port}/api/bootstrap`, {
57
+ signal: controller.signal,
58
+ headers: headers || undefined,
59
+ });
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ if (!res.ok) return { data: null, timedOut: false };
64
+ const body = await res.json();
65
+ return { data: (body && body.data) || null, timedOut: false };
66
+ } catch (err) {
67
+ return { data: null, timedOut: !!(err && err.name === 'AbortError') };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 探测端口上有没有面板在跑:GET /api/bootstrap,短超时。
73
+ *
74
+ * 所有 nx-xx 面板都暴露 /api/bootstrap,响应里带 app.name / app.version ——
75
+ * 这就是跨项目唯一的认领签名(别的服务不会恰好返回它)。
76
+ * 不是面板、无响应、超时一律返回 null。
77
+ *
78
+ * headers 形参给需要鉴权的项目用(nx-as 的管理面要 Bearer 密钥);
79
+ * 默认不传,匿名探测。
80
+ */
81
+ export async function probePanel(port, host = '127.0.0.1', timeoutMs = PROBE_TIMEOUT_MS, headers = null) {
82
+ const { data } = await probePanelOnce(port, host, timeoutMs, headers);
83
+ return data;
84
+ }
85
+
86
+ /**
87
+ * 从 start 起逐个试,返回第一个能监听的端口(最多试 50 个)。
88
+ * 用临时 net.Server 探测、立刻关闭;与 startServer 走同一套 EADDRINUSE 判定。
89
+ */
90
+ export function findFreePort(start, host = '127.0.0.1') {
91
+ return new Promise((resolve_, reject) => {
92
+ const tryPort = (p) => {
93
+ if (p > start + 50) {
94
+ reject(new Error(`找不到空闲端口(从 ${start} 起连续 50 个被占)`));
95
+ return;
96
+ }
97
+ const probe = net.createServer();
98
+ probe.once('error', (err) => {
99
+ if (err && err.code === 'EADDRINUSE') tryPort(p + 1);
100
+ else reject(err);
101
+ });
102
+ probe.listen(p, host, () => probe.close(() => resolve_(p)));
103
+ };
104
+ tryPort(start);
105
+ });
106
+ }
107
+
108
+ /**
109
+ * 端口冲突的统一决策。
110
+ *
111
+ * reuse —— 端口上跑的就是本项目的面板:复用它,不再起第二个进程。
112
+ * 起第二个会让「切项目」变成「切端口」,而用户想看的其实是已经开着的那一个。
113
+ * shift —— 端口被别的东西占着:顺延到下一个空闲端口,再起自己的面板。
114
+ *
115
+ * 返回 { action, port, occupant, existing }:
116
+ * action 'reuse' | 'shift'
117
+ * port reuse 时是原端口;shift 时是下一个空闲端口
118
+ * occupant 占用者的 app 名(探测不到就是 null)
119
+ */
120
+ export async function resolvePortConflict(port, appName, host = '127.0.0.1', headers = null) {
121
+ let probe = await probePanelOnce(port, host, PROBE_TIMEOUT_MS, headers);
122
+ if (probe.timedOut) {
123
+ // 慢应答的面板也认:再等一次。别把「自己的面板」误判成「别人的服务」,
124
+ // 白白再起一个实例——那正是这套机制要消灭的行为。
125
+ probe = await probePanelOnce(port, host, PROBE_TIMEOUT_MS, headers);
126
+ }
127
+ const existing = probe.data;
128
+ const occupant = existing && existing.app ? existing.app.name || null : null;
129
+ if (existing && occupant === appName) {
130
+ return { action: 'reuse', port, occupant, existing };
131
+ }
132
+ const next = await findFreePort(port + 1, host);
133
+ return { action: 'shift', port: next, occupant, existing };
134
+ }
135
+
136
+ /** 占用者的一句话描述,用于终端提示。探测不到就是「未知服务」。 */
137
+ export function describeOccupant(occupant) {
138
+ return occupant || '未知服务';
139
+ }
@@ -17,23 +17,47 @@ async function cmdServe(ctx) {
17
17
  const { openBrowser } = await import('../core/open.js');
18
18
  const { loadStore, storePathInUse } = await import('../core/store.js');
19
19
  const { appHome, displayPath } = await import('../core/paths.js');
20
+ const { autoOpenEnabled, resolvePortConflict, describeOccupant } = await import('../core/serve-port.js');
20
21
 
21
22
  await loadStore(); // 触发首次播种:面板一打开就该有「求职」栏目
23
+ const host = '127.0.0.1';
24
+ // --port 0 = 让 OS 挑一个空闲端口(测试用),所以这里不能用 DEFAULT_PORT 兜底
25
+ const desiredPort = ctx.port;
26
+ // --no-open 与 NX_SERVE_NO_OPEN 走同一个开关:任一为真就不开浏览器
27
+ const canOpen = autoOpenEnabled(ctx['no-open']);
28
+ // --json 分支的字段形状:正常启动与复用旧实例共用一份,避免两条路径漂移
29
+ const jsonLine = (u, p) => JSON.stringify({
30
+ url: u, port: p, host, pid: process.pid,
31
+ storePath: displayPath(storePathInUse()), storePathRaw: storePathInUse(), home: displayPath(appHome()),
32
+ }) + '\n';
33
+ // --json 模式下 stdout 只能有 JSON,人类可读的行改走 stderr
34
+ const say = (s) => (OUTPUT_JSON ? process.stderr : process.stdout).write(s + '\n');
35
+
22
36
  let server;
23
37
  try {
24
- server = await startServer({ port: ctx.port });
38
+ server = await startServer({ port: desiredPort });
25
39
  } catch (e) {
26
- if (e && e.code === 'EADDRINUSE') throw external(`端口 ${ctx.port} 已被占用;换一个:nx-sk serve --port ${ctx.port + 1}(或在 nxSk.port 里改默认端口)`);
27
- throw external(`启动服务失败: ${e && e.message ? e.message : String(e)}`);
40
+ if (!e || e.code !== 'EADDRINUSE') throw external(`启动服务失败: ${e && e.message ? e.message : String(e)}`);
41
+ // 端口被占:先问一句「上面跑的是不是我」。
42
+ const decision = await resolvePortConflict(desiredPort, APP_NAME, host);
43
+ if (decision.action === 'reuse') {
44
+ // 自己的面板已经在跑:不重复启动,直接把浏览器指过去。
45
+ const runningUrl = `http://${host}:${desiredPort}`;
46
+ say(APP_NAME + ' 已在运行:' + runningUrl);
47
+ say('直接打开浏览器(不重复启动);如需重启请先停掉旧实例');
48
+ if (OUTPUT_JSON) process.stdout.write(jsonLine(runningUrl, desiredPort));
49
+ if (canOpen) await openBrowser(runningUrl);
50
+ return { status: 'reused', url: runningUrl, port: desiredPort, host, pid: process.pid };
51
+ }
52
+ // 别人占的:顺延到下一个空闲端口,再起自己的面板。
53
+ say(`端口 ${desiredPort} 已被占用(占用者: ${describeOccupant(decision.occupant)}),改用 ${decision.port}`);
54
+ server = await startServer({ port: decision.port });
28
55
  }
29
56
  // --port 0 = 让 OS 挑一个空闲端口(测试用),所以真实端口要回读,不能信入参
30
- const realPort = server.address() && typeof server.address() === 'object' ? server.address().port : ctx.port;
57
+ const realPort = server.address() && typeof server.address() === 'object' ? server.address().port : desiredPort;
31
58
  const url = `http://127.0.0.1:${realPort}`;
32
59
  if (OUTPUT_JSON) {
33
- process.stdout.write(JSON.stringify({
34
- url, port: realPort, host: '127.0.0.1', pid: process.pid,
35
- storePath: displayPath(storePathInUse()), storePathRaw: storePathInUse(), home: displayPath(appHome()),
36
- }) + '\n');
60
+ process.stdout.write(jsonLine(url, realPort));
37
61
  } else {
38
62
  process.stdout.write([
39
63
  `面板: ${url}`,
@@ -42,7 +66,7 @@ async function cmdServe(ctx) {
42
66
  '机器可读: nx-sk serve --json(先打一行 JSON,再继续常驻)',
43
67
  ].join('\n') + '\n');
44
68
  }
45
- if (!ctx['no-open']) await openBrowser(url);
69
+ if (canOpen) await openBrowser(url);
46
70
 
47
71
  await new Promise((done) => {
48
72
  let closed = false;
@@ -64,7 +88,7 @@ const BUILTINS = [
64
88
  module: 'system',
65
89
  cli: ['serve'],
66
90
  http: null,
67
- summary: '启动 Web 面板(唯一常驻命令;--no-open 给开发与 CI 用)',
91
+ summary: '启动 Web 面板(默认 :7866;--port N 改端口;--no-open 不开浏览器;端口上已有本面板则直接打开它;NX_SERVE_NO_OPEN=1 全局不开浏览器)',
68
92
  flags: { port: { type: 'number', default: DEFAULT_PORT }, 'no-open': { type: 'boolean' } },
69
93
  run: (ctx) => cmdServe(ctx),
70
94
  render: () => '',