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.
- package/CHANGELOG.md +133 -0
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/assets/nx-sk/SKILL.md +102 -0
- package/assets/nx-sk/references/00-design.md +116 -0
- package/assets/nx-sk/references/10-sections-entries.md +165 -0
- package/assets/nx-sk/references/20-secrets.md +170 -0
- package/assets/nx-sk/references/30-export-backup.md +80 -0
- package/assets/nx-sk/references/40-extend.md +75 -0
- package/bin/nx-sk.mjs +11 -0
- package/package.json +56 -0
- package/src/core/crypto.js +73 -0
- package/src/core/errors.js +52 -0
- package/src/core/fields.js +345 -0
- package/src/core/fsx.js +103 -0
- package/src/core/ids.js +19 -0
- package/src/core/open.js +32 -0
- package/src/core/paths.js +141 -0
- package/src/core/render.js +144 -0
- package/src/core/store.js +317 -0
- package/src/core/vault.js +109 -0
- package/src/index.js +14 -0
- package/src/modules/entries/index.js +152 -0
- package/src/modules/entries/kvTable.jsx +158 -0
- package/src/modules/entries/service.js +539 -0
- package/src/modules/entries/view.jsx +240 -0
- package/src/modules/export/index.js +39 -0
- package/src/modules/export/service.js +192 -0
- package/src/modules/export/view.jsx +155 -0
- package/src/modules/sections/index.js +104 -0
- package/src/modules/sections/service.js +337 -0
- package/src/modules/sections/view.jsx +311 -0
- package/src/modules/settings/index.js +34 -0
- package/src/modules/settings/service.js +78 -0
- package/src/modules/settings/view.jsx +193 -0
- package/src/modules/skill/index.js +51 -0
- package/src/modules/skill/service.js +200 -0
- package/src/modules/system/index.js +42 -0
- package/src/modules/system/service.js +155 -0
- package/src/runtime/api.js +101 -0
- package/src/runtime/cli.js +266 -0
- package/src/runtime/registry.js +65 -0
- package/src/runtime/server.js +80 -0
- package/src/runtime/spec.js +158 -0
- package/src/web/frontend/App.jsx +108 -0
- package/src/web/frontend/api/client.js +26 -0
- package/src/web/frontend/components/fieldEditor.jsx +85 -0
- package/src/web/frontend/components/ui.jsx +143 -0
- package/src/web/frontend/index.html +12 -0
- package/src/web/frontend/main.jsx +13 -0
- package/src/web/frontend/registry.js +20 -0
- package/src/web/frontend/store.jsx +87 -0
- package/src/web/frontend/styles.css +301 -0
- package/src/web/public/assets/index-BWGLG7Qa.js +40 -0
- package/src/web/public/assets/index-CwF5hM2v.css +1 -0
- package/src/web/public/assets/view-B6gPh1NQ.js +1 -0
- package/src/web/public/assets/view-CfXXYwW2.js +1 -0
- package/src/web/public/assets/view-CxzKrdA3.js +7 -0
- package/src/web/public/assets/view-DjO1C54o.js +7 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
// 条目(entry):栏目下的一条记录。
|
|
2
|
+
// 写敏感字段时加密、读时**默认原文**(只有显式 --mask 才打码)——
|
|
3
|
+
// 这条边界只在这里实现一次,两端共用。
|
|
4
|
+
//
|
|
5
|
+
// 写入分两趟:趟 A(异步,在草稿 section 上跑)解析字段、加密敏感值;
|
|
6
|
+
// 趟 B(同步,在 mutateStore 事务里)只做赋值。这样加密只发生在事务外,
|
|
7
|
+
// 事务里没有 await,也就没有「改了一半又抛错」的中间态。
|
|
8
|
+
import { loadStore, mutateStore, snapshotStore } from '../../core/store.js';
|
|
9
|
+
import { assertFieldKey } from '../../core/paths.js';
|
|
10
|
+
import { badInput, conflict, notFound } from '../../core/errors.js';
|
|
11
|
+
import { BOOL_FALSE, BOOL_TRUE, findKvSection, findSection, isSensitiveField, sectionNames } from '../../core/fields.js';
|
|
12
|
+
import { completeness, displaySensitive, serializeEntry } from '../../core/render.js';
|
|
13
|
+
import { newId, nowIso } from '../../core/ids.js';
|
|
14
|
+
import { decryptValue, encryptValue, isCipherBlob, maskValue } from '../../core/crypto.js';
|
|
15
|
+
import { resolveVaultKey, sensitiveViewer } from '../../core/vault.js';
|
|
16
|
+
|
|
17
|
+
export function mustFindSection(store, ref) {
|
|
18
|
+
const s = findSection(store, ref);
|
|
19
|
+
if (!s) throw notFound(`找不到栏目: ${ref} —— 现有: ${sectionNames(store)}`);
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 条目定位:id 优先,其次唯一标题;重名时报 CONFLICT 让调用方用 id 指定。 */
|
|
24
|
+
export function resolveEntry(store, ref, sectionRef) {
|
|
25
|
+
const key = String(ref ?? '').trim();
|
|
26
|
+
if (!key) throw badInput('缺少条目定位:给条目 id(e_...)或条目名');
|
|
27
|
+
let list = store.entries;
|
|
28
|
+
if (sectionRef) list = list.filter((e) => e.section === mustFindSection(store, sectionRef).id);
|
|
29
|
+
const byId = list.find((e) => e.id === key);
|
|
30
|
+
if (byId) return byId;
|
|
31
|
+
const byTitle = list.filter((e) => e.title === key);
|
|
32
|
+
if (byTitle.length === 1) return byTitle[0];
|
|
33
|
+
if (byTitle.length > 1) {
|
|
34
|
+
throw conflict(`有 ${byTitle.length} 条同名条目「${key}」,请用 id 指定: ${byTitle.map((e) => e.id).join(' / ')}`);
|
|
35
|
+
}
|
|
36
|
+
throw notFound(`找不到条目: ${ref}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mergeInputs(set, data) {
|
|
40
|
+
const out = {};
|
|
41
|
+
if (data && typeof data === 'object') {
|
|
42
|
+
// 兼容 `{values:{...}}` 与直接扁平 `{字段: 值}` 两种写法
|
|
43
|
+
Object.assign(out, data.values && typeof data.values === 'object' ? data.values : data);
|
|
44
|
+
}
|
|
45
|
+
if (set && typeof set === 'object') Object.assign(out, set);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveFieldDef(section, rawKey) {
|
|
50
|
+
const key = String(rawKey).trim();
|
|
51
|
+
return (section.fields || []).find((x) => x.key === key || x.label === key) || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 把一个没见过的键登记进草稿字典。
|
|
56
|
+
* key 直接用用户敲的那个词(中文也行)——**写什么就存什么**,不必再记一个英文别名。
|
|
57
|
+
*/
|
|
58
|
+
function registerField(section, rawKey, rawValue) {
|
|
59
|
+
// KV 栏目**拒绝加字段**:它的形状就是「一行一个键值对」,多一个字段就散了。
|
|
60
|
+
// 这里必须报错而不是静默登记 —— 静默的后果是用户看到一堆无意义的字段名
|
|
61
|
+
// (真实事故:面板那行「KV 直填」把 `3123312=xxx` 登记成了字段,而不是一个新键)。
|
|
62
|
+
if (section.kv) {
|
|
63
|
+
const wanted = String(rawKey).trim();
|
|
64
|
+
throw badInput(
|
|
65
|
+
`栏目「${section.title}」是一张 KV 表(一行一个键值对),不能加字段「${wanted}」。`
|
|
66
|
+
+ `你要加的应该是一个**键** —— 用 nx-sk key set ${wanted} <值>,`
|
|
67
|
+
+ '或在面板的 KV 表格里加一行。',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const key = assertFieldKey(String(rawKey).trim());
|
|
71
|
+
const def = {
|
|
72
|
+
key,
|
|
73
|
+
label: key,
|
|
74
|
+
type: inferFieldType(rawValue),
|
|
75
|
+
group: section.groups?.[0]?.id || section.fields?.[0]?.group || 'other',
|
|
76
|
+
};
|
|
77
|
+
section.fields.push(def);
|
|
78
|
+
return def;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeValue(def, raw) {
|
|
82
|
+
if (raw === null || raw === undefined) return null;
|
|
83
|
+
const type = def.type;
|
|
84
|
+
if (type === 'bool') {
|
|
85
|
+
if (typeof raw === 'boolean') return raw;
|
|
86
|
+
const s = String(raw).trim().toLowerCase();
|
|
87
|
+
if (BOOL_TRUE.includes(s)) return true;
|
|
88
|
+
if (BOOL_FALSE.includes(s)) return false;
|
|
89
|
+
throw badInput(`字段「${def.label}」需要是/否,收到 ${JSON.stringify(raw)}`);
|
|
90
|
+
}
|
|
91
|
+
if (type === 'number') {
|
|
92
|
+
const n = Number(String(raw).trim());
|
|
93
|
+
if (!Number.isFinite(n)) throw badInput(`字段「${def.label}」需要数字,收到 ${JSON.stringify(raw)}`);
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
if (type === 'tags') {
|
|
97
|
+
const list = (Array.isArray(raw) ? raw.map(String) : String(raw).split(/[,,、;;\s]+/)).map((x) => x.trim()).filter(Boolean);
|
|
98
|
+
if (def.maxItems && list.length > def.maxItems) {
|
|
99
|
+
throw badInput(`字段「${def.label}」最多 ${def.maxItems} 项,收到 ${list.length} 项`);
|
|
100
|
+
}
|
|
101
|
+
return list;
|
|
102
|
+
}
|
|
103
|
+
const s = String(raw);
|
|
104
|
+
return s.trim() === '' ? null : s;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function prepareValue(section, def, raw, current) {
|
|
108
|
+
if (!isSensitiveField(section, def.key)) return normalizeValue(def, raw);
|
|
109
|
+
if (raw === null || raw === undefined || String(raw).trim() === '') return null;
|
|
110
|
+
const text = String(raw);
|
|
111
|
+
// 面板把打码后的值原样回传时,必须保留原密文——否则掩码会被当成新密钥存进去
|
|
112
|
+
if (isCipherBlob(current)) {
|
|
113
|
+
try {
|
|
114
|
+
const { key } = await resolveVaultKey();
|
|
115
|
+
if (maskValue(decryptValue(key, current)) === text) return current;
|
|
116
|
+
} catch { /* 解不开就按新值处理 */ }
|
|
117
|
+
}
|
|
118
|
+
if (text.includes('******')) {
|
|
119
|
+
throw badInput(`字段「${def.label}」收到的是打码占位而非明文。给完整值,或用 --unset ${def.key} 清空。`);
|
|
120
|
+
}
|
|
121
|
+
const { key } = await resolveVaultKey();
|
|
122
|
+
return encryptValue(key, text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 新字段的类型推断。**刻意不推 number**:
|
|
127
|
+
* 18 位的身份证号 / 长数字 ID 超过 2^53,推成数字会**静默丢精度**。
|
|
128
|
+
* 值仍是字符串,所以「直接存 kv」不会因为类型推断损坏数据。
|
|
129
|
+
*/
|
|
130
|
+
function inferFieldType(raw) {
|
|
131
|
+
if (Array.isArray(raw)) return 'tags';
|
|
132
|
+
if (typeof raw === 'boolean') return 'bool';
|
|
133
|
+
const s = String(raw).trim().toLowerCase();
|
|
134
|
+
if (BOOL_TRUE.includes(s) || BOOL_FALSE.includes(s)) return 'bool';
|
|
135
|
+
return 'text';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 趟 A:在草稿 section 上解析字段、算出要写进去的值。
|
|
140
|
+
*
|
|
141
|
+
* **不认识的键不再报错**——直接登记进字段字典再写。
|
|
142
|
+
* 字典从「写入的关卡」降级成「已用字段的台账 + 模板给的填写提示」:
|
|
143
|
+
* 用户想存什么键就存什么键,这才是 KV 该有的手感。
|
|
144
|
+
*/
|
|
145
|
+
async function prepareWrite({ section, currentValues = {}, incoming }) {
|
|
146
|
+
const draft = structuredClone(section);
|
|
147
|
+
const newFields = [];
|
|
148
|
+
const values = {};
|
|
149
|
+
for (const [rawKey, rawValue] of Object.entries(incoming)) {
|
|
150
|
+
let def = resolveFieldDef(draft, rawKey);
|
|
151
|
+
if (!def) {
|
|
152
|
+
def = registerField(draft, rawKey, rawValue);
|
|
153
|
+
newFields.push(def);
|
|
154
|
+
}
|
|
155
|
+
values[def.key] = await prepareValue(draft, def, rawValue, currentValues[def.key]);
|
|
156
|
+
}
|
|
157
|
+
return { values, newFields };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function autoTitle(section, values) {
|
|
161
|
+
const v = section.titleField ? values[section.titleField] : undefined;
|
|
162
|
+
return v === undefined || v === null || v === '' ? '' : String(v);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function listEntries({ section, q } = {}) {
|
|
166
|
+
const store = await loadStore();
|
|
167
|
+
let list = store.entries;
|
|
168
|
+
if (section) list = list.filter((e) => e.section === mustFindSection(store, section).id);
|
|
169
|
+
const viewers = new Map();
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const e of list) {
|
|
172
|
+
const sec = store.sections.find((s) => s.id === e.section);
|
|
173
|
+
if (!sec) continue;
|
|
174
|
+
if (!viewers.has(sec.id)) viewers.set(sec.id, await sensitiveViewer(sec, list.filter((x) => x.section === sec.id)));
|
|
175
|
+
const item = serializeEntry(sec, e, { decrypt: viewers.get(sec.id) });
|
|
176
|
+
if (q) {
|
|
177
|
+
const hay = [item.title, sec.title, ...Object.values(item.values).map((v) => (v === null ? '' : Array.isArray(v) ? v.join(',') : String(v)))]
|
|
178
|
+
.join('\n').toLowerCase();
|
|
179
|
+
if (!hay.includes(String(q).toLowerCase())) continue;
|
|
180
|
+
}
|
|
181
|
+
out.push({ ...item, sectionTitle: sec.title });
|
|
182
|
+
}
|
|
183
|
+
return { count: out.length, total: store.entries.length, entries: out };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function getEntry(ref, { mask, section } = {}) {
|
|
187
|
+
const store = await loadStore();
|
|
188
|
+
const entry = resolveEntry(store, ref, section);
|
|
189
|
+
const sec = store.sections.find((s) => s.id === entry.section);
|
|
190
|
+
if (!sec) throw notFound(`条目 ${entry.id} 指向的栏目已不存在: ${entry.section}`);
|
|
191
|
+
const decrypt = await sensitiveViewer(sec, [entry]);
|
|
192
|
+
return { ...serializeEntry(sec, entry, { mask: !!mask, decrypt }), sectionTitle: sec.title, mask: !!mask };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function addEntry({ section, title, set, data, tags, 'dry-run': dryRun } = {}) {
|
|
196
|
+
const store = await loadStore();
|
|
197
|
+
const sec = mustFindSection(store, section);
|
|
198
|
+
const incoming = mergeInputs(set, data);
|
|
199
|
+
if (!Object.keys(incoming).length && !title) {
|
|
200
|
+
throw badInput(`至少要给一个字段:--set ${sec.fields[0]?.label || '字段'}=值(可重复),或 --data @file.json`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { values, newFields } = await prepareWrite({ section: sec, incoming });
|
|
204
|
+
const finalTitle = String(title || autoTitle(sec, values) || '').trim();
|
|
205
|
+
if (!finalTitle) {
|
|
206
|
+
throw badInput(`无法确定条目名:加 --title <名>,或先填上「${sec.titleField || '首个字段'}」字段`);
|
|
207
|
+
}
|
|
208
|
+
const dup = store.entries.find((e) => e.section === sec.id && e.title === finalTitle);
|
|
209
|
+
if (dup) throw conflict(`栏目「${sec.title}」下已有叫「${finalTitle}」的条目(${dup.id})。要改它就用 entry update,别再加一条。`);
|
|
210
|
+
|
|
211
|
+
const entry = {
|
|
212
|
+
id: newId('e'), section: sec.id, title: finalTitle, tags: Array.isArray(tags) ? tags.map(String) : [],
|
|
213
|
+
values, createdAt: nowIso(), updatedAt: nowIso(),
|
|
214
|
+
};
|
|
215
|
+
if (dryRun) {
|
|
216
|
+
return { status: 'skipped', dryRun: true, wouldCreate: { ...entry, values: maskAll(sec, entry) }, createdFields: newFields.map((f) => f.key) };
|
|
217
|
+
}
|
|
218
|
+
await mutateStore((s) => { s.entries.push(entry); });
|
|
219
|
+
return {
|
|
220
|
+
status: 'ok', created: true, id: entry.id, title: entry.title, section: sec.id,
|
|
221
|
+
completeness: completeness(sec, entry), createdFields: newFields.map((f) => f.key),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function updateEntry(ref, patch = {}) {
|
|
226
|
+
const dryRun = patch['dry-run'];
|
|
227
|
+
const store = await loadStore();
|
|
228
|
+
const target = resolveEntry(store, ref, patch.section);
|
|
229
|
+
const sec = mustFindSection(store, target.section);
|
|
230
|
+
const incoming = mergeInputs(patch.set, patch.data);
|
|
231
|
+
const unset = patch.unset || [];
|
|
232
|
+
|
|
233
|
+
const changedKeys = [...Object.keys(incoming), ...unset.map((k) => `-${k}`)];
|
|
234
|
+
if (!changedKeys.length && patch.title === undefined && patch.tags === undefined) {
|
|
235
|
+
throw badInput('没有要改的内容:用 --set 字段=值 / --data @file.json / --unset 字段 / --title / --tags');
|
|
236
|
+
}
|
|
237
|
+
// 先把 unset 的字段名解析出来(错别字要在写盘前就报掉)
|
|
238
|
+
const unsetKeys = [];
|
|
239
|
+
for (const k of unset) {
|
|
240
|
+
const def = resolveFieldDef(sec, k);
|
|
241
|
+
if (!def) throw notFound(`栏目「${sec.title}」没有字段: ${k}`);
|
|
242
|
+
unsetKeys.push(def.key);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { values, newFields } = await prepareWrite({
|
|
246
|
+
section: sec, currentValues: target.values, incoming,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (dryRun) {
|
|
250
|
+
return {
|
|
251
|
+
status: 'skipped', dryRun: true,
|
|
252
|
+
wouldChange: {
|
|
253
|
+
id: target.id, fields: Object.keys(values), unset: unsetKeys,
|
|
254
|
+
title: patch.title, tags: patch.tags, newFields: newFields.map((f) => f.key),
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 重名会让 `entry get <名字>` / `key get <名字>` 变成二义(只能靠 id 定位),
|
|
260
|
+
// 所以改名时挡一下:KV 表格改键名、agent 改条目标题都走这里。
|
|
261
|
+
if (patch.title !== undefined && String(patch.title) !== target.title) {
|
|
262
|
+
const dup = store.entries.find((e) => e.id !== target.id && e.section === target.section && e.title === String(patch.title));
|
|
263
|
+
if (dup) throw conflict(`同栏目下已有叫「${patch.title}」的了(${dup.id});先合并或删掉那一条。`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const snapshot = await snapshotStore(`entry-update-${target.id}`);
|
|
267
|
+
await mutateStore((s) => {
|
|
268
|
+
const e = s.entries.find((x) => x.id === target.id);
|
|
269
|
+
const section = s.sections.find((x) => x.id === e.section);
|
|
270
|
+
for (const f of newFields) {
|
|
271
|
+
if (!section.fields.some((x) => x.key === f.key)) section.fields.push(f);
|
|
272
|
+
}
|
|
273
|
+
for (const [k, v] of Object.entries(values)) e.values[k] = v;
|
|
274
|
+
for (const k of unsetKeys) e.values[k] = null;
|
|
275
|
+
if (patch.title !== undefined) e.title = String(patch.title);
|
|
276
|
+
if (Array.isArray(patch.tags)) e.tags = patch.tags.map(String);
|
|
277
|
+
e.updatedAt = nowIso();
|
|
278
|
+
if (newFields.length) section.updatedAt = nowIso();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const after = await loadStore();
|
|
282
|
+
const finalEntry = after.entries.find((x) => x.id === target.id);
|
|
283
|
+
const finalSec = after.sections.find((x) => x.id === finalEntry.section);
|
|
284
|
+
return {
|
|
285
|
+
status: 'ok', id: finalEntry.id, title: finalEntry.title, section: finalEntry.section,
|
|
286
|
+
// 报**解析后**的字段 key,而不是用户敲的中文标签——调用方/agent 拿它去对比字典
|
|
287
|
+
changed: [...Object.keys(values), ...unsetKeys.map((k) => `-${k}`)],
|
|
288
|
+
newFields: newFields.map((f) => f.key),
|
|
289
|
+
completeness: completeness(finalSec, finalEntry), snapshot,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function removeEntry(ref, { section, 'dry-run': dryRun } = {}) {
|
|
294
|
+
const store = await loadStore();
|
|
295
|
+
const target = resolveEntry(store, ref, section);
|
|
296
|
+
if (dryRun) {
|
|
297
|
+
return { status: 'skipped', dryRun: true, wouldRemove: { id: target.id, title: target.title, section: target.section } };
|
|
298
|
+
}
|
|
299
|
+
const snapshot = await snapshotStore(`entry-remove-${target.id}`);
|
|
300
|
+
await mutateStore((s) => { s.entries = s.entries.filter((e) => e.id !== target.id); });
|
|
301
|
+
return { status: 'ok', removed: { id: target.id, title: target.title, section: target.section }, snapshot };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 字段字典:agent 在填之前必须先看这个,否则只能靠猜字段名。 */
|
|
305
|
+
export async function fieldDictionary({ section } = {}) {
|
|
306
|
+
const store = await loadStore();
|
|
307
|
+
const shape = (sec) => ({
|
|
308
|
+
id: sec.id,
|
|
309
|
+
title: sec.title,
|
|
310
|
+
titleField: sec.titleField,
|
|
311
|
+
titleLabel: sec.titleLabel,
|
|
312
|
+
groups: sec.groups,
|
|
313
|
+
fields: sec.fields.map((x) => ({
|
|
314
|
+
key: x.key, label: x.label, type: x.type, group: x.group,
|
|
315
|
+
hint: x.hint || '', options: x.options || null, sensitive: isSensitiveField(sec, x.key),
|
|
316
|
+
})),
|
|
317
|
+
});
|
|
318
|
+
if (section) return { count: 1, sections: [shape(mustFindSection(store, section))] };
|
|
319
|
+
return { count: store.sections.length, sections: store.sections.map(shape) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* KV 直通命令(`key *`)——「存个密钥」不该先说 `--section` 再说 `--set 字段=`。
|
|
324
|
+
*
|
|
325
|
+
* 设计要点:这**不是**第二套业务逻辑。下面每个函数都只是把
|
|
326
|
+
* 「哪个栏目 + 哪个值字段」定好,再调 addEntry / updateEntry / removeEntry 本身。
|
|
327
|
+
* 所以加解密、打码、快照、--dry-run 全部只有一份实现,不可能与 `entry` 命令分叉。
|
|
328
|
+
*/
|
|
329
|
+
/**
|
|
330
|
+
* 找 KV 表格:默认是**标了 `kv: true` 的栏目**(密钥模板自带),
|
|
331
|
+
* 也可以用 `--section` 指定任何一个「只有一个值字段」的栏目。
|
|
332
|
+
*/
|
|
333
|
+
async function kvTarget(store, ref) {
|
|
334
|
+
const sec = ref
|
|
335
|
+
? findSection(store, ref)
|
|
336
|
+
: findKvSection(store);
|
|
337
|
+
if (!sec) {
|
|
338
|
+
if (ref) throw notFound(`找不到栏目: ${ref} —— 现有: ${sectionNames(store)}`);
|
|
339
|
+
throw badInput('还没有 KV 栏目:用 `nx-sk section add keys --template secret` 建一个。'
|
|
340
|
+
+ `现有栏目: ${sectionNames(store)}`);
|
|
341
|
+
}
|
|
342
|
+
const named = (sec.fields || []).find((x) => x.key === 'value');
|
|
343
|
+
if (named) return { sec, valueKey: named.key };
|
|
344
|
+
if (!sec.fields || !sec.fields.length) throw badInput(`栏目「${sec.title}」没有任何字段,不能当 KV 用`);
|
|
345
|
+
if (sec.fields.length > 1) {
|
|
346
|
+
throw badInput(`栏目「${sec.title}」有 ${sec.fields.length} 个字段,不是 KV(KV 表格只能有一个值字段)`);
|
|
347
|
+
}
|
|
348
|
+
return { sec, valueKey: sec.fields[0].key };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** KV 名称不是文件路径,所以比 assertSafeName 宽松:允许中文与空格,只挡路径分隔符。 */
|
|
352
|
+
function assertKeyName(name) {
|
|
353
|
+
const s = String(name ?? '').trim();
|
|
354
|
+
if (!s) throw badInput('密钥名不能为空:nx-sk key set <名称> <值>');
|
|
355
|
+
if (s.length > 64) throw badInput(`密钥名过长(${s.length} > 64): ${s}`);
|
|
356
|
+
if (s.includes('/') || s.includes('\\') || s.includes('..')) {
|
|
357
|
+
throw badInput(`密钥名不能含路径分隔符或 '..': ${JSON.stringify(name)}`);
|
|
358
|
+
}
|
|
359
|
+
return s;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function keyList({ mask, section } = {}) {
|
|
363
|
+
const store = await loadStore();
|
|
364
|
+
const { sec, valueKey } = await kvTarget(store, section);
|
|
365
|
+
const mine = store.entries.filter((e) => e.section === sec.id);
|
|
366
|
+
const decrypt = await sensitiveViewer(sec, mine);
|
|
367
|
+
return {
|
|
368
|
+
section: sec.id,
|
|
369
|
+
sectionTitle: sec.title,
|
|
370
|
+
valueKey,
|
|
371
|
+
mask: !!mask,
|
|
372
|
+
count: mine.length,
|
|
373
|
+
keys: mine.map((e) => ({
|
|
374
|
+
id: e.id,
|
|
375
|
+
name: e.title,
|
|
376
|
+
value: displaySensitive(e.values?.[valueKey], { mask: !!mask, decrypt }),
|
|
377
|
+
updatedAt: e.updatedAt,
|
|
378
|
+
})),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function keyGet(name, { mask, section } = {}) {
|
|
383
|
+
const store = await loadStore();
|
|
384
|
+
const { sec, valueKey } = await kvTarget(store, section);
|
|
385
|
+
const key = assertKeyName(name);
|
|
386
|
+
const entry = store.entries.find((e) => e.section === sec.id && e.title === key);
|
|
387
|
+
if (!entry) throw notFound(`没有这个密钥: ${key}(用 'nx-sk key list' 看现有的)`);
|
|
388
|
+
const decrypt = await sensitiveViewer(sec, [entry]);
|
|
389
|
+
return {
|
|
390
|
+
id: entry.id,
|
|
391
|
+
name: entry.title,
|
|
392
|
+
section: sec.id,
|
|
393
|
+
value: displaySensitive(entry.values?.[valueKey], { mask: !!mask, decrypt }),
|
|
394
|
+
mask: !!mask,
|
|
395
|
+
updatedAt: entry.updatedAt,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* KV 的 SET 语义:不存在就建,存在就**覆盖**。
|
|
401
|
+
* 这与 `entry add` 的「重名报 CONFLICT」是刻意不同的——`set` 这个名字本身就说明是覆盖,
|
|
402
|
+
* 返回值里带 `created` 让调用方知道到底发生了哪一种。
|
|
403
|
+
*/
|
|
404
|
+
export async function keySet(name, value, { section, 'dry-run': dryRun } = {}) {
|
|
405
|
+
const key = assertKeyName(name);
|
|
406
|
+
const raw = value === undefined || value === null ? '' : String(value);
|
|
407
|
+
if (!raw.trim()) throw badInput(`密钥值不能为空:nx-sk key set ${key} <值>`);
|
|
408
|
+
|
|
409
|
+
const store = await loadStore();
|
|
410
|
+
const { sec, valueKey } = await kvTarget(store, section);
|
|
411
|
+
|
|
412
|
+
const existing = store.entries.find((e) => e.section === sec.id && e.title === key);
|
|
413
|
+
if (existing) {
|
|
414
|
+
// 两种「没变」都要认出来,否则会产生无意义的新密文 + 新快照:
|
|
415
|
+
// ① 明文与现值相同(重复写同一个 key 是常态,agent 会重试)
|
|
416
|
+
// ② 传进来的是**打码占位**(面板把列表里的值原样回传)
|
|
417
|
+
const decrypt = await sensitiveViewer(sec, [existing]);
|
|
418
|
+
if (decrypt) {
|
|
419
|
+
try {
|
|
420
|
+
const plain = String(decrypt(existing.values?.[valueKey]));
|
|
421
|
+
if (plain === raw || maskValue(plain) === raw) {
|
|
422
|
+
return { status: 'skipped', created: false, unchanged: true, name: key, section: sec.id, id: existing.id };
|
|
423
|
+
}
|
|
424
|
+
} catch { /* 解不开(换过密钥)就按覆盖处理 */ }
|
|
425
|
+
}
|
|
426
|
+
const r = await updateEntry(existing.title, { set: { [valueKey]: raw }, section: sec.id, 'dry-run': dryRun });
|
|
427
|
+
if (r.status === 'skipped') return { ...r, name: key, created: false, section: sec.id };
|
|
428
|
+
return { status: 'ok', created: false, replaced: true, name: key, section: sec.id, id: r.id, snapshot: r.snapshot };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const r = await addEntry({ section: sec.id, title: key, set: { [valueKey]: raw }, 'dry-run': dryRun });
|
|
432
|
+
if (r.status === 'skipped') return { ...r, name: key, created: true, section: sec.id };
|
|
433
|
+
return { status: 'ok', created: true, replaced: false, name: key, section: sec.id, id: r.id };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function keyRemove(name, { section, 'dry-run': dryRun } = {}) {
|
|
437
|
+
const store = await loadStore();
|
|
438
|
+
const { sec } = await kvTarget(store, section);
|
|
439
|
+
const key = assertKeyName(name);
|
|
440
|
+
const entry = store.entries.find((e) => e.section === sec.id && e.title === key);
|
|
441
|
+
if (!entry) throw notFound(`没有这个密钥: ${key}`);
|
|
442
|
+
return removeEntry(entry.id, { 'dry-run': dryRun });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// —— CLI 人读渲染(KV 系列) ——
|
|
446
|
+
export function renderKeyList(d) {
|
|
447
|
+
const lines = [`${d.count} 个密钥 · 栏目 ${d.section}${d.mask ? ' · 已打码' : ''}`];
|
|
448
|
+
for (const k of d.keys) lines.push(` ${String(k.name).padEnd(24)} ${k.value}`);
|
|
449
|
+
lines.push('', `取值: nx-sk key get <名称> 写入: nx-sk key set <名称> <值> (要打码加 --mask)`);
|
|
450
|
+
return lines.join('\n');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function renderKeyGet(d) {
|
|
454
|
+
return `${d.name} = ${d.value}${d.mask ? '\n(已打码显示;去掉 --mask 即原文)' : ''}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function renderKeySet(d) {
|
|
458
|
+
if (d.status === 'skipped' && d.unchanged) return `未改动:${d.name} 的值与现有值相同`;
|
|
459
|
+
if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldCreate || d.wouldChange)}`;
|
|
460
|
+
return `${d.created ? '已新建' : '已覆盖'}密钥 ${d.name}(栏目 ${d.section})`
|
|
461
|
+
+ `${d.snapshot ? `\n快照: ${d.snapshot}` : ''}\n值已密文落盘;核对用 nx-sk key get ${d.name}`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function renderKeyRemove(d) {
|
|
465
|
+
if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldRemove)}`;
|
|
466
|
+
return `已删除密钥 ${d.removed.title}\n快照: ${d.snapshot}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function maskAll(section, entry, decrypt, mask) {
|
|
470
|
+
const out = {};
|
|
471
|
+
for (const [k, v] of Object.entries(entry.values)) {
|
|
472
|
+
out[k] = isSensitiveField(section, k) ? displaySensitive(v, { mask, decrypt }) : v;
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// —— CLI 人读渲染 ——
|
|
478
|
+
export function renderEntryList(d) {
|
|
479
|
+
const lines = [`${d.count} 条 / 共 ${d.total} 条`];
|
|
480
|
+
for (const e of d.entries) {
|
|
481
|
+
lines.push(` ${e.id} ${(e.title || '(无名)').padEnd(16)} ${String(e.completeness.ratio).padStart(3)}% 完整度 [${e.section}]`);
|
|
482
|
+
const missing = e.missing.slice(0, 6).map((m) => m.label).join('、');
|
|
483
|
+
if (missing) lines.push(` 未填: ${missing}${e.missing.length > 6 ? ` 等 ${e.missing.length} 项` : ''}`);
|
|
484
|
+
if (e.completeness?.excluded) {
|
|
485
|
+
lines.push(` 不填: ${e.excluded.map((m) => m.label).join('、')}(主动跳过,不计入完整度)`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
lines.push('', '看全部字段: nx-sk entry get <id> --json 或 nx-sk entry fields --section <id>');
|
|
489
|
+
return lines.join('\n');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function renderEntryGet(d) {
|
|
493
|
+
const c = d.completeness || {};
|
|
494
|
+
const lines = [
|
|
495
|
+
`${d.title || d.id}(${d.id})· 栏目 ${d.section} · 完整度 ${c.filled}/${c.total} = ${c.ratio}%`
|
|
496
|
+
+ (c.excluded ? ` · 另有 ${c.excluded} 项已标不填` : '')
|
|
497
|
+
+ `${d.mask ? ' · 已打码' : ''}`,
|
|
498
|
+
];
|
|
499
|
+
for (const [k, v] of Object.entries(d.values)) {
|
|
500
|
+
if (v === null) continue;
|
|
501
|
+
lines.push(` ${k.padEnd(24)} ${Array.isArray(v) ? v.join('、') : (typeof v === 'boolean' ? (v ? '是' : '否') : String(v))}`);
|
|
502
|
+
}
|
|
503
|
+
if (d.missing.length) {
|
|
504
|
+
lines.push('', `未填(${d.missing.length}): ${d.missing.map((m) => `${m.label}(${m.key})`).join('、')}`);
|
|
505
|
+
}
|
|
506
|
+
// 「不填」和「未填」必须分开说:混在一起会让人以为这些字段还欠着,
|
|
507
|
+
// 而它们恰恰是用户主动决定跳过的。
|
|
508
|
+
if (d.excluded?.length) {
|
|
509
|
+
lines.push('', `不填(${d.excluded.length})· 主动跳过、不计入完整度: `
|
|
510
|
+
+ d.excluded.map((m) => `${m.label}(${m.key}|${m.fillLabel || m.fill})`).join('、'));
|
|
511
|
+
}
|
|
512
|
+
return lines.join('\n');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function renderEntryChange(d) {
|
|
516
|
+
if (d.status === 'skipped') return `试运行(未落盘):${JSON.stringify(d.wouldCreate || d.wouldChange || d.wouldRemove)}`;
|
|
517
|
+
if (d.removed) return `已删除条目 ${d.removed.id}(${d.removed.title})\n快照: ${d.snapshot}`;
|
|
518
|
+
const c = d.completeness;
|
|
519
|
+
if (d.created) {
|
|
520
|
+
return `已创建条目 ${d.title}(${d.id})· 栏目 ${d.section} · 完整度 ${c.filled}/${c.total} = ${c.ratio}%`
|
|
521
|
+
+ (d.createdFields?.length ? `\n新增字段: ${d.createdFields.join(', ')}` : '');
|
|
522
|
+
}
|
|
523
|
+
const extra = d.newFields?.length ? `\n新增字段: ${d.newFields.join(', ')}` : '';
|
|
524
|
+
return `已更新条目 ${d.title}(${d.id})· 改动 ${d.changed.join(', ')} · 完整度 ${c.filled}/${c.total} = ${c.ratio}%${extra}\n快照: ${d.snapshot}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function renderFields(d) {
|
|
528
|
+
const lines = [`字段字典:${d.count} 个栏目`];
|
|
529
|
+
for (const s of d.sections) {
|
|
530
|
+
lines.push('', `【${s.title}】${s.id} 条目名字段: ${s.titleField || '(未设)'} 共 ${s.fields.length} 字段`);
|
|
531
|
+
for (const g of s.groups) {
|
|
532
|
+
lines.push(` ${g.title}(${g.id})`);
|
|
533
|
+
for (const x of s.fields.filter((f) => f.group === g.id)) {
|
|
534
|
+
lines.push(` ${x.key.padEnd(22)} ${x.label}${x.sensitive ? ' [密文]' : ''}${x.options ? ` 可选: ${x.options.join(' / ')}` : ''}${x.hint ? ` ${x.hint}` : ''}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return lines.join('\n');
|
|
539
|
+
}
|