dsh-config-manager 0.1.12 → 0.1.13
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.md +30 -0
- package/README.zh-CN.md +30 -0
- package/lib/adapters/test-helpers.d.ts +4 -2
- package/lib/adapters/test-helpers.js +11 -3
- package/lib/adapters/test-helpers.js.map +1 -1
- package/lib/cli/index.d.ts +44 -0
- package/lib/cli/index.js +230 -0
- package/lib/cli/index.js.map +1 -0
- package/lib/client.d.ts +260 -5
- package/lib/client.js +2519 -820
- package/lib/client.js.map +1 -1
- package/lib/core/analyzer.d.ts +18 -1
- package/lib/core/analyzer.js +29 -0
- package/lib/core/analyzer.js.map +1 -1
- package/lib/core/backup.d.ts +3 -1
- package/lib/core/backup.js +70 -0
- package/lib/core/backup.js.map +1 -1
- package/lib/core/exporter.d.ts +11 -0
- package/lib/core/exporter.js +11 -0
- package/lib/core/exporter.js.map +1 -1
- package/lib/core/importer.d.ts +3 -0
- package/lib/core/importer.js +1 -0
- package/lib/core/importer.js.map +1 -1
- package/lib/core/index.d.ts +1 -0
- package/lib/core/index.js +1 -0
- package/lib/core/index.js.map +1 -1
- package/lib/core/restore.d.ts +92 -0
- package/lib/core/restore.js +505 -0
- package/lib/core/restore.js.map +1 -0
- package/lib/core/run-registry.d.ts +65 -0
- package/lib/core/run-registry.js +120 -0
- package/lib/core/run-registry.js.map +1 -0
- package/lib/core/types.d.ts +20 -0
- package/lib/core/types.js.map +1 -1
- package/lib/index.d.ts +45 -0
- package/lib/index.js +442 -8
- package/lib/index.js.map +1 -1
- package/lib/sync/fs.d.ts +18 -0
- package/lib/sync/fs.js +47 -0
- package/lib/sync/fs.js.map +1 -0
- package/lib/sync/git/git-transport.d.ts +87 -0
- package/lib/sync/git/git-transport.js +333 -0
- package/lib/sync/git/git-transport.js.map +1 -0
- package/lib/sync/layout.d.ts +29 -0
- package/lib/sync/layout.js +166 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/sync-config.d.ts +16 -0
- package/lib/sync/sync-config.js +67 -0
- package/lib/sync/sync-config.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +96 -0
- package/lib/sync/sync-engine.js +236 -0
- package/lib/sync/sync-engine.js.map +1 -0
- package/lib/sync/sync-state.d.ts +31 -0
- package/lib/sync/sync-state.js +103 -0
- package/lib/sync/sync-state.js.map +1 -0
- package/lib/sync/transport.d.ts +50 -0
- package/lib/sync/transport.js +22 -0
- package/lib/sync/transport.js.map +1 -0
- package/package.json +4 -1
- package/src/adapters/test-helpers.ts +10 -3
- package/src/cli/index.ts +278 -0
- package/src/client/ConfigManagerSection.tsx +49 -14
- package/src/client/api.ts +53 -5
- package/src/client/client-types.ts +0 -6
- package/src/client/common/ProgressBar.tsx +31 -19
- package/src/client/common/progress-view-edge.test.ts +60 -0
- package/src/client/common/progress-view.test.ts +79 -0
- package/src/client/common/progress-view.ts +94 -0
- package/src/client/config-manager.module.css +99 -20
- package/src/client/export/ExportView.tsx +63 -49
- package/src/client/import/ImportWizardView.tsx +107 -66
- package/src/client/index.ts +22 -0
- package/src/client/locales.ts +53 -0
- package/src/client/run-store.test.ts +500 -0
- package/src/client/run-store.ts +772 -0
- package/src/client/snapshots/SnapshotsPanel.tsx +240 -0
- package/src/client/sync/SyncSettingsView.tsx +261 -0
- package/src/client/sync/sync-api.test.ts +117 -0
- package/src/client/sync/sync-api.ts +132 -0
- package/src/client/sync/sync-locales.ts +77 -0
- package/src/client/sync/sync-view.test.ts +211 -0
- package/src/client/sync/sync-view.ts +202 -0
- package/src/core/analyzer.ts +50 -1
- package/src/core/backup.ts +78 -2
- package/src/core/exporter.ts +21 -0
- package/src/core/importer.ts +4 -0
- package/src/core/index.ts +5 -0
- package/src/core/restore.ts +587 -0
- package/src/core/run-progress.test.ts +237 -0
- package/src/core/run-registry.test.ts +139 -0
- package/src/core/run-registry.ts +166 -0
- package/src/core/smoke.test.ts +5 -0
- package/src/core/types.ts +22 -0
- package/src/index.ts +476 -8
- package/src/security/security.test.ts +5 -0
- package/src/sync/fs.ts +46 -0
- package/src/sync/git/git-transport.test.ts +456 -0
- package/src/sync/git/git-transport.ts +390 -0
- package/src/sync/layout.test.ts +226 -0
- package/src/sync/layout.ts +183 -0
- package/src/sync/sync-config.ts +72 -0
- package/src/sync/sync-engine.test.ts +299 -0
- package/src/sync/sync-engine.ts +318 -0
- package/src/sync/sync-state.test.ts +118 -0
- package/src/sync/sync-state.ts +123 -0
- package/src/sync/transport.test.ts +110 -0
- package/src/sync/transport.ts +73 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 离线恢复引擎(M2,criterion m2-restore-restores;设计 §8 / 规范 §27)。
|
|
3
|
+
*
|
|
4
|
+
* 与 rollback.ts 的区别:rollback 是导入失败时的自动补偿(在线,经 HostContext
|
|
5
|
+
* 调 DSH 服务);本模块是用户主动的「恢复到导入前状态」——纯离线,零 DSH 运行时
|
|
6
|
+
* 依赖(仅 node 内置 + 本项目零依赖工具),直接操作文件系统与官方 dsh plugin CLI:
|
|
7
|
+
*
|
|
8
|
+
* 1. 宿主整文件还原:hostFileBackups(settings.yaml / settings.json / 用户层与
|
|
9
|
+
* profile 层 cordis.patch.yml)的 blob 写回 $DSH_HOME;快照时不存在、现已出现
|
|
10
|
+
* 的文件删除(restore = 还原到快照那一刻的状态,含移除导入期间新增激活行);
|
|
11
|
+
* 2. pre-restore 双保险:任何覆盖/删除前,把当前文件复制到
|
|
12
|
+
* <snapshotDir>/pre-restore/(可人工反悔);
|
|
13
|
+
* 3. 插件撤销:beforePlugins(导入前基线)与当前已装对比 → 导入期间新增的插件
|
|
14
|
+
* 走官方 `dsh plugin --profile <p> remove <pkg>`(runDshPlugin 通道,零新子进程代码);
|
|
15
|
+
* 4. file 类条目补偿:skills/agentPresets/pluginFiles/sessions 原文件 blob 写回 /
|
|
16
|
+
* 快照时不存在则删除(与 rollback.ts 语义一致);
|
|
17
|
+
* 5. credentials:DSH 不回读值 → existed=true 只生成 manualHint,绝不自动改写。
|
|
18
|
+
*
|
|
19
|
+
* settingsNamespace / patchLine 条目由宿主整文件还原覆盖(settings.yaml /
|
|
20
|
+
* cordis.patch.yml);workspaceRecord 离线无法经 DSH storages 恢复 → 如实提示。
|
|
21
|
+
* 旧快照(无 beforePlugins / hostFileBackups)兼容:缺基线的部分只提示不动作。
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { resolveFileTarget } from './backup.ts';
|
|
27
|
+
import {
|
|
28
|
+
classifyDshPluginFailure, readInstalled, resolveProfileDir, runDshPlugin,
|
|
29
|
+
} from './plugin-cli.ts';
|
|
30
|
+
import { parseJsonSafe } from '../utils/json.ts';
|
|
31
|
+
import type { SectionId } from '../schema/types.ts';
|
|
32
|
+
import type { HostContext, Snapshot, SnapshotStatus } from './types.ts';
|
|
33
|
+
|
|
34
|
+
/* ---------------------------------------------------------------- 类型 */
|
|
35
|
+
|
|
36
|
+
export type RestoreActionKind =
|
|
37
|
+
| 'hostFileRestore' // 宿主整文件 blob → 写回 $DSH_HOME
|
|
38
|
+
| 'hostFileRemove' // 快照时不存在、现已出现的宿主文件 → 删除
|
|
39
|
+
| 'pluginRemove' // 导入期间新增插件 → dsh plugin remove
|
|
40
|
+
| 'fileRestore' // file 类条目 blob → 写回原路径
|
|
41
|
+
| 'fileRemove' // file 类条目快照时不存在 → 删除导入写入的文件
|
|
42
|
+
| 'credentialHint' // 凭据值不可回读 → 人工补录提示(无自动动作)
|
|
43
|
+
| 'skip'; // 无需动作 / 离线无法处理 / 旧快照缺基线
|
|
44
|
+
|
|
45
|
+
export interface RestoreAction {
|
|
46
|
+
kind: RestoreActionKind;
|
|
47
|
+
/** 人类可读描述 */
|
|
48
|
+
description: string;
|
|
49
|
+
/** 目标:相对 $DSH_HOME 的 relPath(hostFile/file 类)或插件名(pluginRemove) */
|
|
50
|
+
target?: string;
|
|
51
|
+
/** hostFileRestore/fileRestore:快照内 blob 相对路径(相对 snapshotDir) */
|
|
52
|
+
blobPath?: string;
|
|
53
|
+
/** pluginRemove:插件包名 */
|
|
54
|
+
pluginName?: string;
|
|
55
|
+
/** credentialHint:人工提示文本 */
|
|
56
|
+
manualHint?: string;
|
|
57
|
+
/** 附加说明(旧快照缺基线 / 离线无法恢复等) */
|
|
58
|
+
detail?: string;
|
|
59
|
+
/** 该动作是否对应 settings 主文件(settingsPath 解析出的文件) */
|
|
60
|
+
isSettings?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RestorePlan {
|
|
64
|
+
snapshotId: string;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
sourceZip: string;
|
|
67
|
+
actions: RestoreAction[];
|
|
68
|
+
summary: {
|
|
69
|
+
hostFileRestores: number;
|
|
70
|
+
hostFileRemoves: number;
|
|
71
|
+
pluginRemoves: number;
|
|
72
|
+
fileRestores: number;
|
|
73
|
+
fileRemoves: number;
|
|
74
|
+
credentialHints: number;
|
|
75
|
+
skips: number;
|
|
76
|
+
};
|
|
77
|
+
/** beforePlugins 基线是否可用(undefined=旧快照无基线 → 不计划任何插件卸载) */
|
|
78
|
+
pluginBaselineConfirmed: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RestoreOptions {
|
|
82
|
+
/** 快照目录 <snapshotsDir>/<id>/(含 snapshot.json 与 blobs/) */
|
|
83
|
+
snapshotDir: string;
|
|
84
|
+
/** $DSH_HOME(默认 ~/.dsh) */
|
|
85
|
+
homeDir: string;
|
|
86
|
+
/** 管理的 DSH profile 名(插件卸载走 profiles/<profile>) */
|
|
87
|
+
profile: string;
|
|
88
|
+
/** 覆盖 settings 文件路径(默认探测 $DSH_HOME/settings.yaml → settings.json) */
|
|
89
|
+
settingsPath?: string;
|
|
90
|
+
/** 注入式插件卸载器(测试用;缺省走 runDshPlugin 官方通道) */
|
|
91
|
+
pluginUninstaller?: (
|
|
92
|
+
name: string, profileDir: string, profile: string,
|
|
93
|
+
) => Promise<{ ok: boolean; message?: string }>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface RestoreReport {
|
|
97
|
+
snapshotId: string;
|
|
98
|
+
/** 已还原/已删除的目标(相对 $DSH_HOME) */
|
|
99
|
+
restored: string[];
|
|
100
|
+
/** 已卸载的插件名 */
|
|
101
|
+
removedPlugins: string[];
|
|
102
|
+
/** 需人工处理的提示(凭据补录等) */
|
|
103
|
+
manualHints: string[];
|
|
104
|
+
/** 失败清单 */
|
|
105
|
+
failed: { item: string; reason: string }[];
|
|
106
|
+
/** 跳过项(无动作 / 离线无法处理) */
|
|
107
|
+
skipped: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SnapshotMeta {
|
|
111
|
+
id: string;
|
|
112
|
+
createdAt: string;
|
|
113
|
+
sourceZip: string;
|
|
114
|
+
status?: SnapshotStatus;
|
|
115
|
+
entryCount: number;
|
|
116
|
+
hostFileBackupCount: number;
|
|
117
|
+
beforePluginCount: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------ 工具 */
|
|
121
|
+
|
|
122
|
+
/** 快照目录内读取 snapshot.json(深度保护解析) */
|
|
123
|
+
async function loadSnapshot(snapshotDir: string): Promise<Snapshot> {
|
|
124
|
+
const parsed = parseJsonSafe(await fs.readFile(path.join(snapshotDir, 'snapshot.json'), 'utf8'));
|
|
125
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
126
|
+
throw new Error(`快照 ${snapshotDir} 的 snapshot.json 不是合法对象`);
|
|
127
|
+
}
|
|
128
|
+
return parsed as Snapshot;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
await fs.access(p);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 越界防御:abs 必须等于 homeDir 或在 homeDir 之内 */
|
|
141
|
+
function isWithinHome(homeDir: string, abs: string): boolean {
|
|
142
|
+
const root = path.resolve(homeDir);
|
|
143
|
+
return abs === root || abs.startsWith(root + path.sep);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** homeDir 内绝对路径(越界抛错) */
|
|
147
|
+
function homeAbs(homeDir: string, relPath: string): string {
|
|
148
|
+
const abs = path.resolve(homeDir, relPath);
|
|
149
|
+
if (!isWithinHome(homeDir, abs)) throw new Error(`路径越界: ${relPath}`);
|
|
150
|
+
return abs;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 快照目录内 blob 绝对路径(防 snapshot.json 里伪造 ../ 越界读) */
|
|
154
|
+
function blobAbs(snapshotDir: string, blobPath: string): string {
|
|
155
|
+
const abs = path.resolve(snapshotDir, blobPath);
|
|
156
|
+
if (!isWithinHome(snapshotDir, abs)) throw new Error(`blob 路径越界: ${blobPath}`);
|
|
157
|
+
return abs;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 绝对路径 → 相对 $DSH_HOME(跨平台归一为 / 分隔;越界返回空串) */
|
|
161
|
+
function toRelPath(homeDir: string, abs: string): string {
|
|
162
|
+
if (!isWithinHome(homeDir, abs)) return '';
|
|
163
|
+
return path.relative(homeDir, abs).split(path.sep).join('/');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** file 类条目的目标绝对路径(复用 backup.ts 的基准目录规则;仅读 homeDir) */
|
|
167
|
+
function resolveFileAbs(homeDir: string, adapter: SectionId, ref: string): string {
|
|
168
|
+
return resolveFileTarget({ homeDir } as unknown as HostContext, adapter, ref);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** settings 文件 relPath:settingsPath 覆盖优先,否则探测 settings.yaml → settings.json */
|
|
172
|
+
async function resolveSettingsRelPath(homeDir: string, settingsPath?: string): Promise<string | undefined> {
|
|
173
|
+
if (settingsPath !== undefined && settingsPath !== '') {
|
|
174
|
+
const rel = toRelPath(homeDir, path.resolve(homeDir, settingsPath));
|
|
175
|
+
return rel === '' ? undefined : rel;
|
|
176
|
+
}
|
|
177
|
+
for (const cand of ['settings.yaml', 'settings.json']) {
|
|
178
|
+
if (await fileExists(path.join(homeDir, cand))) return cand;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 把当前文件复制到 <snapshotDir>/pre-restore/(双保险,命名含序号防碰撞) */
|
|
184
|
+
async function copyToPreRestore(preDir: string, absPath: string, label: string, seq: number): Promise<void> {
|
|
185
|
+
const safe = label.replace(/[\\/:*?"<>|]/g, '_');
|
|
186
|
+
await fs.mkdir(preDir, { recursive: true });
|
|
187
|
+
await fs.copyFile(absPath, path.join(preDir, `${String(seq).padStart(4, '0')}-${safe}`));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 默认插件卸载器:官方 dsh plugin remove 通道 + 失败分类诊断 */
|
|
191
|
+
async function defaultPluginUninstaller(
|
|
192
|
+
name: string, profileDir: string, profile: string,
|
|
193
|
+
): Promise<{ ok: boolean; message?: string }> {
|
|
194
|
+
const result = await runDshPlugin(profileDir, profile, ['remove', name]);
|
|
195
|
+
if (result.exitCode === 0) return { ok: true };
|
|
196
|
+
const output = `${result.stderr}\n${result.stdout}`.trim();
|
|
197
|
+
const failure = classifyDshPluginFailure(output);
|
|
198
|
+
if (failure !== null) {
|
|
199
|
+
return { ok: false, message: `dsh plugin remove ${name} 失败(${failure.code}):${failure.message}` };
|
|
200
|
+
}
|
|
201
|
+
const tail = output.split('\n').slice(-8).join('\n') || '无输出';
|
|
202
|
+
return { ok: false, message: `dsh plugin remove ${name} 失败(exit ${String(result.exitCode)}):${tail}` };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* ------------------------------------------------------------ planRestore */
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 生成恢复动作计划(dry-run 预览的唯一入口;零写入):
|
|
209
|
+
* 读快照 + 探测当前文件/已装插件状态,输出按执行顺序排列的动作清单。
|
|
210
|
+
*/
|
|
211
|
+
export async function planRestore(opts: RestoreOptions): Promise<RestorePlan> {
|
|
212
|
+
const { snapshotDir, homeDir, profile } = opts;
|
|
213
|
+
const snapshot = await loadSnapshot(snapshotDir);
|
|
214
|
+
const settingsRel = await resolveSettingsRelPath(homeDir, opts.settingsPath);
|
|
215
|
+
const actions: RestoreAction[] = [];
|
|
216
|
+
|
|
217
|
+
// 1) 宿主整文件还原(hostFileBackups)
|
|
218
|
+
const hostBackups = snapshot.hostFileBackups ?? [];
|
|
219
|
+
for (const backup of hostBackups) {
|
|
220
|
+
let abs: string;
|
|
221
|
+
try {
|
|
222
|
+
abs = homeAbs(homeDir, backup.relPath);
|
|
223
|
+
} catch {
|
|
224
|
+
actions.push({
|
|
225
|
+
kind: 'skip',
|
|
226
|
+
description: `宿主文件路径越界,跳过: ${backup.relPath}`,
|
|
227
|
+
target: backup.relPath,
|
|
228
|
+
detail: '快照登记路径越界,拒绝恢复',
|
|
229
|
+
});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const currentExists = await fileExists(abs);
|
|
233
|
+
if (backup.existed) {
|
|
234
|
+
if (backup.blobPath === '') {
|
|
235
|
+
actions.push({
|
|
236
|
+
kind: 'skip',
|
|
237
|
+
description: `宿主文件 ${backup.relPath} 缺少 blob 副本,跳过`,
|
|
238
|
+
target: backup.relPath,
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!(await fileExists(blobAbs(snapshotDir, backup.blobPath)))) {
|
|
243
|
+
actions.push({
|
|
244
|
+
kind: 'skip',
|
|
245
|
+
description: `宿主文件 ${backup.relPath} 的 blob 缺失,跳过`,
|
|
246
|
+
target: backup.relPath,
|
|
247
|
+
});
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
actions.push({
|
|
251
|
+
kind: 'hostFileRestore',
|
|
252
|
+
description: `整文件还原 ${backup.relPath}`,
|
|
253
|
+
target: backup.relPath,
|
|
254
|
+
blobPath: backup.blobPath,
|
|
255
|
+
isSettings: backup.relPath === settingsRel,
|
|
256
|
+
detail: currentExists ? '还原前将备份当前文件到 pre-restore/' : '当前文件不存在,直接写入',
|
|
257
|
+
});
|
|
258
|
+
} else if (currentExists) {
|
|
259
|
+
actions.push({
|
|
260
|
+
kind: 'hostFileRemove',
|
|
261
|
+
description: `移除快照时不存在、现已出现的文件 ${backup.relPath}`,
|
|
262
|
+
target: backup.relPath,
|
|
263
|
+
isSettings: backup.relPath === settingsRel,
|
|
264
|
+
detail: '删除前将备份当前文件到 pre-restore/',
|
|
265
|
+
});
|
|
266
|
+
} else {
|
|
267
|
+
actions.push({
|
|
268
|
+
kind: 'skip',
|
|
269
|
+
description: `宿主文件 ${backup.relPath} 快照时不存在且当前也不存在,无动作`,
|
|
270
|
+
target: backup.relPath,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// settings 覆盖提示:settingsPath 指定的文件未被快照登记 → 无法整文件还原
|
|
276
|
+
if (settingsRel !== undefined && !hostBackups.some((b) => b.relPath === settingsRel)) {
|
|
277
|
+
actions.push({
|
|
278
|
+
kind: 'skip',
|
|
279
|
+
description: `settings 文件 ${settingsRel} 未被快照登记,无法整文件还原`,
|
|
280
|
+
isSettings: true,
|
|
281
|
+
detail: '该快照未备份此 settings 文件(可能当时不存在或宿主未暴露 profile)',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2) 插件撤销:beforePlugins 基线 vs 当前已装 → 导入期间新增
|
|
286
|
+
const profileDir = resolveProfileDir(homeDir, profile);
|
|
287
|
+
const baseline = snapshot.beforePlugins;
|
|
288
|
+
if (baseline === undefined) {
|
|
289
|
+
actions.push({
|
|
290
|
+
kind: 'skip',
|
|
291
|
+
description: '旧快照无 beforePlugins 基线,无法自动卸载插件',
|
|
292
|
+
detail: '请人工核对导入期间新增的插件并逐个移除',
|
|
293
|
+
});
|
|
294
|
+
} else {
|
|
295
|
+
const beforeNames = new Set(baseline.map((p) => p.name));
|
|
296
|
+
const added = Object.keys(readInstalled(profileDir)).filter((name) => !beforeNames.has(name));
|
|
297
|
+
for (const name of added) {
|
|
298
|
+
actions.push({
|
|
299
|
+
kind: 'pluginRemove',
|
|
300
|
+
description: `卸载导入期间新增插件 ${name}`,
|
|
301
|
+
target: name,
|
|
302
|
+
pluginName: name,
|
|
303
|
+
detail: baseline.length === 0 ? '快照基线为空(导入前可能未捕获插件清单),请确认后执行' : undefined,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (added.length === 0) {
|
|
307
|
+
actions.push({
|
|
308
|
+
kind: 'skip',
|
|
309
|
+
description: '无导入期间新增插件(当前已装插件 ⊆ 快照基线)',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 3) file 类条目补偿 + credentials 提示(聚合计数供报告)
|
|
315
|
+
let namespaceCount = 0;
|
|
316
|
+
let patchLineCount = 0;
|
|
317
|
+
let workspaceCount = 0;
|
|
318
|
+
let credentialAbsentCount = 0;
|
|
319
|
+
for (const entry of snapshot.entries) {
|
|
320
|
+
switch (entry.kind) {
|
|
321
|
+
case 'file': {
|
|
322
|
+
const rel = toRelPath(homeDir, resolveFileAbs(homeDir, entry.adapter, entry.ref));
|
|
323
|
+
if (rel === '') {
|
|
324
|
+
actions.push({
|
|
325
|
+
kind: 'skip',
|
|
326
|
+
description: `文件条目路径越界,跳过: ${entry.adapter}:${entry.ref}`,
|
|
327
|
+
target: entry.ref,
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const abs = homeAbs(homeDir, rel);
|
|
332
|
+
const currentExists = await fileExists(abs);
|
|
333
|
+
if (entry.existed && entry.copiedTo) {
|
|
334
|
+
if (!(await fileExists(blobAbs(snapshotDir, entry.copiedTo)))) {
|
|
335
|
+
actions.push({
|
|
336
|
+
kind: 'skip',
|
|
337
|
+
description: `文件 ${rel} 的 blob 缺失,跳过`,
|
|
338
|
+
target: rel,
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
actions.push({
|
|
343
|
+
kind: 'fileRestore',
|
|
344
|
+
description: `还原文件 ${rel}(${entry.adapter})`,
|
|
345
|
+
target: rel,
|
|
346
|
+
blobPath: entry.copiedTo,
|
|
347
|
+
detail: currentExists ? '还原前将备份当前文件到 pre-restore/' : '当前文件不存在,直接写入',
|
|
348
|
+
});
|
|
349
|
+
} else if (!entry.existed) {
|
|
350
|
+
if (currentExists) {
|
|
351
|
+
actions.push({
|
|
352
|
+
kind: 'fileRemove',
|
|
353
|
+
description: `删除导入写入的文件 ${rel}(${entry.adapter})`,
|
|
354
|
+
target: rel,
|
|
355
|
+
detail: '删除前将备份当前文件到 pre-restore/',
|
|
356
|
+
});
|
|
357
|
+
} else {
|
|
358
|
+
actions.push({
|
|
359
|
+
kind: 'skip',
|
|
360
|
+
description: `文件 ${rel} 快照时不存在且当前也不存在,无动作`,
|
|
361
|
+
target: rel,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
actions.push({
|
|
366
|
+
kind: 'skip',
|
|
367
|
+
description: `文件 ${rel} 缺少 blob 副本信息,跳过`,
|
|
368
|
+
target: rel,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'credential':
|
|
374
|
+
if (entry.existed) {
|
|
375
|
+
actions.push({
|
|
376
|
+
kind: 'credentialHint',
|
|
377
|
+
description: `凭据 ${entry.ref} 需人工补录`,
|
|
378
|
+
target: entry.ref,
|
|
379
|
+
manualHint: `凭据 "${entry.ref}" 请人工重新填写(DSH 不回读凭据值)`,
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
credentialAbsentCount += 1;
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
case 'settingsNamespace':
|
|
386
|
+
namespaceCount += 1;
|
|
387
|
+
break;
|
|
388
|
+
case 'patchLine':
|
|
389
|
+
patchLineCount += 1;
|
|
390
|
+
break;
|
|
391
|
+
case 'workspaceRecord':
|
|
392
|
+
workspaceCount += 1;
|
|
393
|
+
break;
|
|
394
|
+
default:
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 聚合提示:整文件还原覆盖不了的条目如实说明
|
|
400
|
+
const settingsBacked = hostBackups.some((b) => b.relPath === 'settings.yaml' || b.relPath === 'settings.json');
|
|
401
|
+
const patchBacked = hostBackups.some((b) => b.relPath === 'cordis.patch.yml');
|
|
402
|
+
if (namespaceCount > 0 && !settingsBacked) {
|
|
403
|
+
actions.push({
|
|
404
|
+
kind: 'skip',
|
|
405
|
+
description: `${namespaceCount} 条 settings namespace 条目:快照无 settings 整文件备份,离线无法恢复`,
|
|
406
|
+
detail: '需经在线回滚(rollback.ts)或人工核对 settings.yaml',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (patchLineCount > 0 && !patchBacked) {
|
|
410
|
+
actions.push({
|
|
411
|
+
kind: 'skip',
|
|
412
|
+
description: `${patchLineCount} 条 patch 行条目:快照无 cordis.patch.yml 整文件备份,离线无法恢复`,
|
|
413
|
+
detail: '需经在线回滚(rollback.ts)或人工核对 patch 文件',
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (workspaceCount > 0) {
|
|
417
|
+
actions.push({
|
|
418
|
+
kind: 'skip',
|
|
419
|
+
description: `${workspaceCount} 条 workspace 记录条目:离线无法恢复(数据在 DSH storages)`,
|
|
420
|
+
detail: '需经在线回滚(rollback.ts)或人工核对 workspace 注册表',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (credentialAbsentCount > 0) {
|
|
424
|
+
actions.push({
|
|
425
|
+
kind: 'skip',
|
|
426
|
+
description: `${credentialAbsentCount} 条凭据快照时未配置:若导入已创建,需人工在 DSH 中删除`,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
snapshotId: snapshot.id,
|
|
432
|
+
createdAt: snapshot.createdAt,
|
|
433
|
+
sourceZip: snapshot.sourceZip,
|
|
434
|
+
actions,
|
|
435
|
+
summary: summarizeActions(actions),
|
|
436
|
+
pluginBaselineConfirmed: baseline !== undefined,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function summarizeActions(actions: RestoreAction[]): RestorePlan['summary'] {
|
|
441
|
+
const summary: RestorePlan['summary'] = {
|
|
442
|
+
hostFileRestores: 0, hostFileRemoves: 0, pluginRemoves: 0,
|
|
443
|
+
fileRestores: 0, fileRemoves: 0, credentialHints: 0, skips: 0,
|
|
444
|
+
};
|
|
445
|
+
for (const a of actions) {
|
|
446
|
+
switch (a.kind) {
|
|
447
|
+
case 'hostFileRestore': summary.hostFileRestores += 1; break;
|
|
448
|
+
case 'hostFileRemove': summary.hostFileRemoves += 1; break;
|
|
449
|
+
case 'pluginRemove': summary.pluginRemoves += 1; break;
|
|
450
|
+
case 'fileRestore': summary.fileRestores += 1; break;
|
|
451
|
+
case 'fileRemove': summary.fileRemoves += 1; break;
|
|
452
|
+
case 'credentialHint': summary.credentialHints += 1; break;
|
|
453
|
+
case 'skip': summary.skips += 1; break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return summary;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* ------------------------------------------------------------ restore */
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 执行恢复:按计划顺序 整文件还原 → 插件卸载 → file 补偿,
|
|
463
|
+
* 覆盖/删除前全部先复制到 <snapshotDir>/pre-restore/(双保险),
|
|
464
|
+
* 逐项 try/catch(单项失败不拖垮其余),输出诚实报告。
|
|
465
|
+
*/
|
|
466
|
+
export async function restore(opts: RestoreOptions): Promise<RestoreReport> {
|
|
467
|
+
const plan = await planRestore(opts);
|
|
468
|
+
const { snapshotDir, homeDir, profile } = opts;
|
|
469
|
+
const preDir = path.join(snapshotDir, 'pre-restore');
|
|
470
|
+
const profileDir = resolveProfileDir(homeDir, profile);
|
|
471
|
+
const uninstaller = opts.pluginUninstaller ?? defaultPluginUninstaller;
|
|
472
|
+
|
|
473
|
+
const report: RestoreReport = {
|
|
474
|
+
snapshotId: plan.snapshotId,
|
|
475
|
+
restored: [],
|
|
476
|
+
removedPlugins: [],
|
|
477
|
+
manualHints: [],
|
|
478
|
+
failed: [],
|
|
479
|
+
skipped: [],
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
let seq = 0;
|
|
483
|
+
for (const action of plan.actions) {
|
|
484
|
+
try {
|
|
485
|
+
switch (action.kind) {
|
|
486
|
+
case 'hostFileRestore': {
|
|
487
|
+
const abs = homeAbs(homeDir, action.target!);
|
|
488
|
+
if (await fileExists(abs)) await copyToPreRestore(preDir, abs, action.target!, ++seq);
|
|
489
|
+
const data = await fs.readFile(blobAbs(snapshotDir, action.blobPath!));
|
|
490
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
491
|
+
await fs.writeFile(abs, data);
|
|
492
|
+
report.restored.push(action.target!);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case 'hostFileRemove': {
|
|
496
|
+
const abs = homeAbs(homeDir, action.target!);
|
|
497
|
+
if (await fileExists(abs)) {
|
|
498
|
+
await copyToPreRestore(preDir, abs, action.target!, ++seq);
|
|
499
|
+
await fs.rm(abs, { force: true });
|
|
500
|
+
}
|
|
501
|
+
report.restored.push(action.target!);
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
case 'fileRestore': {
|
|
505
|
+
const abs = homeAbs(homeDir, action.target!);
|
|
506
|
+
if (await fileExists(abs)) await copyToPreRestore(preDir, abs, action.target!, ++seq);
|
|
507
|
+
const data = await fs.readFile(blobAbs(snapshotDir, action.blobPath!));
|
|
508
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
509
|
+
await fs.writeFile(abs, data);
|
|
510
|
+
report.restored.push(action.target!);
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
case 'fileRemove': {
|
|
514
|
+
const abs = homeAbs(homeDir, action.target!);
|
|
515
|
+
if (await fileExists(abs)) {
|
|
516
|
+
await copyToPreRestore(preDir, abs, action.target!, ++seq);
|
|
517
|
+
await fs.rm(abs, { force: true });
|
|
518
|
+
}
|
|
519
|
+
report.restored.push(action.target!);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'pluginRemove': {
|
|
523
|
+
const result = await uninstaller(action.pluginName!, profileDir, profile);
|
|
524
|
+
if (result.ok) {
|
|
525
|
+
report.removedPlugins.push(action.pluginName!);
|
|
526
|
+
} else {
|
|
527
|
+
report.failed.push({ item: `plugin:${action.pluginName}`, reason: result.message ?? '卸载失败' });
|
|
528
|
+
}
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
case 'credentialHint':
|
|
532
|
+
report.manualHints.push(action.manualHint ?? action.description);
|
|
533
|
+
break;
|
|
534
|
+
case 'skip':
|
|
535
|
+
report.skipped.push(action.description);
|
|
536
|
+
break;
|
|
537
|
+
default:
|
|
538
|
+
report.skipped.push(`未知动作 ${String(action.kind)}: ${action.description}`);
|
|
539
|
+
}
|
|
540
|
+
} catch (err) {
|
|
541
|
+
report.failed.push({
|
|
542
|
+
item: action.target ?? action.pluginName ?? action.description,
|
|
543
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return report;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* ------------------------------------------------------------ listSnapshots */
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* 扫描快照根目录下每个子目录的 snapshot.json,返回按 createdAt 倒序的元信息。
|
|
555
|
+
* 目录缺失/条目损坏自动跳过(不阻断其他快照)。
|
|
556
|
+
*/
|
|
557
|
+
export async function listSnapshots(dir: string): Promise<SnapshotMeta[]> {
|
|
558
|
+
let entries;
|
|
559
|
+
try {
|
|
560
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
561
|
+
} catch {
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
const metas: SnapshotMeta[] = [];
|
|
565
|
+
for (const entry of entries) {
|
|
566
|
+
if (!entry.isDirectory()) continue;
|
|
567
|
+
try {
|
|
568
|
+
const parsed = parseJsonSafe(await fs.readFile(path.join(dir, entry.name, 'snapshot.json'), 'utf8'));
|
|
569
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
|
570
|
+
const snapshot = parsed as Snapshot;
|
|
571
|
+
if (typeof snapshot.id !== 'string' || snapshot.id === '') continue;
|
|
572
|
+
metas.push({
|
|
573
|
+
id: snapshot.id,
|
|
574
|
+
createdAt: snapshot.createdAt,
|
|
575
|
+
sourceZip: snapshot.sourceZip,
|
|
576
|
+
status: snapshot.status,
|
|
577
|
+
entryCount: snapshot.entries?.length ?? 0,
|
|
578
|
+
hostFileBackupCount: snapshot.hostFileBackups?.length ?? 0,
|
|
579
|
+
beforePluginCount: snapshot.beforePlugins?.length ?? 0,
|
|
580
|
+
});
|
|
581
|
+
} catch {
|
|
582
|
+
// 损坏 / 非快照目录:跳过
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
metas.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
586
|
+
return metas;
|
|
587
|
+
}
|