@xiaoyuyu6420/dsh-backup 0.14.1 → 0.14.3

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/lib/index.js CHANGED
@@ -54,6 +54,7 @@
54
54
  * 插件的自有存储。
55
55
  */
56
56
  import { createHash, randomUUID } from 'node:crypto';
57
+ import { AsyncLocalStorage } from 'node:async_hooks';
57
58
  import { createReadStream, existsSync, readFileSync, realpathSync } from 'node:fs';
58
59
  import { spawn } from 'node:child_process';
59
60
  import { mkdir, open, copyFile, chmod, readdir, readFile, rename, rm, stat as fsStat, lstat, unlink, writeFile, link } from 'node:fs/promises';
@@ -63,9 +64,11 @@ import { basename, dirname, join, resolve } from 'node:path';
63
64
  import { StringDecoder } from 'node:string_decoder';
64
65
  import { fileURLToPath } from 'node:url';
65
66
  import * as zlib from 'node:zlib';
66
- import { encryptBuffer, decryptBuffer } from './encrypt.js';
67
+ import { decryptFile, encryptFile } from './encrypt.js';
67
68
  import { buildZip, listZip, extractZip, readZipEntry } from './zip.js';
68
69
  import { gfsProtectedSet } from './gfs.js';
70
+ import { safeRelativePath, safeDestination, backupRootRelative, tarLiteralPattern } from './paths.js';
71
+ import { matchesRedaction } from './redact.js';
69
72
  import { acquireLock, releaseLock, heartbeatLock, reclaimLock as reclaimOplock, isLockStale, LOCK_HEARTBEAT_MS } from './oplock.js';
70
73
  import { s3Put, webdavPut, gistSync, gistContent } from './sync-channels.js';
71
74
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -151,7 +154,7 @@ const EXPORT_SECTIONS = Object.freeze([
151
154
  { key: 'settings', label: '设置', paths: ['settings.yaml', 'settings.json'] },
152
155
  { key: 'ui', label: '界面设置', paths: ['ui.json', 'ui-state.json', '.ui'] },
153
156
  { key: 'providers', label: '模型供应商', paths: ['providers'] },
154
- { key: 'plugins', label: '插件 profiles', paths: ['profiles'] },
157
+ { key: 'plugins', label: '插件 profiles', paths: ['profiles', 'local-plugins'] },
155
158
  { key: 'mcp', label: 'MCP 配置', paths: ['cordis.patch.yml', 'cordis.yml'] },
156
159
  { key: 'skills', label: '技能', paths: ['skills'] },
157
160
  { key: 'prompts', label: '提示词', paths: ['prompts'] },
@@ -1351,7 +1354,33 @@ export function apply(ctx, pluginConfig) {
1351
1354
  * (rel 本身可含 glob)。
1352
1355
  */
1353
1356
  function redactExcludeFlags(base) {
1354
- return sensitivePaths().flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
1357
+ return sensitivePatterns().flatMap((rel) => [`--exclude=${base}/${rel}`, `--exclude=*/${rel}`]);
1358
+ }
1359
+
1360
+ // Old releases left rollback copies beside live credentials. Exclude these too.
1361
+ function sensitivePatterns() {
1362
+ return sensitivePaths().flatMap((rel) => [rel, `${rel}.pre-merge-*`, `${rel}.pre-import-*`]);
1363
+ }
1364
+
1365
+ function isSensitiveRelative(rel) {
1366
+ return sensitivePatterns().some((pattern) => matchesRedaction(rel, pattern));
1367
+ }
1368
+
1369
+ async function retainBeforeOverwrite(rel, kind, stamp) {
1370
+ const { dshHome, root } = paths();
1371
+ const sensitive = isSensitiveRelative(rel);
1372
+ const keep = sensitive
1373
+ ? await safeDestination(`${root}/${VAULT_DIR}/${PRESERVED_DIR}`, `${kind}-${stamp}/${rel}`)
1374
+ : await safeDestination(dshHome, `${rel}.pre-${kind}-${stamp}`);
1375
+ await mkdir(dirname(keep), { recursive: true, mode: 0o700 });
1376
+ // Create exclusively and privately before copying; never follow a retained
1377
+ // path left by an earlier operation or expose plaintext through the umask.
1378
+ const fh = await open(keep, 'wx', 0o600);
1379
+ // copyFile copies the source mode too, which could turn a private rollback
1380
+ // file back into 0644. Stream through the already-private file descriptor.
1381
+ try { await fh.writeFile(createReadStream(`${dshHome}/${rel}`)); }
1382
+ finally { await fh.close(); }
1383
+ return keep;
1355
1384
  }
1356
1385
 
1357
1386
  /**
@@ -1687,26 +1716,30 @@ export function apply(ctx, pluginConfig) {
1687
1716
  // 备份/恢复/导入/同步/doctor 这类重活会动 ~/.dsh 与备份目录(vault 刷新、
1688
1717
  // .dsh 挪旁、归档轮换),多实例/多 profile 并发会互相踩。锁文件在备份目录根
1689
1718
  // .oplock.json,记 pid+token+心跳;死锁(pid 已死)下次自动回收,活锁绝不误踢。
1690
- // 同进程重入(restore 内部的快照 doBackup 等)经 heldLock 内存标记直接放行。
1719
+ // 同一异步操作链可重入(restore 内部的快照 doBackup 等)。
1691
1720
  let heldLock = null;
1692
- let heartbeatTimer = null;
1721
+ const operationScope = new AsyncLocalStorage();
1693
1722
 
1694
1723
  async function withOperationLock(operation, fn) {
1695
- if (heldLock) return fn(); // 同进程重入:本进程已持锁,直接跑
1724
+ // Only descendants of the current operation may reenter (restore -> snapshot).
1725
+ // An unrelated RPC in the same process must still compete for the lock.
1726
+ if (heldLock && operationScope.getStore() === heldLock.token) return fn();
1696
1727
  const { root } = paths();
1697
1728
  const lockPath = `${root}/${LOCK_FILE}`;
1698
1729
  const r = await acquireLock(lockPath, operation);
1699
1730
  if (!r.acquired) {
1700
- throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid})——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
1731
+ throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid ?? '未知'}):${r.reason ?? ''}——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
1701
1732
  }
1702
1733
  heldLock = r.lock;
1703
- heartbeatTimer = setInterval(() => { heartbeatLock(lockPath, r.lock.token).catch(() => {}); }, LOCK_HEARTBEAT_MS);
1734
+ let heartbeat = Promise.resolve();
1735
+ const heartbeatTimer = setInterval(() => { heartbeat = heartbeat.then(() => heartbeatLock(lockPath, r.lock.token)).catch(() => {}); }, LOCK_HEARTBEAT_MS);
1704
1736
  try {
1705
- return await fn();
1737
+ return await operationScope.run(r.lock.token, fn);
1706
1738
  } finally {
1707
- if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
1739
+ clearInterval(heartbeatTimer);
1740
+ await heartbeat;
1708
1741
  await releaseLock(lockPath, r.lock.token).catch(() => {});
1709
- heldLock = null;
1742
+ if (heldLock?.token === r.lock.token) heldLock = null;
1710
1743
  }
1711
1744
  }
1712
1745
 
@@ -1716,6 +1749,7 @@ export function apply(ctx, pluginConfig) {
1716
1749
 
1717
1750
  async function doBackupInner(keep, signal, opts = {}) {
1718
1751
  const { home, dshHome, root } = paths();
1752
+ const backupRel = backupRootRelative(dshHome, root);
1719
1753
  const keepN = keep && keep > 0 ? Math.floor(keep) : defaultKeep();
1720
1754
  // 状态文件先写:writeText 会自动创建备份目录,替代 mkdir -p(Windows 无 mkdir.exe)。
1721
1755
  await saveAutoState();
@@ -1735,10 +1769,11 @@ export function apply(ctx, pluginConfig) {
1735
1769
  // 不知情的情况下删掉最后一份好副本。检出项记入 meta.quarantined,恢复侧
1736
1770
  // 如实提示。体检失败不阻断备份(fail-open)。
1737
1771
  let quarantined = [];
1772
+ let scanFailed = false;
1738
1773
  if (!opts?.skipScan) {
1739
1774
  try {
1740
1775
  quarantined = (await runDoctorScan(signal)).corrupt.map((c) => c.rel);
1741
- } catch { /* 扫描异常按无隔离处理 */ }
1776
+ } catch { scanFailed = true; }
1742
1777
  }
