pi-webdav-backup 1.0.1 → 1.3.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 +205 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/args.mjs +27 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -352
- 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/json.mjs +33 -0
- package/extensions/webdav-backup/packages.mjs +375 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-empty-cmd.mjs +167 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-json.mjs +89 -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,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
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-json.mjs — CLI 输出 JSON 解析的容错性
|
|
4
|
+
*
|
|
5
|
+
* 回归背景:空输出曾抛 "Unexpected end of JSON input",
|
|
6
|
+
* 在 pi 里显示为 Extension "command:backup" error,真实错误被吞掉。
|
|
7
|
+
*/
|
|
8
|
+
import { parseCliJson } from "./json.mjs";
|
|
9
|
+
|
|
10
|
+
let pass = 0;
|
|
11
|
+
let fail = 0;
|
|
12
|
+
const check = (n, c, d = "") => {
|
|
13
|
+
if (c) {
|
|
14
|
+
pass++;
|
|
15
|
+
console.log(` ✅ ${n}`);
|
|
16
|
+
} else {
|
|
17
|
+
fail++;
|
|
18
|
+
console.log(` ❌ ${n}${d ? ` — ${d}` : ""}`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
console.log("pi WebDAV 备份 — CLI JSON 解析容错测试\n");
|
|
23
|
+
|
|
24
|
+
// 正常:取最后一个非空行解析
|
|
25
|
+
const r1 = parseCliJson({ stdout: '日志行\n{"ok":true,"files":3}\n', stderr: "", code: 0 });
|
|
26
|
+
check("取最后一个非空行解析", r1 && r1.ok === true && r1.files === 3);
|
|
27
|
+
|
|
28
|
+
const r2 = parseCliJson({ stdout: '{"a":{"b":1}}' });
|
|
29
|
+
check("单行 JSON 也正常", r2 && r2.a && r2.a.b === 1);
|
|
30
|
+
|
|
31
|
+
// 回归:空输出 + stderr → 抛出含真实原因的错误
|
|
32
|
+
let thrown = null;
|
|
33
|
+
try {
|
|
34
|
+
parseCliJson({ stdout: "", stderr: "Cannot find module cli.mjs", code: 1 });
|
|
35
|
+
} catch (e) {
|
|
36
|
+
thrown = e;
|
|
37
|
+
}
|
|
38
|
+
check(
|
|
39
|
+
"空输出抛出含 stderr 的错误(回归)",
|
|
40
|
+
thrown !== null &&
|
|
41
|
+
/Cannot find module/.test(thrown.message) &&
|
|
42
|
+
!/Unexpected end of JSON/.test(thrown.message),
|
|
43
|
+
thrown?.message,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
thrown = null;
|
|
47
|
+
try {
|
|
48
|
+
parseCliJson({ stdout: "\n \n", stderr: "boom", code: 2 });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
thrown = e;
|
|
51
|
+
}
|
|
52
|
+
check("纯空白输出同样容错", thrown !== null && /boom/.test(thrown.message), thrown?.message);
|
|
53
|
+
|
|
54
|
+
thrown = null;
|
|
55
|
+
try {
|
|
56
|
+
parseCliJson({ stdout: "", stderr: "", code: 7 });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
thrown = e;
|
|
59
|
+
}
|
|
60
|
+
check("无输出无 stderr 时提示退出码", thrown !== null && /退出码 7/.test(thrown.message), thrown?.message);
|
|
61
|
+
|
|
62
|
+
// 非 JSON 文本 → 友好错误,不抛裸 JSON 语法错
|
|
63
|
+
thrown = null;
|
|
64
|
+
try {
|
|
65
|
+
parseCliJson({ stdout: "连接失败:401", stderr: "", code: 1 });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
thrown = e;
|
|
68
|
+
}
|
|
69
|
+
check(
|
|
70
|
+
"非 JSON 输出不抛裸语法错误",
|
|
71
|
+
thrown !== null &&
|
|
72
|
+
!/Unexpected end of JSON/.test(thrown.message) &&
|
|
73
|
+
/连接失败/.test(thrown.message),
|
|
74
|
+
thrown?.message,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// 边界
|
|
78
|
+
thrown = null;
|
|
79
|
+
try {
|
|
80
|
+
parseCliJson(null);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
thrown = e;
|
|
83
|
+
}
|
|
84
|
+
check("null 输入不崩溃", thrown !== null);
|
|
85
|
+
|
|
86
|
+
check("JSON 标量也可返回", parseCliJson({ stdout: "42" }) === 42);
|
|
87
|
+
|
|
88
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
89
|
+
process.exit(fail ? 1 : 0);
|