nx-sk 0.3.2 → 0.3.4

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,54 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.4] - 2026-09-26
8
+
9
+ ### Added
10
+
11
+ - **多行值的可靠通道**:`key set <名> -`(stdin)与 `key set <名> --data @文件.json`
12
+ (也接受 `-` 与 JSON 字面量)。对象含 `value` 键时取它,只含一个键时取那个键,
13
+ 多键且无 `value` 直接报错而不是猜。
14
+
15
+ ### Fixed
16
+
17
+ - **Git Bash 下多行参数不再被静默截断**(原始事故):转发器里 `exec node` 走的是
18
+ PATH 上的 node,而它可能是 Volta 之类的中转 shim,那一跳会把 `$'l1\nl2\nl3'`
19
+ 截成 `l1` 且**不报错**——一次性凭据就这么丢掉大半。改为直连本进程的
20
+ `process.execPath`,并带路径失效回退(node 升级换目录时退回 PATH,重跑
21
+ `link:local` 即恢复)。
22
+ - **恢复被删的 `references/40-extend.md`**:它与 SKILL.md 路由表里的条目被**同时**
23
+ 删掉,于是「路由表 ↔ 目录双向对账」两边一起变空、照样通过——这个 ref 就这样被
24
+ 删了两次。新增断言钉住「ref 体系整体不被削掉」(必存 ref 下限 + 编号升序唯一),
25
+ 已做反向验证(删文件时确实变红)。
26
+
27
+ ### Notes
28
+
29
+ - **`.cmd` 那条路修不了**:实测直连真实 `node.exe` 与走 shim 结果**完全相同**
30
+ (多行都截断、尖括号都失败),瓶颈是 cmd.exe 解析 `.cmd` 内容时的 `%*` 展开。
31
+ cmd / PowerShell 下多行值必须走 `-` 或 `@文件`。
32
+ - 原计划要在 CLI 里做「检测到含换行就拒绝」,实测后**放弃**:损坏后 CR 是被删掉而非
33
+ 保留(`$'a\rb'` 实际落盘 `"ab"`),零真阳性;而 `@文件` 读入的合法 CRLF 多行值含
34
+ CR,会被自己的检测拦住(高假阳性)。LF 截断后与合法单行值不可区分——可靠信号不存在。
35
+
36
+ ## [0.3.3] - 2026-09-26
37
+
38
+ ### Fixed
39
+
40
+ - **自定义栏目在 Web 面板整页空白**:栏目的分组元数据有两条读取路径——CLI 从字段
41
+ **推导**(`groupFields(fields)`),面板按**声明数组**(`section.groups`)渲染。
42
+ 用 `section add --fields '{…"group":"basic"…}'` 建栏目而没同时给 `--groups` 时,
43
+ `groups` 是空数组:CLI 显示正常、面板的字段表单整组消失,而页面本身不报错,
44
+ 看起来就像「这个栏目不存在」。
45
+ - 修复:新增 `syncSectionGroups()` 把「分组声明 ⊇ 字段引用」钉成**栏目不变量**,
46
+ 规则只写一份,在 `normalizeSection`(store 读写的唯一收口)与 `section add` 的
47
+ 草稿两处调用——**存量数据读一次即自愈**,此后 `--add-field` / 面板加字段引用到
48
+ 未声明的分组也会自动补齐声明。
49
+
50
+ ### Changed
51
+
52
+ - CHANGELOG 中 0.3.1 那条安全说明不再复述被清除的原文(说明「清理了 PII」的句子
53
+ 本身不该再带上那份 PII)。
54
+
7
55
  ## [0.3.2] - 2026-09-25
8
56
 
9
57
  ### Fixed
@@ -19,10 +67,10 @@
19
67
 
20
68
  ### Security
21
69
 
