@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/README.en.md +4 -0
- package/README.md +6 -2
- package/lib/client.js +3 -3
- package/lib/encrypt.js +111 -12
- package/lib/index.js +251 -117
- package/lib/oplock.js +77 -53
- package/lib/paths.js +56 -0
- package/lib/redact.js +37 -0
- package/package.json +1 -1
- package/rescue/rescue.mjs +34 -3
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 {
|
|
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
|
|
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
|
-
//
|
|
1719
|
+
// 同一异步操作链可重入(restore 内部的快照 doBackup 等)。
|
|
1691
1720
|
let heldLock = null;
|
|
1692
|
-
|
|
1721
|
+
const operationScope = new AsyncLocalStorage();
|
|
1693
1722
|
|
|
1694
1723
|
async function withOperationLock(operation, fn) {
|
|
1695
|
-
|
|
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}
|
|
1731
|
+
throw new Error(`另一操作正在进行(${r.heldBy?.operation ?? '未知'},pid=${r.heldBy?.pid ?? '未知'}):${r.reason ?? ''}——请稍后重试;若确认是残留死锁可用 /backup lock reclaim 回收`);
|
|
1701
1732
|
}
|
|
1702
1733
|
heldLock = r.lock;
|
|
1703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
|
2408
|
-
|
|
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
|
|
2617
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2838
|
+
for (const w of kept) {
|
|
2777
2839
|
try { await copyFile(w.keep, `${dshHome}/${w.rel}`); restored.add(w.rel); } catch { /* 逐个失败落入下方报告 */ }
|
|
2778
2840
|
}
|
|
2779
|
-
|
|
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 =
|
|
2783
|
-
throw new Error(`分类型恢复失败(${reason}),且部分现场未自动还原: ${missing.join(', ')}
|
|
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}
|
|
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'
|
|
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.
|
|
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.
|
|
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
|
|
3105
|
-
|
|
3106
|
-
|
|
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
|
|
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}
|
|
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 =
|
|
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-${
|
|
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 =
|
|
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
|
-
|
|
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))
|
|
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(', ')}
|
|
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 名只能含字母数字 . _
|
|
5406
|
-
|
|
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
|
-
/**
|
|
5566
|
-
async function restoreLocalPlugins(localPlugins, signal) {
|
|
5567
|
-
const { dshHome
|
|
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'
|
|
5572
|
-
|
|
5573
|
-
const
|
|
5574
|
-
if (!(
|
|
5575
|
-
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
|
|
5580
|
-
const
|
|
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
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
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
|
-
|
|
5590
|
-
|
|
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
|
}
|