pi-webdav-backup 1.3.2 → 1.4.0
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 +11 -4
- package/extensions/webdav-backup/apply.mjs +102 -5
- package/extensions/webdav-backup/cli.mjs +5 -1
- package/extensions/webdav-backup/dav-mock.mjs +10 -1
- package/extensions/webdav-backup/test-apply.mjs +72 -2
- package/extensions/webdav-backup/test-webdav.mjs +91 -0
- package/extensions/webdav-backup/webdav.mjs +53 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -442,10 +442,13 @@ node "$env:USERPROFILE\.pi\agent\extensions\webdav-backup\cli.mjs" install-cron
|
|
|
442
442
|
新电脑往往已经装过一些插件——可能和备份里同名但版本不同,也可能多装了备份里没有的。
|
|
443
443
|
直接覆盖会**把同名插件降级、把本机多装的声明抹掉、留下版本混合的“缝合”目录**。所以写回有两种模式:
|
|
444
444
|
|
|
445
|
-
| 模式 | settings.json 的 packages | 本机已装的插件实现 | 本机多装的插件 |
|
|
446
|
-
|
|
447
|
-
| **合并**(默认,推荐) | 本机声明优先,追加备份里本机没有的 | **保留本机版本**,不覆盖 | 保留(声明与代码都留) |
|
|
448
|
-
| 替换(旧机原样) | 完全用备份版 | 被备份覆盖(同名降级) | 声明被移除(代码不删) |
|
|
445
|
+
| 模式 | settings.json 的 packages | npm/package.json 的 dependencies | 本机已装的插件实现 | 本机多装的插件 |
|
|
446
|
+
|---|---|---|---|---|
|
|
447
|
+
| **合并**(默认,推荐) | 本机声明优先,追加备份里本机没有的 | 本机依赖优先,追加备份里本机没有的 | **保留本机版本**,不覆盖 | 保留(声明与代码都留) |
|
|
448
|
+
| 替换(旧机原样) | 完全用备份版 | 完全用备份版 | 被备份覆盖(同名降级) | 声明被移除(代码不删) |
|
|
449
|
+
|
|
450
|
+
> **为何要合并 `npm/package.json`**:pi 启动时会拿它与 `node_modules` 做协调(npm install)。
|
|
451
|
+
> 若直接覆盖成备份版,本机独有的插件会被 npm 当成多余包【卸载】。所以合并模式会保留本机已有依赖。
|
|
449
452
|
|
|
450
453
|
- 交互式 `/backup restore`:本机有插件声明时会先让你选(默认合并)
|
|
451
454
|
- 命令行:`--mode merge`(默认)/ `--mode replace`
|
|
@@ -633,6 +636,8 @@ node extensions/webdav-backup/verify-load.mjs
|
|
|
633
636
|
|
|
634
637
|
| 版本 | 内容 |
|
|
635
638
|
|---|---|
|
|
639
|
+
| **v1.4.0** | **跨平台健壮性**:① 合并/恢复时**过滤旧机器专属的绝对路径本地源**(`E:\...`、`/home/...`、UNC)——`pi install /绝对/路径` 会把路径写进 settings.json,跨机恢复时无效,现在不再带入目标机;② WebDAV 客户端对 **423 Locked / 429 / 5xx 与网络抖动自动重试**(指数退避,尊重 `Retry-After`,上限 4 次),修复实测中两个实例并发上传导致的 423 直接失败;新增 `test-webdav.mjs`(故障注入)与跨平台路径回归用例 |
|
|
640
|
+
| **v1.3.3** | **修复合并模式会卸载本机插件**:`restore-apply` 合并模式此前只合并 `settings.json`,却用备份版**覆盖** `npm/package.json`——pi 下次启动的 npm 协调会把本机独有插件当成多余包卸载(实测于 Windows→Linux 同步)。现改为同时合并 `npm/package.json` 的 `dependencies`(本机优先),结果里标 `npm-dependencies`。另:文档补充「多机同步」与「跨平台密钥」流程 |
|
|
636
641
|
| **v1.3.2** | **跨平台多机同步**:依赖体检修正(npm 提升到父级的依赖不再误报为缺失,按 Node 模块解析算法沿祖先 `node_modules` 查找);文档新增「跨平台密钥(多机同步)」与「多机同步(Windows ↔ Linux)」流程,明确多机用 `file:` / `$ENV` 而非 `dpapi:`,并给出常见坑 |
|
|
637
642
|
| **v1.3.1** | 依赖体检误报修正(npm 提升的依赖被当成缺失) |
|
|
638
643
|
| **v1.3.0** | **换机一键还原**:新增 `includePlugins`(把插件实现 `node_modules` 一并备份,重机恢复即可直接用,无需联网重装);新增 `restore-apply` / `/backup restore-apply`(把恢复结果写回当前 `~/.pi/agent`,覆盖前备份、幂等、防止 `__REDACTED__` 覆盖真值);**写回默认【合并模式】**:本机已装插件保留不被覆盖、`settings.json` 只合并 `packages`(避免删掉/降级本机插件),可选 `--mode replace` 完全还原;`/backup restore` 默认询问是否写回当前 pi,已含插件时不再提示联网补装;`/backup-setup` 向导新增插件代码选项;脱敏审计跳过第三方插件代码(避免误报);排除规则重构(`includePlugins` 开关控制 node_modules) |
|
|
@@ -658,7 +663,9 @@ node extensions/webdav-backup/verify-load.mjs
|
|
|
658
663
|
| 想立刻看到日志 | `/backup-log` |
|
|
659
664
|
| DPAPI 解密失败 | 密文只能在本机本用户下解;换机器/换用户需重新 `/backup keys set` |
|
|
660
665
|
| 提示「环境变量未设置」 | `/backup keys set` 改用 dpapi,或先设好该环境变量 |
|
|
666
|
+
| 恢复报 `HTTP 423` | 网盘并发锁;v1.4.0+ 会自动重试(指数退避,尊重 `Retry-After`)。若持续失败,请确认没有安装**两份** backup 插件(退出时会同时备份),可用 `/backup list` + `pi list` 检查 |
|
|
661
667
|
| 恢复报 `HTTP 302` | Cloudreve/群晖等把下载重定向到对象存储(腾讯云 COS 等);客户端已自动跟随跨主机 302(不携带凭据),请升级到 v1.1.0+ |
|
|
668
|
+
| 恢复后 `settings.json` 多了无效路径 | v1.4.0+ 已过滤旧机器的绝对路径本地源;旧版残留可手动删除 |
|
|
662
669
|
| 恢复后插件不生效 | 若备份时未开启 `includePlugins`,插件代码(`node_modules`)不在备份里,需联网补装:`/backup repair-plugins`(可带 `--dir`)。推荐下次备份开启 `includePlugins: true`,下次即可一键还原 |
|
|
663
670
|
| 写回时提示跳过某些文件 | 归档里是 `__REDACTED__` 而本地已有真实密钥时会被跳过(避免把真密钥抹掉);确认要覆盖可加 `--force` |
|
|
664
671
|
| 补装报 `git clone` 失败 | 确认能访问 github.com 等源;ref 是 commit SHA 时会自动回退为完整 clone + checkout,仍失败则看日志里的 git 输出 |
|
|
@@ -64,6 +64,11 @@ export function isPluginImplementationPath(rel) {
|
|
|
64
64
|
return r.startsWith("git/") || r.includes("/node_modules/");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** agentDir 下的 npm 包清单(决定 pi 启动时 npm 装/卸哪些插件) */
|
|
68
|
+
export function isNpmManifestPath(rel) {
|
|
69
|
+
return String(rel).replace(/\\/g, "/") === "npm/package.json";
|
|
70
|
+
}
|
|
71
|
+
|
|
67
72
|
/**
|
|
68
73
|
* 求一个插件实现文件所属的「插件根目录」相对路径,用于判定本机是否已装该插件:
|
|
69
74
|
* npm/node_modules/foo/lib/x.js → npm/node_modules/foo
|
|
@@ -90,13 +95,41 @@ export function pluginRootRel(rel) {
|
|
|
90
95
|
return null;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* 取 packages 声明的来源字符串(兼容字符串与 { source, ... } 对象)
|
|
100
|
+
*/
|
|
94
101
|
function packageSourceString(p) {
|
|
95
102
|
if (typeof p === "string") return p;
|
|
96
103
|
if (p && typeof p === "object" && typeof p.source === "string") return p.source;
|
|
97
104
|
return null;
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 判断一条 packages 声明是否是「本机绝对路径」形式的本地源。
|
|
109
|
+
*
|
|
110
|
+
* 为何重要:`pi install /绝对/路径` 会把该机器的绝对路径写进 settings.json。
|
|
111
|
+
* 跨平台/跨机器恢复时,这种路径在目标机上必然无效(且可能指到完全无关的位置),
|
|
112
|
+
* 所以合并时要过滤掉,不要带过去。
|
|
113
|
+
*
|
|
114
|
+
* 只认「路径形」字符串;`npm:` / `git:` / `https://` / `builtin:` 等来源不受影响。
|
|
115
|
+
* 注意:相对路径(如 `./ext`、`ext`)是可移植的,不过滤。
|
|
116
|
+
*/
|
|
117
|
+
export function isMachineSpecificPackagePath(source) {
|
|
118
|
+
const s = packageSourceString(source);
|
|
119
|
+
if (!s) return false;
|
|
120
|
+
const v = s.trim();
|
|
121
|
+
if (!v) return false;
|
|
122
|
+
// 显式来源前缀:不是本地路径
|
|
123
|
+
if (/^(npm:|git:|github:|https?:|ssh:|builtin:|file:)/i.test(v)) return false;
|
|
124
|
+
// Windows 盘符绝对路径:C:\foo 或 C:/foo
|
|
125
|
+
if (/^[A-Za-z]:[\\/]/.test(v)) return true;
|
|
126
|
+
// UNC 路径:\\server\share
|
|
127
|
+
if (/^\\\\/.test(v)) return true;
|
|
128
|
+
// POSIX 绝对路径:/home/me/ext(含 \\home 形式的 Windows 风格根部)
|
|
129
|
+
if (v.startsWith("/") || v.startsWith("\\\\")) return true;
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
100
133
|
/**
|
|
101
134
|
* 计算一条 packages 声明的「身份」:npm 看包名、git 看 host/path、local 看路径。
|
|
102
135
|
* 用于合并时按身份去重(同身份视为同一个插件,不因版本号不同而重复)。
|
|
@@ -133,11 +166,18 @@ export function mergeSettingsPackages(backupText, localText) {
|
|
|
133
166
|
|
|
134
167
|
const backupPkgs = Array.isArray(backup.packages) ? backup.packages : [];
|
|
135
168
|
const localPkgs = Array.isArray(local?.packages) ? local.packages : [];
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
|
|
170
|
+
// 本机没有任何声明(新机场景):用备份版,但仍过滤掉旧机器专属的绝对路径
|
|
171
|
+
if (localPkgs.length === 0) {
|
|
172
|
+
const kept = backupPkgs.filter((p) => !isMachineSpecificPackagePath(p));
|
|
173
|
+
if (kept.length === backupPkgs.length) return null; // 无外来源,无需改写
|
|
174
|
+
return JSON.stringify({ ...backup, packages: kept }, null, 2);
|
|
175
|
+
}
|
|
138
176
|
|
|
139
177
|
const localIds = new Set(localPkgs.map(packageIdentity).filter(Boolean));
|
|
140
178
|
const extra = backupPkgs.filter((p) => {
|
|
179
|
+
// 备份里指向旧机器绝对路径的本地源,在目标机上无效 → 不合并
|
|
180
|
+
if (isMachineSpecificPackagePath(p)) return false;
|
|
141
181
|
const id = packageIdentity(p);
|
|
142
182
|
return id && !localIds.has(id);
|
|
143
183
|
});
|
|
@@ -145,6 +185,46 @@ export function mergeSettingsPackages(backupText, localText) {
|
|
|
145
185
|
return JSON.stringify({ ...backup, packages: [...localPkgs, ...extra] }, null, 2);
|
|
146
186
|
}
|
|
147
187
|
|
|
188
|
+
/**
|
|
189
|
+
* 合并 npm/package.json 的 dependencies:本机已有优先(保留版本),追加备份里本机没有的。
|
|
190
|
+
*
|
|
191
|
+
* 为何必须合并:pi 启动时会拿 npm/package.json 与 node_modules 做协调(npm install)。
|
|
192
|
+
* 若 merge 写回时用备份版直接覆盖,本机独有的插件会被 npm 当成多余包【卸载】。
|
|
193
|
+
* 返回合并后的 JSON 文本;无可合并时返回 null(调用方按普通逻辑处理)。
|
|
194
|
+
*/
|
|
195
|
+
export function mergeNpmDependencies(backupText, localText) {
|
|
196
|
+
let backup;
|
|
197
|
+
let local;
|
|
198
|
+
try {
|
|
199
|
+
backup = JSON.parse(String(backupText).replace(/^\uFEFF/, ""));
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
local = JSON.parse(String(localText).replace(/^\uFEFF/, ""));
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (!backup || typeof backup !== "object") return null;
|
|
209
|
+
|
|
210
|
+
const backupDeps = backup.dependencies && typeof backup.dependencies === "object" ? backup.dependencies : {};
|
|
211
|
+
const localDeps = local?.dependencies && typeof local.dependencies === "object" ? local.dependencies : {};
|
|
212
|
+
if (Object.keys(localDeps).length === 0) return null;
|
|
213
|
+
|
|
214
|
+
// 本机已声明的依赖保留本机版本;备份独有的追加进来
|
|
215
|
+
const merged = { ...localDeps };
|
|
216
|
+
let added = 0;
|
|
217
|
+
for (const [name, range] of Object.entries(backupDeps)) {
|
|
218
|
+
if (!(name in merged)) {
|
|
219
|
+
merged[name] = range;
|
|
220
|
+
added++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// 本机声明已包含备份全部依赖且无新增 → 无需改写
|
|
224
|
+
if (added === 0 && Object.keys(merged).length === Object.keys(localDeps).length) return null;
|
|
225
|
+
return JSON.stringify({ ...backup, dependencies: merged }, null, 2);
|
|
226
|
+
}
|
|
227
|
+
|
|
148
228
|
/** 递归列出恢复目录下、应写回 agentDir 的文件(跳过 extra/ 与元数据) */
|
|
149
229
|
export function listRestoredFiles(restoredDir, { extraPrefix = "extra/" } = {}) {
|
|
150
230
|
const root = resolve(restoredDir);
|
|
@@ -224,6 +304,21 @@ export function applyRestoredFiles(restoredDir, agentDir, opts = {}) {
|
|
|
224
304
|
}
|
|
225
305
|
}
|
|
226
306
|
|
|
307
|
+
// merge:npm/package.json 只合并 dependencies,保留本机独有插件。
|
|
308
|
+
// 否则覆盖后,pi 下次启动的 npm 协调会把本机独有的包当成多余包卸载。
|
|
309
|
+
let mergedNpm = false;
|
|
310
|
+
if (merge && isNpmManifestPath(f.rel) && existsSync(target)) {
|
|
311
|
+
try {
|
|
312
|
+
const mergedText = mergeNpmDependencies(srcBuf.toString("utf8"), readFileSync(target, "utf8"));
|
|
313
|
+
if (mergedText !== null) {
|
|
314
|
+
srcBuf = Buffer.from(mergedText, "utf8");
|
|
315
|
+
mergedNpm = true;
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
/* 合并失败则退回普通处理 */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
227
322
|
// merge:本机已装该插件(插件根目录已存在)→ 跳过整个插件,避免降级与版本混合。
|
|
228
323
|
// 但若该文件与本地完全一致,则归为 unchanged(保证重复写回幂等)。
|
|
229
324
|
if (merge) {
|
|
@@ -281,11 +376,13 @@ export function applyRestoredFiles(restoredDir, agentDir, opts = {}) {
|
|
|
281
376
|
let backup = null;
|
|
282
377
|
if (existsSync(target)) backup = backupExisting(target, "apply");
|
|
283
378
|
writeFileSync(target, srcBuf);
|
|
284
|
-
applied.push({ rel: f.rel, target, backup, merged: mergedSettings });
|
|
285
|
-
if (mergedSettings) merged.push({ rel: f.rel, target });
|
|
379
|
+
applied.push({ rel: f.rel, target, backup, merged: mergedSettings || mergedNpm });
|
|
380
|
+
if (mergedSettings) merged.push({ rel: f.rel, target, kind: "settings-packages" });
|
|
381
|
+
if (mergedNpm) merged.push({ rel: f.rel, target, kind: "npm-dependencies" });
|
|
286
382
|
log(
|
|
287
383
|
` ${backup ? "已覆盖" : "已写入"} ${f.rel} → ${target}` +
|
|
288
384
|
(mergedSettings ? "(已合并 packages,保留本机插件声明)" : "") +
|
|
385
|
+
(mergedNpm ? "(已合并 dependencies,保留本机插件依赖)" : "") +
|
|
289
386
|
(backup ? `(原文件备份为 ${backup})` : ""),
|
|
290
387
|
);
|
|
291
388
|
} catch (e) {
|
|
@@ -246,7 +246,11 @@ async function main() {
|
|
|
246
246
|
if (!has("--dry-run")) {
|
|
247
247
|
logTo(`已写回 agent 目录:${agentDir}(${mergeMode ? "合并模式" : "替换模式"})`);
|
|
248
248
|
logTo(` 写入/覆盖 ${r.applied.length} 个,内容一致 ${r.unchanged.length} 个,失败 ${r.failed.length} 个`);
|
|
249
|
-
if (r.merged?.length)
|
|
249
|
+
if (r.merged?.length) {
|
|
250
|
+
const kinds = r.merged.map((m) => m.kind).filter(Boolean);
|
|
251
|
+
if (kinds.includes("settings-packages")) logTo(` 🔀 settings.json 已合并 packages,保留本机已有插件声明`);
|
|
252
|
+
if (kinds.includes("npm-dependencies")) logTo(` 🔀 npm/package.json 已合并 dependencies,保留本机独有插件依赖`);
|
|
253
|
+
}
|
|
250
254
|
for (const x of r.skipped) logTo(` ➖ ${x.rel}(${x.reason})`);
|
|
251
255
|
for (const x of r.failed) logTo(` ❌ ${x.rel} — ${x.error}`);
|
|
252
256
|
if (pluginReady) logTo("插件实现代码已随备份恢复,无需联网重装。");
|
|
@@ -15,6 +15,7 @@ export function startDavServer({
|
|
|
15
15
|
password = "pass",
|
|
16
16
|
requireAuth = true,
|
|
17
17
|
redirectGetsTo = null,
|
|
18
|
+
failFirstPuts = 0,
|
|
18
19
|
} = {}) {
|
|
19
20
|
/** @type {Map<string, Buffer>} */
|
|
20
21
|
const store = new Map();
|
|
@@ -22,6 +23,7 @@ export function startDavServer({
|
|
|
22
23
|
const expectedAuth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
|
|
23
24
|
let putCount = 0;
|
|
24
25
|
let getCount = 0;
|
|
26
|
+
let lockedCount = 0;
|
|
25
27
|
let lastAuth = null;
|
|
26
28
|
|
|
27
29
|
const server = http.createServer((req, res) => {
|
|
@@ -98,6 +100,12 @@ export function startDavServer({
|
|
|
98
100
|
const chunks = [];
|
|
99
101
|
req.on("data", (c) => chunks.push(c));
|
|
100
102
|
req.on("end", () => {
|
|
103
|
+
// 故障注入:前 N 次 PUT 返回 423 Locked
|
|
104
|
+
if (lockedCount < failFirstPuts) {
|
|
105
|
+
lockedCount++;
|
|
106
|
+
res.writeHead(423, { "Retry-After": "1" }).end("locked");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
101
109
|
putCount++;
|
|
102
110
|
store.set(key, Buffer.concat(chunks));
|
|
103
111
|
res.writeHead(201).end();
|
|
@@ -152,13 +160,14 @@ export function startDavServer({
|
|
|
152
160
|
port,
|
|
153
161
|
store,
|
|
154
162
|
dirs,
|
|
155
|
-
stats: () => ({ putCount, getCount }),
|
|
163
|
+
stats: () => ({ putCount, getCount, lockedCount }),
|
|
156
164
|
get lastAuth() {
|
|
157
165
|
return lastAuth;
|
|
158
166
|
},
|
|
159
167
|
reset: () => {
|
|
160
168
|
putCount = 0;
|
|
161
169
|
getCount = 0;
|
|
170
|
+
lockedCount = 0;
|
|
162
171
|
},
|
|
163
172
|
});
|
|
164
173
|
});
|
|
@@ -29,7 +29,7 @@ import { collectFiles } from "./collect.mjs";
|
|
|
29
29
|
import { runBackup } from "./backup.mjs";
|
|
30
30
|
import { restoreBackup } from "./restore.mjs";
|
|
31
31
|
import { analyzePlugins } from "./packages.mjs";
|
|
32
|
-
import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages } from "./apply.mjs";
|
|
32
|
+
import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages, isMachineSpecificPackagePath } from "./apply.mjs";
|
|
33
33
|
|
|
34
34
|
let pass = 0;
|
|
35
35
|
let fail = 0;
|
|
@@ -298,7 +298,7 @@ async function main() {
|
|
|
298
298
|
rmSync(live2, { recursive: true, force: true });
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
//
|
|
301
|
+
// 8. mergeSettingsPackages:本机无声明时返回 null(用备份原样)
|
|
302
302
|
{
|
|
303
303
|
check("本机无 packages 声明时不合并", mergeSettingsPackages('{"packages":["npm:A"]}', '{"theme":"x"}') === null);
|
|
304
304
|
const m = mergeSettingsPackages('{"packages":["npm:A","npm:B"],"theme":"light"}', '{"packages":["npm:B@2"],"theme":"dark"}');
|
|
@@ -306,6 +306,76 @@ async function main() {
|
|
|
306
306
|
check("合并保留本机声明优先、追加备份独有", JSON.stringify(o.packages) === JSON.stringify(["npm:B@2", "npm:A"]) && o.theme === "light", String(m));
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
// ── 8b. 回归:跨机同步不得带入旧机器的绝对路径本地源
|
|
310
|
+
{
|
|
311
|
+
// isMachineSpecificPackagePath 单测
|
|
312
|
+
check("识别 Windows 盘符路径", isMachineSpecificPackagePath("E:\\Users\\me\\ext") === true);
|
|
313
|
+
check("识别正斜杠盘符路径", isMachineSpecificPackagePath("C:/Users/me/ext") === true);
|
|
314
|
+
check("识别 POSIX 绝对路径", isMachineSpecificPackagePath("/home/me/ext") === true);
|
|
315
|
+
check("识别 UNC 路径", isMachineSpecificPackagePath("\\\\server\\share\\ext") === true);
|
|
316
|
+
check("npm 源不误判", isMachineSpecificPackagePath("npm:foo") === false);
|
|
317
|
+
check("git 源不误判", isMachineSpecificPackagePath("git:github.com/u/r@v1") === false);
|
|
318
|
+
check("https 源不误判", isMachineSpecificPackagePath("https://github.com/u/r") === false);
|
|
319
|
+
check("相对路径不误判(可移植)", isMachineSpecificPackagePath("./ext") === false);
|
|
320
|
+
|
|
321
|
+
// 合并时:备份里的 Windows 绝对路径不得进入 Linux 的 packages
|
|
322
|
+
const backup = JSON.stringify({ packages: ["npm:pi-signal-footer", "E:\\Users\\Administrator\\Desktop\\projects\\pi-webdav-backup"] });
|
|
323
|
+
const live = JSON.stringify({ packages: ["npm:@zhangweiii/pi-status-line"] });
|
|
324
|
+
const merged = JSON.parse(mergeSettingsPackages(backup, live));
|
|
325
|
+
check("合并过滤掉旧机器绝对路径", !merged.packages.some((p) => /^[A-Za-z]:[\\/]/.test(p)), JSON.stringify(merged.packages));
|
|
326
|
+
check("合并仍保留可移植的 npm 源", merged.packages.includes("npm:pi-signal-footer"), JSON.stringify(merged.packages));
|
|
327
|
+
check("合并保留本机声明", merged.packages.includes("npm:@zhangweiii/pi-status-line"));
|
|
328
|
+
|
|
329
|
+
// 全新机(本机无 packages):同样要过滤外来绝对路径
|
|
330
|
+
const fresh = mergeSettingsPackages(backup, JSON.stringify({ theme: "dark" }));
|
|
331
|
+
check("全新机场景也过滤外来绝对路径", fresh !== null && !JSON.parse(fresh).packages.some((p) => /^[A-Za-z]:[\\/]/.test(p)), String(fresh));
|
|
332
|
+
check("全新机仅剩可移植源则不改写", mergeSettingsPackages('{"packages":["npm:A"]}', '{}') === null);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── 9. 回归:npm/package.json 必须合并 dependencies(否则 pi 会卸载本机独有插件)
|
|
336
|
+
{
|
|
337
|
+
// 备份来源(Windows):只依赖 A、B
|
|
338
|
+
const backup = mkdtempSync(join(tmpdir(), "pi-npm-merge-bk-"));
|
|
339
|
+
mkdirSync(join(backup, "npm"), { recursive: true });
|
|
340
|
+
writeFileSync(join(backup, "settings.json"), JSON.stringify({ packages: ["npm:A", "npm:B"] }));
|
|
341
|
+
writeFileSync(
|
|
342
|
+
join(backup, "npm", "package.json"),
|
|
343
|
+
JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^1.0.0", B: "^2.0.0" } }),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// 目标机器(Linux):已装 A(本机版本)+ 本机独有的 Z
|
|
347
|
+
const live = mkdtempSync(join(tmpdir(), "pi-npm-merge-live-"));
|
|
348
|
+
mkdirSync(join(live, "npm"), { recursive: true });
|
|
349
|
+
writeFileSync(join(live, "settings.json"), JSON.stringify({ packages: ["npm:A@9", "npm:Z"] }));
|
|
350
|
+
writeFileSync(
|
|
351
|
+
join(live, "npm", "package.json"),
|
|
352
|
+
JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^9.0.0", Z: "^0.1.0" } }),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const r = applyRestoredFiles(backup, live, { merge: true });
|
|
356
|
+
const deps = JSON.parse(readFileSync(join(live, "npm", "package.json"), "utf8")).dependencies;
|
|
357
|
+
check("merge 后 npm 依赖保留本机 A@9", deps.A === "^9.0.0", JSON.stringify(deps));
|
|
358
|
+
check("merge 后 npm 依赖保留本机独有 Z", deps.Z === "^0.1.0", JSON.stringify(deps));
|
|
359
|
+
check("merge 后 npm 依赖追加备份独有的 B", deps.B === "^2.0.0", JSON.stringify(deps));
|
|
360
|
+
check("merged 列表标出 npm-dependencies", r.merged?.some((m) => m.kind === "npm-dependencies"), JSON.stringify(r.merged));
|
|
361
|
+
|
|
362
|
+
// replace:完全用备份版(Z 会从 package.json 消失,由 pi 下次启动自行卸载)
|
|
363
|
+
const live2 = mkdtempSync(join(tmpdir(), "pi-npm-merge-live2-"));
|
|
364
|
+
mkdirSync(join(live2, "npm"), { recursive: true });
|
|
365
|
+
writeFileSync(join(live2, "settings.json"), JSON.stringify({ packages: ["npm:A@9", "npm:Z"] }));
|
|
366
|
+
writeFileSync(
|
|
367
|
+
join(live2, "npm", "package.json"),
|
|
368
|
+
JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^9.0.0", Z: "^0.1.0" } }),
|
|
369
|
+
);
|
|
370
|
+
applyRestoredFiles(backup, live2, { merge: false });
|
|
371
|
+
const deps2 = JSON.parse(readFileSync(join(live2, "npm", "package.json"), "utf8")).dependencies;
|
|
372
|
+
check("replace 后 npm 依赖完全变回备份版", JSON.stringify(deps2) === JSON.stringify({ A: "^1.0.0", B: "^2.0.0" }), JSON.stringify(deps2));
|
|
373
|
+
|
|
374
|
+
rmSync(backup, { recursive: true, force: true });
|
|
375
|
+
rmSync(live, { recursive: true, force: true });
|
|
376
|
+
rmSync(live2, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
309
379
|
console.log(lines.join("\n"));
|
|
310
380
|
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
311
381
|
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-webdav.mjs — WebDAV 客户端健壮性测试
|
|
4
|
+
*
|
|
5
|
+
* 背景:网盘在并发上传/维护时返回 423 Locked / 429 / 5xx;此前无任何重试,
|
|
6
|
+
* 一次抖动就让整次备份失败(实测:两份 backup 插件同时退出 → 423)。
|
|
7
|
+
*
|
|
8
|
+
* 验证:
|
|
9
|
+
* 1. PUT 遇 423 Locked → 自动重试并最终成功
|
|
10
|
+
* 2. Retry-After 头被尊重(不改变正确性,仅验证不报错)
|
|
11
|
+
* 3. 持续 423 超过重试上限时明确失败(不会无限重试)
|
|
12
|
+
* 4. check/list/统计仍正常
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { startDavServer } from "./dav-mock.mjs";
|
|
16
|
+
import { WebDAVClient } from "./webdav.mjs";
|
|
17
|
+
|
|
18
|
+
let pass = 0;
|
|
19
|
+
let fail = 0;
|
|
20
|
+
const lines = [];
|
|
21
|
+
const check = (n, c, d = "") => {
|
|
22
|
+
if (c) { pass++; lines.push(` ✅ ${n}`); }
|
|
23
|
+
else { fail++; lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`); }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
console.log("pi WebDAV 备份 — WebDAV 客户端健壮性测试\n");
|
|
28
|
+
|
|
29
|
+
// ── 1. PUT 前 2 次返回 423,之后成功(验证重试)
|
|
30
|
+
{
|
|
31
|
+
const dav = await startDavServer({ failFirstPuts: 2 });
|
|
32
|
+
dav.dirs.add("/dav");
|
|
33
|
+
const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
|
|
34
|
+
let ok = false, err = null;
|
|
35
|
+
try {
|
|
36
|
+
await c.put("/dav/x.txt", Buffer.from("hello"), { contentType: "text/plain" });
|
|
37
|
+
ok = true;
|
|
38
|
+
} catch (e) { err = e.message; }
|
|
39
|
+
const s = dav.stats();
|
|
40
|
+
check("423 后自动重试并成功", ok === true, err || "");
|
|
41
|
+
check("确实经历了 2 次锁定", s.lockedCount === 2, JSON.stringify(s));
|
|
42
|
+
check("最终成功写入(putCount=1)", s.putCount === 1, JSON.stringify(s));
|
|
43
|
+
const got = await c.get("/dav/x.txt");
|
|
44
|
+
check("重试后内容正确", got && got.toString("utf8") === "hello");
|
|
45
|
+
dav.server.close();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── 2. 持续 423 超过上限 → 明确失败(不悬挂)
|
|
49
|
+
{
|
|
50
|
+
const dav = await startDavServer({ failFirstPuts: 999 });
|
|
51
|
+
dav.dirs.add("/dav");
|
|
52
|
+
const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
|
|
53
|
+
const t0 = Date.now();
|
|
54
|
+
let failed = false, msg = "";
|
|
55
|
+
try {
|
|
56
|
+
await c.put("/dav/y.txt", Buffer.from("x"));
|
|
57
|
+
} catch (e) { failed = true; msg = e.message; }
|
|
58
|
+
const ms = Date.now() - t0;
|
|
59
|
+
check("持续 423 最终失败(不无限重试)", failed === true);
|
|
60
|
+
check("错误信息含 HTTP 423", /423/.test(msg), msg);
|
|
61
|
+
check("重试次数有上限(<40s)", ms < 40000, `${ms}ms`);
|
|
62
|
+
dav.server.close();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 3. 常规操作不受影响
|
|
66
|
+
{
|
|
67
|
+
const dav = await startDavServer();
|
|
68
|
+
dav.dirs.add("/dav");
|
|
69
|
+
const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
|
|
70
|
+
const chk = await c.check();
|
|
71
|
+
check("check 正常", chk.ok === true, JSON.stringify(chk));
|
|
72
|
+
await c.mkdirp("dav/sub");
|
|
73
|
+
await c.put("/dav/sub/a.txt", Buffer.from("A"));
|
|
74
|
+
const list = await c.list("dav/sub");
|
|
75
|
+
check("list 能看到刚上传的文件", list.some((e) => e.name === "a.txt" && !e.isCollection), JSON.stringify(list.map((e) => e.name)));
|
|
76
|
+
await c.del("/dav/sub/a.txt");
|
|
77
|
+
const list2 = await c.list("dav/sub");
|
|
78
|
+
check("delete 生效", !list2.some((e) => e.name === "a.txt"));
|
|
79
|
+
dav.server.close();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(lines.join("\n"));
|
|
83
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
84
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
main().catch((e) => {
|
|
88
|
+
console.log(lines.join("\n"));
|
|
89
|
+
console.error(`\n测试异常:${e?.stack ?? e}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
});
|
|
@@ -17,6 +17,30 @@ import { URL } from "node:url";
|
|
|
17
17
|
const UA = "pi-webdav-backup/1.0";
|
|
18
18
|
const MAX_REDIRECTS = 5;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 瞬时错误重试参数。
|
|
22
|
+
*
|
|
23
|
+
* 背景:网盘(坚果云/群晖/Cloudreve 等)在并发上传、扫描或维护时会返回
|
|
24
|
+
* 423 Locked / 429 / 5xx;同一文件在稍后重试即可成功。之前没有任何重试,
|
|
25
|
+
* 一次抖动就导致整次备份失败(实测:两个实例同时退出备份 → 423)。
|
|
26
|
+
*/
|
|
27
|
+
const MAX_RETRIES = 4;
|
|
28
|
+
const RETRY_BASE_DELAY_MS = 800;
|
|
29
|
+
const RETRYABLE_STATUS = new Set([423, 425, 429, 500, 502, 503, 504]);
|
|
30
|
+
const RETRYABLE_METHODS = new Set(["PUT", "MKCOL", "DELETE", "PROPFIND", "HEAD", "GET"]);
|
|
31
|
+
|
|
32
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
33
|
+
|
|
34
|
+
/** 解析 Retry-After(秒数或 HTTP 日期),返回毫秒;无法解析返回 null */
|
|
35
|
+
function parseRetryAfter(value) {
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
const s = String(value).trim();
|
|
38
|
+
if (/^\d+$/.test(s)) return Number(s) * 1000;
|
|
39
|
+
const t = Date.parse(s);
|
|
40
|
+
if (!Number.isNaN(t)) return Math.max(0, t - Date.now());
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
function request(url, { method, headers = {}, body = null, timeoutMs = 60000, insecureTls = false, redirects = MAX_REDIRECTS }) {
|
|
21
45
|
return new Promise((resolve, reject) => {
|
|
22
46
|
let u;
|
|
@@ -100,6 +124,34 @@ function basicAuth(username, password) {
|
|
|
100
124
|
return { Authorization: `Basic ${token}` };
|
|
101
125
|
}
|
|
102
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 带重试的请求:对 423/429/5xx 与网络抖动指数据退避重试(仅幂等方法)。
|
|
129
|
+
* PUT 幂等(整体覆盖同名文件),故也可安全重试。
|
|
130
|
+
* @returns {Promise<{status:number, headers:object, body:Buffer}>}
|
|
131
|
+
*/
|
|
132
|
+
async function requestWithRetry(url, opts) {
|
|
133
|
+
const method = String(opts.method || "GET").toUpperCase();
|
|
134
|
+
const retryable = RETRYABLE_METHODS.has(method);
|
|
135
|
+
let lastRes = null;
|
|
136
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
137
|
+
let res;
|
|
138
|
+
try {
|
|
139
|
+
res = await request(url, opts);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// 网络层错误(超时/断连)也重试
|
|
142
|
+
if (!retryable || attempt === MAX_RETRIES) throw e;
|
|
143
|
+
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
lastRes = res;
|
|
147
|
+
if (!retryable || !RETRYABLE_STATUS.has(res.status) || attempt === MAX_RETRIES) return res;
|
|
148
|
+
const hinted = parseRetryAfter(res.headers?.["retry-after"]);
|
|
149
|
+
const delay = hinted ?? RETRY_BASE_DELAY_MS * 2 ** attempt;
|
|
150
|
+
await sleep(Math.min(delay, 15000));
|
|
151
|
+
}
|
|
152
|
+
return lastRes;
|
|
153
|
+
}
|
|
154
|
+
|
|
103
155
|
export class WebDAVClient {
|
|
104
156
|
constructor({ baseUrl, username, password, timeoutMs = 60000, insecureTls = false }) {
|
|
105
157
|
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
@@ -122,7 +174,7 @@ export class WebDAVClient {
|
|
|
122
174
|
}
|
|
123
175
|
|
|
124
176
|
async #req(method, remotePath, { headers = {}, body = null } = {}) {
|
|
125
|
-
return
|
|
177
|
+
return requestWithRetry(this.urlFor(remotePath), {
|
|
126
178
|
method,
|
|
127
179
|
headers: { ...this.auth, ...headers },
|
|
128
180
|
body,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-webdav-backup",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "pi 配置与插件数据自动备份到 WebDAV(坚果云 / Nextcloud / 群晖 / 自建):归档与快照两种模式、AES-256-GCM 加密、DPAPI 密钥管理、敏感字段脱敏、agentDir 之外的额外数据(extraScope)、插件实现代码随备份(includePlugins)实现换机一键还原、退出自动备份与每日定时备份。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|