22
- - **清除文档与帮助文本中的真实个人信息**:`赵刘学`→`张三`、`zhaoliuxue`→`zhangsan`,
23
- 涉及 skill 文档示例(`assets/nx-sk/references/`)、CLI 帮助文本(`help entry`)、
24
- 面板 toast 提示与单测夹具;提交作者身份统一为 `nx-sk <nx-sk@local>`。
25
- 已用 `git filter-repo` 重写全部历史并 force-push。
70
+ - **清除文档与帮助文本中的真实个人信息**:真实姓名与邮箱拼音样例统一替换为占位符
71
+ (姓名→`张三`、邮箱前缀→`zhangsan`),涉及 skill 文档示例(`assets/nx-sk/references/`)、
72
+ CLI 帮助文本(`help entry`)、面板 toast 提示与单测夹具;提交作者身份统一为
73
+ `nx-sk <nx-sk@local>`。已用 `git filter-repo` 重写全部历史并 force-push。
26
74
  - 0.3.0 因包含上述信息已在 npm 标记 deprecated(unpublish 被 granular token
27
75
  2FA 政策拦截,需官网手动删除)。
28
76
 
@@ -73,7 +73,7 @@ nx-sk key remove OPENAI_KEY # 删除,写前自动留快照
73
73
  | `10-sections-entries` | **动手填/改信息时必读**——栏目与条目的完整命令、AI 补全个人信息的推荐流程、字段字典怎么扩、读命令与写命令的参数陷阱 |
74
74
  | `20-secrets` | 存/读大模型 key、密文机制、密钥来源(环境变量 vs 本机密钥文件)、什么时候会解不开 |
75
75
  | `30-export-backup` | 整体导出、只导出某个栏目、快照与回滚、明文密钥的取舍 |
76
- | `40-extend` | 要**改这个项目本身**(加一个栏目模板、加一个功能域)时——闭环落点表与静默失效点 |
76
+ | `40-extend` | 要**改这个项目本身**时读——加栏目模板、加功能域(含 12 项闭环表与「哪些地方漏了没有断言会发现」)、四条硬纪律、验证闸门、几个刻意不该改的地方 |
77
77
 
78
78
  取 ref 全文(外部 agent 用这条,不必进终端翻文件):
79
79
 
@@ -91,6 +91,43 @@ nx-sk section list # 列表里 KV 栏目带 [KV 表]
91
91
  - **重复写同一个值不会重写密文**:返回 `{ status: 'skipped', unchanged: true }`。
92
92
  每次加密都用新 IV,无脑重写只会产生一堆无意义的新密文和新快照。
93
93
 
94
+ ### 多行值:走 stdin 或 `@文件`(真实事故)
95
+
96
+ Windows 上命令经启动器转发时,**命令行参数里的换行会被截断**,而命令**照样报成功**——
97
+ 一次性凭据(多行私钥、恢复码、证书)就这么静默丢掉后半截。同一条通道还会让含 `<` `>`
98
+ 的参数整串失败(被当成重定向符,报「系统找不到指定的路径」)。
99
+
100
+ 值的可靠通道有两条,任选:
101
+
102
+ ```bash
103
+ printf '%s' "$KEY" | nx-sk key set OPENAI_KEY - # stdin:值的位置参数写 -
104
+ nx-sk key set OPENAI_KEY --data @C:/Users/me/值.json # 文件:--data @路径(含 value 键)
105
+ ```
106
+
107
+ `--data` 也接受 `-`(从 stdin 读 JSON)与直接给 JSON 字面量;对象含 `value` 键时取它,
108
+ 只含一个键时取那个键,多键且无 `value` 直接报错(不猜)。
109
+
110
+ **路径必须用 Windows 形式**(`C:/Users/...`):Git Bash 的 `/tmp` 在 node 侧看不到
111
+ (实测 ENOENT),node 侧拿到的会是 `D:\tmp\...` 这种对不上的路径。
112
+
113
+ #### 哪些情况下命令行参数本身是安全的
114
+
115
+ 实测(Windows 11 · Volta · Git Bash)分两种情况,**别当成一回事**:
116
+
117
+ | 你在哪敲 | 命令行多行参数 | 说明 |
118
+ | --- | --- | --- |
119
+ | **Git Bash / WSL 终端** | ✅ **安全** | 0.3.4 起转发器直连真实 `node.exe`,绕开了会截断参数的那一跳 |
120
+ | **cmd.exe / PowerShell** | ❌ **损坏** | 瓶颈是 cmd 解析 `.cmd` 文件内容(`%*` 展开),换 node 也治不了 |
121
+
122
+ 所以:**cmd / PowerShell 下多行值一律走 `-` 或 `@文件`**,这是硬要求;
123
+ Git Bash 下裸参数已经能用,但脚本里要可移植的话,仍然推荐上面两条通道。
124
+
125
+ > 没有「检测到损坏就报错」这回事——**做不到**。损坏后的值里 CR 是被**删掉**而非保留
126
+ > (`$'a\rb'` 实际落盘 `"ab"`),所以「含 CR 即损坏」既抓不到真实损坏(零真阳性)、
127
+ > 又会拦住用 `@文件` 写入的**合法 CRLF 多行值**(高假阳性)。而 LF 截断后与一个合法的
128
+ > 单行值完全不可区分。可靠信号不存在,因此只能「提供可靠通道 + 修好通道本身」,
129
+ > 不能靠事后检测兜底。
130
+
94
131
  ## 三、密文机制