1743
1778
  const quarantineFlags = quarantined.map((rel) => `--exclude=${base}/${rel}`);
1744
1779
 
@@ -1761,8 +1796,9 @@ export function apply(ctx, pluginConfig) {
1761
1796
  const redacted = skipVault ? await existingSensitive(dshHome) : await refreshVault(dshHome, home, signal);
1762
1797
  const tar = await ctx.subprocess.resolveExecutable('tar');
1763
1798
  const redactFlags = isTyped ? [] : redactExcludeFlags(base);
1799
+ const destinationFlags = backupRel === null ? [] : [`--exclude=${tarLiteralPattern(`${base}/${backupRel}`)}`];
1764
1800
  try {
1765
- await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parentTar(parent), ...(isTyped ? typePaths : [base])], root, signal);
1801
+ await spawnRun([tar, '--exclude=*node_modules*', '--exclude=.system', ...destinationFlags, ...quarantineFlags, ...redactFlags, ...extraExcludes(), '-czf', name, '-C', parentTar(parent), ...(isTyped ? typePaths : [base])], root, signal);
1766
1802
  } catch (err) {
1767
1803
  // tar 失败前已创建 -f 目标——空壳归档留在备份目录会被面板当正常备份
1768
1804
  // 列出来(#88:29 字节静默空包)。删掉空壳再抛,让失败可见。
@@ -1777,9 +1813,17 @@ export function apply(ctx, pluginConfig) {
1777
1813
  // 替换——旧版插件遇到它只会 tar 解不开,边车只增不改、不误删不误读。
1778
1814
  let encrypted = false;
1779
1815
  if (typeof opts?.password === 'string' && opts.password) {
1780
- const plain = await readFile(out);
1781
- const enc = await encryptBuffer(plain, opts.password);
1782
- await writeBinary(out, enc.ciphertext);
1816
+ // 流式加密 + 临时文件后原子替换:绝不在原路径「整包读入 → 原地覆盖」。
1817
+ // 中途崩溃只会留下完整明文或完整密文,不会留半截密文(#103 同类教训)。
1818
+ const tmpEnc = `${out}.enc-${stampNow()}.tmp`;
1819
+ let enc;
1820
+ try {
1821
+ enc = await encryptFile(out, tmpEnc, opts.password);
1822
+ await rename(tmpEnc, out);
1823
+ } catch (err) {
1824
+ await rm(tmpEnc, { force: true }).catch(() => {});
1825
+ throw err;
1826
+ }
1783
1827
  await writeOwned(`${out}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
1784
1828
  encrypted = true;
1785
1829
  }
@@ -1840,7 +1884,8 @@ export function apply(ctx, pluginConfig) {
1840
1884
  monthly: Number(gfsCfg.gfsMonthly) > 0 ? Math.floor(Number(gfsCfg.gfsMonthly)) : 0,
1841
1885
  yearly: Number(gfsCfg.gfsYearly) > 0 ? Math.floor(Number(gfsCfg.gfsYearly)) : 0,
1842
1886
  });
1843
- const rotateStale = opts.skipRotate
1887
+ const retentionPaused = quarantined.length > 0 || scanFailed;
1888
+ const rotateStale = opts.skipRotate || retentionPaused || SNAPSHOT_PREFIXES.some((p) => name.startsWith(p))
1844
1889
  ? []
1845
1890
  : rotateTail.filter((b) => !tierProtectedSet(rotateTail, rotateBase[0]?.name).has(b.name) && !gfsProtect.has(b.name)).map((b) => b.name);
1846
1891
  if (rotateStale.length) await removeFiles(rotateStale.flatMap((n) => sidecarsFor(n)), root, signal);
@@ -1863,14 +1908,14 @@ export function apply(ctx, pluginConfig) {
1863
1908
  await saveAutoState();
1864
1909
  }
1865
1910
 
1866
- return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, sync, ...(encrypted ? { encrypted: true } : {}), ...(isTyped ? { types, hasCredentials } : {}) };
1911
+ return { path: out, name, sha: shaText, total: rotateBase.length, stale: rotateStale.length, keep: keepN, quarantined, retentionPaused, sync, ...(encrypted ? { encrypted: true } : {}), ...(isTyped ? { types, hasCredentials } : {}) };
1867
1912
  }
1868
1913
 
1869
1914
  /** 备份回执的隔离提示行:体检发现坏会话文件未入档时如实告知 + 指路修复。 */
1870
1915
  function quarantineNote(r) {
1871
1916
  return Array.isArray(r?.quarantined) && r.quarantined.length
1872
- ? `\n⚠️ ${r.quarantined.length} 个损坏会话文件未入档(防轮换毁掉好副本);/backup doctor --repair 修复后可正常备份`
1873
- : '';
1917
+ ? `\n⚠️ ${r.quarantined.length} 个损坏会话文件未入档,已暂停轮换以保留健康副本;/backup doctor --repair 修复后恢复轮换`
1918
+ : r?.retentionPaused ? '\n⚠️ 会话体检未完成,已暂停轮换;体检恢复后再清理旧备份' : '';
1874
1919
  }
1875
1920
 
1876
1921
  // ---------- GitHub 同步 ----------
@@ -2008,7 +2053,8 @@ export function apply(ctx, pluginConfig) {
2008
2053
  } finally {
2009
2054
  await fh.close();
2010
2055
  }
2011
- await spawnRun([git, 'config', 'credential.helper', `store --file=${creds}`], syncDir, signal);
2056
+ const quotedCreds = `'${creds.replace(/'/g, `'\\''`)}'`;
2057
+ await spawnRun([git, 'config', 'credential.helper', `store --file=${quotedCreds}`], syncDir, signal);
2012
2058
  }
2013
2059
  // token 文件绝不能进仓库:git add -A 会把它当普通文件提交
2014
2060
  await writeOwned(`${syncDir}/.gitignore`, '.git-credentials\n');
@@ -2022,6 +2068,10 @@ export function apply(ctx, pluginConfig) {
2022
2068
  * 的 .git-credentials(credential helper),不进进程参数。
2023
2069
  */
2024
2070
  async function githubSync(signal) {
2071
+ return withOperationLock('github-sync', () => githubSyncInner(signal));
2072
+ }
2073
+
2074
+ async function githubSyncInner(signal) {
2025
2075
  const { root } = paths();
2026
2076
  const wt = await ensureSyncWorktree(signal);
2027
2077
  if (wt.reason) return { skipped: wt.reason };
@@ -2098,6 +2148,10 @@ export function apply(ctx, pluginConfig) {
2098
2148
  * 拉回的归档先做 sha256 校验(边车随仓库走),损坏即报且不落列表。
2099
2149
  */
2100
2150
  async function githubPull(signal) {
2151
+ return withOperationLock('github-pull', () => githubPullInner(signal));
2152
+ }
2153
+
2154
+ async function githubPullInner(signal) {
2101
2155
  const { root, home } = paths();
2102
2156
  const wt = await ensureSyncWorktree(signal);
2103
2157
  if (wt.reason) throw new Error(wt.reason);
@@ -2190,7 +2244,7 @@ export function apply(ctx, pluginConfig) {
2190
2244
  function redactSyncChannel(channel, ch) {
2191
2245
  if (!ch) return { channel, type: channel, configured: false };
2192
2246
  const out = { channel, type: ch.type, configured: true, lastPush: ch.lastPush ?? null, lastError: ch.lastError ?? null };
2193
- if (ch.type === 'webdav') out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password);
2247
+ if (ch.type === 'webdav') { out.url = ch.url; out.username = ch.username; out.passwordSet = Boolean(ch.password); }
2194
2248
  if (ch.type === 's3') { out.endpoint = ch.endpoint; out.region = ch.region; out.bucket = ch.bucket; out.pathPrefix = ch.pathPrefix || ''; out.accessKey = ch.accessKey; out.secretKeySet = Boolean(ch.secretKey); out.pathStyle = ch.pathStyle !== false; }
2195
2249
  if (ch.type === 'gist') { out.gistId = ch.gistId || ''; out.tokenSet = Boolean(ch.token); }
2196
2250
  return out;
@@ -2273,6 +2327,10 @@ export function apply(ctx, pluginConfig) {
2273
2327
  * error?, skipped?, gistUrl? },凭据绝不进返回。
2274
2328
  */
2275
2329
  async function syncNow(channel, signal) {
2330
+ return withOperationLock('sync', () => syncNowInner(channel, signal));
2331
+ }
2332
+
2333
+ async function syncNowInner(channel, signal) {
2276
2334
  if (channel === 'github') return githubSync(signal);
2277
2335
  const ch = syncChannels[channel];
2278
2336
  if (!ch) return { error: `未配置同步通道 ${channel}(/backup sync ${channel} set … 或面板配置)` };
@@ -2404,13 +2462,12 @@ export function apply(ctx, pluginConfig) {
2404
2462
  // meta.encrypted 标记了加密但边车缺失:这是不一致状态,拒绝而非猜着解
2405
2463
  throw new Error(`加密边车(.encrypted.json)缺失或损坏,无法解密——请核对备份是否完整(技术细节: ${snippet(String(err && err.message ? err.message : err), 80)})`);
2406
2464
  }
2407
- const ciphertext = await readFile(`${root}/${archiveName}`);
2408
- const plain = await decryptBuffer(ciphertext, sidecar, password);
2465
+ const tmpName = `.decrypt-${stampNow()}.tar.gz`;
2466
+ // 直接流式解密到目标临时文件:大归档不整包入内存,认证失败时不留半截明文。
2467
+ await decryptFile(`${root}/${archiveName}`, `${root}/${tmpName}`, sidecar, password);
2409
2468
  // 隐藏名 `.decrypt-<时间戳>.tar.gz`:listBackups 要求 `dsh-` 前缀,隐藏点开头
2410
2469
  // 的临时文件绝不进用户列表/轮换;位于备份目录内、相对名传 tar,规避 Windows
2411
2470
  // GNU tar 盘符冒号问题。finally 里必清理(明文 tar.gz 不能遗留)。
2412
- const tmpName = `.decrypt-${stampNow()}.tar.gz`;
2413
- await writeBinary(`${root}/${tmpName}`, plain);
2414
2471
  return { name: tmpName, cleanup: () => rm(`${root}/${tmpName}`, { force: true }) };
2415
2472
  }
2416
2473
 
@@ -2612,9 +2669,10 @@ export function apply(ctx, pluginConfig) {
2612
2669
  * cwd 为备份目录、-f 用纯文件名(规避 Windows GNU tar 盘符冒号问题),
2613
2670
  * 逐行解析 -tvzf 输出并拒绝备份根之外的任何条目——含不安全条目直接抛错。
2614
2671
  */
2615
- async function safeArchiveEntries(pickedName, signal) {
2616
- const { root, dshHome } = paths();
2617
- const base = dshHome.split('/').pop();
2672
+ async function safeArchiveEntries(pickedName, signal, options = {}) {
2673
+ const env = paths();
2674
+ const root = options.root ?? env.root;
2675
+ const base = options.base ?? env.dshHome.split('/').pop();
2618
2676
  const tar = await ctx.subprocess.resolveExecutable('tar');
2619
2677
  // 清单流式读取(#119):不用有界收集——大归档的清单超过 spill 上限时会被截断,
2620
2678
  // 碎片会被误判成"不安全条目"而拒绝整包(详见 streamListingLines 注释)。
@@ -2639,7 +2697,7 @@ export function apply(ctx, pluginConfig) {
2639
2697
  // 救援台全链路失效(#112)。硬链接不能跨文件系统、也不能指向目录,目标同为
2640
2698
  // 归档内相对路径时解压不会逃出备份根;符号链接(l)、设备等仍一律拒绝。
2641
2699
  const plainOk = Boolean(parsed) && Boolean(name) && (parsed.type === '-' || parsed.type === 'd') && insideBase(name);
2642
- const hardOk = Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
2700
+ const hardOk = options.allowHardlinks !== false && Boolean(parsed) && parsed.type === 'h' && insideBase(name) && insideBase(link);
2643
2701
  if (!plainOk && !hardOk) {
2644
2702
  bad.push(parsed ? name : s);
2645
2703
  } else {
@@ -2756,13 +2814,17 @@ export function apply(ctx, pluginConfig) {
2756
2814
  targetExists, preflight: preflightLines(preflight, profileDirs),
2757
2815
  };
2758
2816
  }
2759
- // 留档将覆盖的现有文件(.pre-merge-<时间戳>),失败可逐个还原。
2817
+ const newTargets = [];
2818
+ for (const entry of fileTargets) {
2819
+ const rel = entry.slice(`${base}/`.length);
2820
+ await safeDestination(dshHome, rel);
2821
+ if (!(await existsStat(`${dshHome}/${rel}`))) newTargets.push(rel);
2822
+ }
2823
+ // Validate every target before creating rollback copies or changing data.
2760
2824
  const stamp = stampNow();
2761
2825
  const kept = [];
2762
2826
  for (const w of willOverwrite) {
2763
- const keep = `${dshHome}/${w.rel}.pre-merge-${stamp}`;
2764
- await mkdir(dirname(keep), { recursive: true });
2765
- await copyFile(`${dshHome}/${w.rel}`, keep);
2827
+ const keep = await retainBeforeOverwrite(w.rel, 'merge', stamp);
2766
2828
  kept.push({ ...w, keep });
2767
2829
  }
2768
2830
  try {
@@ -2773,14 +2835,18 @@ export function apply(ctx, pluginConfig) {
2773
2835
  // 时把留档路径交代清楚,绝不静默停在半截状态。
2774
2836
  const reason = err && err.message ? err.message : String(err);
2775
2837
  const restored = new Set();
2776
- for (const w of willOverwrite) {
2838
+ for (const w of kept) {
2777
2839
  try { await copyFile(w.keep, `${dshHome}/${w.rel}`); restored.add(w.rel); } catch { /* 逐个失败落入下方报告 */ }
2778
2840
  }
2779
- if (restored.size === willOverwrite.length) {
2841
+ const cleanupFailed = [];
2842
+ for (const rel of newTargets) {
2843
+ try { await rm(`${dshHome}/${rel}`, { recursive: true, force: true }); } catch { cleanupFailed.push(rel); }
2844
+ }
2845
+ if (restored.size === willOverwrite.length && !cleanupFailed.length) {
2780
2846
  throw new Error(`分类型恢复失败(${reason})。已从留档还原全部覆盖现场,数据保持恢复前状态;排查后可重试`);
2781
2847
  }
2782
- const missing = willOverwrite.filter((w) => !restored.has(w.rel)).map((w) => w.rel);
2783
- throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}。对应的 *.pre-merge-${stamp} 留档仍在原目录旁,手工拷回即可`);
2848
+ const missing = kept.filter((w) => !restored.has(w.rel)).map((w) => `${w.rel}(留档: ${w.keep})`);
2849
+ throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${[...missing, ...cleanupFailed].join(', ')}。请从上述留档手工还原`);
2784
2850
  }
2785
2851
  // 可选依赖重装:merge 含 profiles 时对新解出的 profile 跑 pnpm install。
2786
2852
  const deps = { installed: [], failed: [], note: null };
@@ -2826,6 +2892,12 @@ export function apply(ctx, pluginConfig) {
2826
2892
  };
2827
2893
  }
2828
2894
 
2895
+ // Moving DSH_HOME would also move the selected archive, vault and operation
2896
+ // lock. Require relocating the complete backup directory before replacement.
2897
+ if (backupRootRelative(dshHome, root) !== null) {
2898
+ throw new Error('整包恢复前请把整个备份目录(含 vault)移到 DSH_HOME 外,并更新备份路径;当前备份目录位于待替换的数据目录内');
2899
+ }
2900
+
2829
2901
  // 恢复前自动快照当前数据,并把当前数据移到旁边(而非合并覆盖)。
2830
2902
  // 快照用 dsh-pre-restore- 前缀与用户常规备份区分,经 listBackups 过滤
2831
2903
  // 后不进列表/轮换/latest 选择;每次恢复清掉上一次的快照防累积。
@@ -2936,7 +3008,7 @@ export function apply(ctx, pluginConfig) {
2936
3008
  '✅ 分类型恢复完成',
2937
3009
  ` 已合并写入: ${r.restored} 项 ← 来自 ${r.archive.split('/').pop()}(类型: ${types})`,
2938
3010
  ];
2939
- if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(.pre-merge-*,确认后可自行删除)`);
3011
+ if (r.kept?.length) lines.push(` 覆盖的旧文件已留档: ${r.kept.length} 个(敏感文件在本机 vault/preserved,其余为 .pre-merge-*,确认后可自行删除)`);
2940
3012
  for (const p of r.preflight ?? []) if (p.startsWith('⚠️')) lines.push(` ${p}`);
2941
3013
  if (r.deps?.note) lines.push(` 插件依赖: ${r.deps.note}`);
2942
3014
  else if (r.deps?.installed?.length) lines.push(` 插件依赖: 已装好 ${r.deps.installed.join(', ')}`);
@@ -3023,10 +3095,12 @@ export function apply(ctx, pluginConfig) {
3023
3095
  * .git/.system(任意深度);凭据节段按 includeCredentials 决定入包或记入脱敏清单。
3024
3096
  */
3025
3097
  async function walkExportFiles(dshHome, includeCredentials, signal) {
3098
+ const backupRel = backupRootRelative(dshHome, paths().root);
3026
3099
  const files = [];
3027
3100
  const redacted = [];
3028
3101
  const skipDirs = new Set(['node_modules', '.git', '.system']);
3029
3102
  async function walk(abs, rel) {
3103
+ if (rel === backupRel) return;
3030
3104
  if (signal?.aborted) throw new Error('操作已取消');
3031
3105
  if (files.length >= EXPORT_MAX_FILES) throw new Error(`导出文件数超过上限(${EXPORT_MAX_FILES}),请检查 ~/.dsh 是否有异常目录树`);
3032
3106
  let st;
@@ -3044,7 +3118,7 @@ export function apply(ctx, pluginConfig) {
3044
3118
  }
3045
3119
  if (!st.isFile()) return;
3046
3120
  const section = sectionKeyForTop(rel.split('/')[0]);
3047
- if (section === 'credentials' && !includeCredentials) { redacted.push(rel); return; }
3121
+ if (!includeCredentials && (section === 'credentials' || isSensitiveRelative(rel))) { redacted.push(rel); return; }
3048
3122
  files.push({ rel, abs, bytes: st.size, section });
3049
3123
  }
3050
3124
  await walk(dshHome, '');
@@ -3069,6 +3143,10 @@ export function apply(ctx, pluginConfig) {
3069
3143
 
3070
3144
  /** 导出:把 ~/.dsh 打成单文件 ZIP(可选加密)。 */
3071
3145
  async function exportConfig(opts, signal) {
3146
+ return withOperationLock('export', () => exportConfigInner(opts, signal));
3147
+ }
3148
+
3149
+ async function exportConfigInner(opts, signal) {
3072
3150
  const { home, dshHome, root } = paths();
3073
3151
  const includeCredentials = opts?.includeCredentials === true;
3074
3152
  const password = typeof opts?.password === 'string' && opts.password ? opts.password : null;
@@ -3086,9 +3164,11 @@ export function apply(ctx, pluginConfig) {
3086
3164
  // 本地 link:/file: 插件迁移:npm pack 成 tarball 打进导出,导入侧解包改 file:
3087
3165
  const localPlugins = await scanLocalPlugins(signal);
3088
3166
  const packedPlugins = localPlugins.filter((p) => p.status === 'packed');
3167
+ // Different profiles may reference distinct packages with the same npm name/version.
3168
+ for (const p of packedPlugins) p.exportName = `${randomUUID()}-${p.tarballName}`;
3089
3169
  const encrypted = Boolean(password);
3090
3170
  const manifest = buildManifest(home, encrypted, redacted, sectionStats);
3091
- manifest.localPlugins = localPlugins.map((p) => ({ profile: p.profile, name: p.name, spec: p.spec, status: p.status, tarball: p.tarballName ?? null, warning: p.warning ?? null }));
3171
+ manifest.localPlugins = localPlugins.map((p) => ({ profile: p.profile, name: p.name, spec: p.spec, status: p.status, tarball: p.exportName ?? null, warning: p.warning ?? null }));
3092
3172
  const finalName = `dsh-config-${stampNow()}.zip`;
3093
3173
  const finalPath = `${root}/${finalName}`;
3094
3174
  const tmpPath = `${root}/.export-${stampNow()}.tmp.zip`;
@@ -3098,12 +3178,20 @@ export function apply(ctx, pluginConfig) {
3098
3178
  try {
3099
3179
  const entryList = [{ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) }];
3100
3180
  for (const f of files) entryList.push({ name: f.rel, sourcePath: f.abs });
3101
- for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.tarballName}`, sourcePath: p.tarballPath });
3181
+ for (const p of packedPlugins) entryList.push({ name: `local-plugins/${p.exportName}`, sourcePath: p.tarballPath });
3102
3182
  await buildZip(tmpPath, entryList, { signal });
3103
3183
  if (encrypted) {
3104
- const plain = await readFile(tmpPath);
3105
- const enc = await encryptBuffer(plain, password);
3106
- await writeBinary(finalPath, enc.ciphertext);
3184
+ const tmpEnc = `${finalPath}.enc-${stampNow()}.tmp`;
3185
+ let enc;
3186
+ try {
3187
+ enc = await encryptFile(tmpPath, tmpEnc, password);
3188
+ await rename(tmpEnc, finalPath);
3189
+ } catch (err) {
3190
+ await rm(tmpEnc, { force: true }).catch(() => {});
3191
+ throw err;
3192
+ } finally {
3193
+ await rm(tmpPath, { force: true }).catch(() => {});
3194
+ }
3107
3195
  await writeOwned(`${finalPath}.encrypted.json`, `${JSON.stringify(enc.sidecar)}\n`);
3108
3196
  } else {
3109
3197
  await rename(tmpPath, finalPath);
@@ -4065,11 +4153,9 @@ export function apply(ctx, pluginConfig) {
4065
4153
  if (encrypted) {
4066
4154
  if (!password) throw new Error('这份导出已加密,需要口令才能读取(口令只经面板 RPC 内存态传入,绝不落盘)');
4067
4155
  const sidecar = JSON.parse(await readFile(sidecarPath, 'utf8'));
4068
- const ciphertext = await readFile(src);
4069
- const plain = await decryptBuffer(ciphertext, sidecar, password);
4070
4156
  const { root } = paths();
4071
4157
  const tmp = `${root}/.export-dec-${stampNow()}.zip`;
4072
- await writeBinary(tmp, plain);
4158
+ await decryptFile(src, tmp, sidecar, password);
4073
4159
  return { zipPath: tmp, encrypted: true, cleanup: () => rm(tmp, { force: true }) };
4074
4160
  }
4075
4161
  return { zipPath: src, encrypted: false, cleanup: null };
@@ -4087,6 +4173,28 @@ export function apply(ctx, pluginConfig) {
4087
4173
  if (Number(manifest?.version) > EXPORT_FORMAT_VERSION) {
4088
4174
  throw new Error(`导出包版本过新(version=${manifest.version},本机支持 ${EXPORT_FORMAT_VERSION})——请升级插件后导入`);
4089
4175
  }
4176
+ if (manifest.localPlugins !== undefined) {
4177
+ if (!Array.isArray(manifest.localPlugins)) throw new Error('manifest.localPlugins 必须是数组');
4178
+ const seen = new Set();
4179
+ manifest.localPlugins = manifest.localPlugins.map((p) => {
4180
+ if (!p || typeof p !== 'object') throw new Error('本地插件元数据无效');
4181
+ safeRelativePath(p.name);
4182
+ if (!/^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(p.name)) throw new Error('本地插件名称无效');
4183
+ safeRelativePath(p.profile);
4184
+ if (!/^[A-Za-z0-9._-]+$/.test(p.profile)) throw new Error('本地插件 profile 无效');
4185
+ const key = `${p.profile}/${p.name}`;
4186
+ if (seen.has(key)) throw new Error(`重复的本地插件: ${key}`);
4187
+ seen.add(key);
4188
+ // Canonical export field is tarball; accept the older import-side spelling too.
4189
+ const tarball = p.tarball ?? p.tarballName;
4190
+ if (p.tarball && p.tarballName && p.tarball !== p.tarballName) throw new Error('本地插件 tarball 字段冲突');
4191
+ if (p.status === 'packed') {
4192
+ safeRelativePath(tarball);
4193
+ if (tarball.includes('/') || !tarball.endsWith('.tgz')) throw new Error('本地插件 tarball 路径无效');
4194
+ }
4195
+ return { ...p, tarball };
4196
+ });
4197
+ }
4090
4198
  return manifest;
4091
4199
  }
4092
4200
 
@@ -4165,7 +4273,7 @@ export function apply(ctx, pluginConfig) {
4165
4273
  const remap = await remapScan(zipPath, manifest, home);
4166
4274
  const compatibility = compatScore(manifest, home);
4167
4275
  const totalFiles = sections.reduce((s, x) => s + x.files, 0);
4168
- const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,可在界面逐项选择覆盖)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
4276
+ const summary = `📦 导入预览 —— 只读,还没动磁盘\n来源: ${manifest.source?.host ?? '未知'}(${manifest.source?.platform ?? '未知平台'})\n将导入 ${totalFiles} 个文件、${sections.length} 个节段${conflicts.length ? `\n⚠️ 其中 ${conflicts.length} 个目标文件已存在(默认保留本机,不改动已有文件)` : ''}${remap.length ? '\n⚠️ 需要跨机绝对路径 remap(见 remap 列表)' : ''}\n兼容性: ${compatibility.score}/100${compatibility.reasons.length ? `(${compatibility.reasons.join(';')})` : ''}`;
4169
4277
  return { ok: true, encrypted, manifest, sections, totalFiles, totalBytes: manifest.totalBytes ?? 0, conflicts, remap, compatibility, summary };
4170
4278
  } finally {
4171
4279
  if (cleanup) await cleanup().catch(() => {});
@@ -4199,7 +4307,7 @@ export function apply(ctx, pluginConfig) {
4199
4307
  const willWrite = [];
4200
4308
  const willSkip = [];
4201
4309
  for (const e of selected) {
4202
- const targetAbs = `${dshHome}/${e.name}`;
4310
+ const targetAbs = await safeDestination(dshHome, e.name);
4203
4311
  const exists = await existsStat(targetAbs);
4204
4312
  if (exists && decisions[e.name] !== 'overwrite') { willSkip.push(e.name); continue; }
4205
4313
  willWrite.push({ ...e, targetAbs, conflict: exists });
@@ -4227,80 +4335,75 @@ export function apply(ctx, pluginConfig) {
4227
4335
  await prunePrefixedSnapshots(IMPORT_SNAPSHOT_PREFIX, 1, snap.name);
4228
4336
  } catch { /* 快照失败不阻断导入 */ }
4229
4337
 
4230
- // 测试专用故障注入(test-only):DSH_BACKUP_IMPORT_FAIL=1 时,在「自动快照完成
4231
- // 之后、真正写入之前」抛一次错,模拟导入中途失败——零依赖 smoke 据此断言失败
4232
- // 路径与快照安全网(~/.dsh 未被改动)。生产默认绝不触发:环境变量未显式命中
4233
- // '1' 时此分支永不进入。
4234
- if (process.env.DSH_BACKUP_IMPORT_FAIL === '1') {
4235
- throw new Error('test-only import fault injection (DSH_BACKUP_IMPORT_FAIL=1)');
4236
- }
4237
4338
 
4238
4339
  // 解压到暂存目录(extractZip 内 Zip Slip/符号链接/zip bomb/CRC 全套防线),再拷入 ~/.dsh
4239
- staging = `${root}/.import-stage-${stampNow()}`;
4340
+ staging = `${root}/.import-stage-${randomUUID()}`;
4240
4341
  await extractZip(zipPath, staging, { names: new Set(willWrite.map((e) => e.name)), signal });
4241
4342
  const stamp = stampNow();
4242
4343
  const backups = [];
4243
4344
  const applied = [];
4244
4345
  const overwrote = [];
4346
+ const createdPluginDirs = [];
4347
+ let remapped = 0;
4348
+ let pluginsRestored = { restored: [], skipped: [] };
4245
4349
  try {
4246
4350
  for (const e of willWrite) {
4247
4351
  if (signal?.aborted) throw new Error('操作已取消');
4352
+ await safeDestination(dshHome, e.name);
4248
4353
  await mkdir(dirname(e.targetAbs), { recursive: true });
4249
4354
  if (e.conflict) {
4250
- const keep = `${e.targetAbs}.pre-import-${stamp}`;
4251
- await copyFile(e.targetAbs, keep);
4355
+ const keep = await retainBeforeOverwrite(e.name, 'import', stamp);
4252
4356
  backups.push({ name: e.name, targetAbs: e.targetAbs, keep });
4253
4357
  }
4254
- await copyFile(`${staging}/${e.name}`, e.targetAbs);
4358
+ // Record before copyFile: even a failed copy may leave partial new bytes.
4255
4359
  applied.push(e.name);
4360
+ await copyFile(`${staging}/${e.name}`, e.targetAbs);
4256
4361
  if (e.conflict) overwrote.push(e.name);
4257
4362
  }
4363
+ // 校验:写入的文件必须存在(防半截写入被误判成功)
4364
+ const missing = [];
4365
+ for (const e of willWrite) if (!(await existsStat(e.targetAbs))) missing.push(e.name);
4366
+ if (missing.length) throw new Error(`导入校验失败,部分文件未落盘: ${missing.slice(0, 5).join(', ')}(快照: ${snapshotName ?? '未生成'})`);
4367
+
4368
+ // 跨机 remap:对写入的文本配置做 from→to 前缀替换(settings / cordis)
4369
+ if (Array.isArray(opts?.remap) && opts.remap.length) {
4370
+ for (const m of opts.remap) {
4371
+ if (!m || typeof m.from !== 'string' || !m.from || typeof m.to !== 'string') continue;
4372
+ for (const rel of ['settings.yaml', 'settings.json', 'cordis.yml', 'cordis.patch.yml']) {
4373
+ if (!willWrite.some((e) => e.name === rel)) continue;
4374
+ const p = `${dshHome}/${rel}`;
4375
+ if (!(await existsStat(p))) continue;
4376
+ const text = await readFile(p, 'utf8');
4377
+ const next = text.split(m.from).join(m.to);
4378
+ if (next !== text) { await writeFile(p, next, 'utf8'); remapped += 1; }
4379
+ }
4380
+ }
4381
+ }
4382
+
4383
+ // Only selected, actually written tarballs and profile manifests may be changed.
4384
+ pluginsRestored = await restoreLocalPlugins(manifest.localPlugins, signal, { willWrite, createdPluginDirs });
4258
4385
  } catch (err) {
4259
4386
  // 回滚:覆盖的先还原留档,新增的删除;回滚再失败则给出快照/留档指引,绝不静默停在半截。
4260
4387
  const failed = [];
4388
+ for (const dir of createdPluginDirs.reverse()) {
4389
+ try { await rm(dir, { recursive: true, force: true }); } catch { failed.push(dir); }
4390
+ }
4261
4391
  for (const b of backups) {
4262
- try { await copyFile(b.keep, b.targetAbs); } catch { failed.push(b.name); }
4392
+ try { await copyFile(b.keep, b.targetAbs); } catch { failed.push(`${b.name}(留档: ${b.keep})`); }
4263
4393
  }
4264
4394
  for (const n of applied) {
4265
- if (!backups.some((b) => b.name === n)) await rm(`${dshHome}/${n}`, { force: true }).catch(() => {});
4395
+ if (!backups.some((b) => b.name === n)) {
4396
+ try { await rm(`${dshHome}/${n}`, { force: true }); } catch { failed.push(n); }
4397
+ }
4266
4398
  }
4267
4399
  const snapNote = snapshotName ? `\n已自动快照: ${snapshotName},可 /backup restore ${snapshotName} 整体回滚。` : '';
4268
4400
  if (failed.length) {
4269
- throw new Error(`导入失败并已尽力回滚:${friendlyReason(err)}。部分留档未还原: ${failed.join(', ')}(对应 *.pre-import-${stamp} 留档在原目录旁)。${snapNote}`);
4401
+ throw new Error(`导入失败并已尽力回滚:${friendlyReason(err)}。部分留档未还原: ${failed.join(', ')}。${snapNote}`);
4270
4402
  }
4271
4403
  throw new Error(`导入失败,已自动回滚到导入前状态:${friendlyReason(err)}。${snapNote}`);
4272
4404
  }
4273
4405
 
4274
- // 校验:写入的文件必须存在(防半截写入被误判成功)
4275
- const missing = [];
4276
- for (const e of willWrite) if (!(await existsStat(e.targetAbs))) missing.push(e.name);
4277
- if (missing.length) throw new Error(`导入校验失败,部分文件未落盘: ${missing.slice(0, 5).join(', ')}(快照: ${snapshotName ?? '未生成'})`);
4278
-
4279
- // 跨机 remap:对写入的文本配置做 from→to 前缀替换(settings / cordis)
4280
- let remapped = 0;
4281
- if (Array.isArray(opts?.remap) && opts.remap.length) {
4282
- for (const m of opts.remap) {
4283
- if (!m || typeof m.from !== 'string' || !m.from || typeof m.to !== 'string') continue;
4284
- for (const rel of ['settings.yaml', 'settings.json', 'cordis.yml', 'cordis.patch.yml']) {
4285
- if (!willWrite.some((e) => e.name === rel)) continue;
4286
- const p = `${dshHome}/${rel}`;
4287
- if (!(await existsStat(p))) continue;
4288
- const text = await readFile(p, 'utf8');
4289
- const next = text.split(m.from).join(m.to);
4290
- if (next !== text) { await writeFile(p, next, 'utf8'); remapped += 1; }
4291
- }
4292
- }
4293
- }
4294
-
4295
- // 本地 link:/file: 插件迁移:解包 local-plugins tarball 到 ~/.dsh/local-plugins/ 并改回 file:
4296
- const localPluginEntries = entries.filter((e) => !e.isDirectory && e.name.startsWith('local-plugins/'));
4297
- let pluginsRestored = { restored: [], skipped: [] };
4298
- if (localPluginEntries.length && Array.isArray(manifest?.localPlugins)) {
4299
- await extractZip(zipPath, dshHome, { names: new Set(localPluginEntries.map((e) => e.name)), signal });
4300
- pluginsRestored = await restoreLocalPlugins(manifest.localPlugins, signal);
4301
- }
4302
-
4303
- const summary = `✅ 导入完成\n 已写入: ${applied.length} 个文件(覆盖 ${overwrote.length}、新增 ${applied.length - overwrote.length}、跳过已存在 ${willSkip.length})${remapped ? `\n 已 remap: ${remapped} 个配置文件` : ''}${pluginsRestored.restored.length ? `\n 本地插件已装回 file:: ${pluginsRestored.restored.map((p) => p.name).join(', ')}` : ''}${pluginsRestored.skipped.length ? `\n ⚠️ 本地插件需手动处理: ${pluginsRestored.skipped.map((p) => `${p.name}(${p.reason})`).join(', ')}` : ''}${snapshotName ? `\n 导入前快照: ${snapshotName}` : '\n ⚠️ 导入前快照未生成(建议确认无误后手动备份一次)'}${backups.length ? `\n 覆盖的旧文件已留档 *.pre-import-${stamp}(确认后可删)` : ''}\n ⏸️ 待办: 重启 dsh 生效`;
4406
+ const summary = `✅ 导入完成\n 已写入: ${applied.length} 个文件(覆盖 ${overwrote.length}、新增 ${applied.length - overwrote.length}、跳过已存在 ${willSkip.length})${remapped ? `\n 已 remap: ${remapped} 个配置文件` : ''}${pluginsRestored.restored.length ? `\n 本地插件已装回 file:: ${pluginsRestored.restored.map((p) => p.name).join(', ')}` : ''}${pluginsRestored.skipped.length ? `\n ⚠️ 本地插件需手动处理: ${pluginsRestored.skipped.map((p) => `${p.name}(${p.reason})`).join(', ')}` : ''}${snapshotName ? `\n 导入前快照: ${snapshotName}` : '\n ⚠️ 导入前快照未生成(建议确认无误后手动备份一次)'}${backups.length ? `\n 覆盖的旧文件已留档(敏感文件在本机 vault/preserved,其余在原目录旁;确认后可删)` : ''}\n ⏸️ 待办: 重启 dsh 生效`;
4304
4407
  return { ok: true, encrypted, applied: applied.length, skipped: willSkip.length, overwrote: overwrote.length, snapshot: snapshotName, remapped, pluginsRestored, summary };
4305
4408
  } finally {
4306
4409
  if (staging) await rm(staging, { recursive: true, force: true }).catch(() => {});
@@ -5336,6 +5439,10 @@ export function apply(ctx, pluginConfig) {
5336
5439
 
5337
5440
  /** 分级清理:'cache' 只删可再生缓存/暂存;'expired' 再删过期备份(快照/同步/vault 永不删)。 */
5338
5441
  async function cleanup(scope, signal) {
5442
+ return withOperationLock('cleanup', () => cleanupInner(scope, signal));
5443
+ }
5444
+
5445
+ async function cleanupInner(scope, signal) {
5339
5446
  const { root } = paths();
5340
5447
  const removed = [];
5341
5448
  const rmdir = (dir) => rm(`${root}/${dir}`, { recursive: true, force: true });
@@ -5350,6 +5457,10 @@ export function apply(ctx, pluginConfig) {
5350
5457
  }
5351
5458
  }
5352
5459
  if (scope === 'expired') {
5460
+ const scan = await runDoctorScan(signal);
5461
+ if (scan.corruptCount) {
5462
+ return { ok: true, removed: removed.length, names: removed, summary: `检测到 ${scan.corruptCount} 个损坏会话,已暂停过期备份清理,保留健康副本;请先修复会话` };
5463
+ }
5353
5464
  // 过期备份:keep 窗口外且不在日/周/月/年分层保护内的全量归档才删
5354
5465
  const keepN = defaultKeep();
5355
5466
  const all = await listBackups();
@@ -5402,8 +5513,9 @@ export function apply(ctx, pluginConfig) {
5402
5513
  /** 校验 profile 名(拒绝空/路径段/非法字符/保留名)。 */
5403
5514
  function validateProfileName(name) {
5404
5515
  if (typeof name !== 'string' || !name.trim()) return 'profile 名不能为空';
5405
- if (!/^[A-Za-z0-9._-]+$/.test(name)) return `profile 名只能含字母数字 . _ -(收到 ${name})`;
5406
- if (APP_OWNED_PROFILES.has(name)) return `profile ${name} 是应用内置 profile,只读不可改`;
5516
+ if (name === '.' || name === '..' || !/^[A-Za-z0-9._-]+$/.test(name)) return `profile 名只能含字母数字 . _ -,且不能是 . 或 ..(收到 ${name})`;
5517
+ try { safeRelativePath(name); } catch { return `不安全的 profile 名: ${name}`; }
5518
+ if (APP_OWNED_PROFILES.has(name.toLowerCase())) return `profile ${name} 是应用内置 profile,只读不可改`;
5407
5519
  return null;
5408
5520
  }
5409
5521
 
@@ -5562,32 +5674,54 @@ export function apply(ctx, pluginConfig) {
5562
5674
  return result;
5563
5675
  }
5564
5676
 
5565
- /** 导入侧:把打包的本地插件解包到 `<dshHome>/local-plugins/<name>/` 并改回 file:。 */
5566
- async function restoreLocalPlugins(localPlugins, signal) {
5567
- const { dshHome, root } = paths();
5677
+ /** 导入侧:解包到本机独立目录并把选中的 profile 依赖改回 file:。 */
5678
+ async function restoreLocalPlugins(localPlugins, signal, { willWrite, createdPluginDirs }) {
5679
+ const { dshHome } = paths();
5568
5680
  const restored = [];
5569
5681
  const skipped = [];
5682
+ const written = new Set(willWrite.map((e) => e.name));
5570
5683
  for (const p of Array.isArray(localPlugins) ? localPlugins : []) {
5571
- if (p.status !== 'packed' || !p.tarballName) { skipped.push({ name: p.name, reason: p.warning || p.status }); continue; }
5572
- // 导入包内 tarball 已随 export 打进了 ZIP(importApply 解压到暂存后经 names 落盘到 dshHome 下)
5573
- const src = `${dshHome}/local-plugins/${p.tarballName}`;
5574
- if (!(await existsStat(src))) { skipped.push({ name: p.name, reason: 'tarball 不在导入包内' }); continue; }
5575
- const targetDir = `${dshHome}/local-plugins/${p.name}`;
5576
- await mkdir(targetDir, { recursive: true });
5577
- const tar = await ctx.subprocess.resolveExecutable('tar');
5578
- await spawnRun([tar, '-xzf', src, '-C', parentTar(targetDir)], dshHome, signal);
5579
- // 把 profile 的 package.json 里 link:/file: 改回 file: 指向解包目录
5580
- const pkgPath = `${dshHome}/profiles/${p.profile}/package.json`;
5684
+ if (p.status !== 'packed') { skipped.push({ name: p.name, reason: p.warning || p.status }); continue; }
5685
+ const tarRel = `local-plugins/${p.tarball}`;
5686
+ const pkgRel = `profiles/${p.profile}/package.json`;
5687
+ if (!written.has(tarRel) || !written.has(pkgRel)) {
5688
+ skipped.push({ name: p.name, reason: '插件包或 profile 未选中,或已选择保留本机' });
5689
+ continue;
5690
+ }
5691
+ const src = await safeDestination(dshHome, tarRel);
5692
+ const pkgPath = await safeDestination(dshHome, pkgRel);
5693
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
5694
+ const fields = ['dependencies', 'devDependencies'].filter((field) =>
5695
+ pkg[field] && Object.hasOwn(pkg[field], p.name) && /^(?:link|file):/.test(pkg[field][p.name]));
5696
+ if (!fields.length) { skipped.push({ name: p.name, reason: 'profile 没有对应的本地插件依赖' }); continue; }
5697
+ // npm pack always wraps files in package/. Reject traversal, links, and special
5698
+ // entries before extracting into a fresh staging directory, never into live data.
5699
+ await safeArchiveEntries(basename(src), signal, { root: dirname(src), base: 'package', allowHardlinks: false });
5700
+ const installRel = `local-plugins/installed-${randomUUID()}`;
5701
+ const installRoot = await safeDestination(dshHome, installRel);
5702
+ await mkdir(dirname(installRoot), { recursive: true });
5703
+ await mkdir(installRoot, { mode: 0o700 });
5704
+ createdPluginDirs.push(installRoot);
5705
+ // Stage on the target filesystem so external-disk backup destinations do
5706
+ // not make the final rename fail with EXDEV.
5707
+ const stage = `${installRoot}/.unpack`;
5708
+ await mkdir(stage, { mode: 0o700 });
5581
5709
  try {
5582
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
5583
- for (const field of ['dependencies', 'devDependencies']) {
5584
- if (pkg[field] && typeof pkg[field] === 'object' && pkg[field][p.name]) {
5585
- pkg[field][p.name] = `file:${targetDir}`;
5586
- }
5587
- }
5710
+ const tar = await ctx.subprocess.resolveExecutable('tar');
5711
+ await spawnRun([tar, '-xzf', basename(src), '-C', stage], dirname(src), signal);
5712
+ const packageJson = await safeDestination(stage, 'package/package.json');
5713
+ const packed = JSON.parse(await readFile(packageJson, 'utf8'));
5714
+ if (!packed || typeof packed !== 'object' || Array.isArray(packed)) throw new Error('本地插件 package.json 无效');
5715
+ const targetDir = await safeDestination(installRoot, p.name);
5716
+ await mkdir(dirname(targetDir), { recursive: true });
5717
+ // Point file: at the actual package root, not the tar's parent directory.
5718
+ await rename(`${stage}/package`, targetDir);
5719
+ for (const field of fields) pkg[field][p.name] = `file:${targetDir}`;
5588
5720
  await writeOwned(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
5589
- } catch { /* package.json 改不了就跳过,如实标注 */ }
5590
- restored.push({ name: p.name, profile: p.profile, dir: targetDir });
5721
+ restored.push({ name: p.name, profile: p.profile, dir: targetDir });
5722
+ } finally {
5723
+ await rm(stage, { recursive: true, force: true }).catch(() => {});
5724
+ }
5591
5725
  }
5592
5726
  return { restored, skipped };
5593
5727
  }