pi-webdav-backup 1.0.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/LICENSE +21 -0
- package/README.md +355 -0
- package/extensions/webdav-backup/audit-real.mjs +84 -0
- package/extensions/webdav-backup/auth-migrate.mjs +242 -0
- package/extensions/webdav-backup/backup.mjs +684 -0
- package/extensions/webdav-backup/cli.mjs +352 -0
- package/extensions/webdav-backup/collect.mjs +125 -0
- package/extensions/webdav-backup/config-paths.mjs +32 -0
- package/extensions/webdav-backup/config.mjs +199 -0
- package/extensions/webdav-backup/cron.mjs +90 -0
- package/extensions/webdav-backup/crypto.mjs +163 -0
- package/extensions/webdav-backup/dav-mock.mjs +145 -0
- package/extensions/webdav-backup/migrate.mjs +167 -0
- package/extensions/webdav-backup/restore.mjs +220 -0
- package/extensions/webdav-backup/run-tests.mjs +41 -0
- package/extensions/webdav-backup/secrets.mjs +404 -0
- package/extensions/webdav-backup/tar.mjs +111 -0
- package/extensions/webdav-backup/test-auth-migrate.mjs +187 -0
- package/extensions/webdav-backup/test-cli.mjs +331 -0
- package/extensions/webdav-backup/test-redact.mjs +271 -0
- package/extensions/webdav-backup/test-restore.mjs +219 -0
- package/extensions/webdav-backup/test-secrets.mjs +268 -0
- package/extensions/webdav-backup/test.mjs +442 -0
- package/extensions/webdav-backup/verify-load.mjs +89 -0
- package/extensions/webdav-backup/webdav-backup.example.json +38 -0
- package/extensions/webdav-backup/webdav.mjs +218 -0
- package/extensions/webdav-backup.ts +766 -0
- package/package.json +41 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test.mjs — 端到端测试
|
|
4
|
+
*
|
|
5
|
+
* 起一个内存 WebDAV 服务端(支持 PROPFIND/MKCOL/PUT/GET/HEAD/DELETE),
|
|
6
|
+
* 然后跑真实的备份引擎,验证:
|
|
7
|
+
* 1. 归档模式上传成功
|
|
8
|
+
* 2. 加密模式可解回原文件
|
|
9
|
+
* 3. 快照模式增量跳过
|
|
10
|
+
* 4. 脱敏:auth.json 密钥不上传明文
|
|
11
|
+
* 5. glob 排除生效
|
|
12
|
+
* 6. prune 清理旧版本
|
|
13
|
+
*
|
|
14
|
+
* 用法:node test.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import http from "node:http";
|
|
18
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { gunzipSync } from "node:zlib";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
import { loadConfig } from "./config.mjs";
|
|
25
|
+
import { runBackup, pruneOldArchives } from "./backup.mjs";
|
|
26
|
+
import { decryptFile } from "./crypto.mjs";
|
|
27
|
+
import { WebDAVClient } from "./webdav.mjs";
|
|
28
|
+
import { collectFiles, makeExcluder } from "./collect.mjs";
|
|
29
|
+
|
|
30
|
+
let pass = 0;
|
|
31
|
+
let fail = 0;
|
|
32
|
+
const results = [];
|
|
33
|
+
|
|
34
|
+
function check(name, cond, detail = "") {
|
|
35
|
+
if (cond) {
|
|
36
|
+
pass++;
|
|
37
|
+
results.push(` ✅ ${name}`);
|
|
38
|
+
} else {
|
|
39
|
+
fail++;
|
|
40
|
+
results.push(` ❌ ${name}${detail ? ` — ${detail}` : ""}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────── 内存 WebDAV 服务端
|
|
45
|
+
function startDavServer() {
|
|
46
|
+
/** @type {Map<string, Buffer>} */
|
|
47
|
+
const store = new Map();
|
|
48
|
+
const dirs = new Set(["/"]);
|
|
49
|
+
const auth = "Basic " + Buffer.from("user:pass").toString("base64");
|
|
50
|
+
let putCount = 0;
|
|
51
|
+
let getCount = 0;
|
|
52
|
+
|
|
53
|
+
const server = http.createServer((req, res) => {
|
|
54
|
+
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
55
|
+
const key = path.replace(/\/+$/, "") || "/";
|
|
56
|
+
|
|
57
|
+
if (req.headers.authorization !== auth) {
|
|
58
|
+
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="dav"' });
|
|
59
|
+
res.end("unauthorized");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
64
|
+
|
|
65
|
+
if (method === "PROPFIND") {
|
|
66
|
+
const depth = req.headers.depth ?? "0";
|
|
67
|
+
if (!dirs.has(key) && !store.has(key)) {
|
|
68
|
+
res.writeHead(404).end("not found");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const children = [];
|
|
72
|
+
if (depth !== "0") {
|
|
73
|
+
for (const d of dirs) {
|
|
74
|
+
if (d !== key && d.startsWith(key === "/" ? "/" : `${key}/`) && !d.slice(key === "/" ? 1 : key.length + 1).includes("/")) {
|
|
75
|
+
children.push({ href: d, isDir: true, size: 0, lm: new Date().toUTCString() });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const [p, buf] of store) {
|
|
79
|
+
if (p.startsWith(`${key === "/" ? "" : key}/`)) {
|
|
80
|
+
const rest = p.slice(key === "/" ? 1 : key.length + 1);
|
|
81
|
+
if (rest && !rest.includes("/")) {
|
|
82
|
+
children.push({ href: p, isDir: false, size: buf.length, lm: new Date().toUTCString() });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const responses = [
|
|
88
|
+
{ href: key, isDir: !store.has(key), size: store.get(key)?.length ?? 0, lm: new Date().toUTCString() },
|
|
89
|
+
...children,
|
|
90
|
+
];
|
|
91
|
+
const xml =
|
|
92
|
+
`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n` +
|
|
93
|
+
responses
|
|
94
|
+
.map(
|
|
95
|
+
(e) =>
|
|
96
|
+
` <D:response><D:href>${e.href}</D:href><D:propstat><D:prop>` +
|
|
97
|
+
`<D:getcontentlength>${e.size}</D:getcontentlength>` +
|
|
98
|
+
`<D:getlastmodified>${e.lm}</D:getlastmodified>` +
|
|
99
|
+
`<D:resourcetype>${e.isDir ? "<D:collection/>" : ""}</D:resourcetype>` +
|
|
100
|
+
`</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`,
|
|
101
|
+
)
|
|
102
|
+
.join("\n") +
|
|
103
|
+
`\n</D:multistatus>`;
|
|
104
|
+
res.writeHead(207, { "Content-Type": "application/xml" }).end(xml);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (method === "MKCOL") {
|
|
109
|
+
if (dirs.has(key) || store.has(key)) {
|
|
110
|
+
res.writeHead(405).end("exists");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
dirs.add(key);
|
|
114
|
+
res.writeHead(201).end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (method === "PUT") {
|
|
119
|
+
const chunks = [];
|
|
120
|
+
req.on("data", (c) => chunks.push(c));
|
|
121
|
+
req.on("end", () => {
|
|
122
|
+
putCount++;
|
|
123
|
+
store.set(key, Buffer.concat(chunks));
|
|
124
|
+
res.writeHead(201).end();
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (method === "GET") {
|
|
130
|
+
if (!store.has(key)) {
|
|
131
|
+
res.writeHead(404).end();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
getCount++;
|
|
135
|
+
const buf = store.get(key);
|
|
136
|
+
res.writeHead(200, { "Content-Length": String(buf.length) }).end(buf);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (method === "HEAD") {
|
|
141
|
+
if (!store.has(key)) {
|
|
142
|
+
res.writeHead(404).end();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
res.writeHead(200, { "Content-Length": String(store.get(key).length) }).end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (method === "DELETE") {
|
|
150
|
+
if (store.delete(key)) {
|
|
151
|
+
res.writeHead(204).end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (dirs.delete(key)) {
|
|
155
|
+
res.writeHead(204).end();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
res.writeHead(404).end();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
res.writeHead(405).end("method not allowed");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
server.listen(0, "127.0.0.1", () => {
|
|
167
|
+
const addr = server.address();
|
|
168
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
169
|
+
resolve({
|
|
170
|
+
server,
|
|
171
|
+
port,
|
|
172
|
+
store,
|
|
173
|
+
dirs,
|
|
174
|
+
stats: () => ({ putCount, getCount }),
|
|
175
|
+
reset: () => {
|
|
176
|
+
putCount = 0;
|
|
177
|
+
getCount = 0;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─────────────────────────────── 测试主体
|
|
185
|
+
async function main() {
|
|
186
|
+
console.log("pi WebDAV 备份 — 端到端测试\n");
|
|
187
|
+
|
|
188
|
+
const dav = await startDavServer();
|
|
189
|
+
const baseUrl = `http://127.0.0.1:${dav.port}/dav`;
|
|
190
|
+
dav.dirs.add("/dav");
|
|
191
|
+
|
|
192
|
+
// 构造假的 agent 目录
|
|
193
|
+
const agentDir = mkdtempSync(join(tmpdir(), "pi-backup-test-"));
|
|
194
|
+
mkdirSync(join(agentDir, "extensions", "my-ext"), { recursive: true });
|
|
195
|
+
mkdirSync(join(agentDir, "sessions", "proj"), { recursive: true });
|
|
196
|
+
mkdirSync(join(agentDir, "tmp"), { recursive: true });
|
|
197
|
+
mkdirSync(join(agentDir, "npm", "node_modules", "junk"), { recursive: true });
|
|
198
|
+
|
|
199
|
+
writeFileSync(join(agentDir, "extensions", "my-ext", "index.ts"), "export default () => {};\n");
|
|
200
|
+
writeFileSync(join(agentDir, "extensions", "note.md"), "# 我的扩展说明\n".repeat(50));
|
|
201
|
+
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "dark" }, null, 2));
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(agentDir, "auth.json"),
|
|
204
|
+
JSON.stringify({ openai: { key: "sk-SECRET-SHOULD-NOT-LEAK", type: "api" } }, null, 2),
|
|
205
|
+
);
|
|
206
|
+
writeFileSync(
|
|
207
|
+
join(agentDir, "webdav-backup.json"),
|
|
208
|
+
JSON.stringify(
|
|
209
|
+
{
|
|
210
|
+
remote: { url: baseUrl, username: "user", password: "pass", remoteDir: "pi-backup", remoteName: "pi-agent" },
|
|
211
|
+
keepVersions: 2,
|
|
212
|
+
},
|
|
213
|
+
null,
|
|
214
|
+
2,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
writeFileSync(join(agentDir, "sessions", "proj", "s1.jsonl"), '{"big":"session"}\n');
|
|
218
|
+
writeFileSync(join(agentDir, "tmp", "junk.tmp"), "should be excluded");
|
|
219
|
+
writeFileSync(join(agentDir, "npm", "node_modules", "junk", "index.js"), "module.exports={}");
|
|
220
|
+
writeFileSync(join(agentDir, "npm", "package.json"), '{"name":"x"}');
|
|
221
|
+
|
|
222
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
223
|
+
|
|
224
|
+
// ── 1. 配置加载
|
|
225
|
+
const loaded = loadConfig();
|
|
226
|
+
check("配置加载成功", loaded.ok, loaded.errors.join("; "));
|
|
227
|
+
check("内置排除包含 sessions/**", loaded.config.exclude.includes("sessions/**"));
|
|
228
|
+
|
|
229
|
+
// ── 2. glob 排除
|
|
230
|
+
const isExcluded = makeExcluder(["tmp/**", "**/node_modules/**", "sessions/**"]);
|
|
231
|
+
check("排除 tmp/junk.tmp", isExcluded("tmp/junk.tmp"));
|
|
232
|
+
check("排除嵌套 node_modules", isExcluded("npm/node_modules/junk/index.js"));
|
|
233
|
+
check("排除 sessions", isExcluded("sessions/proj/s1.jsonl"));
|
|
234
|
+
check("不排除 extensions/note.md", !isExcluded("extensions/note.md"));
|
|
235
|
+
|
|
236
|
+
// ── 3. 文件收集
|
|
237
|
+
const collected = collectFiles({
|
|
238
|
+
agentDir,
|
|
239
|
+
scope: ["."],
|
|
240
|
+
exclude: loaded.config.exclude,
|
|
241
|
+
});
|
|
242
|
+
const rels = collected.files.map((f) => f.rel);
|
|
243
|
+
check("收集到 extensions/note.md", rels.includes("extensions/note.md"));
|
|
244
|
+
check("收集到嵌套扩展文件", rels.includes("extensions/my-ext/index.ts"));
|
|
245
|
+
check("未收集 sessions 文件", !rels.some((r) => r.startsWith("sessions/")));
|
|
246
|
+
check("未收集 tmp 文件", !rels.some((r) => r.startsWith("tmp/")));
|
|
247
|
+
check("未收集 node_modules", !rels.some((r) => r.includes("node_modules/")));
|
|
248
|
+
|
|
249
|
+
// ── 4. 连接检查
|
|
250
|
+
const client = new WebDAVClient({ baseUrl, username: "user", password: "pass", timeoutMs: 5000 });
|
|
251
|
+
const conn = await client.check();
|
|
252
|
+
check("WebDAV 连接与认证", conn.ok, conn.error);
|
|
253
|
+
|
|
254
|
+
const badAuth = new WebDAVClient({ baseUrl, username: "user", password: "WRONG", timeoutMs: 5000 });
|
|
255
|
+
const badConn = await badAuth.check();
|
|
256
|
+
check("错误密码被识别为认证失败", !badConn.ok && badConn.status === 401, JSON.stringify(badConn));
|
|
257
|
+
|
|
258
|
+
// ── 5. dry run 不上传
|
|
259
|
+
dav.reset();
|
|
260
|
+
const dry = await runBackup(loaded.config, {
|
|
261
|
+
log: () => {},
|
|
262
|
+
agentDir,
|
|
263
|
+
stateFile: join(agentDir, "state.json"),
|
|
264
|
+
dryRun: true,
|
|
265
|
+
});
|
|
266
|
+
check("试运行成功", dry.ok && dry.dryRun === true);
|
|
267
|
+
check("试运行未产生上传", dav.stats().putCount === 0, `putCount=${dav.stats().putCount}`);
|
|
268
|
+
|
|
269
|
+
// ── 6. 归档模式
|
|
270
|
+
const arch = await runBackup(loaded.config, {
|
|
271
|
+
log: () => {},
|
|
272
|
+
agentDir,
|
|
273
|
+
stateFile: join(agentDir, "state.json"),
|
|
274
|
+
reason: "manual",
|
|
275
|
+
});
|
|
276
|
+
check("归档备份成功", arch.ok === true, arch.error);
|
|
277
|
+
check("归档已上传", arch.ok && arch.files > 0, `files=${arch.files}`);
|
|
278
|
+
check("敏感文件已脱敏", arch.redacted > 0, `redacted=${arch.redacted}`);
|
|
279
|
+
|
|
280
|
+
const gz = dav.store.get(`/dav/pi-backup/${arch.archive}`);
|
|
281
|
+
check("远端存在归档文件", !!gz && gz.length > 0);
|
|
282
|
+
|
|
283
|
+
// ── 7. 解包检查内容 + 脱敏验证
|
|
284
|
+
if (gz) {
|
|
285
|
+
const tar = gunzipSync(gz);
|
|
286
|
+
const text = tar.toString("latin1");
|
|
287
|
+
check("归档含扩展文件", text.includes("extensions/note.md"));
|
|
288
|
+
check("归档含嵌套扩展", text.includes("extensions/my-ext/index.ts"));
|
|
289
|
+
check("归档不含 sessions", !text.includes("sessions/proj"));
|
|
290
|
+
check("归档不含 tmp", !text.includes("junk.tmp"));
|
|
291
|
+
check("归档不含 node_modules", !text.includes("node_modules/junk"));
|
|
292
|
+
check("auth.json 密钥已脱敏", !text.includes("sk-SECRET-SHOULD-NOT-LEAK"), "明文密钥泄漏!");
|
|
293
|
+
check("归档内含 REDACTED 占位", text.includes("__REDACTED__"));
|
|
294
|
+
check("归档含 manifest", text.includes(".pi-backup-manifest.json"));
|
|
295
|
+
} else {
|
|
296
|
+
check("归档内容检查", false, "归档文件缺失,跳过");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── 8. 加密模式
|
|
300
|
+
const encConfig = { ...loaded.config, encrypt: true, encryptKey: "a-very-secret-passphrase-123", remoteName: "pi-enc" };
|
|
301
|
+
const enc = await runBackup(encConfig, {
|
|
302
|
+
log: () => {},
|
|
303
|
+
agentDir,
|
|
304
|
+
stateFile: join(agentDir, "state.json"),
|
|
305
|
+
reason: "manual",
|
|
306
|
+
});
|
|
307
|
+
check("加密备份成功", enc.ok === true, enc.error);
|
|
308
|
+
check("加密归档名以 .pibak 结尾", String(enc.archive).endsWith(".pibak"), String(enc.archive));
|
|
309
|
+
|
|
310
|
+
const encBlob = dav.store.get(`/dav/pi-backup/${enc.archive}`);
|
|
311
|
+
check("加密文件已上传", !!encBlob);
|
|
312
|
+
if (encBlob) {
|
|
313
|
+
check("密文中不含明文密钥", !encBlob.toString("latin1").includes("sk-SECRET-SHOULD-NOT-LEAK"));
|
|
314
|
+
check("密文不含明文文件名", !encBlob.toString("latin1").includes("extensions/note.md"));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 解密回原样
|
|
318
|
+
if (encBlob) {
|
|
319
|
+
const outFile = join(agentDir, "decrypted.tar.gz");
|
|
320
|
+
const encFile = join(agentDir, "roundtrip.pibak");
|
|
321
|
+
writeFileSync(encFile, encBlob);
|
|
322
|
+
const dec = await decryptFile(encFile, outFile, "a-very-secret-passphrase-123");
|
|
323
|
+
check("解密成功并返回 header", !!dec.header && dec.header.name === enc.archive);
|
|
324
|
+
const plain = gunzipSync(readFileSync(outFile));
|
|
325
|
+
const plainText = plain.toString("latin1");
|
|
326
|
+
check("解密后可读到扩展文件", plainText.includes("extensions/note.md"));
|
|
327
|
+
check("解密后仍不含明文密钥", !plainText.includes("sk-SECRET-SHOULD-NOT-LEAK"));
|
|
328
|
+
|
|
329
|
+
// 错误密码必须失败
|
|
330
|
+
let wrongFailed = false;
|
|
331
|
+
try {
|
|
332
|
+
await decryptFile(encFile, join(agentDir, "wrong.tar.gz"), "wrong-password-here-xxxx");
|
|
333
|
+
} catch {
|
|
334
|
+
wrongFailed = true;
|
|
335
|
+
}
|
|
336
|
+
check("错误密码解密失败", wrongFailed);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── 9. 快照模式增量
|
|
340
|
+
const snapConfig = { ...loaded.config, snapshot: true, remoteName: "pi-snap" };
|
|
341
|
+
dav.reset();
|
|
342
|
+
const s1 = await runBackup(snapConfig, {
|
|
343
|
+
log: () => {},
|
|
344
|
+
agentDir,
|
|
345
|
+
stateFile: join(agentDir, "state.json"),
|
|
346
|
+
reason: "manual",
|
|
347
|
+
});
|
|
348
|
+
check("快照首次备份成功", s1.ok === true, s1.error);
|
|
349
|
+
check("快照首次全部上传", s1.uploaded === s1.files, `uploaded=${s1.uploaded} files=${s1.files}`);
|
|
350
|
+
|
|
351
|
+
const firstPutCount = dav.stats().putCount;
|
|
352
|
+
dav.reset();
|
|
353
|
+
const s2 = await runBackup(snapConfig, {
|
|
354
|
+
log: () => {},
|
|
355
|
+
agentDir,
|
|
356
|
+
stateFile: join(agentDir, "state.json"),
|
|
357
|
+
reason: "manual",
|
|
358
|
+
});
|
|
359
|
+
check("快照第二次备份成功", s2.ok === true, s2.error);
|
|
360
|
+
check("快照第二次跳过未变化文件", s2.skippedSame > 0, `skippedSame=${s2.skippedSame}`);
|
|
361
|
+
check("快照第二次上传显著减少", s2.uploaded < s1.uploaded, `s1=${s1.uploaded} s2=${s2.uploaded}`);
|
|
362
|
+
check("远端有 snapshot manifest", !!dav.store.get("/dav/pi-backup/pi-snap/manifest.json"));
|
|
363
|
+
check("快照保留了目录结构", !!dav.store.get("/dav/pi-backup/pi-snap/extensions/my-ext/index.ts"));
|
|
364
|
+
|
|
365
|
+
// 改一个文件后,只有它被重传
|
|
366
|
+
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ theme: "light" }, null, 2) + "\n");
|
|
367
|
+
dav.reset();
|
|
368
|
+
const s3 = await runBackup(snapConfig, {
|
|
369
|
+
log: () => {},
|
|
370
|
+
agentDir,
|
|
371
|
+
stateFile: join(agentDir, "state.json"),
|
|
372
|
+
reason: "manual",
|
|
373
|
+
});
|
|
374
|
+
check("改动后备份成功", s3.ok === true, s3.error);
|
|
375
|
+
check("只重传变化的文件(含配置类)", s3.uploaded <= 4, `uploaded=${s3.uploaded}`);
|
|
376
|
+
|
|
377
|
+
// ── 10. prune 清理旧版本
|
|
378
|
+
const pruneConfig = { ...loaded.config, remoteName: "pi-prune", remoteDir: "pi-prune-dir" };
|
|
379
|
+
for (let i = 0; i < 4; i++) {
|
|
380
|
+
await runBackup(pruneConfig, {
|
|
381
|
+
log: () => {},
|
|
382
|
+
agentDir,
|
|
383
|
+
stateFile: join(agentDir, "state.json"),
|
|
384
|
+
reason: "manual",
|
|
385
|
+
});
|
|
386
|
+
await new Promise((r) => setTimeout(r, 1100)); // 时间戳精确到秒
|
|
387
|
+
}
|
|
388
|
+
const pruneClient = new WebDAVClient({ baseUrl, username: "user", password: "pass", timeoutMs: 5000 });
|
|
389
|
+
const before = (await pruneClient.list("pi-prune-dir")).filter(
|
|
390
|
+
(e) => !e.isCollection && e.name.startsWith("pi-prune-"),
|
|
391
|
+
).length;
|
|
392
|
+
// 另一个前缀的备份不应被误删
|
|
393
|
+
const otherBefore = (await pruneClient.list("pi-backup")).filter(
|
|
394
|
+
(e) => !e.isCollection && e.name.startsWith("pi-agent-"),
|
|
395
|
+
).length;
|
|
396
|
+
|
|
397
|
+
const pruned = await pruneOldArchives({ ...pruneConfig }, 2, () => {});
|
|
398
|
+
check("清理旧版本删除了多余归档", pruned.deleted.length === Math.max(0, before - 2), `before=${before} deleted=${pruned.deleted.length}`);
|
|
399
|
+
check("清理保留了指定数量", pruned.kept === 2, `kept=${pruned.kept}`);
|
|
400
|
+
|
|
401
|
+
const otherAfter = (await pruneClient.list("pi-backup")).filter(
|
|
402
|
+
(e) => !e.isCollection && e.name.startsWith("pi-agent-"),
|
|
403
|
+
).length;
|
|
404
|
+
check("未误删其他前缀的备份", otherAfter === otherBefore, `before=${otherBefore} after=${otherAfter}`);
|
|
405
|
+
|
|
406
|
+
// ── 11. 断网/错误路径
|
|
407
|
+
const deadClient = new WebDAVClient({ baseUrl: "http://127.0.0.1:1/dav", username: "u", password: "p", timeoutMs: 1500 });
|
|
408
|
+
let deadOk = false;
|
|
409
|
+
try {
|
|
410
|
+
const r = await deadClient.check();
|
|
411
|
+
deadOk = r.ok;
|
|
412
|
+
} catch {
|
|
413
|
+
deadOk = false;
|
|
414
|
+
}
|
|
415
|
+
check("不可达服务器不误报成功", deadOk === false);
|
|
416
|
+
|
|
417
|
+
// ── 12. 日志与状态写入
|
|
418
|
+
const stateWritten = existsSync(join(agentDir, "state.json"));
|
|
419
|
+
check("状态文件已写入", stateWritten);
|
|
420
|
+
if (stateWritten) {
|
|
421
|
+
const st = JSON.parse(readFileSync(join(agentDir, "state.json"), "utf8"));
|
|
422
|
+
check("状态含 lastBackupAt", typeof st.lastBackupAt === "string");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// 清理
|
|
426
|
+
dav.server.close();
|
|
427
|
+
try {
|
|
428
|
+
rmSync(agentDir, { recursive: true, force: true });
|
|
429
|
+
} catch {
|
|
430
|
+
/* ignore */
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log(results.join("\n"));
|
|
434
|
+
console.log(`\n结果:${pass} 通过,${fail} 失败`);
|
|
435
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
main().catch((e) => {
|
|
439
|
+
console.log(results.join("\n"));
|
|
440
|
+
console.error(`\n测试异常:${e?.stack ?? e}`);
|
|
441
|
+
process.exit(1);
|
|
442
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 校验 webdav-backup.ts 能否被 pi 的加载器(jiti)正常导入,
|
|
3
|
+
* 并检查默认导出与命令注册是否符合 pi 的扩展契约。
|
|
4
|
+
*
|
|
5
|
+
* pi-coding-agent 安装目录自动探测;也可用环境变量 PI_PKG_PATH 显式指定。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
function resolvePiRoot() {
|
|
13
|
+
const candidates = [
|
|
14
|
+
process.env.PI_PKG_PATH,
|
|
15
|
+
join(homedir(), "AppData", "Roaming", "npm", "node_modules", "@earendil-works", "pi-coding-agent"),
|
|
16
|
+
join(homedir(), ".pi", "npm", "node_modules", "@earendil-works", "pi-coding-agent"),
|
|
17
|
+
"/usr/lib/node_modules/@earendil-works/pi-coding-agent",
|
|
18
|
+
"/usr/local/lib/node_modules/@earendil-works/pi-coding-agent",
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
const hit = candidates.find((c) => existsSync(join(c, "dist", "index.js")));
|
|
21
|
+
if (!hit) {
|
|
22
|
+
console.error("找不到 pi-coding-agent 安装,请设置环境变量 PI_PKG_PATH 指向其安装目录");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
return hit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PI_ROOT = resolvePiRoot();
|
|
29
|
+
const PI_PKG = join(PI_ROOT, "dist", "index.js");
|
|
30
|
+
|
|
31
|
+
let createJiti;
|
|
32
|
+
try {
|
|
33
|
+
({ createJiti } = await import(
|
|
34
|
+
pathToFileURL(join(PI_ROOT, "node_modules", "jiti", "lib", "jiti.mjs")).href
|
|
35
|
+
));
|
|
36
|
+
} catch {
|
|
37
|
+
({ createJiti } = await import("jiti"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const jiti = createJiti(import.meta.url, {
|
|
41
|
+
interopDefault: true,
|
|
42
|
+
moduleCache: false,
|
|
43
|
+
alias: { "@earendil-works/pi-coding-agent": PI_PKG },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const registered = { commands: [], events: [], tools: [] };
|
|
47
|
+
const fakePi = {
|
|
48
|
+
on: (evt) => registered.events.push(evt),
|
|
49
|
+
registerCommand: (name) => registered.commands.push(name),
|
|
50
|
+
registerTool: (t) => registered.tools.push(t?.name ?? "?"),
|
|
51
|
+
registerShortcut: () => {},
|
|
52
|
+
registerFlag: () => {},
|
|
53
|
+
sendMessage: () => {},
|
|
54
|
+
appendEntry: () => {},
|
|
55
|
+
exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const mod = await jiti.import("../webdav-backup.ts");
|
|
59
|
+
const factory = mod.default ?? mod;
|
|
60
|
+
if (typeof factory !== "function") {
|
|
61
|
+
console.error("❌ 默认导出不是函数:", typeof factory);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
await factory(fakePi);
|
|
65
|
+
|
|
66
|
+
const expectCmds = ["backup", "backup-setup", "backup-cron", "backup-log"];
|
|
67
|
+
const missingCmds = expectCmds.filter((c) => !registered.commands.includes(c));
|
|
68
|
+
console.log("注册的命令:", registered.commands.join(", "));
|
|
69
|
+
console.log("订阅的事件:", registered.events.join(", "));
|
|
70
|
+
console.log("缺失命令:", missingCmds.length ? missingCmds.join(", ") : "(无)");
|
|
71
|
+
|
|
72
|
+
// 引擎模块可加载
|
|
73
|
+
const cliSrc = readFileSync(new URL("./cli.mjs", import.meta.url), "utf8");
|
|
74
|
+
const cliOk = cliSrc.length > 0;
|
|
75
|
+
console.log("cli.mjs 存在且非空:", cliOk);
|
|
76
|
+
|
|
77
|
+
// CLI 路径不变量:入口按「同目录 → webdav-backup 子目录」探测 cli.mjs,
|
|
78
|
+
// 布局破坏会静默导致 /backup 系列命令失效,这里做回归防护
|
|
79
|
+
const cliProbeHit = existsSync(fileURLToPath(new URL("../webdav-backup/cli.mjs", import.meta.url)));
|
|
80
|
+
console.log("入口探测路径 extensions/webdav-backup/cli.mjs 存在:", cliProbeHit);
|
|
81
|
+
|
|
82
|
+
const ok =
|
|
83
|
+
missingCmds.length === 0 &&
|
|
84
|
+
registered.events.includes("session_shutdown") &&
|
|
85
|
+
registered.commands.length === expectCmds.length &&
|
|
86
|
+
cliOk &&
|
|
87
|
+
cliProbeHit;
|
|
88
|
+
console.log(ok ? "\n✅ 扩展契约校验通过" : "\n❌ 扩展契约校验失败");
|
|
89
|
+
process.exit(ok ? 0 : 1);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_说明": "示例配置。复制到 ~/.pi/agent/webdav-backup.json 后按需修改,或直接运行 /backup-setup 用向导生成。",
|
|
3
|
+
|
|
4
|
+
"enabled": true,
|
|
5
|
+
|
|
6
|
+
"remote": {
|
|
7
|
+
"_url_坚果云": "https://dav.jianguoyun.com/dav/",
|
|
8
|
+
"_url_Nextcloud": "https://your-cloud.example.com/remote.php/dav/files/<用户名>/",
|
|
9
|
+
"_url_群晖WebDAV": "https://your-nas.example.com:5006/pi-backup",
|
|
10
|
+
"_url_自建rclone": "http://127.0.0.1:8080/pi-backup",
|
|
11
|
+
"url": "https://dav.jianguoyun.com/dav/",
|
|
12
|
+
|
|
13
|
+
"_username_说明": "坚果云填注册邮箱;Nextcloud 填用户名",
|
|
14
|
+
"username": "you@example.com",
|
|
15
|
+
|
|
16
|
+
"_password_说明": "密钥引用,推荐 dpapi:webdav-password(先用 /backup keys set webdav-password 创建)。也可用 $ENV_VAR 或 file:路径;留空则读环境变量 PI_WEBDAV_BACKUP_PASSWORD",
|
|
17
|
+
"password": "dpapi:webdav-password",
|
|
18
|
+
|
|
19
|
+
"remoteDir": "pi-backup",
|
|
20
|
+
"remoteName": "pi-agent"
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
"scope": ["."],
|
|
24
|
+
"exclude": [],
|
|
25
|
+
"includeSessions": false,
|
|
26
|
+
"snapshot": false,
|
|
27
|
+
|
|
28
|
+
"encrypt": false,
|
|
29
|
+
"encryptKey": "dpapi:backup-key",
|
|
30
|
+
|
|
31
|
+
"backupOnExit": true,
|
|
32
|
+
"backupOnExitMinIntervalMinutes": 30,
|
|
33
|
+
"backupOnExitTimeoutMs": 600000,
|
|
34
|
+
|
|
35
|
+
"keepVersions": 10,
|
|
36
|
+
"timeoutMs": 60000,
|
|
37
|
+
"insecureTls": false
|
|
38
|
+
}
|