95
132
 
96
133
  - 算法 **AES-256-GCM**(`core/crypto.js`)。每个值独立 IV,带认证标签。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-sk",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,53 @@
1
+ // CLI 参数安全:把「命令行传不了的东西」换成可传的形式。
2
+ //
3
+ // 为什么不放 runtime/spec.js:那里的 coerce() 是 CLI 与 HTTP **共用**的纯净解码器,
4
+ // 而 HTTP 没有「命令行通道」这回事。CLI 专属的通道问题集中在 CLI 适配层解决。
5
+ //
6
+ // 背景(真实事故,见 ref 20-secrets):Windows 上值经命令行参数传递时,启动器 shim
7
+ // (Volta shim / npm 生成的 .cmd)会把换行当命令分隔符截断、把 < > 当重定向符,
8
+ // 而命令**报成功**——一次性凭据就这么静默丢掉大半。文件与管道是可靠通道。
9
+ //
10
+ // 为什么不在这里做「检测损坏就报错」:实测做不到(详见 cli.js 的 expandSafeArgs)。
11
+ // 损坏后的值里 CR 是被**删掉**而非保留,所以「含 CR」既抓不到损坏(零真阳性)、
12
+ // 又会拦住用 --data 写入的合法 CRLF 多行值(高假阳性);LF 截断后则与合法单行值
13
+ // 完全不可区分。可靠的信号不存在,因此只能提供可靠通道 + 修好通道本身。
14
+ import { readFileSync } from 'node:fs';
15
+ import { badInput } from './errors.js';
16
+
17
+ /**
18
+ * `--data` 的值:JSON 字面量 / `@文件路径` / `-`(stdin)。
19
+ *
20
+ * takeStdin 由调用方注入——CLI 传 readFileSync(0),测试能换桩。
21
+ * 「读 stdin」因此不是一个无法测试的隐式依赖,也让本模块保持纯函数(不自己碰 fd 0)。
22
+ */
23
+ export function parseDataArg(raw, takeStdin) {
24
+ const s = String(raw ?? '').trim();
25
+ if (!s) throw badInput('--data 需要一个 JSON 字面量、@文件路径,或 -(从 stdin 读)');
26
+ if (s === '-' || s === '@-') return parseJson(takeStdin(), '-');
27
+ if (s.startsWith('@')) return parseJson(readText(s.slice(1)), s.slice(1));
28
+ return parseJson(s, '--data'); // 直接给的字面 JSON
29
+ }
30
+
31
+ /** 值本身走 stdin 的入口(`key set NAME -`)。true 表示「这个值来自 stdin」。 */
32
+ export function parseStdinValue(raw) {
33
+ return String(raw ?? '').trim() === '-';
34
+ }
35
+
36
+ function parseJson(text, src) {
37
+ try {
38
+ return JSON.parse(text);
39
+ } catch (e) {
40
+ throw badInput(`--data 不是合法 JSON(${src}): ${e.message}`);
41
+ }
42
+ }
43
+
44
+ function readText(p) {
45
+ try {
46
+ return readFileSync(p, 'utf8');
47
+ } catch (e) {
48
+ // 路径写法是最常见的坑:Git Bash 的 /tmp 在 node 侧看不到(真实事故 ENOENT),
49
+ // 所以错误里直接把 Windows 路径形式写出来,省掉一轮排查。
50
+ throw badInput(`读取文件失败: ${p}(${e.message})—— Windows 上请用 C:/Users/... 形式的路径`);
51
+ }
52
+ }
53
+
@@ -294,6 +294,25 @@ export function groupFields(fields) {
294
294
  return order.map((id) => ({ id, fields: map.get(id) }));
295
295
  }
