pi-webdav-backup 1.0.2 → 1.3.1
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 +201 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -357
- package/extensions/webdav-backup/collect.mjs +104 -2
- package/extensions/webdav-backup/config.mjs +53 -7
- package/extensions/webdav-backup/dav-mock.mjs +166 -145
- package/extensions/webdav-backup/extras.mjs +420 -0
- package/extensions/webdav-backup/packages.mjs +397 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-plugins.mjs +363 -0
- package/extensions/webdav-backup/test-restore.mjs +286 -219
- package/extensions/webdav-backup/webdav-backup.example.json +4 -9
- package/extensions/webdav-backup/webdav.mjs +258 -218
- package/extensions/webdav-backup.ts +1024 -766
- package/package.json +2 -2
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages.mjs — 恢复后修复「插件装不回来」的问题
|
|
3
|
+
*
|
|
4
|
+
* 背景:备份有意排除 node_modules(体积大、含平台相关二进制、lockfile 足以重建)。
|
|
5
|
+
* - npm 装的插件:备份里只有 npm/package.json + package-lock.json,没有实现代码
|
|
6
|
+
* - git 装的插件:备份里有源码,但该包自己的 node_modules 也被排除
|
|
7
|
+
*
|
|
8
|
+
* pi 只在「git 包目录整个不存在」时才会重新克隆并装依赖;目录在、只是
|
|
9
|
+
* node_modules 缺失时,pi 不会补装,插件会静默带病运行。所以恢复后需要这里兜底。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只做两件事:
|
|
12
|
+
* analyzePlugins(agentDir) —— 体检:哪些插件缺安装、哪些缺依赖
|
|
13
|
+
* repairPlugins(agentDir) —— 按体检结果补装(npm install / git clone)
|
|
14
|
+
*
|
|
15
|
+
* 零依赖,只用 Node 内置模块。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 解析 git 仓库来源(与 pi 的 parseGitUrl / splitRef 语义对齐):
|
|
24
|
+
* - ref 从路径部分的第一个 "@" 切开(ref 可含 "/",如 feature/x)
|
|
25
|
+
* - 去掉结尾 ".git"(pi 的安装路径也去掉,不一致会装到 pi 找不到的目录)
|
|
26
|
+
* - 支持 scp 形式 git@host:path、显式协议 URL、git: 简写 host/path
|
|
27
|
+
* 返回 { host, path, ref, url } 或 null。
|
|
28
|
+
*/
|
|
29
|
+
export function parseGitRepo(raw) {
|
|
30
|
+
let repo = String(raw).trim();
|
|
31
|
+
let ref = null;
|
|
32
|
+
let host = "";
|
|
33
|
+
let path = "";
|
|
34
|
+
|
|
35
|
+
const scp = repo.match(/^git@([^:]+):(.+)$/);
|
|
36
|
+
const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(repo);
|
|
37
|
+
|
|
38
|
+
if (scp) {
|
|
39
|
+
const body = scp[2];
|
|
40
|
+
const i = body.indexOf("@");
|
|
41
|
+
if (i > 0) {
|
|
42
|
+
ref = body.slice(i + 1);
|
|
43
|
+
repo = `git@${scp[1]}:${body.slice(0, i)}`;
|
|
44
|
+
}
|
|
45
|
+
host = scp[1];
|
|
46
|
+
path = i > 0 ? body.slice(0, i) : body;
|
|
47
|
+
} else if (isUrl) {
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(repo);
|
|
50
|
+
const p = u.pathname.replace(/^\/+/, "");
|
|
51
|
+
const i = p.indexOf("@");
|
|
52
|
+
if (i > 0) {
|
|
53
|
+
ref = p.slice(i + 1);
|
|
54
|
+
u.pathname = `/${p.slice(0, i)}`;
|
|
55
|
+
repo = u.toString().replace(/\/$/, "");
|
|
56
|
+
path = p.slice(0, i);
|
|
57
|
+
} else {
|
|
58
|
+
path = p;
|
|
59
|
+
}
|
|
60
|
+
host = u.hostname;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// git: 简写 host/path[@ref]
|
|
66
|
+
const slash = repo.indexOf("/");
|
|
67
|
+
if (slash <= 0) return null;
|
|
68
|
+
host = repo.slice(0, slash);
|
|
69
|
+
const rest = repo.slice(slash + 1);
|
|
70
|
+
const i = rest.indexOf("@");
|
|
71
|
+
if (i > 0) {
|
|
72
|
+
ref = rest.slice(i + 1);
|
|
73
|
+
path = rest.slice(0, i);
|
|
74
|
+
} else {
|
|
75
|
+
path = rest;
|
|
76
|
+
}
|
|
77
|
+
repo = `https://${host}/${path}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// pi 的 buildGitSource:去掉结尾 .git,路径至少两段(user/project)
|
|
81
|
+
path = String(path).replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
|
|
82
|
+
if (!host || path.split("/").filter(Boolean).length < 2) return null;
|
|
83
|
+
return { host, path, ref: ref || null, url: repo };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 解析 settings.json 里的一条 package 声明。
|
|
88
|
+
* 支持字符串形式与 { source, enabled } 对象形式。
|
|
89
|
+
*/
|
|
90
|
+
export function parsePackageSource(source) {
|
|
91
|
+
const s = typeof source === "string" ? source.trim() : String(source?.source ?? "").trim();
|
|
92
|
+
if (!s) return null;
|
|
93
|
+
|
|
94
|
+
if (s.startsWith("npm:")) {
|
|
95
|
+
const spec = s.slice(4).trim();
|
|
96
|
+
// 兼容 @scope/name@version 与 name@version
|
|
97
|
+
const at = spec.lastIndexOf("@");
|
|
98
|
+
const hasVersion = at > 0;
|
|
99
|
+
return {
|
|
100
|
+
type: "npm",
|
|
101
|
+
source: s,
|
|
102
|
+
name: hasVersion ? spec.slice(0, at) : spec,
|
|
103
|
+
version: hasVersion ? spec.slice(at + 1) : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (s.startsWith("git:")) {
|
|
108
|
+
const parsed = parseGitRepo(s.slice(4).trim());
|
|
109
|
+
if (parsed) return { type: "git", source: s, ...parsed };
|
|
110
|
+
return { type: "local", source: s, path: s };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 显式协议 URL / scp 形式:与 pi 一致,都算 git 来源
|
|
114
|
+
if (/^(https?|ssh|git):\/\//i.test(s) || /^git@[^:]+:.+$/.test(s)) {
|
|
115
|
+
const parsed = parseGitRepo(s);
|
|
116
|
+
if (parsed) return { type: "git", source: s, ...parsed };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 本地路径:不做安装,仅记录
|
|
120
|
+
return { type: "local", source: s, path: s };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 读取 settings.json 里声明的包(忽略 enabled:false 的) */
|
|
124
|
+
export function readDeclaredPackages(agentDir) {
|
|
125
|
+
const file = join(agentDir, "settings.json");
|
|
126
|
+
if (!existsSync(file)) return [];
|
|
127
|
+
let raw;
|
|
128
|
+
try {
|
|
129
|
+
raw = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const list = Array.isArray(raw?.packages) ? raw.packages : [];
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const entry of list) {
|
|
136
|
+
if (entry && typeof entry === "object" && entry.enabled === false) continue;
|
|
137
|
+
const parsed = parsePackageSource(entry);
|
|
138
|
+
if (parsed) out.push(parsed);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 该包在 agent 目录里的安装位置(pi 的约定) */
|
|
144
|
+
export function installPathFor(agentDir, pkg) {
|
|
145
|
+
if (pkg.type === "npm") return join(agentDir, "npm", "node_modules", ...pkg.name.split("/"));
|
|
146
|
+
if (pkg.type === "git") return join(agentDir, "git", pkg.host, ...pkg.path.split("/"));
|
|
147
|
+
if (pkg.type === "local") return pkg.path;
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 读包目录里的 package.json,返回 { deps, optional }(依赖名列表);无法读取返回 null */
|
|
152
|
+
function readDeps(pkgDir) {
|
|
153
|
+
const file = join(pkgDir, "package.json");
|
|
154
|
+
if (!existsSync(file)) return null;
|
|
155
|
+
try {
|
|
156
|
+
const pkg = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
157
|
+
return {
|
|
158
|
+
deps: Object.keys(pkg.dependencies ?? {}),
|
|
159
|
+
optional: Object.keys(pkg.optionalDependencies ?? {}),
|
|
160
|
+
};
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 依赖解析根:从 pkgDir 起逐级向上收集所有 node_modules(Node 模块解析算法)。
|
|
168
|
+
* npm 的依赖会被提升到父级(如 agentDir/npm/node_modules),只查 pkgDir/node_modules
|
|
169
|
+
* 会误报“缺依赖”——尤其是 npm 安装的包。
|
|
170
|
+
*/
|
|
171
|
+
function dependencyRoots(pkgDir) {
|
|
172
|
+
const roots = [];
|
|
173
|
+
let dir = pkgDir;
|
|
174
|
+
for (let i = 0; i < 12; i++) {
|
|
175
|
+
roots.push(join(dir, "node_modules"));
|
|
176
|
+
const parent = dirname(dir);
|
|
177
|
+
if (parent === dir) break;
|
|
178
|
+
dir = parent;
|
|
179
|
+
}
|
|
180
|
+
return roots;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 缺依赖检测:逐个依赖名沿 Node 解析路径查找(含祖先 node_modules 的提升)。
|
|
185
|
+
* 只看 dependencies;只判断“包目录/node_modules 存在”会漏掉「拷了一半」的情况。
|
|
186
|
+
* 返回缺失的依赖名数组;无法判断时返回 null。
|
|
187
|
+
*/
|
|
188
|
+
function missingDeps(pkgDir, depNames) {
|
|
189
|
+
if (!depNames) return null;
|
|
190
|
+
const roots = dependencyRoots(pkgDir);
|
|
191
|
+
return depNames.filter((name) => {
|
|
192
|
+
const parts = String(name).split("/");
|
|
193
|
+
// 命中任一祖先 node_modules 即视为已安装
|
|
194
|
+
return !roots.some((root) => existsSync(join(root, ...parts)));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 体检:恢复后哪些插件需要补装。
|
|
200
|
+
* @returns {{ok: boolean, packages: Array, issues: Array}}
|
|
201
|
+
*/
|
|
202
|
+
export function analyzePlugins(agentDir) {
|
|
203
|
+
const declared = readDeclaredPackages(agentDir);
|
|
204
|
+
const packages = [];
|
|
205
|
+
const issues = [];
|
|
206
|
+
|
|
207
|
+
for (const pkg of declared) {
|
|
208
|
+
if (pkg.type === "local") {
|
|
209
|
+
packages.push({ source: pkg.source, type: "local", status: "skip", detail: "本地路径,不参与安装" });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const dir = installPathFor(agentDir, pkg);
|
|
214
|
+
if (!existsSync(dir)) {
|
|
215
|
+
const detail = pkg.type === "npm" ? `缺少安装:${dir}` : `缺少克隆:${dir}`;
|
|
216
|
+
packages.push({ source: pkg.source, type: pkg.type, status: "missing-install", detail, path: dir });
|
|
217
|
+
issues.push({ source: pkg.source, type: pkg.type, kind: "missing-install", detail, path: dir });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 目录在:检查依赖是否齐(git 包尤其常见——源码恢复了,node_modules 没恢复)
|
|
222
|
+
const deps = readDeps(dir);
|
|
223
|
+
const missing = missingDeps(dir, deps?.deps ?? null);
|
|
224
|
+
if (missing && missing.length > 0) {
|
|
225
|
+
const detail = `缺少依赖:${missing.slice(0, 3).join("、")}${missing.length > 3 ? ` 等 ${missing.length} 个` : ""}`;
|
|
226
|
+
packages.push({ source: pkg.source, type: pkg.type, status: "missing-deps", detail, path: dir });
|
|
227
|
+
issues.push({ source: pkg.source, type: pkg.type, kind: "missing-deps", detail, path: dir });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
packages.push({ source: pkg.source, type: pkg.type, status: "ok", detail: "已安装", path: dir });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { ok: issues.length === 0, packages, issues };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Windows 下 spawnSync(..., shell: true) 会把参数原样拼进 cmd /c,
|
|
239
|
+
* 含空格的路径(用户名带空格等)会被拆开,必须自己加引号。
|
|
240
|
+
*/
|
|
241
|
+
function quoteArg(a) {
|
|
242
|
+
const s = String(a);
|
|
243
|
+
return /[\s"^&|<>()%!]/.test(s) ? `"${s.replace(/"/g, '\"')}"` : s;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 默认执行器:返回 {ok, error},不抛异常。带超时,避免网络卡死时挂住 UI */
|
|
247
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
248
|
+
|
|
249
|
+
function defaultRun(command, args, cwd, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
250
|
+
const useShell = process.platform === "win32";
|
|
251
|
+
const res = spawnSync(command, useShell ? args.map(quoteArg) : args, {
|
|
252
|
+
cwd,
|
|
253
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
254
|
+
windowsHide: true,
|
|
255
|
+
encoding: "utf8",
|
|
256
|
+
shell: useShell,
|
|
257
|
+
timeout: timeoutMs,
|
|
258
|
+
killSignal: "SIGKILL",
|
|
259
|
+
});
|
|
260
|
+
if (res.error) {
|
|
261
|
+
const isTimeout = res.error.code === "ETIMEDOUT" || /timed? ?out/i.test(res.error.message);
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: isTimeout ? `执行超时(${Math.round(timeoutMs / 1000)}s):${command} ${args.join(" ")}` : res.error.message,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (res.status !== 0) {
|
|
268
|
+
const tail = String(res.stderr || res.stdout || "").trim().split("\n").slice(-3).join(" ");
|
|
269
|
+
return { ok: false, error: `${command} 退出码 ${res.status}${tail ? `:${tail}` : ""}` };
|
|
270
|
+
}
|
|
271
|
+
return { ok: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 确保 npm 工作目录存在(与 pi 一致:package.json + .gitignore) */
|
|
275
|
+
function ensureNpmProject(npmDir) {
|
|
276
|
+
mkdirSync(npmDir, { recursive: true });
|
|
277
|
+
const ignore = join(npmDir, ".gitignore");
|
|
278
|
+
if (!existsSync(ignore)) writeFileSync(ignore, "*\n!.gitignore\n", "utf8");
|
|
279
|
+
const pkgJson = join(npmDir, "package.json");
|
|
280
|
+
if (!existsSync(pkgJson)) {
|
|
281
|
+
writeFileSync(pkgJson, JSON.stringify({ name: "pi-extensions", private: true }, null, 2), "utf8");
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 按体检结果补装插件。
|
|
287
|
+
* @param {string} agentDir
|
|
288
|
+
* @param {{log?: Function, dryRun?: boolean, run?: Function, timeoutMs?: number}} opts
|
|
289
|
+
* @returns {{ok: boolean, repaired: Array, failed: Array, skipped: Array}}
|
|
290
|
+
*/
|
|
291
|
+
export function repairPlugins(agentDir, opts = {}) {
|
|
292
|
+
const log = opts.log ?? (() => {});
|
|
293
|
+
const dryRun = opts.dryRun === true;
|
|
294
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
295
|
+
const run = opts.run ?? ((c, a, dir) => defaultRun(c, a, dir, timeoutMs));
|
|
296
|
+
const analysis = analyzePlugins(agentDir);
|
|
297
|
+
|
|
298
|
+
const repaired = [];
|
|
299
|
+
const failed = [];
|
|
300
|
+
const skipped = [];
|
|
301
|
+
|
|
302
|
+
for (const issue of analysis.issues) {
|
|
303
|
+
const pkg = parsePackageSource(issue.source);
|
|
304
|
+
if (!pkg) {
|
|
305
|
+
skipped.push({ source: issue.source, reason: "无法解析" });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (pkg.type === "npm") {
|
|
310
|
+
const npmDir = join(agentDir, "npm");
|
|
311
|
+
const spec = pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name;
|
|
312
|
+
log(`补装 npm 插件 ${spec} …`);
|
|
313
|
+
if (dryRun) {
|
|
314
|
+
skipped.push({ source: issue.source, reason: `dry-run:将执行 npm install ${spec}(在 ${npmDir})` });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!existsSync(npmDir)) ensureNpmProject(npmDir);
|
|
318
|
+
// --legacy-peer-deps 与 pi 一致:不要自动装宿主提供的 @earendil-works/pi-* peer,
|
|
319
|
+
// 否则装出来的陈旧 peer 会挡住 pi 的更新
|
|
320
|
+
const r = run("npm", ["install", spec, "--legacy-peer-deps", "--no-fund", "--no-audit"], npmDir);
|
|
321
|
+
if (r.ok) repaired.push({ source: issue.source, action: `npm install ${spec}` });
|
|
322
|
+
else failed.push({ source: issue.source, error: r.error });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (pkg.type === "git") {
|
|
327
|
+
const dir = installPathFor(agentDir, pkg);
|
|
328
|
+
const needsClone = issue.kind === "missing-install";
|
|
329
|
+
|
|
330
|
+
if (needsClone) {
|
|
331
|
+
// 先走快路径:浅克隆 + --branch(分支 / 标签)。
|
|
332
|
+
// commit SHA 之类的 ref --branch 认不了,失败时回退成 pi 的做法:完整 clone + checkout。
|
|
333
|
+
const shallowArgs = ["clone", "--depth", "1"];
|
|
334
|
+
if (pkg.ref) shallowArgs.push("--branch", pkg.ref);
|
|
335
|
+
shallowArgs.push(pkg.url, dir);
|
|
336
|
+
log(`克隆 git 插件 ${pkg.url}${pkg.ref ? `@${pkg.ref}` : ""} …`);
|
|
337
|
+
if (dryRun) {
|
|
338
|
+
skipped.push({ source: issue.source, reason: `dry-run:将执行 git ${shallowArgs.join(" ")}` });
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
let r = run("git", shallowArgs, agentDir);
|
|
342
|
+
if (!r.ok && pkg.ref) {
|
|
343
|
+
log(`浅克隆失败(ref=${pkg.ref} 可能不是分支/标签),回退为完整克隆 + checkout …`);
|
|
344
|
+
try {
|
|
345
|
+
rmSync(dir, { recursive: true, force: true });
|
|
346
|
+
} catch {
|
|
347
|
+
/* ignore */
|
|
348
|
+
}
|
|
349
|
+
r = run("git", ["clone", pkg.url, dir], agentDir);
|
|
350
|
+
if (r.ok) r = run("git", ["checkout", pkg.ref], dir);
|
|
351
|
+
}
|
|
352
|
+
if (!r.ok) {
|
|
353
|
+
try {
|
|
354
|
+
rmSync(dir, { recursive: true, force: true });
|
|
355
|
+
} catch {
|
|
356
|
+
/* ignore */
|
|
357
|
+
}
|
|
358
|
+
failed.push({ source: issue.source, error: r.error });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 克隆完 / 目录已在,都检查依赖(逐个依赖名查 node_modules,同 pi)
|
|
364
|
+
const deps = readDeps(dir);
|
|
365
|
+
const missing = missingDeps(dir, deps?.deps ?? null);
|
|
366
|
+
if (missing && missing.length > 0) {
|
|
367
|
+
log(`安装 ${pkg.path} 的依赖(${missing.length} 个缺失)…`);
|
|
368
|
+
if (dryRun) {
|
|
369
|
+
skipped.push({ source: issue.source, reason: `dry-run:将执行 npm install(在 ${dir})` });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
// --omit=dev 与 pi 一致:只装运行时依赖
|
|
373
|
+
const r = run("npm", ["install", "--omit=dev", "--no-fund", "--no-audit"], dir);
|
|
374
|
+
if (!r.ok) {
|
|
375
|
+
failed.push({ source: issue.source, error: r.error });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
repaired.push({
|
|
381
|
+
source: issue.source,
|
|
382
|
+
action: needsClone ? `git clone${pkg.ref ? ` (${pkg.ref})` : ""}` : "npm install",
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
skipped.push({ source: issue.source, reason: "不支持的来源类型" });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
ok: failed.length === 0,
|
|
392
|
+
repaired,
|
|
393
|
+
failed,
|
|
394
|
+
skipped,
|
|
395
|
+
before: analysis,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
@@ -28,6 +28,7 @@ import { WebDAVClient } from "./webdav.mjs";
|
|
|
28
28
|
import { archiveNameRe } from "./backup.mjs";
|
|
29
29
|
import { decryptFile } from "./crypto.mjs";
|
|
30
30
|
import { extractTar } from "./tar.mjs";
|
|
31
|
+
import { listExtraFiles, readExtraManifest, EXTRA_MANIFEST_NAME } from "./extras.mjs";
|
|
31
32
|
|
|
32
33
|
/** 列出远端可恢复的备份(归档 + 快照目录) */
|
|
33
34
|
export async function listBackups(config) {
|
|
@@ -170,7 +171,10 @@ async function restoreArchive({ client, config, target, destDir, log, workDir })
|
|
|
170
171
|
log("解包中…");
|
|
171
172
|
const entries = await extractTar(rawTar, destDir);
|
|
172
173
|
const fileCount = entries.filter((e) => !e.dir).length;
|
|
174
|
+
const extras = listExtraFiles(destDir).map((f) => ({ rel: f.rel, target: f.target, orig: f.orig, mapped: f.mapped }));
|
|
175
|
+
const extraSource = readExtraManifest(destDir)?.source ?? null;
|
|
173
176
|
log(`恢复完成:${fileCount} 个文件 → ${destDir}`);
|
|
177
|
+
if (extras.length > 0) log(`其中 ${extras.length} 个属于 agentDir 之外的额外数据(extra/),可用 /backup apply-extras 归位`);
|
|
174
178
|
|
|
175
179
|
return {
|
|
176
180
|
ok: true,
|
|
@@ -178,6 +182,8 @@ async function restoreArchive({ client, config, target, destDir, log, workDir })
|
|
|
178
182
|
source: target.name,
|
|
179
183
|
dest: destDir,
|
|
180
184
|
files: fileCount,
|
|
185
|
+
extras,
|
|
186
|
+
extraSource,
|
|
181
187
|
encrypted: target.name.endsWith(".pibak"),
|
|
182
188
|
header,
|
|
183
189
|
};
|
|
@@ -208,13 +214,24 @@ async function restoreSnapshot({ client, config, target, destDir, log, workDir }
|
|
|
208
214
|
if (done % 20 === 0) log(` 已恢复 ${done}/${files.length}`);
|
|
209
215
|
}
|
|
210
216
|
|
|
217
|
+
// extra/ 的可移植映射单独上传,不在 manifest.files 里,需显式取回(否则无法跨机重映射)
|
|
218
|
+
try {
|
|
219
|
+
const em = await client.get(`${target.remoteDir}/${EXTRA_MANIFEST_NAME}`);
|
|
220
|
+
if (em) writeFileSync(join(destDir, EXTRA_MANIFEST_NAME), em);
|
|
221
|
+
} catch {
|
|
222
|
+
/* 旧版快照无此文件,忽略 */
|
|
223
|
+
}
|
|
224
|
+
|
|
211
225
|
log(`恢复完成:${done} 个文件 → ${destDir}${failed.length ? `(${failed.length} 个失败)` : ""}`);
|
|
226
|
+
const extras = listExtraFiles(destDir).map((f) => ({ rel: f.rel, target: f.target, orig: f.orig, mapped: f.mapped }));
|
|
227
|
+
if (extras.length > 0) log(`其中 ${extras.length} 个属于 agentDir 之外的额外数据(extra/),可用 /backup apply-extras 归位`);
|
|
212
228
|
return {
|
|
213
229
|
ok: failed.length === 0,
|
|
214
230
|
mode: "snapshot",
|
|
215
231
|
source: target.name,
|
|
216
232
|
dest: destDir,
|
|
217
233
|
files: done,
|
|
234
|
+
extras,
|
|
218
235
|
failed,
|
|
219
236
|
};
|
|
220
237
|
}
|