nx-sk 0.4.0 → 0.6.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 (40) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +44 -2
  3. package/assets/nx-sk/SKILL.md +31 -1
  4. package/assets/nx-sk/references/00-design.md +3 -2
  5. package/assets/nx-sk/references/10-sections-entries.md +10 -2
  6. package/assets/nx-sk/references/40-files.md +104 -0
  7. package/package.json +1 -1
  8. package/src/core/attachments.js +378 -0
  9. package/src/core/fields.js +58 -2
  10. package/src/core/paths.js +5 -0
  11. package/src/core/render.js +1 -0
  12. package/src/core/store.js +3 -0
  13. package/src/modules/backup/service.js +113 -18
  14. package/src/modules/entries/service.js +35 -2
  15. package/src/modules/files/index.js +114 -0
  16. package/src/modules/files/service.js +276 -0
  17. package/src/modules/files/view.jsx +342 -0
  18. package/src/modules/sections/index.js +10 -0
  19. package/src/modules/sections/service.js +55 -2
  20. package/src/modules/sections/view.jsx +30 -2
  21. package/src/runtime/api.js +10 -1
  22. package/src/runtime/registry.js +2 -1
  23. package/src/web/frontend/App.jsx +36 -18
  24. package/src/web/frontend/api/files.js +28 -0
  25. package/src/web/frontend/components/fieldEditor.jsx +72 -1
  26. package/src/web/frontend/registry.js +1 -0
  27. package/src/web/frontend/styles.css +104 -7
  28. package/src/web/public/assets/files-BNh48qeo.js +1 -0
  29. package/src/web/public/assets/{index-Bbe3iqam.js → index-B7CtqgoG.js} +10 -9
  30. package/src/web/public/assets/index-DLtjhflt.css +1 -0
  31. package/src/web/public/assets/view-1kwWQYbz.js +7 -0
  32. package/src/web/public/assets/view-BAYyPWdU.js +1 -0
  33. package/src/web/public/assets/{view-Djo-TKw9.js → view-BIdVqIGe.js} +1 -1
  34. package/src/web/public/assets/{view-CekTHUPO.js → view-Bu70Q6GO.js} +1 -1
  35. package/src/web/public/assets/view-D5LfHr7W.js +1 -0
  36. package/src/web/public/assets/{view-b8TAoyhU.js → view-DXqI-4_F.js} +1 -1
  37. package/src/web/public/index.html +2 -2
  38. package/src/web/public/assets/index-C1bxqdjl.css +0 -1
  39. package/src/web/public/assets/view-Bnwx5y_O.js +0 -7
  40. package/src/web/public/assets/view-DuKJm1oq.js +0 -1
@@ -5,15 +5,26 @@
5
5
  //
6
6
  // 复用 core/crypto.js 的原语(deriveKey / encryptValue / decryptValue),
7
7
  // 与字段级密钥库(core/vault.js)相互独立:备份口令是一次性的,不写进 vault。
8
+ //
9
+ // 跨机器恢复:store.json 里凭据原本是「本机 vault key(AES-256-GCM + kid)」加密的,
10
+ // 备份打包会把这份密文**再裹一层**——用备份口令派生 key 重加密一份副本,写进
11
+ // `reWrapped`。导入时先解开 reWrapped 拿明文,再用**目标机器**的 vault key 重新加密,
12
+ // 写回主字段、丢掉 reWrapped。这样:
13
+ // - 备份包在 A 机器上能独立解开(不依赖 A 的 ~/.vaultkey / NX_SK_PASSPHRASE)
14
+ // - 导入 B 机器后,凭据绑的是 B 的本地密钥,跟其他密文一样走 decryptValue
15
+ // - 没有「reWrapped 的密文只能输口令解」的尾巴——一次导入就兑回本机
16
+ // 没有 reWrapped 的密文(早期版本/未带口令的导出)依然走 kid 校验;换机会得到明确 BLOCKED。
8
17
  import fsp from 'node:fs/promises';