296
296
 
297
+ /**
298
+ * 分组声明的**唯一补全规则**:字段引用了未声明的分组 → 追加 `{ id, title: id }`。
299
+ *
300
+ * 背景(servers 栏目事故):CLI 的分组渲染从字段**推导**(groupFields),
301
+ * 面板却读**声明数组**(section.groups)——`--fields` 引用了 `--groups`
302
+ * 没声明的分组时,两端分叉,面板整个字段表单消失而 CLI 看起来一切正常。
303
+ * 所以「分组声明 ⊇ 字段引用」必须是不变量,在 normalize(读写唯一收口)
304
+ * 和 section add 的草稿上调用同一个函数,规则就不可能有第二份。
305
+ */
306
+ export function syncSectionGroups(groups, fields) {
307
+ const out = [...(groups || [])];
308
+ const declared = new Set(out.map((g) => g.id));
309
+ for (const f of fields || []) {
310
+ const gid = f.group || 'other';
311
+ if (!declared.has(gid)) { declared.add(gid); out.push({ id: gid, title: gid }); }
312
+ }
313
+ return out;
314
+ }
315
+
297
316
  export function fieldByKey(section, key) {
298
317
  return (section?.fields || []).find((x) => x.key === key) || null;
299
318
  }
package/src/core/store.js CHANGED
@@ -4,7 +4,7 @@ import fsp from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { displayPath, snapshotDir, storeFile } from './paths.js';
6
6
  import { ensureDir, pathExists, writeAtomic, writeJsonAtomic } from './fsx.js';
7
- import { instantiateTemplate, seedSections } from './fields.js';
7
+ import { instantiateTemplate, seedSections, syncSectionGroups } from './fields.js';
8
8
  import { nowIso, stamp } from './ids.js';
9
9
 
