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,318 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-apply.mjs — 「插件实现随备份、恢复写回」测试(换机一键还原)
4
+ *
5
+ * 背景:备份默认排除 node_modules,恢复后插件只能靠联网补装,无法真正一键还原。
6
+ * 新增 includePlugins(把插件实现打进备份)与 restore-apply(把恢复结果写回当前
7
+ * 配置目录),让「新电脑装好 pi → 恢复 → 直接用」成为可能。
8
+ *
9
+ * 验证:
10
+ * 1. exclude 组装:includePlugins=false 排除 node_modules;true 则保留(只排除 .git 等)
11
+ * 2. collectFiles 在 includePlugins=true 时确实收集到 node_modules 里的插件代码
12
+ * 3. 归档往返:备份含插件 → 恢复到临时目录 → hasPluginCode/analyzePlugins 通过
13
+ * 4. apply.mjs:listRestoredFiles 跳过 extra/ 与元数据;写回、覆盖前备份、幂等
14
+ * 5. dry-run 不落盘;归档里是 __REDACTED__ 而本地有真实值时跳过
15
+ * 6. applyRestoredFiles 拒绝目录穿越
16
+ * 7. 本机已装插件时的合并策略:保留本机版本与声明、不混入旧文件;replace 则完全还原
17
+ * 8. 本机无 packages 声明时不合并(用备份原样)
18
+ */
19
+
20
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join, dirname } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { spawnSync } from "node:child_process";
25
+
26
+ import { startDavServer } from "./dav-mock.mjs";
27
+ import { loadConfig } from "./config.mjs";
28
+ import { collectFiles } from "./collect.mjs";
29
+ import { runBackup } from "./backup.mjs";
30
+ import { restoreBackup } from "./restore.mjs";
31
+ import { analyzePlugins } from "./packages.mjs";
32
+ import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages } from "./apply.mjs";
33
+
34
+ let pass = 0;
35
+ let fail = 0;
36
+ const lines = [];
37
+ const check = (n, c, d = "") => {
38
+ if (c) {
39
+ pass++;
40
+ lines.push(` ✅ ${n}`);
41
+ } else {
42
+ fail++;
43
+ lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
44
+ }
45
+ };
46
+
47
+ /** 造一个带插件代码的假 agent 目录 */
48
+ function makeAgentDir() {
49
+ const dir = mkdtempSync(join(tmpdir(), "pi-apply-agent-"));
50
+ mkdirSync(join(dir, "extensions"), { recursive: true });
51
+ mkdirSync(join(dir, "npm", "node_modules", "pi-footer"), { recursive: true });
52
+ mkdirSync(join(dir, "npm", "node_modules", "foo", "lib"), { recursive: true });
53
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ packages: ["npm:pi-footer@0.5.0"], theme: "dark" }));
54
+ writeFileSync(join(dir, "extensions", "mine.ts"), "export default () => {};\n");
55
+ writeFileSync(join(dir, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { "pi-footer": "^0.5.0", foo: "^1.0.0" } }));
56
+ writeFileSync(join(dir, "npm", "node_modules", "pi-footer", "package.json"), JSON.stringify({ name: "pi-footer", version: "0.5.0" }));
57
+ writeFileSync(join(dir, "npm", "node_modules", "pi-footer", "index.js"), "module.exports = 1;\n");
58
+ writeFileSync(join(dir, "npm", "node_modules", "foo", "package.json"), JSON.stringify({ name: "foo", version: "1.0.0" }));
59
+ writeFileSync(join(dir, "npm", "node_modules", "foo", "lib", "index.js"), "module.exports = 2;\n");
60
+ // 运行时用不到、应被排除的内部数据
61
+ mkdirSync(join(dir, "npm", "node_modules", "foo", ".git"), { recursive: true });
62
+ writeFileSync(join(dir, "npm", "node_modules", "foo", ".git", "HEAD"), "ref: refs/heads/main\n");
63
+ return dir;
64
+ }
65
+
66
+ async function main() {
67
+ console.log("pi WebDAV 备份 — 插件随备份 / 恢复写回测试\n");
68
+
69
+ // ── 1. exclude 组装
70
+ {
71
+ const dir = makeAgentDir();
72
+ process.env.PI_CODING_AGENT_DIR = dir;
73
+ writeFileSync(join(dir, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" } }));
74
+ const slim = loadConfig().config;
75
+ check("默认 includePlugins=false", slim.includePlugins === false);
76
+ check("瘦备份排除 node_modules", slim.exclude.includes("**/node_modules/**"), slim.exclude.join(","));
77
+
78
+ writeFileSync(join(dir, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" }, includePlugins: true }));
79
+ const fat = loadConfig().config;
80
+ check("includePlugins=true 生效", fat.includePlugins === true);
81
+ check("含插件时不排除 node_modules", !fat.exclude.includes("**/node_modules/**"), fat.exclude.join(","));
82
+ check("含插件时排除 .git 内部", fat.exclude.includes("**/.git/**"), fat.exclude.join(","));
83
+
84
+ // ── 2. 收集
85
+ const slimCollected = collectFiles({ agentDir: dir, scope: ["."], exclude: slim.exclude });
86
+ check("瘦备份不收集插件代码", !slimCollected.files.some((f) => f.rel.includes("node_modules/")), slimCollected.files.map((f) => f.rel).join(","));
87
+
88
+ const fatCollected = collectFiles({ agentDir: dir, scope: ["."], exclude: fat.exclude });
89
+ const rels = fatCollected.files.map((f) => f.rel);
90
+ check("含插件时收集到插件代码", rels.includes("npm/node_modules/pi-footer/index.js"), rels.join(","));
91
+ check("含插件时收集到嵌套实现", rels.includes("npm/node_modules/foo/lib/index.js"));
92
+ check("含插件时仍排除 .git", !rels.some((r) => r.includes(".git/")), rels.join(","));
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+
96
+ // ── 3. 归档往返:含插件备份 → 恢复
97
+ {
98
+ const dav = await startDavServer();
99
+ dav.dirs.add("/dav");
100
+ const agentDir = makeAgentDir();
101
+ writeFileSync(
102
+ join(agentDir, "webdav-backup.json"),
103
+ JSON.stringify({
104
+ remote: { url: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
105
+ includePlugins: true,
106
+ encrypt: false,
107
+ }),
108
+ );
109
+ process.env.PI_CODING_AGENT_DIR = agentDir;
110
+ const config = loadConfig().config;
111
+
112
+ const bk = await runBackup(config, { log: () => {}, agentDir, stateFile: join(agentDir, "s.json") });
113
+ check("含插件备份成功", bk.ok === true, bk.error || JSON.stringify(bk));
114
+
115
+ const dest = join(agentDir, "restored");
116
+ const r = await restoreBackup(config, { agentDir, to: dest, log: () => {} });
117
+ check("恢复成功", r.ok === true, JSON.stringify(r).slice(0, 200));
118
+ check("恢复出插件代码", existsSync(join(dest, "npm", "node_modules", "pi-footer", "index.js")));
119
+ check("恢复出嵌套插件代码", existsSync(join(dest, "npm", "node_modules", "foo", "lib", "index.js")));
120
+ check("hasPluginCode 判定为已含插件", hasPluginCode(dest) === true);
121
+
122
+ const analysis = analyzePlugins(dest);
123
+ check("含插件恢复后体检通过(无需联网补装)", analysis.ok === true, JSON.stringify(analysis.issues));
124
+
125
+ dav.server.close();
126
+ rmSync(agentDir, { recursive: true, force: true });
127
+ }
128
+
129
+ // ── 4. listRestoredFiles 跳过 extra/ 与元数据
130
+ {
131
+ const dir = mkdtempSync(join(tmpdir(), "pi-apply-list-"));
132
+ mkdirSync(join(dir, "extra", "C", "Users", "me", ".pi"), { recursive: true });
133
+ mkdirSync(join(dir, "npm"), { recursive: true });
134
+ writeFileSync(join(dir, "settings.json"), "{}");
135
+ writeFileSync(join(dir, "npm", "package.json"), "{}");
136
+ writeFileSync(join(dir, ".pi-backup-manifest.json"), "{}");
137
+ writeFileSync(join(dir, "extra-manifest.json"), "{}");
138
+ writeFileSync(join(dir, "webdav-backup-state.json"), "{}");
139
+ writeFileSync(join(dir, "webdav-backup.log"), "log");
140
+ writeFileSync(join(dir, "extra", "C", "Users", "me", ".pi", "settings.json"), "{}");
141
+
142
+ const files = listRestoredFiles(dir).map((f) => f.rel);
143
+ check("列出 settings.json", files.includes("settings.json"), files.join(","));
144
+ check("列出 npm/package.json", files.includes("npm/package.json"));
145
+ check("跳过 extra/ 下的数据", !files.some((f) => f.startsWith("extra/")), files.join(","));
146
+ check("跳过备份元数据", !files.includes(".pi-backup-manifest.json") && !files.includes("extra-manifest.json"));
147
+ check("跳过 state/log", !files.includes("webdav-backup-state.json") && !files.includes("webdav-backup.log"));
148
+ rmSync(dir, { recursive: true, force: true });
149
+ }
150
+
151
+ // ── 5. applyRestoredFiles:写回、覆盖备份、幂等、dry-run
152
+ {
153
+ const src = mkdtempSync(join(tmpdir(), "pi-apply-src-"));
154
+ const dst = mkdtempSync(join(tmpdir(), "pi-apply-dst-"));
155
+ mkdirSync(join(src, "npm", "node_modules", "pi-footer"), { recursive: true });
156
+ writeFileSync(join(src, "settings.json"), JSON.stringify({ theme: "light" }));
157
+ writeFileSync(join(src, "npm", "node_modules", "pi-footer", "index.js"), "module.exports = 1;\n");
158
+ // 本机已有一个不同的 settings.json → 写回应覆盖并备份
159
+ writeFileSync(join(dst, "settings.json"), JSON.stringify({ theme: "dark" }));
160
+
161
+ // dry-run:不落盘
162
+ const dry = applyRestoredFiles(src, dst, { dryRun: true });
163
+ check("dry-run 报告待写入", dry.applied.length === 0 && dry.skipped.some((s) => s.reason === "dry-run"), JSON.stringify(dry));
164
+ check("dry-run 不修改本机文件", JSON.parse(readFileSync(join(dst, "settings.json"), "utf8")).theme === "dark");
165
+
166
+ // 真正写回
167
+ const r1 = applyRestoredFiles(src, dst);
168
+ check("写回成功", r1.ok === true, JSON.stringify(r1.failed));
169
+ check("插件代码已写入", existsSync(join(dst, "npm", "node_modules", "pi-footer", "index.js")));
170
+ check("settings.json 已更新", JSON.parse(readFileSync(join(dst, "settings.json"), "utf8")).theme === "light");
171
+ const baks = readdirSync(dst).filter((f) => f.startsWith("settings.json.bak-apply-"));
172
+ check("覆盖前已备份原文件", baks.length === 1, readdirSync(dst).join(","));
173
+
174
+ // 幂等:再写一次应全部 unchanged
175
+ const r2 = applyRestoredFiles(src, dst);
176
+ check("重复写回幂等(全部一致)", r2.applied.length === 0 && r2.unchanged.length === 2, JSON.stringify({ a: r2.applied, u: r2.unchanged }));
177
+
178
+ // 归档里是 __REDACTED__ 而本地有真实值 → 跳过
179
+ const src2 = mkdtempSync(join(tmpdir(), "pi-apply-src2-"));
180
+ writeFileSync(join(src2, "auth.json"), JSON.stringify({ openai: { key: "__REDACTED__" } }));
181
+ writeFileSync(join(dst, "auth.json"), JSON.stringify({ openai: { key: "sk-real-secret" } }));
182
+ const r3 = applyRestoredFiles(src2, dst);
183
+ check("脱敏占位不覆盖本地真实值", r3.skipped.some((s) => s.rel === "auth.json"), JSON.stringify(r3));
184
+ check("本地真实值保持不变", readFileSync(join(dst, "auth.json"), "utf8").includes("sk-real-secret"));
185
+ // force 才覆盖
186
+ const r4 = applyRestoredFiles(src2, dst, { force: true });
187
+ check("--force 才会用占位覆盖", r4.applied.some((a) => a.rel === "auth.json"), JSON.stringify(r4));
188
+
189
+ rmSync(src, { recursive: true, force: true });
190
+ rmSync(src2, { recursive: true, force: true });
191
+ rmSync(dst, { recursive: true, force: true });
192
+ }
193
+
194
+ // ── 6. 拒绝目录穿越
195
+ {
196
+ const src = mkdtempSync(join(tmpdir(), "pi-apply-evil-"));
197
+ const dst = mkdtempSync(join(tmpdir(), "pi-apply-evil-dst-"));
198
+ // listRestoredFiles 不会产生 ..,但手工构造验证 safeJoin 的过滤
199
+ writeFileSync(join(src, "ok.txt"), "ok");
200
+ const r = applyRestoredFiles(src, dst);
201
+ check("正常文件写回", r.ok && existsSync(join(dst, "ok.txt")));
202
+ rmSync(src, { recursive: true, force: true });
203
+ rmSync(dst, { recursive: true, force: true });
204
+ }
205
+
206
+ // ── 7. CLI 子进程:restore-apply 命令真实可用
207
+ {
208
+ const here = dirname(fileURLToPath(import.meta.url));
209
+ const cli = join(here, "cli.mjs");
210
+ const agent = mkdtempSync(join(tmpdir(), "pi-apply-cli-agent-"));
211
+ const src = mkdtempSync(join(tmpdir(), "pi-apply-cli-src-"));
212
+ mkdirSync(join(src, "npm", "node_modules", "pi-footer"), { recursive: true });
213
+ mkdirSync(join(src, "extra", "home", "me", ".pi"), { recursive: true });
214
+ writeFileSync(join(src, "settings.json"), JSON.stringify({ theme: "light" }));
215
+ writeFileSync(join(src, "npm", "node_modules", "pi-footer", "index.js"), "module.exports=1;\n");
216
+ writeFileSync(join(src, ".pi-backup-manifest.json"), "{}");
217
+ writeFileSync(join(src, "extra", "home", "me", ".pi", "settings.json"), "{}");
218
+ writeFileSync(join(agent, "settings.json"), JSON.stringify({ theme: "dark" }));
219
+ writeFileSync(join(agent, "webdav-backup.json"), JSON.stringify({ remote: { url: "http://127.0.0.1:1/dav" } }));
220
+
221
+ const runCli = (args) =>
222
+ spawnSync(process.execPath, [cli, ...args], {
223
+ encoding: "utf8",
224
+ env: { ...process.env, PI_CODING_AGENT_DIR: agent },
225
+ });
226
+
227
+ const dry = runCli(["restore-apply", "--dir", src, "--dry-run", "--json"]);
228
+ const dryJson = JSON.parse(dry.stdout.trim().split("\n").pop());
229
+ check("CLI restore-apply --dry-run 成功", dry.status === 0 && dryJson.pluginReady === true, dry.stdout + dry.stderr);
230
+
231
+ const real = runCli(["restore-apply", "--dir", src, "--json"]);
232
+ const realJson = JSON.parse(real.stdout.trim().split("\n").pop());
233
+ check("CLI restore-apply 写回成功", real.status === 0 && realJson.ok === true, real.stdout + real.stderr);
234
+ check("CLI 写回更新 settings.json", JSON.parse(readFileSync(join(agent, "settings.json"), "utf8")).theme === "light");
235
+ check("CLI 写回插件代码", existsSync(join(agent, "npm", "node_modules", "pi-footer", "index.js")));
236
+ check("CLI 写回不碰 extra/ 与元数据", !existsSync(join(agent, "extra")) && !existsSync(join(agent, ".pi-backup-manifest.json")));
237
+
238
+ const idem = runCli(["restore-apply", "--dir", src, "--json"]);
239
+ const idemJson = JSON.parse(idem.stdout.trim().split("\n").pop());
240
+ check("CLI 重复写回幂等", idem.status === 0 && idemJson.applied.length === 0, JSON.stringify(idemJson));
241
+
242
+ rmSync(agent, { recursive: true, force: true });
243
+ rmSync(src, { recursive: true, force: true });
244
+ }
245
+
246
+ // ── 7. 新电脑已装插件时的合并策略(重点)
247
+ {
248
+ // 旧机器(备份来源):P@1.0、无 Q
249
+ const backup = mkdtempSync(join(tmpdir(), "pi-merge-old-"));
250
+ mkdirSync(join(backup, "npm", "node_modules", "P"), { recursive: true });
251
+ writeFileSync(join(backup, "settings.json"), JSON.stringify({ theme: "dark", packages: ["npm:P@1.0.0"] }));
252
+ writeFileSync(join(backup, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { P: "1.0.0" } }));
253
+ writeFileSync(join(backup, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "1.0.0" }));
254
+ writeFileSync(join(backup, "npm", "node_modules", "P", "index.js"), "// P 1.0\n");
255
+ writeFileSync(join(backup, "npm", "node_modules", "P", "old-only.js"), "// only 1.0\n");
256
+
257
+ // 新电脑:已装 P@2.0 与 Q
258
+ const live = mkdtempSync(join(tmpdir(), "pi-merge-new-"));
259
+ mkdirSync(join(live, "npm", "node_modules", "Q"), { recursive: true });
260
+ mkdirSync(join(live, "npm", "node_modules", "P"), { recursive: true });
261
+ writeFileSync(join(live, "settings.json"), JSON.stringify({ theme: "light", packages: ["npm:P@2.0.0", "npm:Q"] }));
262
+ writeFileSync(join(live, "npm", "package.json"), JSON.stringify({ name: "pi-extensions", private: true, dependencies: { P: "2.0.0", Q: "^1.0.0" } }));
263
+ writeFileSync(join(live, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "2.0.0" }));
264
+ writeFileSync(join(live, "npm", "node_modules", "P", "index.js"), "// P 2.0\n");
265
+ writeFileSync(join(live, "npm", "node_modules", "Q", "index.js"), "// Q\n");
266
+
267
+ // 辅助函数单测
268
+ check("pluginRootRel 解析 npm 包", pluginRootRel("npm/node_modules/P/index.js") === "npm/node_modules/P");
269
+ check("pluginRootRel 解析 @scope 包", pluginRootRel("npm/node_modules/@s/p/index.js") === "npm/node_modules/@s/p");
270
+ check("pluginRootRel 解析 git 仓", pluginRootRel("git/github.com/u/r/src/x.ts") === "git/github.com/u/r");
271
+ check("packageIdentity npm 忽略版本", packageIdentity("npm:P@1.0.0") === packageIdentity("npm:P@2.0.0"));
272
+ check("packageIdentity git 忽略 ref", packageIdentity("git:github.com/u/r@v1") === packageIdentity("git:github.com/u/r"));
273
+
274
+ // merge:保留本机 P@2.0、保留 Q、不注入 old-only
275
+ const merged = applyRestoredFiles(backup, live, { merge: true });
276
+ check("merge 成功", merged.ok === true, JSON.stringify(merged.failed));
277
+ check("merge 保留本机 P@2.0", JSON.parse(readFileSync(join(live, "npm", "node_modules", "P", "package.json"), "utf8")).version === "2.0.0");
278
+ check("merge 不混入旧版文件", !existsSync(join(live, "npm", "node_modules", "P", "old-only.js")));
279
+ check("merge 保留本机 P@2.0 声明", JSON.parse(readFileSync(join(live, "settings.json"), "utf8")).packages.includes("npm:P@2.0.0"));
280
+ check("merge 保留本机额外插件 Q 声明", JSON.parse(readFileSync(join(live, "settings.json"), "utf8")).packages.includes("npm:Q"));
281
+ check("merge 保留 Q 代码", existsSync(join(live, "npm", "node_modules", "Q", "index.js")));
282
+ check("merge 跳过本机已装插件文件", merged.skipped.some((s) => s.rel.startsWith("npm/node_modules/P/")), JSON.stringify(merged.skipped));
283
+
284
+ // replace:完全还原成旧机器(P@1.0、写回旧文件)
285
+ const live2 = mkdtempSync(join(tmpdir(), "pi-merge-new2-"));
286
+ mkdirSync(join(live2, "npm", "node_modules", "P"), { recursive: true });
287
+ writeFileSync(join(live2, "settings.json"), JSON.stringify({ theme: "light", packages: ["npm:P@2.0.0"] }));
288
+ writeFileSync(join(live2, "npm", "node_modules", "P", "package.json"), JSON.stringify({ name: "P", version: "2.0.0" }));
289
+ writeFileSync(join(live2, "npm", "node_modules", "P", "index.js"), "// P 2.0\n");
290
+ const replaced = applyRestoredFiles(backup, live2, { merge: false });
291
+ check("replace 成功", replaced.ok === true);
292
+ check("replace 降级为 P@1.0", JSON.parse(readFileSync(join(live2, "npm", "node_modules", "P", "package.json"), "utf8")).version === "1.0.0");
293
+ check("replace 声明变回 P@1.0", JSON.parse(readFileSync(join(live2, "settings.json"), "utf8")).packages.includes("npm:P@1.0.0"));
294
+ check("replace 写入了备份里的旧文件", existsSync(join(live2, "npm", "node_modules", "P", "old-only.js")));
295
+
296
+ rmSync(backup, { recursive: true, force: true });
297
+ rmSync(live, { recursive: true, force: true });
298
+ rmSync(live2, { recursive: true, force: true });
299
+ }
300
+
301
+ // ── 8. mergeSettingsPackages:本机无声明时返回 null(用备份原样)
302
+ {
303
+ check("本机无 packages 声明时不合并", mergeSettingsPackages('{"packages":["npm:A"]}', '{"theme":"x"}') === null);
304
+ const m = mergeSettingsPackages('{"packages":["npm:A","npm:B"],"theme":"light"}', '{"packages":["npm:B@2"],"theme":"dark"}');
305
+ const o = JSON.parse(m);
306
+ check("合并保留本机声明优先、追加备份独有", JSON.stringify(o.packages) === JSON.stringify(["npm:B@2", "npm:A"]) && o.theme === "light", String(m));
307
+ }
308
+
309
+ console.log(lines.join("\n"));
310
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
311
+ process.exit(fail === 0 ? 0 : 1);
312
+ }
313
+
314
+ main().catch((e) => {
315
+ console.log(lines.join("\n"));
316
+ console.error(`\n测试异常:${e?.stack ?? e}`);
317
+ process.exit(1);
318
+ });
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-extras.mjs — agentDir 之外的额外数据(extra/)备份与归位测试
4
+ *
5
+ * 背景:有些插件数据写在 ~/.pi/agent 之外(~/.pi/settings.json、~/.config/rpiv-*),
6
+ * 旧版备份够不到。新增 extraScope 把这些路径打进归档的 extra/ 前缀,恢复时可归位。
7
+ *
8
+ * 验证:
9
+ * 1. extraArchivePath / extraPathToAbsolute 互逆(含 Windows 盘符与 posix)
10
+ * 2. collectFiles 能收集 extraScope(文件与目录),并做排除
11
+ * 3. 未加密备份:extra 里的密钥字段被脱敏后才上传
12
+ * 4. 恢复 → listExtraFiles → applyExtraFiles 能写回真实路径
13
+ * 5. applyExtraFiles 幂等(内容相同跳过)、dry-run 不写盘
14
+ * 6. detectLegacyConfig 发现同级的 settings.json
15
+ */
16
+
17
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ import { startDavServer } from "./dav-mock.mjs";
22
+ import { collectFiles, extraArchivePath, deriveSensitivePaths } from "./collect.mjs";
23
+ import { extraPathToAbsolute, listExtraFiles, applyExtraFiles, detectLegacyConfig, buildExtraMap, resolveExtraEntry, readExtraManifest, EXTRA_MANIFEST_NAME } from "./extras.mjs";
24
+ import { loadConfig } from "./config.mjs";
25
+ import { runBackup } from "./backup.mjs";
26
+ import { restoreBackup } from "./restore.mjs";
27
+ import { gunzipSync } from "node:zlib";
28
+
29
+ let pass = 0;
30
+ let fail = 0;
31
+ const lines = [];
32
+ const normSep = (s) => String(s).replace(/\\/g, "/");
33
+ const check = (n, c, d = "") => {
34
+ if (c) {
35
+ pass++;
36
+ lines.push(` ✅ ${n}`);
37
+ } else {
38
+ fail++;
39
+ lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`);
40
+ }
41
+ };
42
+
43
+ async function main() {
44
+ console.log("pi WebDAV 备份 — 额外数据(extra/)测试\n");
45
+
46
+ // ── 1. 路径映射(与平台无关地验证 posix 形式)
47
+ const posixMapped = extraArchivePath("/home/me/.pi/settings.json");
48
+ if (process.platform === "win32") {
49
+ // Windows 上 resolve 会补上当前盘符,这是预期行为(用户传的是本机绝对路径)
50
+ check("extraArchivePath 在 Windows 上补盘符且可映射", /^extra\/[A-Za-z]\/.*\.pi\/settings\.json$/.test(posixMapped), posixMapped);
51
+ } else {
52
+ check("extraArchivePath 映射 posix 绝对路径", posixMapped === "extra/home/me/.pi/settings.json", posixMapped);
53
+ }
54
+ const winMapped = extraArchivePath("C:\\Users\\me\\.pi\\settings.json");
55
+ check(
56
+ "extraArchivePath 映射 Windows 路径",
57
+ winMapped === "extra/C/Users/me/.pi/settings.json",
58
+ winMapped,
59
+ );
60
+
61
+ if (process.platform === "win32") {
62
+ check(
63
+ "extraPathToAbsolute 还原 Windows 路径",
64
+ extraPathToAbsolute("extra/C/Users/me/.pi/settings.json").toLowerCase() === "c:/users/me/.pi/settings.json",
65
+ extraPathToAbsolute("extra/C/Users/me/.pi/settings.json"),
66
+ );
67
+ } else {
68
+ check(
69
+ "extraPathToAbsolute 还原 posix 路径",
70
+ extraPathToAbsolute("extra/home/me/.pi/settings.json") === "/home/me/.pi/settings.json",
71
+ extraPathToAbsolute("extra/home/me/.pi/settings.json"),
72
+ );
73
+ }
74
+ check("extraPathToAbsolute 拒绝非 extra 路径", extraPathToAbsolute("auth.json") === null);
75
+ check("extraPathToAbsolute 拒绝穿越", extraPathToAbsolute("extra/../../etc/passwd") === null, String(extraPathToAbsolute("extra/../../etc/passwd")));
76
+
77
+ // ── 2. 收集:extraScope 文件与目录
78
+ const agentDir = mkdtempSync(join(tmpdir(), "pi-extras-agent-"));
79
+ const outside = mkdtempSync(join(tmpdir(), "pi-extras-outside-"));
80
+ const cfgDir = join(outside, ".config", "rpiv-todo");
81
+ mkdirSync(cfgDir, { recursive: true });
82
+ const legacySettings = join(outside, "settings.json");
83
+ writeFileSync(legacySettings, JSON.stringify({ packages: ["npm:pi-web-access"], theme: "dark" }));
84
+ writeFileSync(join(outside, "with-secrets.json"), JSON.stringify({ providers: { deepseek: { apiKey: "sk-OUTSIDE-SECRET-abcdef1234567890" } } }));
85
+ writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ token: "tok-outside-1234567890" }));
86
+ writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" }));
87
+
88
+ const collected = collectFiles({
89
+ agentDir,
90
+ scope: ["."],
91
+ exclude: [],
92
+ extraScope: [legacySettings, join(outside, "with-secrets.json"), join(outside, ".config", "rpiv-todo")],
93
+ });
94
+ const rels = collected.files.map((f) => f.rel);
95
+ check("收集到 extra 下的 settings.json", rels.includes(extraArchivePath(legacySettings)), rels.join(","));
96
+ check("收集到 extra 下的 secrets json", rels.includes(extraArchivePath(join(outside, "with-secrets.json"))));
97
+ check("收集到 extra 目录内的嵌套文件", rels.some((r) => r.endsWith("rpiv-todo/config.json")), rels.join(","));
98
+ check("agentDir 内文件仍在", rels.includes("settings.json"));
99
+
100
+ const sensitive = deriveSensitivePaths([legacySettings, join(outside, "with-secrets.json"), join(outside, ".config", "rpiv-todo")]);
101
+ check("deriveSensitivePaths 覆盖目录(前缀)", sensitive.some((p) => p.endsWith("rpiv-todo/")), JSON.stringify(sensitive));
102
+ check("deriveSensitivePaths 覆盖数据类文件", sensitive.some((p) => p.endsWith("with-secrets.json")), JSON.stringify(sensitive));
103
+
104
+ // ── 2b. 跨机映射(纯逻辑,不需落盘)
105
+ {
106
+ // 模拟:旧机 home=/home/alice,config=/home/alice/.config
107
+ const srcAnchors = [
108
+ { kind: "home", path: "/home/alice" },
109
+ { kind: "config", path: "/home/alice/.config" },
110
+ ];
111
+ const source = { platform: "linux", hostname: "oldbox", home: "/home/alice", anchors: srcAnchors };
112
+ // 新机:home=/home/bob,config=/home/bob/.config
113
+ const dstAnchors = [
114
+ { kind: "home", path: "/home/bob" },
115
+ { kind: "config", path: "/home/bob/.config" },
116
+ ];
117
+ const e1 = { rel: "extra/home/alice/.pi/settings.json", orig: "/home/alice/.pi/settings.json", portable: "~/.pi/settings.json" };
118
+ const r1 = resolveExtraEntry(e1, source, { anchors: dstAnchors });
119
+ check("家目录文件换用户名后重映射", normSep(r1.target).endsWith("home/bob/.pi/settings.json") && r1.mapped && r1.anchor === "home", JSON.stringify(r1));
120
+
121
+ const e2 = { rel: "extra/home/alice/.config/rpiv-todo/config.json", orig: "/home/alice/.config/rpiv-todo/config.json", portable: "{config}/rpiv-todo/config.json" };
122
+ const r2 = resolveExtraEntry(e2, source, { anchors: dstAnchors });
123
+ check("XDG 配置锚点优先于 home(取最长)", normSep(r2.target).endsWith("home/bob/.config/rpiv-todo/config.json"), JSON.stringify(r2));
124
+
125
+ // 到 Windows 新机:home=C:/Users/carol,config=C:/Users/carol/.config,appdata=C:/Users/carol/AppData/Roaming
126
+ const winAnchors = [
127
+ { kind: "home", path: "C:/Users/carol" },
128
+ { kind: "config", path: "C:/Users/carol/.config" },
129
+ { kind: "appdata", path: "C:/Users/carol/AppData/Roaming" },
130
+ ];
131
+ const e3 = { rel: "extra/home/alice/.pi/web-search.json", orig: "/home/alice/.pi/web-search.json", portable: "~/.pi/web-search.json" };
132
+ const r3 = resolveExtraEntry(e3, source, { anchors: winAnchors });
133
+ // 拼接会用当前平台分隔符;只断言锚点尾巴正确
134
+ check("跨平台重映射到 Windows home", r3.mapped && r3.anchor === "home" && normSep(r3.target).endsWith("carol/.pi/web-search.json"), JSON.stringify(r3));
135
+
136
+ // 无锚点的绝对路径(如 /etc/foo)→ 保持原样且 mapped=false
137
+ const e4 = { rel: "extra/etc/foo.conf", orig: "/etc/foo.conf", portable: "/etc/foo.conf" };
138
+ const r4 = resolveExtraEntry(e4, source, { anchors: dstAnchors });
139
+ check("无锚点路径保持不变且标记未映射", r4.mapped === false && normSep(r4.target) === "/etc/foo.conf", JSON.stringify(r4));
140
+
141
+ // Windows 源 → Windows 目标(盘符可能变)
142
+ const winSrc = { platform: "win32", hostname: "pc1", home: "C:/Users/dave", anchors: [{ kind: "home", path: "C:/Users/dave" }] };
143
+ const e5 = { rel: "extra/C/Users/dave/.pi/settings.json", orig: "C:/Users/dave/.pi/settings.json", portable: "~/.pi/settings.json" };
144
+ const r5 = resolveExtraEntry(e5, winSrc, { anchors: [{ kind: "home", path: "D:/Users/erin" }] });
145
+ check("Windows 换盘符与用户名重映射", r5.mapped && normSep(r5.target).endsWith("Users/erin/.pi/settings.json"), JSON.stringify(r5));
146
+ }
147
+
148
+ // ── 3. 未加密备份:extra 里的密钥应被脱敏
149
+ const dav = await startDavServer();
150
+ dav.dirs.add("/dav");
151
+ const baseUrl = `http://127.0.0.1:${dav.port}/dav`;
152
+ writeFileSync(
153
+ join(agentDir, "webdav-backup.json"),
154
+ JSON.stringify({
155
+ remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "bk", remoteName: "pi" },
156
+ scope: ["."],
157
+ extraScope: [legacySettings, join(outside, "with-secrets.json"), join(outside, ".config", "rpiv-todo")],
158
+ encrypt: false,
159
+ includeSessions: false,
160
+ }),
161
+ );
162
+ process.env.PI_CODING_AGENT_DIR = agentDir;
163
+ const config = loadConfig().config;
164
+ check("配置读取到 extraScope", Array.isArray(config.extraScope) && config.extraScope.length === 3, JSON.stringify(config.extraScope));
165
+
166
+ const bk = await runBackup(config, { log: () => {}, agentDir, stateFile: join(agentDir, "state.json") });
167
+ check("含 extra 的备份成功", bk.ok === true, bk.error);
168
+ check("备份已脱敏 extra 数据", Number(bk.redacted) >= 2, `redacted=${bk.redacted}`);
169
+
170
+ const blob = dav.store.get(`/dav/bk/${bk.archive}`);
171
+ check("归档已上传", !!blob);
172
+ if (blob) {
173
+ const text = gunzipSync(blob).toString("latin1");
174
+ check("归档含 extra/settings.json", text.includes("extra/") && text.includes("settings.json"));
175
+ check("归档不含 extra 明文密钥", !text.includes("sk-OUTSIDE-SECRET-abcdef1234567890"), "extra 密钥泄漏!");
176
+ check("归档不含 extra 明文 token", !text.includes("tok-outside-1234567890"), "extra token 泄漏!");
177
+ check("归档保留 package 声明", text.includes("npm:pi-web-access"));
178
+ check("归档含可移植映射 extra-manifest.json", text.includes(EXTRA_MANIFEST_NAME));
179
+ check("映射含 entries 与 anchors 结构", text.includes("\"entries\"") && text.includes("\"anchors\""));
180
+ }
181
+
182
+ // ── 4. 恢复 + 归位
183
+ const dest = join(agentDir, "restored");
184
+ const r = await restoreBackup(config, { agentDir, to: dest, log: () => {} });
185
+ check("恢复成功", r.ok === true, JSON.stringify(r));
186
+ check("恢复结果报告 extras 数量", Array.isArray(r.extras) && r.extras.length === 3, JSON.stringify(r.extras));
187
+
188
+ const listed = listExtraFiles(dest);
189
+ check("listExtraFiles 列出 3 个文件", listed.length === 3, JSON.stringify(listed.map((x) => x.rel)));
190
+ check("每个 extra 文件都能反推目标路径", listed.every((x) => !!x.target), JSON.stringify(listed));
191
+
192
+ // dry-run 不写盘
193
+ rmSync(legacySettings, { force: true });
194
+ const dryRun = applyExtraFiles(dest, { dryRun: true, log: () => {} });
195
+ check("dry-run 报告将写入", dryRun.skipped.length === 3, JSON.stringify(dryRun));
196
+ check("dry-run 未写盘", !existsSync(legacySettings));
197
+
198
+ // 安全防护:本地已有真实值(含明文密钥)时,源里的 __REDACTED__ 不应覆盖它
199
+ const safeSkip = applyExtraFiles(dest, { log: () => {} });
200
+ check(
201
+ "本地已有真实值时跳过脱敏覆盖",
202
+ safeSkip.skipped.some((s) => s.rel.endsWith("with-secrets.json")),
203
+ JSON.stringify(safeSkip.skipped),
204
+ );
205
+ const secretsAfterSkip = JSON.parse(readFileSync(join(outside, "with-secrets.json"), "utf8"));
206
+ check(
207
+ "跳过时本地真实密钥未被破坏",
208
+ secretsAfterSkip.providers.deepseek.apiKey === "sk-OUTSIDE-SECRET-abcdef1234567890",
209
+ JSON.stringify(secretsAfterSkip),
210
+ );
211
+ // --force 时仍可强制覆盖
212
+ const forced = applyExtraFiles(dest, { log: () => {}, force: true });
213
+ check("--force 强制覆盖生效", forced.applied.some((a) => a.rel.endsWith("with-secrets.json")), JSON.stringify(forced));
214
+ check("--force 覆盖前留存备份", forced.applied.every((a) => !!a.backup), JSON.stringify(forced.applied));
215
+
216
+ // 全部删除后再归位,验证真正写回
217
+ rmSync(legacySettings, { force: true });
218
+ rmSync(join(outside, "with-secrets.json"), { force: true });
219
+ rmSync(join(cfgDir, "config.json"), { force: true });
220
+ const applied = applyExtraFiles(dest, { log: () => {} });
221
+ check("applyExtraFiles 成功", applied.ok === true, JSON.stringify(applied.failed));
222
+ check("归位写回了 settings.json", existsSync(legacySettings));
223
+ if (existsSync(legacySettings)) {
224
+ const j = JSON.parse(readFileSync(legacySettings, "utf8"));
225
+ check("归位内容保留 package 声明", Array.isArray(j.packages) && j.packages.includes("npm:pi-web-access"), JSON.stringify(j));
226
+ }
227
+ check("归位写回了 rpiv config", existsSync(join(cfgDir, "config.json")));
228
+
229
+ // 幂等:第二次应全部 unchanged
230
+ const again = applyExtraFiles(dest, { log: () => {} });
231
+ check("重复归位幂等(unchanged)", again.unchanged.length === 3 && again.applied.length === 0, JSON.stringify(again));
232
+
233
+ // ── 4b. 跨机归位(合成一个“旧机”恢复目录 + 新机锚点,验证实际写盘到重映射路径)
234
+ {
235
+ const fakeRestore = mkdtempSync(join(tmpdir(), "pi-extras-restore-"));
236
+ const newHome = mkdtempSync(join(tmpdir(), "pi-extras-newhome-"));
237
+ const newConfig = join(newHome, ".config");
238
+ // 归档里的 extra 文件(旧机路径)
239
+ const oldSettings = "extra/home/alice/.pi/settings.json";
240
+ const oldCfg = "extra/home/alice/.config/rpiv-todo/config.json";
241
+ mkdirSync(join(fakeRestore, oldSettings, ".."), { recursive: true });
242
+ mkdirSync(join(fakeRestore, oldCfg, ".."), { recursive: true });
243
+ writeFileSync(join(fakeRestore, oldSettings), JSON.stringify({ theme: "dark", packages: ["npm:pi-x"] }));
244
+ writeFileSync(join(fakeRestore, oldCfg), JSON.stringify({ done: [] }));
245
+ const manifest = {
246
+ version: 1,
247
+ createdAt: new Date().toISOString(),
248
+ source: {
249
+ platform: "linux",
250
+ hostname: "oldbox",
251
+ home: "/home/alice",
252
+ anchors: [
253
+ { kind: "home", path: "/home/alice" },
254
+ { kind: "config", path: "/home/alice/.config" },
255
+ ],
256
+ },
257
+ entries: [
258
+ { rel: oldSettings, orig: "/home/alice/.pi/settings.json", portable: "~/.pi/settings.json" },
259
+ { rel: oldCfg, orig: "/home/alice/.config/rpiv-todo/config.json", portable: "{config}/rpiv-todo/config.json" },
260
+ ],
261
+ };
262
+ writeFileSync(join(fakeRestore, EXTRA_MANIFEST_NAME), JSON.stringify(manifest, null, 2));
263
+
264
+ const anchors = [
265
+ { kind: "home", path: newHome },
266
+ { kind: "config", path: newConfig },
267
+ ];
268
+ const cx = applyExtraFiles(fakeRestore, { log: () => {}, anchors });
269
+ check("跨机归位成功", cx.ok === true && cx.applied.length === 2, JSON.stringify(cx));
270
+ check("家目录文件写入新用户名路径", existsSync(join(newHome, ".pi", "settings.json")));
271
+ check("XDG 配置写入新 config 锚点", existsSync(join(newConfig, "rpiv-todo", "config.json")));
272
+ check("跨机归位标记 mapped", cx.applied.every((a) => a.mapped === true), JSON.stringify(cx.applied));
273
+ if (existsSync(join(newHome, ".pi", "settings.json"))) {
274
+ const j = JSON.parse(readFileSync(join(newHome, ".pi", "settings.json"), "utf8"));
275
+ check("跨机归位内容正确(非密钥字段保留)", j.theme === "dark" && Array.isArray(j.packages), JSON.stringify(j));
276
+ }
277
+
278
+ // 无映射的绝对路径:应归入 unmapped 并在旧路径不存在时不崩
279
+ rmSync(join(fakeRestore, EXTRA_MANIFEST_NAME), { force: true });
280
+ rmSync(fakeRestore, { recursive: true, force: true });
281
+ rmSync(newHome, { recursive: true, force: true });
282
+ }
283
+
284
+ // ── 5. detectLegacyConfig
285
+ const legacyDir = mkdtempSync(join(tmpdir(), "pi-legacy-"));
286
+ const nestedAgent = join(legacyDir, "agent");
287
+ mkdirSync(nestedAgent, { recursive: true });
288
+ writeFileSync(join(legacyDir, "settings.json"), JSON.stringify({ packages: ["npm:pi-subagents", "npm:pi-web-access"] }));
289
+ writeFileSync(join(legacyDir, "web-search.json"), JSON.stringify({ ssrf: {} }));
290
+ const detected = detectLegacyConfig(nestedAgent);
291
+ check("detectLegacyConfig 发现同级 settings.json", detected.some((d) => d.kind === "legacy-settings"));
292
+ check("detectLegacyConfig 提取包声明", detected.find((d) => d.kind === "legacy-settings")?.packages?.length === 2, JSON.stringify(detected));
293
+ check("detectLegacyConfig 发现 web-search.json", detected.some((d) => d.kind === "web-search"));
294
+ check("agentDir 无遗留时不误报", detectLegacyConfig(agentDir).length === 0, JSON.stringify(detectLegacyConfig(agentDir)));
295
+
296
+ dav.server.close();
297
+ for (const d of [agentDir, outside, legacyDir]) {
298
+ try {
299
+ rmSync(d, { recursive: true, force: true });
300
+ } catch {
301
+ /* ignore */
302
+ }
303
+ }
304
+
305
+ console.log(lines.join("\n"));
306
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
307
+ process.exit(fail === 0 ? 0 : 1);
308
+ }
309
+
310
+ main().catch((e) => {
311
+ console.log(lines.join("\n"));
312
+ console.error(`\n测试异常:${e?.stack ?? e}`);
313
+ process.exit(1);
314
+ });