nx-sk 0.3.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/LICENSE +21 -0
  3. package/README.md +222 -0
  4. package/assets/nx-sk/SKILL.md +102 -0
  5. package/assets/nx-sk/references/00-design.md +116 -0
  6. package/assets/nx-sk/references/10-sections-entries.md +165 -0
  7. package/assets/nx-sk/references/20-secrets.md +170 -0
  8. package/assets/nx-sk/references/30-export-backup.md +80 -0
  9. package/assets/nx-sk/references/40-extend.md +75 -0
  10. package/bin/nx-sk.mjs +11 -0
  11. package/package.json +56 -0
  12. package/src/core/crypto.js +73 -0
  13. package/src/core/errors.js +52 -0
  14. package/src/core/fields.js +345 -0
  15. package/src/core/fsx.js +103 -0
  16. package/src/core/ids.js +19 -0
  17. package/src/core/open.js +32 -0
  18. package/src/core/paths.js +141 -0
  19. package/src/core/render.js +144 -0
  20. package/src/core/store.js +317 -0
  21. package/src/core/vault.js +109 -0
  22. package/src/index.js +14 -0
  23. package/src/modules/entries/index.js +152 -0
  24. package/src/modules/entries/kvTable.jsx +158 -0
  25. package/src/modules/entries/service.js +539 -0
  26. package/src/modules/entries/view.jsx +240 -0
  27. package/src/modules/export/index.js +39 -0
  28. package/src/modules/export/service.js +192 -0
  29. package/src/modules/export/view.jsx +155 -0
  30. package/src/modules/sections/index.js +104 -0
  31. package/src/modules/sections/service.js +337 -0
  32. package/src/modules/sections/view.jsx +311 -0
  33. package/src/modules/settings/index.js +34 -0
  34. package/src/modules/settings/service.js +78 -0
  35. package/src/modules/settings/view.jsx +193 -0
  36. package/src/modules/skill/index.js +51 -0
  37. package/src/modules/skill/service.js +200 -0
  38. package/src/modules/system/index.js +42 -0
  39. package/src/modules/system/service.js +155 -0
  40. package/src/runtime/api.js +101 -0
  41. package/src/runtime/cli.js +266 -0
  42. package/src/runtime/registry.js +65 -0
  43. package/src/runtime/server.js +80 -0
  44. package/src/runtime/spec.js +158 -0
  45. package/src/web/frontend/App.jsx +108 -0
  46. package/src/web/frontend/api/client.js +26 -0
  47. package/src/web/frontend/components/fieldEditor.jsx +85 -0
  48. package/src/web/frontend/components/ui.jsx +143 -0
  49. package/src/web/frontend/index.html +12 -0
  50. package/src/web/frontend/main.jsx +13 -0
  51. package/src/web/frontend/registry.js +20 -0
  52. package/src/web/frontend/store.jsx +87 -0
  53. package/src/web/frontend/styles.css +301 -0
  54. package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
  55. package/src/web/public/assets/index-CwF5hM2v.css +1 -0
  56. package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
  57. package/src/web/public/assets/view-CfXXYwW2.js +1 -0
  58. package/src/web/public/assets/view-CxzKrdA3.js +7 -0
  59. package/src/web/public/assets/view-DjO1C54o.js +7 -0
  60. package/src/web/public/index.html +13 -0