10
10
  /** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
@@ -38,7 +38,7 @@ function emptyStore(seed) {
38
38
 
39
39
  function normalizeSection(raw) {
40
40
  if (!raw || typeof raw !== 'object' || !raw.id) return null;
41
- return {
41
+ const out = {
42
42
  id: String(raw.id),
43
43
  title: String(raw.title || raw.id),
44
44
  description: String(raw.description || ''),
@@ -52,6 +52,11 @@ function normalizeSection(raw) {
52
52
  createdAt: raw.createdAt || null,
53
53
  updatedAt: raw.updatedAt || null,
54
54
  };
55
+ // 「分组声明 ⊇ 字段引用」是栏目不变量:字段引用了没声明的分组,面板的分组表单
56
+ // 会整组消失(真实事故:servers 栏目 groups 为空,Web 端白屏而 CLI 正常)。
57
+ // 放在 normalize 收口——每条读写路径都会过这里,存量数据读一次就自愈。
58
+ out.groups = syncSectionGroups(out.groups, out.fields);
59
+ return out;
55
60
  }
56
61
 
57
62
  function normalizeEntry(raw) {
@@ -129,6 +129,9 @@ export default {
129
129
  args: ['name', 'value'],
130
130
  flags: {
131
131
  value: { type: 'string', hint: '值的另一种给法(值以 - 开头时用 --value=<值>)' },
132
+ // 多行值经命令行会被 Windows 启动器 shim 截断成首行且不报错(见 ref 20-secrets),
133
+ // 文件与 stdin 是可靠通道。取 value 键;没有则取对象里唯一那个键。
134
+ data: { type: 'string', hint: '@文件.json(含 value 键),或 -(从 stdin 读原文)' },
132
135
  section: { type: 'string' },
133
136
  'dry-run': { type: 'boolean' },
134
137
  },
@@ -3,7 +3,7 @@
3
3
  import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
4
4
  import { assertFieldKey, assertSafeId } from '../../core/paths.js';
5
5
  import { badInput, blocked, conflict, notFound } from '../../core/errors.js';
6
- import { FILL_POLICIES, FIELD_TYPES, findSection, groupFields, instantiateTemplate, sectionNames, templateSummaries } from '../../core/fields.js';
6
+ import { FILL_POLICIES, FIELD_TYPES, findSection, groupFields, instantiateTemplate, sectionNames, syncSectionGroups, templateSummaries } from '../../core/fields.js';
7
7
  import { dumpSection, formatValue } from '../../core/render.js';
8
8
  import { sensitiveViewer } from '../../core/vault.js';
9
9
  import { nowIso } from '../../core/ids.js';
@@ -116,6 +116,9 @@ export async function addSection({ id, title, template, description, order, fiel
116
116
  const defaultGroup = draft.groups[0]?.id || 'other';
117
117
  draft.fields = fields.map((x) => validateFieldDef(x, { allowGroup: defaultGroup }));
118
118
  }
119
+ // 与 normalize 同一条规则:--fields 引用了 --groups 没声明的分组时自动补上,
120
+ // 否则面板按声明数组渲染会整组消失(CLI 从字段推导所以看不出来)。
121
+ draft.groups = syncSectionGroups(draft.groups, draft.fields);
119
122
  draft.createdAt = nowIso();
120
123
  draft.updatedAt = draft.createdAt;
121
124
 
@@ -1,8 +1,10 @@
1
1
  // 通用 CLI 运行器:解析 → 匹配 → 校验 → 渲染 → help。
2
2
  // 命令表由 action 声明派生,所以「Web 上能做的 CLI 都能做」是结构保证,不靠人记。
3
+ import { readFileSync } from 'node:fs';
3
4
  import { resolve as resolvePath } from 'node:path';
4
5
  import { APP_NAME, DEFAULT_PORT, ENV_STORE, VERSION } from '../core/paths.js';
5
6
  import { badInput, external, toErrorShape } from '../core/errors.js';
7
+ import { parseDataArg, parseStdinValue } from '../core/argsafe.js';
6
8
  import { ACTIONS, MODULES, MODULE_TITLES, commandEntry } from './registry.js';
7
9
  import { applySpec, argSpecsOf, cliPathsOf, flagSpecsOf, usageOf } from './spec.js';
8
10
 
@@ -198,7 +200,60 @@ function parseArgs(action, tokens) {
198
200
  for (let i = 0; i < positional.length; i++) raw[argSpecs[i].name] = positional[i];
199
201
  // 显式 flag 覆盖位置参数:`key set K --value=<值>` 与 `key set K <值>` 必须等价,
200
202
  // 而后者在值以 `-` 开头时会被当成 flag(那时只能走前者)。
201
- return { ...raw, ...flags };
203
+ // 位置参数与 flag 一起交给展开函数:`key set K -` 的 `-` 是位置参数,
204
+ // 不能因为它「看起来像 flag」就漏掉。
205
+ return expandSafeArgs({ ...raw, ...flags });
206
+ }
207
+
208
+ /**
209
+ * 安全通道展开 —— CLI 独有的「通道知识」全部集中在这一个函数里。
210
+ *
211
+ * Windows 上值经命令行参数进来时,启动器 shim(Volta / npm 的 .cmd)会把换行
212
+ * 当命令分隔符截断,而命令**报成功**——一次性凭据就这么静默丢掉大半(真实事故)。
213
+ * 文件与 stdin 不受影响,所以这里把 `--data @文件` / `-` 在进 applySpec 之前
214
+ * 换成真正的值。放这里而不是 spec.js:那边的 coerce() 是 CLI 与 HTTP 共用的
215
+ * 纯净解码器,而 HTTP 没有「命令行通道」这回事。
216
+ */
217
+ function expandSafeArgs(raw) {
218
+ const out = { ...raw };
219
+ let stdinUsed = false;
220
+ // stdin 只能被消费一次:两个参数都写 `-` 的话第二个读到的是空,
221
+ // 静默读空比报错危险,所以显式拒绝。
222
+ const takeStdin = () => {
223
+ if (stdinUsed) throw badInput('stdin 只能被一个参数使用(已经有一个参数用 - 读过了)');
224
+ stdinUsed = true;
225
+ return readFileSync(0, 'utf8');
226
+ };
227
+
228
+ // --data 统一在这里解析成对象:spec.coerce 的 json 分支对「已是对象」的值直接返回,
229
+ // 所以两处不会各解析一遍,`-`(stdin)这条新通道也自然被 coerce 认作合法输入。
230
+ //
231
+ // 注意顺序:**先**解析 --data,再处理 `-`。反过来的话 `--data -` 会在 data 还没读 stdin
232
+ // 时就被判定成「value 也是 stdin」,把两次消费搅在一起(真实踩到的顺序 bug)。
233
+ if (typeof out.data === 'string') out.data = parseDataArg(out.data, takeStdin);
234
+
235
+ // `--data` 是位置参数 value 的替代给法(KV 表格只有一个值字段,语义确定),
236
+ // 所以给了 data 就不该再因为缺 value 而报用法错。
237
+ // 取 value 键;没有 value 键时取对象里唯一那个键。
238
+ //
239
+ // 位置参数 `-` 要**先于**这里的提取被认出来,否则 `key set K -` 的 `-` 会被
240
+ // 当成字面值 `-`。所以 stdin 判定放在 data 提取之后、用「value 是否还等于 -」判断。
241
+ if (out.value === undefined && out.data && typeof out.data === 'object' && !Array.isArray(out.data)) {
242
+ const keys = Object.keys(out.data);
243
+ if (out.data.value !== undefined) out.value = out.data.value;
244
+ else if (keys.length === 1) out.value = out.data[keys[0]];
245
+ else throw badInput(`--data 需要含 value 键,或只含一个键(收到 ${keys.length} 个: ${keys.join(', ')})`);
246
+ }
247
+
248
+ if (typeof out.value === 'string' && parseStdinValue(out.value)) out.value = takeStdin();
249
+
250
+ // 注意:这里**没有**「值含换行就报错」的检测,因为做不到。
251
+ // 实测(Windows 11 / Volta):损坏后的值里 CR 是被**删掉**而不是保留,
252
+ // 所以「含 CR」既抓不到损坏(零真阳性),又会拦住用 --data 写入的合法
253
+ // CRLF 多行值(高假阳性)。而 LF 截断后与合法单行值完全不可区分。
254
+ // 能做的只有:① 给出可靠通道(--data / stdin,本函数);② 让通道本身不再损坏
255
+ // (scripts/link-local.mjs 的转发器直连 node.exe)。
256
+ return out;
202
257
  }
203
258
 
204
259
  const STATUS_NOTE = {