9
18
  import { randomBytes } from 'node:crypto';
10
19
  import { resolve } from 'node:path';
11
20
  import { displayPath, snapshotDir } from '../../core/paths.js';
12
21
  import { ensureDir, fileSize, pathExists, writeAtomic } from '../../core/fsx.js';
13
22
  import { loadStore, saveStore, snapshotStore } from '../../core/store.js';
14
- import { deriveKey, encryptValue, decryptValue } from '../../core/crypto.js';
23
+ import { decryptValue, deriveKey, encryptValue, isCipherBlob } from '../../core/crypto.js';
15
24
  import { badInput, blocked } from '../../core/errors.js';
16
25
  import { nowIso, stamp } from '../../core/ids.js';
26
+ import { isSensitiveField } from '../../core/fields.js';
27
+ import { resolveVaultKey } from '../../core/vault.js';
17
28
 
18
29
  export const BACKUP_KIND = 'encrypted-backup';
19
30
  export const BACKUP_VERSION = 1;
@@ -32,11 +43,80 @@ function passwordFrom(ctx) {
32
43
  return s;
33
44
  }
34
45
 
46
+ /**
47
+ * 给 payload 里的每条密文字段加一层「口令派生的重加密」。
48
+ *
49
+ * 输入是 buildBackup 准备好的 store payload;输出是**新对象**,原对象不变。
50
+ * 已带 reWrapped 的不再二次包——避免循环。
51
+ */
52
+ async function reWrapSecretsInPlace(payload, password, salt) {
53
+ const sections = payload.sections || [];
54
+ const entries = payload.entries || [];
55
+ const transportKey = deriveKey(password, salt);
56
+ const { key: vaultKey } = await resolveVaultKey();
57
+
58
+ const newEntries = [];
59
+ let reWrapped = 0;
60
+ for (const e of entries) {
61
+ const sec = sections.find((s) => s.id === e.section);
62
+ if (!sec || !e.values) { newEntries.push(e); continue; }
63
+ const nextValues = { ...e.values };
64
+ let touched = false;
65
+ for (const def of sec.fields || []) {
66
+ if (!isSensitiveField(sec, def.key)) continue;
67
+ const v = nextValues[def.key];
68
+ if (!isCipherBlob(v) || v.reWrapped) continue;
69
+ // 用本机 vault key 解开 → 用口令派生 key 重加密 → 挂到 reWrapped 上。
70
+ // 原来的密文保留——A 机用原 vaultKey 仍能解(不动既有逻辑)。
71
+ const plain = decryptValue(vaultKey, v);
72
+ nextValues[def.key] = { ...v, reWrapped: encryptValue(transportKey, plain) };
73
+ touched = true; reWrapped++;
74
+ }
75
+ newEntries.push(touched ? { ...e, values: nextValues } : e);
76
+ }
77
+ return { ...payload, entries: newEntries, _reWrapped: reWrapped };
78
+ }
79
+
80
+ /**
81
+ * 反向:把 reWrapped 副本兑回本机 vault key。
82
+ * 拿不到口令(密码错)会抛 BLOCKED;拿到口令则静默完成,用户不感知。
83
+ */
84
+ async function unwrapSecretsToLocal(payload, password, salt) {
85
+ const sections = payload.sections || [];
86
+ const entries = payload.entries || [];
87
+ const transportKey = deriveKey(password, salt);
88
+ const { key: vaultKey } = await resolveVaultKey();
89
+
90
+ const newEntries = [];
91
+ let unwrapped = 0;
92
+ for (const e of entries) {
93
+ const sec = sections.find((s) => s.id === e.section);
94
+ if (!sec || !e.values) { newEntries.push(e); continue; }
95
+ const nextValues = { ...e.values };
96
+ let touched = false;
97
+ for (const def of sec.fields || []) {
98
+ if (!isSensitiveField(sec, def.key)) continue;
99
+ const v = nextValues[def.key];
100
+ if (!isCipherBlob(v) || !v.reWrapped) continue;
101
+ const plain = decryptValue(transportKey, v.reWrapped);
102
+ // tenant 用来保留 A 机原 blob 的非密文字段(如 v/alg)但**不能**包含 iv/ct/tag/kid——
103
+ // 这些必须来自新的 encryptValue(用本机 vaultKey 生成的)。否则 spread 后会把
104
+ // 新的 iv/ct/tag 覆盖回 A 机的旧值,写进 store.json 的密文就解不开了。
105
+ const { reWrapped: _rw, iv: _oldIv, ct: _oldCt, tag: _oldTag, kid: _oldKid, ...tenant } = v;
106
+ void _rw; void _oldIv; void _oldCt; void _oldTag; void _oldKid;
107
+ nextValues[def.key] = { ...encryptValue(vaultKey, plain), ...tenant };
108
+ touched = true; unwrapped++;
109
+ }
110
+ newEntries.push(touched ? { ...e, values: nextValues } : e);
111
+ }
112
+ return { ...payload, entries: newEntries, _reWrapped: unwrapped };
113
+ }
114
+
35
115
  /** 生成加密备份:返回归档对象(HTTP 端据此让浏览器下载)与条目计数。 */