@@ -0,0 +1,165 @@
1
+ # 10 · 栏目与条目
2
+
3
+ > 归属 `nx-sk` skill 主文档。**动手填/改信息时读这篇。**
4
+
5
+ ## 一、数据模型
6
+
7
+ ```
8
+ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
9
+ ├─ settings 全局配置(见 30-export-backup)
10
+ ├─ sections[] 栏目:id / title / order / template / titleField / groups[] / fields[]
11
+ └─ entries[] 条目:id(e_xxx) / section / title / tags[] / values{字段key: 值} / 时间戳
12
+ ```
13
+
14
+ - 一个**栏目** = 一组同构条目 + 一份字段字典(`groups[]` 分组、`fields[]` 字段定义)。
15
+ - 一个**条目** = 一行数据,值放在 `values` 里,键是字段 `key`。
16
+ - 定位方式都放宽:栏目与条目**都接受 id 或名字**。条目名重名时报 `CONFLICT` 让你改用 id。
17
+
18
+ ## 二、栏目命令(集合资源,五操作齐备)
19
+
20
+ | 操作 | 命令 | 接口 |
21
+ | --- | --- | --- |
22
+ | 列表 | `nx-sk section list` | `GET /api/sections` |
23
+ | 单条 | `nx-sk section get <id或标题>` | `GET /api/sections/:ref` |
24
+ | 新增 | `nx-sk section add <id> --title <标题> --template job` | `POST /api/sections` |
25
+ | 修改 | `nx-sk section update <id> [--title …] [--add-field '{…}'] [--remove-field a --remove-field b]` | `PATCH /api/sections/:ref` |
26
+ | 删除 | `nx-sk section remove <id> [--force]` | `DELETE /api/sections/:ref` |
27
+ | **全量** | `nx-sk section dump <id> [--mask]` | `GET /api/sections/:ref/dump` |
28
+ | 模板 | `nx-sk section templates` | `GET /api/sections/templates` |
29
+
30
+ - **`remove` 在栏目非空时会被 `blocked` 挡住**(除非 `--force`)——防止一键抹掉全部档案。
31
+ - `remove` 目标不存在时**报 `NOT_FOUND`**(不静默成功),就是为了让「打错一个字」当场暴露。
32
+ - `dump` 是「快速获得一个栏目的全部信息」的正解:字段字典 + 全部条目 + 每个条目的完整度与**未填清单**。
33
+
34
+ 内置模板:`job`(求职 / 个人信息,约 70 字段 / 9 分组)、`secret`(密钥,**单字段 KV**——细节见 `20-secrets`)。
35
+
36
+ ## 三、条目命令
37
+
38
+ | 操作 | 命令 | 接口 |
39
+ | --- | --- | --- |
40
+ | 列表 | `nx-sk entry list [--section job] [--q 关键字]` | `GET /api/entries` |
41
+ | 字段字典 | `nx-sk entry fields [--section job]` | `GET /api/entries/fields` |
42
+ | 单条 | `nx-sk entry get <id或名字> [--mask] [--section job]` | `GET /api/entries/:ref` |
43
+ | 新增 | `nx-sk entry add --section job --set 姓名=张三 [--set 电话=…]` | `POST /api/entries` |
44
+ | 修改 | `nx-sk entry update <ref> --set 期望城市=北京、上海 [--unset 微信]` | `PATCH /api/entries/:ref` |
45
+ | 删除 | `nx-sk entry remove <ref>` | `DELETE /api/entries/:ref` |
46
+
47
+ ### 参数要点
48
+
49
+ | 参数 | 说明 |
50
+ | --- | --- |
51
+ | `--set 字段=值` | 可重复。键**可用字段 key 或中文标签**。值按字段 type 强转(bool 认`是/否/true/false`;tags 用 `、` 或 `,` 分隔;number 认数字串) |
52
+ | `--data <json>` / `--data @file.json` | 一次写多字段。`--data` 优先被 `--set` 覆盖 |
53
+ | `--unset a,b` | 清空若干字段(**这才是清空的正确姿势**,不是 `--set a=`) |
54
+ | (已取消)`--allow-new-field` | 现在**默认就是自动登记**,不必再带这个 flag |
55
+ | `--title` | 条目名。不传则取栏目的 `titleField`(求职栏目是 `name`/姓名) |
56
+ | `--dry-run` | 试运行:返回 `wouldCreate` / `wouldChange`,不落盘 |
57
+ | `--mask` | 读命令专用:密文字段**打码**显示(默认是原文;投屏/截图时用) |
58
+
59
+ ### 三个容易踩的坑
60
+
61
+ 1. **`--set 字段=`(空值)等于清空,与 `--unset` 等效**;但把**打码后的值**(含 `******`)写回去会被拒绝——那通常意味着「不修改」,什么都不传即可。
62
+ 2. **`entry add` 同栏目同名直接报 `CONFLICT`**,不会静默再插一条。要改就用 `entry update`。
63
+ 3. **`--section` 是栏目过滤,不是条目参数**。`entry update` 的 `--section` 只用来消歧同名条目。
64
+
65
+ ## 四、AI 补全个人信息的推荐流程(本 skill 的主场景)
66
+
67
+ ```bash
68
+ # ① 看现在有什么、缺什么
69
+ nx-sk section dump job --json
70
+ # → entries[].missing[] 就是「还差哪些字段」的清单
71
+
72
+ # ② 看字段字典(key / label / type / options / hint),别猜字段名
73
+ nx-sk entry fields --section job --json
74
+
75
+ # ③ 批量补:先试运行,确认后再落盘
76
+ nx-sk entry update 赵刘学 --data @补全.json --dry-run
77
+ nx-sk entry update 赵刘学 --data @补全.json
78
+ # 补全.json 形如:{"身份证号":"…","英语等级":"CET-4","期望城市":["北京"]}
79
+
80
+ # ④ 复核
81
+ nx-sk entry get 赵刘学 --json
82
+ ```
83
+
84
+ **纪律**:
85
+
86
+ - 只写用户提供或已确认的值。不确定的字段**留空**,不要编造。
87
+ - 身份证号、手机号、紧急联系人电话这类字段,写之前跟用户确认一次来源。
88
+ - 每次批量写之前先 `--dry-run`。
89
+
90
+ ## 五、扩字段字典(不写代码)
91
+
92
+ **最省事的做法:直接写。** 字典里没有的键会自动登记(键名就是它本身),类型按值推断
93
+ (`是/否` → bool,数组 → tags,其余 → text)。
94
+
95
+ ```bash
96
+ nx-sk entry update 赵刘学 --set 期望行业=互联网 --set 技术博客=https://example.com
97
+ ```
98
+
99
+ **刻意不推 number**:18 位身份证号这类长数字超过 2^53,推成数字会静默丢精度。
100
+
101
+ 需要给字段配上候选值 / 提示文案 / 密文标记时,再显式声明:
102
+
103
+ ```bash
104
+ nx-sk section update job --add-field '{"key":"blog","label":"技术博客","type":"text","group":"contact","hint":"URL"}'
105
+
106
+ # 删字段:只影响台账,已填的值留在条目里(不再显示 ≠ 删掉)
107
+ nx-sk section update job --remove-field blog
108
+
109
+ # 整体替换字典(少数场景)
110
+ nx-sk section update job --fields @fields.json
111
+ ```
112
+
113
+ 字段定义支持:`key`(字母开头)、`label`、`type`(`text|textarea|number|date|month|bool|select|tags|secret`)、
114
+ `group`、`hint`、`options`(候选项)、`sensitive`(true 则密文落盘)、`maxItems`(tags 上限)、
115
+ `fill`(填写策略,见下)。
116
+
117
+ > `select` 的 `options` 是**候选项不是白名单**:真实值(如某个少数民族)不在候选里也允许写入。
118
+
119
+ ### 填写策略 `fill`:有些格子填了反而减分
120
+
121
+ 不是每个格子都值得填。应届生的「上一家公司」、政治面貌为「群众」时的「入党时间」——
122
+ 填上去不是补充,是噪音。`fill` 把这种判断**变成字段字典里的数据**,不用每次靠人记:
123
+
124
+ | 值 | 含义 | 计入完整度 |
125
+ | --- | --- | --- |
126
+ | `normal` | 照常填(**默认**——字段上不写 `fill` 就是它) | 是 |
127
+ | `optional` | 不必填:填了不亏,没填也不算「缺失」 | 否 |
128
+ | `avoid` | 不填:填了可能反而减分 | 否 |
129
+
130
+ ```bash
131
+ # 一次标几个,可重复;label 或 key 都认
132
+ nx-sk section update job --fill 入党时间=avoid --fill 上一家公司=avoid --fill 兴趣爱好=optional
133
+
134
+ # 改回照常填
135
+ nx-sk section update job --fill 入党时间=normal
136
+ ```
137
+
138
+ 被排除的字段**既不计入分子也不计入分母**,所以完整度反映的是「你真正该填的那些」。
139
+ 但它们不会被藏起来——`missing`(欠着的)和 `excluded`(主动跳过的)是分开报的:
140
+
141
+ ```bash
142
+ nx-sk entry get 赵刘学
143
+ # 赵刘学(e_…)· 栏目 job · 完整度 8/9 = 89% · 另有 3 项已标不填
144
+ # 未填(1): 高考科目(gaokaoSubjects)
145
+ # 不填(3)· 主动跳过、不计入完整度: 入党时间(partyJoinDate|不填)、…
146
+ ```
147
+
148
+ `--json` 里对应 `completeness.excluded`(个数)、`completeness.excludedFilled`
149
+ (标了不填却仍然填了的个数——非 0 就说明策略该更新了)、以及顶层的 `excluded[]` 数组。
150
+ `entry fields` / `section dump` 的字段字典会带上 `fill` 与 `fillLabel`,面板据此打标签。
151
+
152
+ > **给 agent 的三条**:① `fill: 'avoid'` 的字段**不要主动填**,`optional` 可填可跳过;
153
+ > ② 两者都**不要**出现在「还缺什么」的清单里;③ 用户说「这个不用填」时,
154
+ > 用 `--fill <字段>=avoid` 记下来,而不是靠下一轮上下文记住。
155
+
156
+ ## 六、Web 面板
157
+
158
+ `nx-sk serve` → 每个栏目一个 tab,共用同一个条目视图:
159
+
160
+ - 左侧条目列表(带完整度),右侧按分组渲染的字段表单;
161
+ - 密文字段默认显示原文;「打码显示(投屏/截图时用)」勾选等价于 `--mask`(选择持久化在 localStorage);
162
+ - 保存是 **PATCH**:只有被你改动过的字段才会发出去(面板会显示「N 处改动」);
163
+ - 每个页面底部会派生显示该模块的 CLI 等价命令(可点击复制)。
164
+
165
+ 面板上没有 CLI 做不到的事——两边共享同一套 action 声明。
@@ -0,0 +1,170 @@
1
+ # 20 · 密钥(KV)与密文
2
+
3
+ > 归属 `nx-sk` skill 主文档。存/取大模型 API Key、排查「解不开」时读这篇。
4
+
5
+ ## 一、模型:一张 KV 表
6
+
7
+ 密钥栏目标了 `kv: true` —— 它**就是一张表**,「一行一个键值对」:
8
+
9
+ - **面板**:一张两列表格(名称 / 值),改值即改、加行即加键。没有「条目列表」、
10
+ 没有字段表单、没有完整度百分比 —— 那些是「栏目 = 一族同构条目」才需要的,
11
+ 密钥不是那种东西。
12
+ - **CLI**:`key set/get/list/remove` 四条。
13
+
14
+ 表长这样(值以密文落盘,读写默认给原文):
15
+
16
+ | 键(条目名) | 值(唯一字段 `value`) |
17
+ | --- | --- |
18
+ | `OPENAI_KEY` | `sk-…`(密文落盘) |
19
+ | `DEEPSEEK_KEY` | `sk-…` |
20
+ | `OPENAI_BASE_URL` | `https://…` |
21
+
22
+ 「这个 key 干什么用」写进键名里就够了 —— 存一个 key 不该先填一张表。
23
+ (早期版本有 8 个字段:服务商 / 接口地址 / 模型 / 用途 / 有效期 / 额度 / 备注。实测是负担,已删。)
24
+
25
+ ## 二、四条命令(日常只用这四条)
26
+
27
+ ```bash
28
+ nx-sk key set OPENAI_KEY sk-xxxxxxxx # 写入:不存在则新建,存在则覆盖
29
+ nx-sk key get OPENAI_KEY # 取值:默认就是原文
30
+ nx-sk key get OPENAI_KEY --mask # 投屏/截图时才打码
31
+ nx-sk key list # 列出所有键(默认原文)
32
+ nx-sk key remove OPENAI_KEY # 删除(删前自动留快照)
33
+ ```
34
+
35
+ | 命令 | 接口 | 说明 |
36
+ | --- | --- | --- |
37
+ | `key list [--mask]` | `GET /api/keys[?mask=1]` | 全部键,按写入顺序 |
38
+ | `key get <名称> [--mask]` | `GET /api/keys/:name[?mask=1]` | 单个键的值 |
39
+ | `key set <名称> <值>` | `PUT /api/keys/:name` | **SET 语义**:存在即覆盖 |
40
+ | `key remove <名称>` | `DELETE /api/keys/:name` | 不存在报 `NOT_FOUND`;写前留快照 |
41
+
42
+ 四条都接受 `--section <栏目>`(HTTP 侧是 `?section=` 或 body 里的 `section`),
43
+ 缺省作用在**标了 `kv` 的那个栏目**上。
44
+
45
+ ### 与 `entry` 的关系(重要)
46
+
47
+ `key *` **不是第二套实现**,是 `entry *` 的语法糖:它把「哪个栏目 + 哪个值字段」定好,
48
+ 再调同一批 service 函数。所以加解密、快照、`--dry-run` 只有一份实现,不可能分叉。
49
+
50
+ 等价关系:
51
+
52
+ ```
53
+ nx-sk key set OPENAI_KEY sk-xxx
54
+ ≡ nx-sk entry add --section secret --title OPENAI_KEY --set value=sk-xxx (新建时)
55
+ ≡ nx-sk entry update OPENAI_KEY --set value=sk-xxx (覆盖时)
56
+ ```
57
+
58
+ 作用在哪个栏目?**看栏目自己的 `kv` 标记**(不再有 `settings.kvSection` 这种间接层):
59
+
60
+ ```bash
61
+ nx-sk section add mykeys --template secret # 从密钥模板建,自带 kv: true
62
+ nx-sk section update someid --kv # 把现有栏目标成 KV 表(取消写 --kv=false)
63
+ nx-sk section list # 列表里 KV 栏目带 [KV 表] 前缀
64
+ ```
65
+
66
+ ### KV 栏目**不许加字段**
67
+
68
+ 它的形状就是「一行一个键值对」。往里塞字段会让「值在哪一列」变得不确定,
69
+ 所以直接报错:
70
+
71
+ ```
72
+ 栏目「密钥」是一张 KV 表(一行一个键值对),不能加字段「089」。
73
+ 你要加的应该是一个**键** —— 用 nx-sk key set 089 <值>,或在面板的 KV 表格里加一行。
74
+ ```
75
+
76
+ > 这条是真实事故换来的:面板早先那行「KV 直填」在密钥栏目里把 `3123312=xxx` 当成了
77
+ > **新字段**登记,于是栏目字段从 1 个变成 3 个,用户在界面上看到的是一堆无意义的
78
+ > 「模板建议:089、3123312」。现在走 `key set` 才是唯一正确的加键方式。
79
+
80
+ ### 两个刻意的语义差别
81
+
82
+ | | `entry add` | `key set` |
83
+ | --- | --- | --- |
84
+ | 遇到重名 | 报 `CONFLICT` | **静默覆盖**(KV 的 SET 就是覆盖) |
85
+ | 返回值 | — | `created: true/false`,调用方据此知道是哪一种 |
86
+
87
+ 另外两点:
88
+
89
+ - **值以 `-` 开头时**用 `--value=<值>`(`key set K --value=--x`)——`--x` 会被当成 flag。
90
+ 显式 flag 覆盖位置参数,两种写法等价。
91
+ - **重复写同一个值不会重写密文**:返回 `{ status: 'skipped', unchanged: true }`。
92
+ 每次加密都用新 IV,无脑重写只会产生一堆无意义的新密文和新快照。
93
+
94
+ ## 三、密文机制
95
+
96
+ - 算法 **AES-256-GCM**(`core/crypto.js`)。每个值独立 IV,带认证标签。
97
+ - 落盘的密文对象(**明文绝不进 store.json**):
98
+
99
+ ```json
100
+ { "v": 1, "alg": "aes-256-gcm", "kid": "9f3a1c07be22", "iv": "…", "tag": "…", "ct": "…" }
101
+ ```
102
+
103
+ - `kid` 是**密钥指纹**(密钥 sha256 前 12 位)。换过密钥来源时给出明确报错,而不是解出乱码。
104
+
105
+ ### 读出来是原文,落盘才是密文
106
+
107
+ **默认原文**:本机单人工具,数据是给自己看的。默认打码等于每次取值都先拦自己一道,
108
+ 所以现在**不拦**——`key get` / `key list` / `entry get` / `section dump` 默认都给原文。
109
+
110
+ 要打码形态(投屏、录屏、截图给别人看)时显式加 `--mask`:
111
+
112
+ ```bash
113
+ nx-sk key get OPENAI_KEY --mask # sk-x******xxxx
114
+ nx-sk key list --mask
115
+ nx-sk export run --no-secrets # 导出文件里也打码
116
+ ```
117
+
118
+ 打码规则:长度 ≤ 10 全部打码;否则前 4 + `******` + 后 4。
119
+
120
+ **代价要清楚**:终端里有明文、`--json` 输出里有明文、投屏时会被看到。
121
+ 密码管理器那种「默认遮住」的取舍对本机自用是净成本,但对**分享**是必要的 ——
122
+ 所以外发前记得加 `--mask` / `--no-secrets`。
123
+
124
+ ## 四、密钥从哪来(优先级固定)
125
+
126
+ | 优先级 | 来源 | 行为 |
127
+ | --- | --- | --- |
128
+ | 1 | 环境变量 `NX_SK_PASSPHRASE` | `scrypt(口令, ~/nx-sk/.vaultsalt)` 派生。**口令不落盘**,盐落盘 |
129
+ | 2 | `~/nx-sk/.vaultkey` | 32 字节随机密钥,首次写入密文时自动生成(权限尽力设 0600) |
130
+
131
+ ```bash
132
+ # 口令模式(更安全:口令不进磁盘)
133
+ export NX_SK_PASSPHRASE='你的长口令'
134
+ nx-sk key get OPENAI_KEY
135
+
136
+ # 本机密钥文件模式(默认,零配置)
137
+ nx-sk key set OPENAI_KEY sk-xxxxxxxx
138
+ ```
139
+
140
+ **同一份密文只能由同一个密钥解开**,所以换口令 / 删 `.vaultkey` 都会让旧密文报
141
+ `BLOCKED`「当前密钥与写入时的密钥不一致」——这是设计如此,不是 bug;改回去就能解开。
142
+ 因此:**密钥文件要跟数据一起备份**,或者用口令模式并把口令记在别处。
143
+
144
+ 查看当前来源:`nx-sk bootstrap --json` 的 `vault` 字段。
145
+
146
+ ## 五、纪律(写给 agent)
147
+
148
+ - 默认原文是给**你自己看**的;一旦要把输出交给别人(日志、issue、截图、导出文件),
149
+ 先加 `--mask` / `--no-secrets`。
150
+ - 导出文件里 `withSecrets` 字段会明确标出这份文件含不含明文 —— **外发前先看这个字段**。
151
+ - 用户想「把密钥同步到云端」——做不到,也别假装能做到。nx-sk 只在本机落盘与本地导出。
152
+
153
+ ## 六、写入防呆
154
+
155
+ | 情况 | 行为 |
156
+ | --- | --- |
157
+ | 值含 `******`(打码占位)且**不等于**当前值的掩码 | 报 `INVALID_INPUT` 拒绝 —— 那多半是把打码串当成了新值 |
158
+ | 值**恰好等于**当前值的掩码(面板原样回传) | 视为「不修改」,保留原密文 |
159
+ | 值为空 | 报 `INVALID_INPUT`(空密钥没有意义;要清空请用 `key remove`) |
160
+
161
+ ## 七、排查「解不开」
162
+
163
+ | 症状 | 原因 | 处理 |
164
+ | --- | --- | --- |
165
+ | `BLOCKED 当前密钥与写入时的密钥不一致` | 换了口令,或删了 `.vaultkey` | 改回原来的来源(旧口令 / 恢复密钥文件) |
166
+ | `BLOCKED 解密失败:密文已损坏,或密钥不正确` | store.json 被手工改过 / GCM 认证失败 | 用 `~/nx-sk/backup/` 里的快照恢复 |
167
+ | `.vaultkey` 不见了 | 被系统清理或误删 | 有口令模式的盐文件 `.vaultsalt` 与口令还能救;否则密文不可逆 |
168
+
169
+ **密文不可逆是特性,不是缺陷** —— 代价是「忘记密钥 = 数据丢失」,
170
+ 回报是「store.json 泄露不等于凭据泄露」。
@@ -0,0 +1,80 @@
1
+ # 30 · 导出与备份
2
+
3
+ > 归属 `nx-sk` skill 主文档。要「整体导出 / 拿走数据 / 回滚」时读这篇。
4
+
5
+ ## 一、整体导出
6
+
7
+ ```bash
8
+ nx-sk export run # 全部栏目 → ~/nx-sk/export/<时间戳>-nx-sk.{json,md}
9
+ nx-sk export run --format json # 只要 JSON(md | json | both)
10
+ nx-sk export run --section job # 只导「求职」栏目
11
+ nx-sk export run --out D:/备份/nx-sk # 指定输出目录
12
+ nx-sk export run --dry-run # 试运行:只告诉你将写哪些文件、多大
13
+ nx-sk export run # 默认就含密钥明文(本机自己用)
14
+ nx-sk export run --no-secrets # 打码后再导出(要分享给别人时用)
15
+ nx-sk export list # 历次导出 + 输出目录里的文件
16
+ ```
17
+
18
+ | 参数 | 说明 |
19
+ | --- | --- |
20
+ | `--format` | `json` / `md` / `both`,缺省取 `settings.exportFormat` |
21
+ | `--section` | 只导一个栏目 |
22
+ | `--out` | 输出目录,缺省取 `settings.exportDir`,再缺省 `~/nx-sk/export` |
23
+ | `--with-secrets` / `--no-secrets` | 是否含密文字段明文。两个都不给时取 `settings.includeSecretsInExport`(默认 **true**——本机自己用,不拦自己) |
24
+ | `--dry-run` | 不写盘 |
25
+
26
+ 导出文件里 `withSecrets` 字段会明确标出这份文件是不是含明文——**分享前先看这个字段**。
27
+
28
+ JSON 结构(稳定契约,脚本可依赖):
29
+
30
+ ```json
31
+ {
32
+ "app": "nx-sk", "version": "0.1.0", "generatedAt": "…", "storePath": "~/nx-sk/store.json",
33
+ "withSecrets": false,
34
+ "counts": { "sections": 2, "entries": 1 },
35
+ "sections": [ { "id":"job","title":"求职","groups":[…],"fields":[…] } ],
36
+ "entries": [ { "id":"e_…","section":"job","title":"赵刘学","completeness":{…},"values":{…} } ]
37
+ }
38
+ ```
39
+
40
+ > 未填字段导出成 `null`(不是空串),方便消费方区分「没填」与「填了空」。
41
+
42
+ ## 二、快照(自动,不用手动)
43
+
44
+ **每一次破坏性写入之前**都会把整库快照写到 `~/nx-sk/backup/<时间戳>-<原因>.json`:
45
+
46
+ | 触发点 | 快照名 |
47
+ | --- | --- |
48
+ | 改设置 | `…-setting-set.json` |
49
+ | 加/改/删栏目 | `…-section-add.json` / `…-section-update.json` / `…-section-remove-<id>.json` |
50
+ | 改/删条目 | `…-entry-update-<id>.json` / `…-entry-remove-<id>.json` |
51
+
52
+ 最多保留 20 份(自动清理最旧的)。在 `nx-sk bootstrap --json` 的 `snapshots` 字段能看到最近 5 份;
53
+ 面板的「设置」页有完整列表。
54
+
55
+ **回滚 = 把那份 JSON 拷回 `store.json`**:
56
+
57
+ ```bash
58
+ cp ~/nx-sk/backup/20260924-144238-entry-remove-e_xxx.json ~/nx-sk/store.json
59
+ ```
60
+
61
+ 快照里含**密文**(不是明文),所以恢复后仍需要同一个密钥才能解——这正是我们要的:
62
+ 快照本身不是泄露面。
63
+
64
+ ## 三、store.json 的意外保护
65
+
66
+ 如果 `store.json` 解析失败(手工编辑打错了逗号),服务**不会**把它当作空库继续跑并覆盖掉:
67
+
68
+ 1. 先把原文件改名为 `store.json.corrupt-<时间戳>.json`;
69
+ 2. 再以降级的空结构启动;
70
+ 3. 面板「设置」页会显示这条提示,`bootstrap --json` 的 `corrupt` 字段也有。
71
+
72
+ ## 四、导出 vs 快照,别搞混
73
+
74
+ | | 导出(export) | 快照(backup) |
75
+ | --- | --- | --- |
76
+ | 谁发起 | 用户显式命令 | 系统在每次破坏性写入前自动 |
77
+ | 目的 | 带走 / 备份 / 交给别人 | 误操作回滚 |
78
+ | 内容 | 结构化 + 可读的 Markdown;凭据默认含明文(`--no-secrets` 可打码) | 原样的整库 JSON(含密文) |
79
+ | 位置 | `~/nx-sk/export/`(可改) | `~/nx-sk/backup/`(固定) |
80
+ | 回滚用 | 不能直接回滚(形状不同) | 能,拷回 store.json 即可 |
@@ -0,0 +1,75 @@
1
+ # 40 · 扩展:改这个项目本身
2
+
3
+ > 归属 `nx-sk` skill 主文档。**要加栏目模板、加功能域、改对外契约时读这篇。**
4
+ > 只填数据的话不用看——那走 `10-sections-entries` 的「扩字段字典」一节就够。
5
+
6
+ ## 一、加一个栏目模板(最常见)
7
+
8
+ 1. `src/core/fields.js` 的 `TEMPLATES` 里加一个 `{ id, label, description, titleField, titleLabel, groups, fields }`。
9
+ 2. 完成。`section templates` / 面板的「新建栏目」会自动列出它。
10
+
11
+ 不需要动任何别的文件——模板是**数据**。
12
+
13
+ ## 二、加一个功能域(`src/modules/<域>/`)
14
+
15
+ 按闭环表逐条对。**第 6、7、11 项没有任何断言会替你发现**,是真正会漏的地方:
16
+
17
+ | # | 落点 | 漏了会怎样 | 谁会发现 |
18
+ | --- | --- | --- | --- |
19
+ | 1 | `modules/<域>/service.js` | —(业务本身) | — |
20
+ | 2 | `modules/<域>/index.js` | 命令不存在 | 装载期自检 / 一致性测试 |
21
+ | 3 | `modules/<域>/view.jsx` | 声明了 view 却没文件 → **构建期报错** | `vite build` |
22
+ | 4 | `runtime/registry.js` 的 `MODULES` | 模块整个不生效 | 一致性测试(目录 ↔ 注册表对账) |
23
+ | 5 | `web/frontend/registry.js` 的 `VIEWS` | 后端有 view、前端没登记 | 一致性测试(两侧视图表对账) |
24
+ | 6 | `src/index.js` 的 `export * as <域>` | 库用方拿不到该 service | ❗**无任何断言,纯静默** |
25
+ | 7 | `eslint.config.js` 的 `SIBLING_MODULES` 加 `'../<域>/*'` | 新模块悄悄变成「谁都可以依赖」 | ❗**无任何断言,纯静默** |
26
+ | 8 | `core/paths.js` / `core/fields.js` 新增常量(如有) | 散落字面量 | 靠 review |
27
+ | 9 | `tests/unit/` 纯逻辑断言 | 算法只被端到端覆盖 | 靠自律 |
28
+ | 10 | `tests/smoke.mjs` 只读断言 | 端到端坏了没人知道 | 无断言 |
29
+ | 11 | **`assets/nx-sk/references/`** | agent 永远不知道这条命令存在 | ❗**单向断言**(只保证「文档提到的命令一定存在」,反方向不强制) |
30
+ | 12 | `README.md` 命令总表 / `CHANGELOG.md` | 文档漂移 | 无断言 |
31
+
32
+ 第 7 项的禁列是**逐模块枚举**的,不是通配——这是刻意的:加一行就等于提醒「你在加一个可能有依赖的模块」。
33
+ 但代价是漏补**完全静默**,且规则随模块数增加持续衰减(加 5 个漏 3 次,规则就废了一半)。
34
+
35
+ ## 三、四条硬纪律
36
+
37
+ 1. **一条 action 同时声明 `cli` 与 `http`**(`http` 可为 `null`)。别在别处再抄一份命令表。
38
+ 2. **写命令的 flag 可以有 `default`;读命令的过滤 flag 一律不带。**
39
+ 带了就会让「不传 = 全部」的分支永远走不到——不报错、不崩,只是永远返回半个结果。
40
+ 3. **每个写命令都要 `--dry-run`;破坏性写之前 `snapshotStore()`。**
41
+ `remove` 类要选定幂等语义(本项目选 **`NOT_FOUND` 报错**,不静默成功)并写进 `summary`。
42
+ 4. **集合资源才凑 CRUD 五动词**(`list/get/add/update/remove`)。
43
+ 单例配置退化成 `get/set`;动作集合按动作命名(`export run` / `skill install`);
44
+ 聚合模块(`system`)不必有视图。**别硬凑** —— `system add` 这种一看就知道是凑的。
45
+
46
+ ## 四、验证闸门(改完必须跑)
47
+
48
+ ```bash
49
+ pnpm test # = lint + build + 冒烟 + 单测
50
+ ```
51
+
52
+ 四道闸各自拦什么:
53
+
54
+ | 闸 | 拦什么 | 怎么确认它真的会触发 |
55
+ | --- | --- | --- |
56
+ | 装载期自检(`registry.js`) | 重复 action id / CLI 路径 / HTTP 路由;缺 `cli`;`http` 既不是数组也不是 `null` | 故意加一条重复命令,确认启动即抛错 |
57
+ | 模块目录 ↔ 注册表对账 | 写了目录忘登记(反之亦然) | 建一个空模块目录,跑单测看是否报「没有登记」 |
58
+ | 后端模块 ↔ 前端视图对账 | 有 view 却没登记 tab,或登记了不存在的视图 | 删掉前端 `VIEWS` 里某一行,跑单测 |
59
+ | 视图里的 `/api` 字面量 | 面板调了不存在的端点 | 把某个 `api('/api/x')` 改成不存在的路径,跑单测 |
60
+ | CLI 路径可解析回自身 | 命令匹配算法被改坏 | 靠既有断言 |
61
+ | lint 分层 | 越层 import / 前端 import `node:*` / 模块互依 | **必须做一次反向测试**:临时写个违例文件,确认 lint 退出码非 0 |
62
+
63
+ 最后一条特别值得做:**lint 规则写错了不会报错,只会永远通过——一个从没红过的规则等于不存在。**
64
+
65
+ ## 五、几个不该改的地方
66
+
67
+ | 别做 | 为什么 |
68
+ | --- | --- |
69
+ | 给 `.card` 加 `box-shadow` | 容器投影 = 一堆岛屿,用户 0.3 秒找不到按钮 |
70
+ | 把 `.row` 改成靠 padding 定高 | 高度不可控,行间距不一致;本项目是**行 28px / 按钮 32px** 的两级密度 |
71
+ | 把 `.btn` 高度改成 36px「更好按」 | 破坏 4px 差的密度二元性 |
72
+ | 给 `.tab.active` 再叠一层 `--shadow` | tab 浮起来抢焦点 |
73
+ | 用 `font-size: 14px` 让文字「看得清」 | 破坏 12px 的信息密度节奏 |
74
+ | 在前端 `import` 任何 `core/` 或 `runtime/` | 会把 Node 侧代码打进浏览器包 → dev 白屏 |
75
+ | 用变量拼 `lazy(() => import(x))` | Vite 静态分析不了,失去代码分割 |
package/bin/nx-sk.mjs ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // nx-sk 的唯一可执行入口:只做两件事——转发 argv、兜住未捕获的异常。
3
+ // 所有命令分发都在 src/runtime/cli.js(那里才是命令表)。
4
+ import { runCli } from '../src/runtime/cli.js';
5
+
6
+ runCli(process.argv.slice(2)).catch((err) => {
7
+ const message = err && err.message ? err.message : String(err);
8
+ process.stderr.write(`nx-sk: ${message}\n`);
9
+ if (process.env.NX_SK_DEBUG) process.stderr.write(`${err && err.stack}\n`);
10
+ process.exitCode = 1;
11
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "nx-sk",
3
+ "version": "0.3.0",
4
+ "description": "nx-sk · 本机个人资源管理器(server-cli-web 骨架):栏目化存储个人信息与大模型密钥,CLI 与 Web 同源,敏感字段密文落盘。",
5
+ "type": "module",
6
+ "bin": {
7
+ "nx-sk": "bin/nx-sk.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "assets/",
13
+ "README.md",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18.17"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/ZHLX2005/nx-sk.git"
22
+ },
23
+ "homepage": "https://github.com/ZHLX2005/nx-sk#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/ZHLX2005/nx-sk/issues"
26
+ },
27
+ "packageManager": "pnpm@9.12.0",
28
+ "nxSk": {
29
+ "port": 7866,
30
+ "vitePort": 5180
31
+ },
32
+ "scripts": {
33
+ "dev": "node scripts/dev.mjs",
34
+ "link:local": "node scripts/link-local.mjs",
35
+ "unlink:local": "node scripts/link-local.mjs --unlink",
36
+ "dev:vite": "vite",
37
+ "dev:serve": "node bin/nx-sk.mjs serve --no-open",
38
+ "build": "vite build && node scripts/link-local.mjs --auto",
39
+ "start": "pnpm run build && node bin/nx-sk.mjs serve",
40
+ "lint": "eslint .",
41
+ "test:unit": "node --test",
42
+ "test:smoke": "node tests/smoke.mjs",
43
+ "test": "pnpm run lint && pnpm run build && pnpm run test:smoke && pnpm run test:unit"
44
+ },
45
+ "dependencies": {
46
+ "react": "^18.3.1",
47
+ "react-dom": "^18.3.1"
48
+ },
49
+ "devDependencies": {
50
+ "@vitejs/plugin-react": "^4.3.4",
51
+ "eslint": "^9.17.0",
52
+ "eslint-plugin-react": "^7.37.5",
53
+ "eslint-plugin-react-hooks": "^7.1.1",
54
+ "vite": "^6.0.7"
55
+ }
56
+ }
@@ -0,0 +1,73 @@
1
+ // 密文原语:AES-256-GCM。只做算法,不认识 store、不认识栏目。
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from 'node:crypto';
3
+ import { blocked, badInput } from './errors.js';
4
+
5
+ export const ALG = 'aes-256-gcm';
6
+ export const KEY_BYTES = 32; // AES-256
7
+ export const BLOB_VERSION = 1;
8
+
9
+ export function newKey() {
10
+ return randomBytes(KEY_BYTES);
11
+ }
12
+
13
+ export function deriveKey(passphrase, salt) {
14
+ const s = Buffer.isBuffer(salt) ? salt : Buffer.from(String(salt), 'base64');
15
+ // scrypt 默认 N=16384, r=8, p=1 —— 对本机工具足够,且是同步的(无回调地狱)
16
+ return scryptSync(String(passphrase), s, KEY_BYTES);
17
+ }
18
+
19
+ /** 密钥指纹:写进密文,用来在「口令/密钥来源变了」时给出明确报错,而不是静默解不开。 */
20
+ export function keyId(key) {
21
+ return createHash('sha256').update(key).digest('hex').slice(0, 12);
22
+ }
23
+
24
+ /**
25
+ * 加密一个字符串值 → 可 JSON 化的密文对象。
26
+ * 明文绝不会以任何形式留在返回值里。
27
+ */
28
+ export function encryptValue(key, plaintext) {
29
+ const iv = randomBytes(12);
30
+ const cipher = createCipheriv(ALG, key, iv);
31
+ const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
32
+ return {
33
+ v: BLOB_VERSION,
34
+ alg: ALG,
35
+ kid: keyId(key),
36
+ iv: iv.toString('base64'),
37
+ tag: cipher.getAuthTag().toString('base64'),
38
+ ct: ct.toString('base64'),
39
+ };
40
+ }
41
+
42
+ export function isCipherBlob(value) {
43
+ return !!value && typeof value === 'object' && typeof value.ct === 'string' && typeof value.iv === 'string';
44
+ }
45
+
46
+ export function decryptValue(key, blob) {
47
+ if (!isCipherBlob(blob)) throw badInput('不是合法的密文对象');
48
+ if (blob.alg !== ALG) throw badInput(`不支持的加密算法: ${blob.alg}`);
49
+ if (blob.kid && blob.kid !== keyId(key)) {
50
+ throw blocked(
51
+ '当前密钥与写入时的密钥不一致,无法解密。' +
52
+ '(换过 NX_SK_PASSPHRASE,或删掉过 ~/nx-sk/.vaultkey 都会这样——旧密文只能用旧密钥解)',
53
+ );
54
+ }
55
+ try {
56
+ const decipher = createDecipheriv(ALG, key, Buffer.from(blob.iv, 'base64'));
57
+ decipher.setAuthTag(Buffer.from(blob.tag, 'base64'));
58
+ return Buffer.concat([decipher.update(Buffer.from(blob.ct, 'base64')), decipher.final()]).toString('utf8');
59
+ } catch {
60
+ throw blocked('解密失败:密文已损坏,或密钥不正确。');
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 脱敏展示:`sk-1234567890abcdef` → `sk-1****cdef`。
66
+ * 长度不足以露出首尾时全部打码——**宁可比用户预期更严**,泄露是不可逆的。
67
+ */
68
+ export function maskValue(text) {
69
+ const s = String(text ?? '');
70
+ if (!s) return '';
71
+ if (s.length <= 10) return '*'.repeat(s.length);
72
+ return `${s.slice(0, 4)}${'*'.repeat(6)}${s.slice(-4)}`;
73
+ }