nx-sk 0.3.3 → 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 +29 -0
- package/assets/nx-sk/SKILL.md +1 -1
- package/assets/nx-sk/references/20-secrets.md +37 -0
- package/package.json +1 -1
- package/src/core/argsafe.js +53 -0
- package/src/modules/entries/index.js +3 -0
- package/src/runtime/cli.js +56 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@
|
|
|
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
|
+
|
|
7
36
|
## [0.3.3] - 2026-09-26
|
|
8
37
|
|
|
9
38
|
### Fixed
|
package/assets/nx-sk/SKILL.md
CHANGED
|
@@ -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
|
@@ -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
|
+
|
|
@@ -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
|
},
|
package/src/runtime/cli.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|