36
116
  export async function buildBackup(ctx) {
37
117
  const password = passwordFrom(ctx);
38
118
  const store = await loadStore();
39
- const payload = {
119
+ const raw = {
40
120
  version: store.version,
41
121
  settings: store.settings,
42
122
  sections: store.sections,
@@ -45,8 +125,9 @@ export async function buildBackup(ctx) {
45
125
  };
46
126
 
47
127
  const salt = randomBytes(16);
48
- const key = deriveKey(password, salt);
49
- const data = encryptValue(key, JSON.stringify(payload));
128
+ const wrapKey = deriveKey(password, salt);
129
+ const payload = await reWrapSecretsInPlace(raw, password, salt);
130
+ const data = encryptValue(wrapKey, JSON.stringify(payload));
50
131
  const archive = {
51
132
  app: 'nx-sk',
52
133
  kind: BACKUP_KIND,
@@ -56,7 +137,11 @@ export async function buildBackup(ctx) {
56
137
  salt: salt.toString('base64'),
57
138
  data,
58
139
  };
59
- const counts = { sections: store.sections.length, entries: store.entries.length };
140
+ const counts = {
141
+ sections: store.sections.length,
142
+ entries: store.entries.length,
143
+ reWrapped: payload._reWrapped || 0,
144
+ };
60
145
  if (ctx['dry-run']) return { dryRun: true, counts };
61
146
  return { archive, counts };
62
147
  }
@@ -97,7 +182,7 @@ function parseArchive(text) {
97
182
  return archive;
98
183
  }
99
184
 
100
- /** 用口令解密归档文本 → 备份时的 store 负载。口令错误在此抛 BLOCKED。 */
185
+ /** 用口令解密归档文本 → 备份时的 store 负载 + archive(带 salt)。口令错误在此抛 BLOCKED。 */
101
186
  export function decryptBackup(text, password) {
102
187
  const archive = parseArchive(text);
103
188
  if (!String(password ?? '')) throw badInput('请输入备份时设置的口令。');
@@ -114,12 +199,12 @@ export function decryptBackup(text, password) {
114
199
  if (!payload || !Array.isArray(payload.sections) || !Array.isArray(payload.entries)) {
115
200
  throw badInput('备份内容缺少 sections / entries,无法恢复。');
116
201
  }
117
- return { payload, createdAt: archive.createdAt };
202
+ return { payload, createdAt: archive.createdAt, salt: archive.salt };
118
203
  }
119
204
 
120
- /** 恢复:解密 → 先给当前数据留安全快照 → 整份替换(写入会过 normalize 迁移到当前版本)。 */
205
+ /** 恢复:解密 → 把 reWrapped 兑回本机 vault key → 留安全快照 → 整份替换。 */
121
206
  export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
122
- const { payload, createdAt } = decryptBackup(String(content ?? ''), password);
207
+ const { payload, createdAt, salt } = decryptBackup(String(content ?? ''), password);
123
208
 
124
209
  if (dryRun) {
125
210
  return {
@@ -127,25 +212,31 @@ export async function restoreBackup({ content, password, 'dry-run': dryRun }) {
127
212
  backupCreatedAt: createdAt || '',
128
213
  sections: payload.sections.length,
129
214
  entries: payload.entries.length,
215
+ reWrapped: payload._reWrapped || 0,
130
216
  };
131
217
  }
132
218
 
219
+ // 把 reWrapped 兑回本机 vault key:解口令派生密文 → 用本机 vaultKey 重加密。
220
+ // 失败(口令对但 reWrapped 损坏)会被 unwrapSecretsToLocal 抛 BLOCKED。
221
+ const unwrapped = await unwrapSecretsToLocal(payload, password, salt);
222
+
133
223
  // 覆盖前留一份可回滚快照(落到 backup/ 目录),避免误操作丢数据
134
224
  const safety = await snapshotStore('before-restore');
135
225
 
136
226
  await saveStore({
137
- version: payload.version,
138
- settings: payload.settings,
139
- sections: payload.sections,
140
- entries: payload.entries,
141
- exports: Array.isArray(payload.exports) ? payload.exports : [],
227
+ version: unwrapped.version,
228
+ settings: unwrapped.settings,
229
+ sections: unwrapped.sections,
230
+ entries: unwrapped.entries,
231
+ exports: Array.isArray(unwrapped.exports) ? unwrapped.exports : [],
142
232
  });
143
233
 
144
234
  return {
145
235
  status: 'restored',
146
236
  backupCreatedAt: createdAt || '',
147
- sections: payload.sections.length,
148
- entries: payload.entries.length,
237
+ sections: unwrapped.sections.length,
238
+ entries: unwrapped.entries.length,
239
+ reWrapped: unwrapped._reWrapped || 0,
149
240
  safetySnapshot: displayPath(safety),
150
241
  };
151
242
  }
@@ -166,22 +257,26 @@ export async function importBackup(ctx) {
166
257
 
167
258
  export function renderBackup(d) {
168
259
  if (d.status === 'skipped') {
169
- return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`;
260
+ return `试运行(未写盘、未下载)\n将加密备份: ${d.sections} 个栏目 / ${d.entries} 条条目(AES-256-GCM)`
261
+ + (d.reWrapped ? `\n携带口令派生重加密的密文字段: ${d.reWrapped} 个(其他机器用本次口令可解开)` : '');
170
262
  }
171
263
  return [
172
264
  `已生成加密备份(AES-256-GCM,口令派生密钥、口令不落盘)`,
173
265
  `文件: ${d.file} ${d.bytes} 字节`,
174
266
  `内容: ${d.sections} 个栏目 / ${d.entries} 条条目`,
267
+ `携带口令派生重加密的密文字段: ${d.reWrapped || 0} 个(其他机器用本次口令可解开)`,
175
268
  `恢复时需要输入本次设置的口令。`,
176
269
  ].join('\n');
177
270
  }
178
271
 
179
272
  export function renderRestore(d) {
180
273
  if (d.status === 'dry-run') {
181
- return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目,生成于 ${d.backupCreatedAt || '未知'}`;
274
+ return `试运行(未改动当前数据)\n备份可正常解密: ${d.sections} 个栏目 / ${d.entries} 条条目`
275
+ + `,含口令派生重加密的密文字段 ${d.reWrapped || 0} 个,生成于 ${d.backupCreatedAt || '未知'}`;
182
276
  }
183
277
  return [
184
278
  `已从加密备份恢复:${d.sections} 个栏目 / ${d.entries} 条条目`,
279
+ `兑回本机密钥库的密文字段: ${d.reWrapped || 0} 个`,
185
280
  `备份生成于: ${d.backupCreatedAt || '未知'}`,
186
281
  `恢复前的当前数据已留安全快照: ${d.safetySnapshot}`,
187
282
  ].join('\n');
@@ -13,6 +13,8 @@ import { completeness, displaySensitive, serializeEntry } from '../../core/rende
13
13
  import { newId, nowIso } from '../../core/ids.js';
14
14
  import { decryptValue, encryptValue, isCipherBlob, maskValue } from '../../core/crypto.js';
15
15
  import { resolveVaultKey, sensitiveViewer } from '../../core/vault.js';
16
+ import * as att from '../../core/attachments.js';
17
+ import { displayPath } from '../../core/paths.js';
16
18
 
17
19
  export function mustFindSection(store, ref) {
18
20
  const s = findSection(store, ref);
@@ -100,6 +102,13 @@ function normalizeValue(def, raw) {
100
102
  }
101
103
  return list;
102
104
  }
105
+ if (type === 'file') {
106
+ // 值 = 附件 id(或唯一文件名)。id 全局唯一,天然做到「各条目附件互不串台」;
107
+ // 同一份原件被多条条目引用也没问题(sha256 去重后是同一个 id)。
108
+ // 不校验存在性——附件可能被还原/删掉,前端按「引用的附件不存在」展示。
109
+ const s = String(raw).trim();
110
+ return s === '' ? null : s;
111
+ }
103
112
  const s = String(raw);
104
113
  return s.trim() === '' ? null : s;
105
114
  }
@@ -174,7 +183,12 @@ export async function listEntries({ section, q } = {}) {
174
183
  if (!viewers.has(sec.id)) viewers.set(sec.id, await sensitiveViewer(sec, list.filter((x) => x.section === sec.id)));
175
184
  const item = serializeEntry(sec, e, { decrypt: viewers.get(sec.id) });
176
185
  if (q) {
177
- const hay = [item.title, sec.title, ...Object.values(item.values).map((v) => (v === null ? '' : Array.isArray(v) ? v.join(',') : String(v)))]
186
+ // file 字段值是附件 id,把解析出的附件名也加进搜索文本,才能按「简历/成绩单」搜到条目
187
+ const fileNames = sec.fields
188
+ .filter((f) => f.type === 'file')
189
+ .map((f) => att.findFileRecord(store, e.values?.[f.key])?.name || '');
190
+ const hay = [item.title, sec.title, ...fileNames,
191
+ ...Object.values(item.values).map((v) => (v === null ? '' : Array.isArray(v) ? v.join(',') : String(v)))]
178
192
  .join('\n').toLowerCase();
179
193
  if (!hay.includes(String(q).toLowerCase())) continue;
180
194
  }
@@ -189,7 +203,21 @@ export async function getEntry(ref, { mask, section } = {}) {
189
203
  const sec = store.sections.find((s) => s.id === entry.section);
190
204
  if (!sec) throw notFound(`条目 ${entry.id} 指向的栏目已不存在: ${entry.section}`);
191
205
  const decrypt = await sensitiveViewer(sec, [entry]);
192
- return { ...serializeEntry(sec, entry, { mask: !!mask, decrypt }), sectionTitle: sec.title, mask: !!mask };
206
+ const serialized = serializeEntry(sec, entry, { mask: !!mask, decrypt });
207
+ // 附带 file 字段引用的附件信息:键 = 字段值(即附件 id / 文件名),值为名称 / 路径 / 链接
208
+ const files = {};
209
+ for (const f of sec.fields.filter((x) => x.type === 'file')) {
210
+ const ref = serialized.values[f.key];
211
+ if (!ref) continue;
212
+ const rec = att.findFileRecord(store, ref);
213
+ if (!rec) continue;
214
+ const links = att.fileLinks(rec);
215
+ files[ref] = {
216
+ id: rec.id, name: rec.name, ext: rec.ext, isImage: att.isImageExt(rec.ext),
217
+ path: displayPath(att.fileRecordAbs(rec)), url: links.url, downloadUrl: links.downloadUrl,
218
+ };
219
+ }
220
+ return { ...serialized, sectionTitle: sec.title, mask: !!mask, files };
193
221
  }
194
222
 
195
223
  export async function addEntry({ section, title, set, data, tags, 'dry-run': dryRun } = {}) {
@@ -312,6 +340,7 @@ export async function fieldDictionary({ section } = {}) {
312
340
  groups: sec.groups,
313
341
  fields: sec.fields.map((x) => ({
314
342
  key: x.key, label: x.label, type: x.type, group: x.group,
343
+ kind: x.kind || '',
315
344
  hint: x.hint || '', options: x.options || null, sensitive: isSensitiveField(sec, x.key),
316
345
  })),
317
346
  });
@@ -498,6 +527,10 @@ export function renderEntryGet(d) {
498
527
  ];
499
528
  for (const [k, v] of Object.entries(d.values)) {
500
529
  if (v === null) continue;
530
+ if (d.files && d.files[v]) {
531
+ lines.push(` ${k.padEnd(24)} ${d.files[v].name} ${d.files[v].path}`);
532
+ continue;
533
+ }
501
534
  lines.push(` ${k.padEnd(24)} ${Array.isArray(v) ? v.join('、') : (typeof v === 'boolean' ? (v ? '是' : '否') : String(v))}`);
502
535
  }
503
536
  if (d.missing.length) {
@@ -0,0 +1,114 @@
1
+ import * as service from './service.js';
2
+
3
+ export default {
4
+ id: 'files',
5
+ title: '附件',
6
+ order: 650,
7
+ view: () => import('./view.jsx'),
8
+ // 一组同级实体,每个附件有标识,用户会想替换/还原/删除其中一个 → 集合资源
9
+ resource: 'file',
10
+ actions: [
11
+ {
12
+ id: 'file.list',
13
+ cli: ['file', 'list'],
14
+ http: ['GET', '/api/files'],
15
+ summary: '列附件(可按栏目/关键字过滤);返回「哪些栏目在引用附件」与存储目录。组织方式由栏目驱动,无预定义分类',
16
+ flags: {
17
+ section: { type: 'string', hint: '只看某栏目引用的附件(none = 只列没有任何条目引用的)' },
18
+ category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'], hint: '按磁盘分类过滤(内部属性,一般不用)' },
19
+ q: { type: 'string', hint: '关键字(名称 / 备注 / 标签 / 引用它的栏目条目)' },
20
+ },
21
+ run: (ctx) => service.listFiles(ctx),
22
+ render: (d) => service.renderFileList(d),
23
+ },
24
+ {
25
+ id: 'file.get',
26
+ cli: ['file', 'get'],
27
+ http: ['GET', '/api/files/:ref'],
28
+ summary: '读单个附件的台账:物理路径、链接、被哪些栏目/条目/字段引用、原始位置、标签、备注',
29
+ args: ['ref'],
30
+ run: (ctx) => service.getFile(ctx.ref),
31
+ render: (d) => service.renderFileGet(d),
32
+ },
33
+ {
34
+ id: 'file.add',
35
+ cli: ['file', 'add'],
36
+ http: ['POST', '/api/files'],
37
+ summary: '导入附件:--path 本机路径默认移动(--copy 仅复制);Web 上传走 content(base64)+name。同内容自动去重',
38
+ flags: {
39
+ path: { type: 'string', hint: '本机文件路径(默认把原件移动进库)' },
40
+ content: { type: 'string', hint: 'base64 内容(Web 上传自动带,可为 data URL)' },
41
+ name: { type: 'string', hint: '文件名(content 上传时必填)' },
42
+ category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'] },
43
+ copy: { type: 'boolean', hint: '只复制、保留原件(默认移动)' },
44
+ tags: { type: 'array' },
45
+ note: { type: 'string' },
46
+ 'dry-run': { type: 'boolean', hint: '只校验与计算落点,不移动、不写入' },
47
+ },
48
+ run: (ctx) => service.addFile(ctx),
49
+ render: (d) => service.renderFileChange(d),
50
+ },
51
+ {
52
+ id: 'file.update',
53
+ cli: ['file', 'update'],
54
+ http: ['PATCH', '/api/files/:ref'],
55
+ summary: '改附件:名称、分类(物理文件随之移动)、标签、备注',
56
+ args: ['ref'],
57
+ flags: {
58
+ name: { type: 'string' },
59
+ category: { type: 'string', enum: ['resume', 'avatar', 'transcript', 'certificate', 'document', 'other'] },
60
+ tags: { type: 'array' },
61
+ note: { type: 'string' },
62
+ 'dry-run': { type: 'boolean' },
63
+ },
64
+ run: (ctx) => service.updateFile(ctx.ref, ctx),
65
+ render: (d) => service.renderFileChange(d),
66
+ },
67
+ {
68
+ id: 'file.remove',
69
+ cli: ['file', 'remove'],
70
+ http: ['DELETE', '/api/files/:ref'],
71
+ summary: '删附件(连物理原件一起删);写前自动留台账快照。要保留原件请用 file restore',
72
+ args: ['ref'],
73
+ flags: { 'dry-run': { type: 'boolean' } },
74
+ run: (ctx) => service.removeFile(ctx.ref, ctx),
75
+ render: (d) => service.renderFileChange(d),
76
+ },
77
+ {
78
+ id: 'file.content',
79
+ cli: ['file', 'content'],
80
+ http: ['GET', '/api/files/:ref/content'],
81
+ // raw:HTTP 不走 JSON 包装,直接流式输出文件本体(浏览器内预览/下载)
82
+ raw: true,
83
+ summary: '取附件本体:浏览器内直接预览(?download=1 强制下载);CLI 打印物理路径与链接',
84
+ args: ['ref'],
85
+ flags: { download: { type: 'boolean', hint: '强制下载而非内联预览' } },
86
+ run: (ctx) => service.contentInfo(ctx.ref, ctx),
87
+ serveRaw: (ctx, frame) => service.serveRaw(ctx, frame),
88
+ render: (d) => service.renderContent(d),
89
+ },
90
+ {
91
+ id: 'file.restore',
92
+ cli: ['file', 'restore'],
93
+ http: ['POST', '/api/files/:ref/restore'],
94
+ summary: '可逆操作:把附件移回原始位置并撤销台账(--copy 只导出副本、库中保留;--to 指定目录)',
95
+ args: ['ref'],
96
+ flags: {
97
+ to: { type: 'string', hint: '目标目录(Web 上传件无原始位置时必填)' },
98
+ copy: { type: 'boolean', hint: '只导出副本,库中保留原件与台账' },
99
+ 'dry-run': { type: 'boolean' },
100
+ },
101
+ run: (ctx) => service.restoreFileAction(ctx.ref, ctx),
102
+ render: (d) => service.renderFileChange(d),
103
+ },
104
+ {
105
+ id: 'file.open',
106
+ cli: ['file', 'open'],
107
+ http: null, // 本机动作,不通过 HTTP 暴露
108
+ summary: '用系统默认程序打开附件原件',
109
+ args: ['ref'],
110
+ run: (ctx) => service.openFileAction(ctx.ref),
111
+ render: (d) => service.renderOpen(d),
112
+ },
113
+ ],
114
+ };