nx-sk 0.15.1 → 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,27 @@
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
+
20
+ ## 0.15.2 (2026-10-20)
21
+
22
+ ### Changed
23
+
24
+ - **skill 改名:`kv-source` → `sk-source`**——与 sk-pdf / sk-needdo / sk-thesis 用同一个
25
+ `sk-` 前缀(它们都是「教 agent 做一类事」的配套 skill,`nx-*` 前缀留给 CLI 本体)。
26
+ 已装过旧名的:`nx-sk skill install sk-source --force` 装新的,旧的目录可手动删。
27
+
7
28
  ## 0.15.1 (2026-10-20)
8
29
 
9
30
  ### Fixed
@@ -43,7 +64,7 @@
43
64
  - 三条纪律写进代码与文档:**原文不改写**、**不确定就留空**、**敏感内容不进袋**(袋是明文的,
44
65
  密码走「密钥」栏目或 `sensitive` 字段);袋**不计完整度**(暂存区不是「该填的格子」)。
45
66
  - 新栏目(模板或自定义)都自带这个字段;老栏目用 `nx-sk source ensure` 一次补齐。
46
- - **`kv-source` skill**(随包分发,`nx-sk skill install kv-source`):讲透「原始资料 ↔ 字段」
67
+ - **`sk-source` skill**(随包分发,`nx-sk skill install sk-source`):讲透「原始资料 ↔ 字段」
47
68
  的双向同步、映射表纪律(把握不足就不写)、以及各种文件(PDF/截图/邮件/表格)怎么进袋。
48
69
 
49
70
  ### Fixed
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
  | 命令 | 作用 |
@@ -160,7 +160,7 @@ nx-sk source ensure # 给老栏目补上这个字段(模板
160
160
  **把袋子里的东西整理成字段**(agent 侧)有配套 skill:
161
161
 
162
162
  ```bash
163
- nx-sk skill install kv-source # 装到 ~/.claude/skills/kv-source
163
+ nx-sk skill install sk-source # 装到 ~/.claude/skills/sk-source
164
164
  ```
165
165
 
166
166
  它讲的是双向同步:source → 字段(整理)与 字段 → source(回写留痕),
@@ -162,7 +162,7 @@ nx-sk section update job --fields @fields.json
162
162
  > 把乱数据硬塞进结构化字段(把结构搞脏)或干脆不存(信息丢了)两个坏选择。
163
163
  > - 面板上:条目表单底部一条**折叠条**「原始资料 · N 条」,点开是右侧抽屉——
164
164
  > 左清单、中间正文,文件可拖进去(也给了「选择文件」按钮与粘贴框:拖拽不能是唯一入口)。
165
- > - CLI:`nx-sk source list / get / add / update / remove / ensure`(细节见 `kv-source` skill)。
165
+ > - CLI:`nx-sk source list / get / add / update / remove / ensure`(细节见 `sk-source` skill)。
166
166
  > - 三条纪律:**原文不改写**(整理结果写进字段或新的一条)、**不确定就留空**、
167
167
  > **敏感内容不进袋**(袋是明文的,密码走「密钥」栏目或 `sensitive` 字段)。
168
168
  > - 袋**不计完整度**(`fill: optional`):它是暂存区,不是「该填的格子」。
@@ -1,9 +1,9 @@
1
1
  ---
2
- name: kv-source
2
+ name: sk-source
3
3
  description: 在 nx-sk 的栏目里做「原始资料 ↔ 结构化字段」的双向同步——把杂乱资料(邮件、聊天记录、截图、PDF 摘录、手抄的表、网址)先原样收进条目的「原始资料」袋,再按栏目字段字典整理成结构化字段;也支持反向把已确认的字段值回写进袋子留痕。当用户说"把这些乱的整理进 nx-sk"、"这堆资料归到字段里"、"source 和字段同步一下"、"把邮件/截图/文档存下来并归档"、"原始资料整理成表"、"回写一下出处",或给来一堆没格式的内容却已经指定了栏目/条目时使用。触发词:source、原始资料、乱入、整理、同步、归档、乱数据、收件箱、字段同步、资料袋。不适用:用户已经说清要写哪个字段(直接 entry update 即可)、栏目结构本身还没定(先走 sk-needdo 定结构)、PDF 要转成图文 SOP(用 sk-pdf)。
4
4
  ---
5
5
 
6
- # kv-source · 原始资料 ↔ 字段 的双向同步
6
+ # sk-source · 原始资料 ↔ 字段 的双向同步
7
7
 
8
8
  一句话:**先原样收下,再按字典整理;整理完留痕,两头能对回去。**
9
9
 
@@ -1,6 +1,6 @@
1
1
  # 10 · 双向同步手册
2
2
 
3
- > `kv-source` skill 的详细页。**什么时候读**:袋里有东西要整理(第 3 步)、
3
+ > `sk-source` skill 的详细页。**什么时候读**:袋里有东西要整理(第 3 步)、
4
4
  > 或要把字段值回写进袋(第 4 步)时。
5
5
 
6
6
  ## 两种方向,先分清
@@ -1,6 +1,6 @@
1
1
  # 20 · 文件与资料怎么进袋
2
2
 
3
- > `kv-source` skill 的详细页。**什么时候读**:用户丢来的是文件、截图、PDF、
3
+ > `sk-source` skill 的详细页。**什么时候读**:用户丢来的是文件、截图、PDF、
4
4
  > 邮件、表格这些「非纯文字」的东西时。
5
5
 
6
6
  ## 原则:原件进附件库,袋里只留引用
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.15.1",
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
+ }
@@ -1,6 +1,6 @@
1
1
  // 原始资料袋(`type: 'source'`)的条目形状与归一规则。
2
2
  //
3
- // 为什么单独一个 core 模块:这份形状同时被 CLI、面板、kv-source skill 三处读写。
3
+ // 为什么单独一个 core 模块:这份形状同时被 CLI、面板、sk-source skill 三处读写。
4
4
  // 形状若各写一份,迟早漂成「面板能显示、agent 读不出」——所以口径只此一处。
5
5
  //
6
6
  // 一条 item 的形状:
@@ -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: () => '',