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,141 @@
1
+ // 路径与安全校验的**唯一定义处**。别在别处再拼一次 home 路径。
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { badInput } from './errors.js';
7
+
8
+ export const PROJECT_ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url)));
9
+ export const ASSETS_DIR = join(PROJECT_ROOT, 'assets');
10
+ export const PUBLIC_DIR = join(PROJECT_ROOT, 'src', 'web', 'public');
11
+
12
+ const pkg = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'));
13
+ export const APP_NAME = pkg.name; // 'nx-sk'
14
+ export const APP_LABEL = 'nx-sk · 个人资源管理器';
15
+ export const VERSION = pkg.version;
16
+ export const SKILL_NAME = APP_NAME;
17
+
18
+ // 端口只有一个来源:package.json 的 nxSk.port。服务端与 vite 代理都从这里取——
19
+ // 散成字面量的话改一次就会漏一处,而漏掉 vite 代理的表现是「dev 模式下 /api 全 404」,
20
+ // 很难和「端口」联系起来。
21
+ export const DEFAULT_PORT = Number(pkg.nxSk?.port ?? 7866);
22
+ export const DEV_VITE_PORT = Number(pkg.nxSk?.vitePort ?? 5180);
23
+
24
+ /**
25
+ * 数据根目录:`~/nx-sk/`
26
+ *
27
+ * 骨架 ref [[05-state-storage]] 的默认是 `~/.<主题名>/`(隐藏目录)。
28
+ * 本项目按用户明确要求改成 `~/nx-sk/`——一眼能找到、能手动改、能整体拷走,
29
+ * 这对「本地绝对安全 + 支持整体导出」的诉求更重要。
30
+ *
31
+ * 环境变量覆盖**不是可选项**:没有它,测试会写脏用户的真实数据。
32
+ */
33
+ export const ENV_HOME = 'NX_SK_HOME';
34
+ export const ENV_STORE = 'NX_SK_STORE';
35
+ export const ENV_PASSPHRASE = 'NX_SK_PASSPHRASE';
36
+
37
+ const DEFAULT_HOME = join(homedir(), APP_NAME);
38
+
39
+ export function appHome() {
40
+ const fromEnv = process.env[ENV_HOME];
41
+ return fromEnv ? resolve(fromEnv) : DEFAULT_HOME;
42
+ }
43
+
44
+ export function storeFile() {
45
+ const fromEnv = process.env[ENV_STORE];
46
+ return fromEnv ? resolve(fromEnv) : join(appHome(), 'store.json');
47
+ }
48
+
49
+ export function exportDir() {
50
+ return join(appHome(), 'export');
51
+ }
52
+
53
+ export function snapshotDir() {
54
+ return join(appHome(), 'backup');
55
+ }
56
+
57
+ export function vaultKeyFile() {
58
+ return join(appHome(), '.vaultkey');
59
+ }
60
+
61
+ export function vaultSaltFile() {
62
+ return join(appHome(), '.vaultsalt');
63
+ }
64
+
65
+ /** 把用户给的相对路径挂到 base 下,并保证结果仍在 base 内(拒绝 `..` 越界)。 */
66
+ export function resolveInside(base, rel) {
67
+ const baseAbs = resolve(base);
68
+ const target = resolve(baseAbs, rel);
69
+ if (target !== baseAbs && !target.startsWith(baseAbs + sep)) {
70
+ throw badInput(`路径越界: ${rel}`);
71
+ }
72
+ return target;
73
+ }
74
+
75
+ export function isInside(parent, child) {
76
+ const p = resolve(parent);
77
+ const c = resolve(child);
78
+ return c === p || c.startsWith(p + sep);
79
+ }
80
+
81
+ /** 统一的展示用路径:正斜杠、home 前缀缩写成 `~/`。不参与任何文件操作。 */
82
+ export function displayPath(p) {
83
+ if (!p) return '';
84
+ const posix = String(p).split(sep).join('/');
85
+ const home = homedir().split(sep).join('/');
86
+ return posix.startsWith(home + '/') ? '~' + posix.slice(home.length) : posix;
87
+ }
88
+
89
+ // —— 名称/ID 校验 ——
90
+ // 名称与路径**分开校验**:用「目录名」的规则去卡文件路径会误伤 `.gitignore` 这类正常文件。
91
+
92
+ /** section id:小写字母开头,只允许 [a-z0-9_-],最长 64。 */
93
+ export function assertSafeId(id, what = 'id') {
94
+ const s = String(id ?? '').trim();
95
+ if (!/^[a-z][a-z0-9_-]{0,63}$/.test(s)) {
96
+ throw badInput(`${what} 非法: ${JSON.stringify(id)}(小写字母开头,仅含 a-z 0-9 _ -,最长 64)`);
97
+ }
98
+ return s;
99
+ }
100
+
101
+ /** skill 名等:单段名称,不含路径分隔符、不是绝对路径、不含 `..`。 */
102
+ export function assertSafeName(name, what = '名称') {
103
+ const s = String(name ?? '').trim();
104
+ if (!s) throw badInput(`${what}不能为空`);
105
+ if (isAbsolute(s) || s.includes('/') || s.includes('\\') || s.includes('..')) {
106
+ throw badInput(`${what} 非法: ${JSON.stringify(name)}(不允许路径分隔符或 '..')`);
107
+ }
108
+ if (!/^[\w.@-]{1,64}$/.test(s)) {
109
+ throw badInput(`${what} 非法: ${JSON.stringify(name)}(仅允许字母数字与 . _ @ -,最长 64)`);
110
+ }
111
+ return s;
112
+ }
113
+
114
+ /**
115
+ * 字段 key。**允许中文**——字典是「已用字段的台账」,用户敲什么键就该存什么键
116
+ * (`--set 微信号=xxx` 之后 `values` 里就是 `微信号`,不用记一个英文别名)。
117
+ * 仍然挡掉空白、路径分隔符与 `..`:那些会让 CLI 与 URL 变难用。
118
+ */
119
+ export function assertFieldKey(key) {
120
+ const s = String(key ?? '').trim();
121
+ if (!s) throw badInput('字段 key 不能为空');
122
+ if (s.length > 48) throw badInput(`字段 key 过长(${s.length} > 48): ${s}`);
123
+ if (/[\s/\\]/.test(s) || s.includes('..')) {
124
+ throw badInput(`字段 key 非法: ${JSON.stringify(key)}(不允许空白、路径分隔符或 '..';中文可以)`);
125
+ }
126
+ return s;
127
+ }
128
+
129
+ /** 归一化跨平台相对路径,用于目录树比对。 */
130
+ export function toPosix(p) {
131
+ return String(p).split(sep).join('/');
132
+ }
133
+
134
+ /** 相对 base 的 posix 路径,越界返回 null。 */
135
+ export function relPosix(base, target) {
136
+ const r = relative(resolve(base), resolve(target));
137
+ if (!r || r.startsWith('..') || isAbsolute(r)) return null;
138
+ return toPosix(r);
139
+ }
140
+
141
+ export { normalize as normalizePath };
@@ -0,0 +1,144 @@
1
+ // 纯渲染逻辑(不碰磁盘、不碰平台)——值格式化、条目序列化、栏目全量视图。
2
+ // 放 core 是为了能单测:这些函数决定了「读出去的形状」,是最该被钉住的契约。
3
+ //
4
+ // 这里**不解密**:解密需要密钥(异步、且属于安全边界),所以由调用方把一个
5
+ // `decrypt(blob) => 明文` 传进来(core/vault.js 的 `sensitiveViewer` 负责造它)。
6
+ // 这样 render 保持纯函数,而「什么时候允许看明文」仍然只有一个决策点。
7
+ import { isCipherBlob, maskValue } from './crypto.js';
8
+ import { FILL_LABELS, fillPolicy, isExcludedField, isSensitiveField } from './fields.js';
9
+
10
+ export function isBlank(v) {
11
+ if (v === null || v === undefined) return true;
12
+ if (Array.isArray(v)) return v.length === 0;
13
+ return String(v).trim() === '';
14
+ }
15
+
16
+ /** 值 → 人读字符串(bool / tags / 数组 / 标量)。 */
17
+ export function formatValue(value) {
18
+ if (isBlank(value)) return '';
19
+ if (Array.isArray(value)) return value.map(String).join('、');
20
+ if (typeof value === 'boolean') return value ? '是' : '否';
21
+ return String(value);
22
+ }
23
+
24
+ /**
25
+ * 敏感字段的展示形态。
26
+ *
27
+ * **默认原文**:这是本机单人工具,数据是给自己看的——默认打码等于每次都先拦自己一道。
28
+ * 落盘仍然是密文(AES-256-GCM),所以「store.json 泄露」不等于「凭据泄露」。
29
+ * 想看打码形态(投屏 / 截图时)显式传 `mask: true`。
30
+ *
31
+ * 三种输入都要处理:
32
+ * 密文对象 → 有 decrypt 就解开,没有就标「需要密钥」
33
+ * 明文 → 历史数据可能存的是明文,原样返回
34
+ * 空 → null
35
+ */
36
+ export function displaySensitive(raw, { mask = false, decrypt } = {}) {
37
+ if (raw === null || raw === undefined || raw === '') return null;
38
+ if (!isCipherBlob(raw)) return mask ? maskValue(String(raw)) : String(raw);
39
+ if (!decrypt) return mask ? '[已加密]' : '[需要密钥才能解密]';
40
+ try {
41
+ const plain = decrypt(raw);
42
+ return mask ? maskValue(plain) : plain;
43
+ } catch {
44
+ return '[无法解密:密钥或密文与写入时不一致]';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * 条目的**唯一序列化出口**(面板、`entry list`、`section dump` 共用一份)。
50
+ * 两个出口各写一遍序列化,迟早会分叉成「CLI 看得到、面板看不到」。
51
+ */
52
+ export function serializeEntry(section, entry, { mask = false, decrypt } = {}) {
53
+ const c = completeness(section, entry);
54
+ const values = {};
55
+ for (const def of section.fields) {
56
+ const raw = entry.values?.[def.key];
57
+ if (isBlank(raw)) { values[def.key] = null; continue; }
58
+ values[def.key] = isSensitiveField(section, def.key)
59
+ ? displaySensitive(raw, { mask, decrypt })
60
+ : raw;
61
+ }
62
+ return {
63
+ id: entry.id,
64
+ section: entry.section,
65
+ title: entry.title,
66
+ tags: entry.tags || [],
67
+ createdAt: entry.createdAt,
68
+ updatedAt: entry.updatedAt,
69
+ completeness: {
70
+ filled: c.filled,
71
+ total: c.total,
72
+ ratio: c.ratio,
73
+ excluded: c.excluded.length,
74
+ excludedFilled: c.excludedFilled,
75
+ },
76
+ missing: c.missing.map((x) => ({ key: x.key, label: x.label })),
77
+ // 与 missing 并列:这些是**主动不填**的字段,不是「还没填」。
78
+ // 两者混在一起会让「补全清单」和「不填清单」分不开。
79
+ excluded: c.excluded,
80
+ values,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * 完整度:**只统计照常填的字段**。给「AI 帮我补全信息」当导航用。
86
+ *
87
+ * `optional` / `avoid` 两类字段既不计入 `total` 也不计入 `filled`——
88
+ * 否则「还缺 N 项」会永远挂着一批用户主动决定不填的字段,导航就失效了。
89
+ * 它们不会凭空消失:`excluded` 单独列出来,`excludedFilled` 记录其中已填了几个
90
+ * (用户可能没改策略却还是填了值,这个数能提示策略该更新了)。
91
+ */
92
+ export function completeness(section, entry) {
93
+ const fields = section?.fields || [];
94
+ const counted = fields.filter((d) => !isExcludedField(d));
95
+ const excluded = fields.filter((d) => isExcludedField(d));
96
+ const missing = [];
97
+ let filled = 0;
98
+ for (const def of counted) {
99
+ if (isBlank(entry?.values?.[def.key])) missing.push(def);
100
+ else filled++;
101
+ }
102
+ const total = counted.length;
103
+ return {
104
+ filled,
105
+ total,
106
+ ratio: total ? Math.round((filled / total) * 100) : 0,
107
+ missing,
108
+ excluded: excluded.map((d) => ({ key: d.key, label: d.label, fill: fillPolicy(d), fillLabel: FILL_LABELS[fillPolicy(d)] })),
109
+ excludedFilled: excluded.filter((d) => !isBlank(entry?.values?.[d.key])).length,
110
+ };
111
+ }
112
+
113
+ /** 栏目的纯数据视图:所有条目 + 全部字段(含未填),供 `section dump` 与面板用。 */
114
+ export function dumpSection(section, entries, { mask = false, decrypt } = {}) {
115
+ const mine = entries.filter((e) => e.section === section.id);
116
+ return {
117
+ section: {
118
+ id: section.id,
119
+ title: section.title,
120
+ description: section.description,
121
+ order: section.order,
122
+ template: section.template,
123
+ kv: section.kv === true,
124
+ titleField: section.titleField,
125
+ titleLabel: section.titleLabel,
126
+ fields: section.fields.length,
127
+ },
128
+ groups: section.groups,
129
+ fields: section.fields.map((x) => ({
130
+ key: x.key,
131
+ label: x.label,
132
+ type: x.type,
133
+ group: x.group,
134
+ hint: x.hint || '',
135
+ options: x.options || null,
136
+ sensitive: isSensitiveField(section, x.key),
137
+ // 填写策略要跟着字段字典出去,否则面板与 agent 不知道哪个格子「不用填」
138
+ fill: fillPolicy(x),
139
+ fillLabel: FILL_LABELS[fillPolicy(x)],
140
+ })),
141
+ count: mine.length,
142
+ entries: mine.map((e) => serializeEntry(section, e, { mask, decrypt })),
143
+ };
144
+ }
@@ -0,0 +1,317 @@
1
+ // 状态读写:原子写 + 按 mtime 失效的缓存 + 读-改-写事务 + 版本迁移。
2
+ // 所有模块都通过这里碰磁盘,不自己拼路径、不自己 JSON.parse。
3
+ import fsp from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { displayPath, snapshotDir, storeFile } from './paths.js';
6
+ import { ensureDir, pathExists, writeAtomic, writeJsonAtomic } from './fsx.js';
7
+ import { instantiateTemplate, seedSections } from './fields.js';
8
+ import { nowIso, stamp } from './ids.js';
9
+
10
+ /** v2:密钥栏目从 8 字段模板改成单字段 KV(名称 → 值)。
11
+ * v3:栏目有了 `kv` 标记——KV 栏目是一张表(面板渲染成表格,key 命令默认作用在它上面),
12
+ * 并去掉 `settings.kvSection` 这个冗余概念。 */
13
+ export const STORE_VERSION = 3;
14
+ export const MAX_SNAPSHOTS = 20;
15
+ export const MAX_EXPORT_HISTORY = 30;
16
+
17
+ export function defaultSettings() {
18
+ return {
19
+ exportDir: '', // 空 = ~/nx-sk/export
20
+ exportFormat: 'both', // json | md | both
21
+ includeSecretsInExport: true, // 默认含明文:本机单人工具,自己看不拦自己
22
+ defaultSection: 'job',
23
+ };
24
+ }
25
+
26
+ function emptyStore(seed) {
27
+ const at = nowIso();
28
+ return {
29
+ version: STORE_VERSION,
30
+ settings: defaultSettings(),
31
+ // 只有「store 文件压根不存在」才播种。文件在、但 sections 为空
32
+ // 说明用户自己删光了栏目——这时候再播种会把它变回来,等于删不掉。
33
+ sections: seed ? seedSections(at) : [],
34
+ entries: [],
35
+ exports: [],
36
+ };
37
+ }
38
+
39
+ function normalizeSection(raw) {
40
+ if (!raw || typeof raw !== 'object' || !raw.id) return null;
41
+ return {
42
+ id: String(raw.id),
43
+ title: String(raw.title || raw.id),
44
+ description: String(raw.description || ''),
45
+ order: Number.isFinite(raw.order) ? raw.order : 50,
46
+ template: raw.template ? String(raw.template) : null,
47
+ kv: raw.kv === true,
48
+ titleField: raw.titleField ? String(raw.titleField) : null,
49
+ titleLabel: String(raw.titleLabel || '名称'),
50
+ groups: Array.isArray(raw.groups) ? raw.groups.map((g) => ({ id: String(g.id), title: String(g.title || g.id) })) : [],
51
+ fields: Array.isArray(raw.fields) ? raw.fields.map((x) => ({ ...x, key: String(x.key), label: String(x.label || x.key) })) : [],
52
+ createdAt: raw.createdAt || null,
53
+ updatedAt: raw.updatedAt || null,
54
+ };
55
+ }
56
+
57
+ function normalizeEntry(raw) {
58
+ if (!raw || typeof raw !== 'object' || !raw.id) return null;
59
+ return {
60
+ id: String(raw.id),
61
+ section: String(raw.section || ''),
62
+ title: String(raw.title || ''),
63
+ tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
64
+ values: raw.values && typeof raw.values === 'object' ? { ...raw.values } : {},
65
+ createdAt: raw.createdAt || null,
66
+ updatedAt: raw.updatedAt || null,
67
+ };
68
+ }
69
+
70
+ // v1 的密钥模板字段。迁移时**不动 values 里这些键**——只把 keyValue 搬到 value,
71
+ // 并把字典缩成单字段。「不再显示」和「删掉」是两件事:删掉就再也找不回来了。
72
+ const LEGACY_SECRET_KEYS = ['keyValue', 'provider', 'baseUrl', 'model', 'purpose', 'expiresAt', 'quota', 'note'];
73
+
74
+ /**
75
+ * v1 → v2:把「密钥」栏目从 8 字段模板迁成单字段 KV。
76
+ *
77
+ * 幂等:只有 `version < 2` 才调用,且落盘后 version 变 2,不会再触发。
78
+ */
79
+ function migrateSecretToKv(store) {
80
+ const sec = store.sections.find((s) => s.id === 'secret');
81
+ if (!sec) return null;
82
+ const looksLegacy = sec.fields.some((x) => LEGACY_SECRET_KEYS.includes(x.key))
83
+ && !sec.fields.some((x) => x.key === 'value');
84
+ if (!looksLegacy) return null;
85
+
86
+ const tpl = instantiateTemplate('secret', { id: sec.id, title: sec.title, order: sec.order });
87
+ const moved = [];
88
+ for (const e of store.entries.filter((x) => x.section === sec.id)) {
89
+ const v = e.values || (e.values = {});
90
+ const empty = v.value === undefined || v.value === null || v.value === '';
91
+ if (empty && v.keyValue !== undefined && v.keyValue !== null && v.keyValue !== '') {
92
+ v.value = v.keyValue;
93
+ moved.push(e.id);
94
+ }
95
+ }
96
+ sec.groups = tpl.groups;
97
+ sec.fields = tpl.fields;
98
+ sec.titleField = tpl.titleField;
99
+ sec.titleLabel = tpl.titleLabel;
100
+ sec.template = 'secret';
101
+ sec.description = tpl.description;
102
+ sec.updatedAt = nowIso();
103
+
104
+ const stillHolding = store.entries
105
+ .filter((e) => e.section === sec.id)
106
+ .reduce((n, e) => n + LEGACY_SECRET_KEYS.filter((k) => k !== 'keyValue'
107
+ && e.values?.[k] !== undefined && e.values?.[k] !== null && e.values?.[k] !== '').length, 0);
108
+
109
+ return {
110
+ section: sec.id,
111
+ fieldsBefore: LEGACY_SECRET_KEYS.length + 1,
112
+ fieldsAfter: tpl.fields.length,
113
+ entries: store.entries.filter((e) => e.section === sec.id).length,
114
+ movedValues: moved.length,
115
+ legacyValuesKept: stillHolding,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * v2 → v3:把「密钥」栏目标成 KV 表,并把误当字段用的键值搬进值列。
121
+ *
122
+ * 背景:面板早先那行「KV 直填」在 KV 栏目里也把 `089=xxx` 当成**新字段**登记了
123
+ * (字段名 = `089`),而用户的本意是「加一个新键」。这里把「唯一一个非空且不是 value
124
+ * 的字段值」搬进 value —— 该条目就变成「标题 = 键名,value = 值」的正常样子。
125
+ * 其余留在 values 里的键**一律不删**(不再显示 ≠ 删掉)。
126
+ */
127
+ function migrateKvToTable(store, oldSettings) {
128
+ const marked = store.sections.filter((s) => s.kv);
129
+ const target = marked[0]
130
+ || store.sections.find((s) => s.id === String(oldSettings?.kvSection || 'secret'));
131
+ if (!target) return null;
132
+
133
+ const tpl = instantiateTemplate('secret', { id: target.id, title: target.title, order: target.order });
134
+ const fieldsBefore = target.fields.length;
135
+ const moved = [];
136
+
137
+ for (const e of store.entries.filter((x) => x.section === target.id)) {
138
+ const v = e.values || (e.values = {});
139
+ const valueEmpty = v.value === undefined || v.value === null || v.value === '';
140
+ if (!valueEmpty) continue;
141
+ const others = Object.keys(v).filter((k) => k !== 'value' && v[k] !== undefined && v[k] !== null && v[k] !== '');
142
+ if (others.length === 1) { v.value = v[others[0]]; moved.push(`${e.title}:${others[0]}`); }
143
+ }
144
+
145
+ target.kv = true;
146
+ target.fields = tpl.fields;
147
+ target.groups = tpl.groups;
148
+ target.titleField = tpl.titleField;
149
+ target.titleLabel = tpl.titleLabel;
150
+ target.template = 'secret';
151
+ target.description = tpl.description;
152
+ target.updatedAt = nowIso();
153
+
154
+ return {
155
+ section: target.id,
156
+ fieldsBefore,
157
+ fieldsAfter: tpl.fields.length,
158
+ movedValues: moved.length,
159
+ movedDetail: moved,
160
+ };
161
+ }
162
+
163
+ export function normalize(data) {
164
+ if (!data || typeof data !== 'object') return emptyStore(true);
165
+ const oldVersion = Number(data.version || 1);
166
+ const rawSettings = data.settings && typeof data.settings === 'object' ? data.settings : {};
167
+ const base = emptyStore(false);
168
+ base.version = STORE_VERSION;
169
+ base.settings = { ...base.settings, ...rawSettings };
170
+ // v3 起 kvSection 没了:哪个栏目是 KV,由栏目自己的 kv 标记说了算
171
+ delete base.settings.kvSection;
172
+ base.sections = (Array.isArray(data.sections) ? data.sections : []).map(normalizeSection).filter(Boolean);
173
+ base.entries = (Array.isArray(data.entries) ? data.entries : []).map(normalizeEntry).filter(Boolean);
174
+ base.exports = Array.isArray(data.exports) ? data.exports.slice(0, MAX_EXPORT_HISTORY) : [];
175
+ if (oldVersion < 2) base._migration = migrateSecretToKv(base);
176
+ if (oldVersion < 3) {
177
+ const m = migrateKvToTable(base, rawSettings);
178
+ if (m) base._migration = { ...(base._migration || {}), ...m };
179
+ }
180
+ return base;
181
+ }
182
+
183
+ let cache = null;
184
+ let cachePath = null;
185
+ let cacheMtime = -1;
186
+ let cacheCorruptNote = null;
187
+ let cacheMigrationNote = null;
188
+
189
+ export function storePathInUse() {
190
+ return storeFile();
191
+ }
192
+
193
+ /** 最近一次读盘时把损坏文件挪走的记录(bootstrap / health 会展示)。 */
194
+ export function corruptNote() {
195
+ return cacheCorruptNote;
196
+ }
197
+
198
+ /** 最近一次数据迁移的记录(含原始快照路径),供 bootstrap 展示。 */
199
+ export function migrationNote() {
200
+ return cacheMigrationNote;
201
+ }
202
+
203
+ export async function loadStore(explicitPath) {
204
+ const p = explicitPath || storeFile();
205
+ try {
206
+ const st = await fsp.stat(p);
207
+ if (cache && cachePath === p && cacheMtime === st.mtimeMs) return cache;
208
+ const raw = await fsp.readFile(p, 'utf8');
209
+ let parsed = null;
210
+ let corrupt = null;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ } catch (e) {
214
+ // 读失败**降级返回空结构**(工具不该启动即崩),但先把损坏文件留一份——
215
+ // 直接返回空结构的话,下一次写入就把用户的数据永久覆盖了。
216
+ corrupt = join(p + '.corrupt-' + stamp() + '.json');
217
+ try { await fsp.rename(p, corrupt); } catch { /* 尽力 */ }
218
+ cacheCorruptNote = { from: p, to: corrupt, error: String(e && e.message) };
219
+ }
220
+ const oldVersion = Number((parsed && parsed.version) || 1);
221
+ const next = normalize(parsed);
222
+
223
+ if (!corrupt && oldVersion < STORE_VERSION && next._migration) {
224
+ // 迁移前先把**原始文件**原样留一份(不是迁移后的),出问题能整份退回
225
+ const snapshot = join(snapshotDir(), `${stamp()}-migrate-v${oldVersion}-to-v${STORE_VERSION}.json`);
226
+ await ensureDir(snapshotDir());
227
+ try { await fsp.copyFile(p, snapshot); } catch { /* 尽力 */ }
228
+ cacheMigrationNote = {
229
+ from: oldVersion,
230
+ to: STORE_VERSION,
231
+ snapshot: displayPath(snapshot),
232
+ snapshotRaw: snapshot,
233
+ ...next._migration,
234
+ };
235
+ delete next._migration;
236
+ // 就地落盘:version 写进去之后就不会再迁移(幂等)
237
+ await saveStore(next, p);
238
+ return next;
239
+ }
240
+
241
+ delete next._migration;
242
+ cache = next;
243
+ cachePath = p;
244
+ // 解析失败时文件已被挪走,mtime 用 -1 强制下次重新判断
245
+ cacheMtime = corrupt ? -1 : st.mtimeMs;
246
+ return cache;
247
+ } catch {
248
+ cache = emptyStore(true);
249
+ cachePath = p;
250
+ cacheMtime = -1;
251
+ return cache;
252
+ }
253
+ }
254
+
255
+ export async function saveStore(next, explicitPath) {
256
+ const p = explicitPath || storeFile();
257
+ const data = normalize(next);
258
+ delete data._migration;
259
+ await ensureDir(join(p, '..'));
260
+ // 原子写:临时文件 + rename。中断时用户要么看到旧数据,要么看到新数据。
261
+ await writeAtomic(p, JSON.stringify(data, null, 2) + '\n');
262
+ cache = data;
263
+ cachePath = p;
264
+ cacheMtime = -1;
265
+ try { cacheMtime = (await fsp.stat(p)).mtimeMs; } catch { /* 保持 -1 */ }
266
+ return data;
267
+ }
268
+
269
+ /**
270
+ * 读-改-写事务:fn 直接改传入的**深拷贝**;抛错则不落盘。
271
+ * structuredClone 是必需的——直接改缓存对象会让「抛错不落盘」名存实亡。
272
+ */
273
+ export async function mutateStore(fn, explicitPath) {
274
+ const cur = structuredClone(await loadStore(explicitPath));
275
+ const result = fn(cur);
276
+ await saveStore(cur, explicitPath);
277
+ return result === undefined ? cur : result;
278
+ }
279
+
280
+ /** 破坏性写入前留一份可回滚快照。 */
281
+ export async function snapshotStore(reason, explicitPath) {
282
+ const cur = await loadStore(explicitPath);
283
+ const dir = snapshotDir();
284
+ await ensureDir(dir);
285
+ const file = join(dir, `${stamp()}-${reason}.json`);
286
+ await writeJsonAtomic(file, cur);
287
+ await pruneSnapshots(dir);
288
+ return file;
289
+ }
290
+
291
+ async function pruneSnapshots(dir) {
292
+ try {
293
+ const items = (await fsp.readdir(dir)).filter((n) => n.endsWith('.json')).sort();
294
+ const extra = items.length - MAX_SNAPSHOTS;
295
+ for (let i = 0; i < extra; i++) await fsp.rm(join(dir, items[i]), { force: true });
296
+ } catch { /* 清理失败不影响主流程 */ }
297
+ }
298
+
299
+ export async function listSnapshots() {
300
+ const dir = snapshotDir();
301
+ try {
302
+ const items = (await fsp.readdir(dir)).filter((n) => n.endsWith('.json')).sort().reverse();
303
+ const out = [];
304
+ for (const name of items.slice(0, MAX_SNAPSHOTS)) {
305
+ const p = join(dir, name);
306
+ const st = await fsp.stat(p);
307
+ out.push({ name, path: p, bytes: st.size, at: st.mtime.toISOString() });
308
+ }
309
+ return out;
310
+ } catch {
311
+ return [];
312
+ }
313
+ }
314
+
315
+ export async function storeExists() {
316
+ return pathExists(storeFile());
317
+ }