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.
@@ -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
  }