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,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli.mjs — 备份引擎命令行入口
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node cli.mjs backup [--reason manual|exit] [--dry-run] [--json]
|
|
7
|
+
* node cli.mjs check [--json] 测试 WebDAV 连通性与认证
|
|
8
|
+
* node cli.mjs list [--json] 列出远端已有备份
|
|
9
|
+
* node cli.mjs prune [--keep N] 清理旧备份
|
|
10
|
+
* node cli.mjs restore [--file 名称] [--to 目录] [--list] [--json] 恢复备份
|
|
11
|
+
* node cli.mjs set-secret --name webdav-password [--value X] 用 DPAPI 加密保存密钥
|
|
12
|
+
* node cli.mjs list-secrets [--json] 列出已存的 DPAPI 密钥(不含明文)
|
|
13
|
+
* node cli.mjs rm-secret --name X 删除 DPAPI 密钥
|
|
14
|
+
* node cli.mjs migrate [--method dpapi|env|file] [--dry-run] 把配置明文密钥迁成引用
|
|
15
|
+
* node cli.mjs auth-scan [--json] 扫描 auth.json 里的明文 API 密钥
|
|
16
|
+
* node cli.mjs auth-migrate [--method env|dpapi] [--dry-run] 迁移 auth.json 密钥
|
|
17
|
+
* node cli.mjs doctor [--json] 体检:配置、密钥来源、连接性
|
|
18
|
+
* node cli.mjs install-cron [--time HH:MM] 安装 Windows 计划任务(每日备份)
|
|
19
|
+
* node cli.mjs uninstall-cron 移除计划任务
|
|
20
|
+
*
|
|
21
|
+
* 输出人类可读文本或 JSON(--json),退出码 0 成功 / 1 失败。
|
|
22
|
+
* --value 优先,其次读 stdin(避免密钥出现在进程列表里)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { appendFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { loadConfig, configPath, statePath, logPath, resolveAgentDir } from "./config.mjs";
|
|
29
|
+
import { runBackup, pruneOldArchives, readState } from "./backup.mjs";
|
|
30
|
+
import { WebDAVClient } from "./webdav.mjs";
|
|
31
|
+
import {
|
|
32
|
+
setDpapiSecret,
|
|
33
|
+
listDpapiSecrets,
|
|
34
|
+
deleteDpapiSecret,
|
|
35
|
+
dpapiAvailable,
|
|
36
|
+
resolveSecret,
|
|
37
|
+
isPlaintextSecret,
|
|
38
|
+
} from "./secrets.mjs";
|
|
39
|
+
|
|
40
|
+
const args = process.argv.slice(2);
|
|
41
|
+
const cmd = (args[0] ?? "backup").toLowerCase();
|
|
42
|
+
const has = (f) => args.includes(f);
|
|
43
|
+
const val = (f, d = null) => {
|
|
44
|
+
const i = args.indexOf(f);
|
|
45
|
+
return i >= 0 && args[i + 1] ? args[i + 1] : d;
|
|
46
|
+
};
|
|
47
|
+
const asJson = has("--json");
|
|
48
|
+
|
|
49
|
+
function logTo(line) {
|
|
50
|
+
const stamp = new Date().toISOString();
|
|
51
|
+
const text = `[${stamp}] ${line}\n`;
|
|
52
|
+
try {
|
|
53
|
+
appendFileSync(logPath(), text, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
if (!asJson) process.stdout.write(`${line}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finish(payload, code) {
|
|
61
|
+
if (asJson) process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
62
|
+
process.exit(code ?? (payload.ok ? 0 : 1));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从 --value 或 stdin 读取密钥,避免出现在命令行历史/进程列表中 */
|
|
66
|
+
async function readSecretInput() {
|
|
67
|
+
const v = val("--value", null);
|
|
68
|
+
if (v !== null) return v;
|
|
69
|
+
if (process.stdin.isTTY) return null;
|
|
70
|
+
const chunks = [];
|
|
71
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
72
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function main() {
|
|
76
|
+
const agentDir = resolveAgentDir();
|
|
77
|
+
|
|
78
|
+
// ── 密钥管理命令(不需要已加载配置)
|
|
79
|
+
if (cmd === "set-secret") {
|
|
80
|
+
const name = val("--name", null);
|
|
81
|
+
if (!name) {
|
|
82
|
+
return finish({ ok: false, error: "缺少 --name" }, 1);
|
|
83
|
+
}
|
|
84
|
+
if (!dpapiAvailable()) {
|
|
85
|
+
return finish({ ok: false, error: "DPAPI 仅支持 Windows;其他平台请用 --method file 或环境变量" }, 1);
|
|
86
|
+
}
|
|
87
|
+
const value = await readSecretInput();
|
|
88
|
+
if (value === null || value === "") {
|
|
89
|
+
return finish(
|
|
90
|
+
{ ok: false, error: "缺少密钥值:用 --value 传入,或通过 stdin 输入(推荐)" },
|
|
91
|
+
1,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
setDpapiSecret(name, value);
|
|
96
|
+
logTo(`已用 DPAPI 加密保存密钥「${name}」`);
|
|
97
|
+
return finish({ ok: true, name, store: "dpapi" }, 0);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return finish({ ok: false, error: e instanceof Error ? e.message : String(e) }, 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (cmd === "list-secrets") {
|
|
104
|
+
const items = listDpapiSecrets();
|
|
105
|
+
if (!asJson) {
|
|
106
|
+
if (items.length === 0) logTo("没有已保存的 DPAPI 密钥。");
|
|
107
|
+
else for (const s of items) logTo(` ${s.name} ${s.updatedAt ?? ""} [${s.scope}]`);
|
|
108
|
+
}
|
|
109
|
+
return finish({ ok: true, secrets: items, available: dpapiAvailable() }, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (cmd === "rm-secret") {
|
|
113
|
+
const name = val("--name", null);
|
|
114
|
+
if (!name) return finish({ ok: false, error: "缺少 --name" }, 1);
|
|
115
|
+
const removed = deleteDpapiSecret(name);
|
|
116
|
+
logTo(removed ? `已删除密钥「${name}」` : `未找到密钥「${name}」`);
|
|
117
|
+
return finish({ ok: removed, name, removed }, removed ? 0 : 1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (cmd === "migrate") {
|
|
121
|
+
const { migrateSecrets } = await import("./migrate.mjs");
|
|
122
|
+
const method = String(val("--method", "dpapi"));
|
|
123
|
+
const r = await migrateSecrets({ method, log: logTo, dryRun: has("--dry-run") });
|
|
124
|
+
if (!r.ok) logTo(`迁移失败:${r.error}`);
|
|
125
|
+
return finish(r, r.ok ? 0 : 1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── auth.json 明文 API 密钥迁移
|
|
129
|
+
if (cmd === "auth-scan") {
|
|
130
|
+
const { scanAuthSecrets } = await import("./auth-migrate.mjs");
|
|
131
|
+
const scan = scanAuthSecrets();
|
|
132
|
+
if (!asJson) {
|
|
133
|
+
if (!scan.ok) logTo(`扫描失败:${scan.error}`);
|
|
134
|
+
else if (scan.items.length === 0) logTo("auth.json 没有条目。");
|
|
135
|
+
else {
|
|
136
|
+
logTo(`auth.json: ${scan.path}`);
|
|
137
|
+
for (const it of scan.items) {
|
|
138
|
+
if (it.kind === "plaintext")
|
|
139
|
+
logTo(` ⚠️ ${it.provider}: 明文密钥 (${it.keyLength} 字符) → 建议 $${it.suggestedEnv}`);
|
|
140
|
+
else if (it.kind === "reference") logTo(` ✅ ${it.provider}: ${it.current}`);
|
|
141
|
+
else logTo(` · ${it.provider}: ${it.reason}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return finish({ ok: scan.ok, ...scan }, scan.ok ? 0 : 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (cmd === "auth-migrate") {
|
|
149
|
+
const { migrateAuthJson } = await import("./auth-migrate.mjs");
|
|
150
|
+
const method = String(val("--method", "env"));
|
|
151
|
+
const r = await migrateAuthJson({ method, log: logTo, dryRun: has("--dry-run") });
|
|
152
|
+
if (!r.ok && r.error) logTo(`迁移失败:${r.error}`);
|
|
153
|
+
else if (r.message) logTo(r.message);
|
|
154
|
+
if (r.warnings?.length) for (const w of r.warnings) logTo(`警告:${w}`);
|
|
155
|
+
return finish(r, r.ok ? 0 : 1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const loaded = loadConfig();
|
|
159
|
+
|
|
160
|
+
if (!loaded.ok) {
|
|
161
|
+
const err = loaded.errors.join("; ");
|
|
162
|
+
logTo(`配置错误:${err}`);
|
|
163
|
+
finish({ ok: false, error: err, errors: loaded.errors, configPath: configPath() }, 1);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const { config, warnings } = loaded;
|
|
167
|
+
for (const w of warnings) logTo(`警告:${w}`);
|
|
168
|
+
|
|
169
|
+
if (cmd === "doctor") {
|
|
170
|
+
const report = {
|
|
171
|
+
ok: loaded.ok,
|
|
172
|
+
configPath: configPath(),
|
|
173
|
+
url: config.url,
|
|
174
|
+
username: config.username || "(未设置)",
|
|
175
|
+
password: {
|
|
176
|
+
source: loaded.secrets?.passwordSource ?? "empty",
|
|
177
|
+
plaintext: isPlaintextSecret(loaded.raw?.remote?.password),
|
|
178
|
+
},
|
|
179
|
+
encryptKey: {
|
|
180
|
+
configured: !!config.encryptKey,
|
|
181
|
+
source: loaded.secrets?.encryptKeySource ?? "empty",
|
|
182
|
+
plaintext: isPlaintextSecret(loaded.raw?.encryptKey),
|
|
183
|
+
},
|
|
184
|
+
errors: loaded.errors,
|
|
185
|
+
warnings: loaded.warnings,
|
|
186
|
+
dpapiAvailable: dpapiAvailable(),
|
|
187
|
+
connectivity: null,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (loaded.ok && config.url) {
|
|
191
|
+
try {
|
|
192
|
+
const c = new WebDAVClient({
|
|
193
|
+
baseUrl: config.url,
|
|
194
|
+
username: config.username,
|
|
195
|
+
password: config.password,
|
|
196
|
+
timeoutMs: config.timeoutMs,
|
|
197
|
+
insecureTls: config.insecureTls,
|
|
198
|
+
});
|
|
199
|
+
const r = await c.check();
|
|
200
|
+
report.connectivity = { ok: r.ok, status: r.status, error: r.error };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
report.connectivity = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!asJson) {
|
|
207
|
+
logTo("配置体检");
|
|
208
|
+
logTo(` 配置文件 ${report.configPath}`);
|
|
209
|
+
logTo(` WebDAV ${report.url || "(未设置)"}`);
|
|
210
|
+
logTo(` 用户名 ${report.username}`);
|
|
211
|
+
logTo(
|
|
212
|
+
` 密码来源 ${report.password.source}${report.password.plaintext ? " ⚠️ 明文" : ""}`,
|
|
213
|
+
);
|
|
214
|
+
logTo(
|
|
215
|
+
` 加密密钥 ${report.encryptKey.configured ? report.encryptKey.source : "(未配置)"}${
|
|
216
|
+
report.encryptKey.plaintext ? " ⚠️ 明文" : ""
|
|
217
|
+
}`,
|
|
218
|
+
);
|
|
219
|
+
logTo(` DPAPI 可用 ${report.dpapiAvailable ? "是" : "否(非 Windows)"}`);
|
|
220
|
+
logTo(
|
|
221
|
+
` 连接测试 ${
|
|
222
|
+
report.connectivity
|
|
223
|
+
? report.connectivity.ok
|
|
224
|
+
? "通过 ✅"
|
|
225
|
+
: `失败:${report.connectivity.error}`
|
|
226
|
+
: "未执行"
|
|
227
|
+
}`,
|
|
228
|
+
);
|
|
229
|
+
if (report.errors.length) logTo(` 错误:${report.errors.join(";")}`);
|
|
230
|
+
if (report.warnings.length) logTo(` 告警:${report.warnings.join(";")}`);
|
|
231
|
+
}
|
|
232
|
+
return finish(report, report.ok ? 0 : 1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (cmd === "check") {
|
|
236
|
+
logTo(`检查 ${config.url} …`);
|
|
237
|
+
const client = new WebDAVClient({
|
|
238
|
+
baseUrl: config.url,
|
|
239
|
+
username: config.username,
|
|
240
|
+
password: config.password,
|
|
241
|
+
timeoutMs: config.timeoutMs,
|
|
242
|
+
insecureTls: config.insecureTls,
|
|
243
|
+
});
|
|
244
|
+
const r = await client.check();
|
|
245
|
+
if (r.ok) {
|
|
246
|
+
logTo("WebDAV 连接正常,认证通过。");
|
|
247
|
+
finish({ ok: true, status: r.status, url: config.url });
|
|
248
|
+
} else {
|
|
249
|
+
logTo(`连接失败:${r.error}`);
|
|
250
|
+
finish({ ok: false, error: r.error, status: r.status }, 1);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (cmd === "list") {
|
|
256
|
+
const client = new WebDAVClient({
|
|
257
|
+
baseUrl: config.url,
|
|
258
|
+
username: config.username,
|
|
259
|
+
password: config.password,
|
|
260
|
+
timeoutMs: config.timeoutMs,
|
|
261
|
+
insecureTls: config.insecureTls,
|
|
262
|
+
});
|
|
263
|
+
const entries = await client.list(config.remoteDir);
|
|
264
|
+
const files = entries.filter((e) => !e.isCollection);
|
|
265
|
+
if (!asJson) {
|
|
266
|
+
if (files.length === 0) logTo(`远端 ${config.remoteDir}/ 暂无备份文件。`);
|
|
267
|
+
else {
|
|
268
|
+
logTo(`远端 ${config.remoteDir}/ 共 ${files.length} 个文件:`);
|
|
269
|
+
for (const f of files) {
|
|
270
|
+
logTo(` ${f.name} ${(f.size / 1024).toFixed(1)} KB ${f.lastModified ?? ""}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finish({ ok: true, files });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (cmd === "prune") {
|
|
279
|
+
const keep = Number(val("--keep", config.keepVersions));
|
|
280
|
+
const r = await pruneOldArchives(config, keep, logTo);
|
|
281
|
+
finish({ ok: true, ...r });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (cmd === "restore") {
|
|
286
|
+
const { restoreBackup } = await import("./restore.mjs");
|
|
287
|
+
const r = await restoreBackup(config, {
|
|
288
|
+
agentDir,
|
|
289
|
+
file: val("--file", null),
|
|
290
|
+
to: val("--to", null),
|
|
291
|
+
listOnly: has("--list"),
|
|
292
|
+
log: logTo,
|
|
293
|
+
});
|
|
294
|
+
finish(r, r.ok ? 0 : 1);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (cmd === "status") {
|
|
299
|
+
const state = readState(statePath());
|
|
300
|
+
finish({ ok: true, state, configPath: configPath() });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (cmd === "install-cron" || cmd === "uninstall-cron") {
|
|
305
|
+
const { installCron, uninstallCron } = await import("./cron.mjs");
|
|
306
|
+
if (cmd === "install-cron") {
|
|
307
|
+
const r = await installCron({ time: val("--time", "03:00"), log: logTo });
|
|
308
|
+
finish({ ok: r.ok, ...r });
|
|
309
|
+
} else {
|
|
310
|
+
const r = await uninstallCron({ log: logTo });
|
|
311
|
+
finish({ ok: r.ok, ...r });
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (cmd === "backup" || cmd === "run") {
|
|
317
|
+
const reason = val("--reason", "manual");
|
|
318
|
+
if (!config.enabled && reason === "exit") {
|
|
319
|
+
logTo("自动备份已禁用(enabled: false),跳过。");
|
|
320
|
+
finish({ ok: true, skipped: true, reason: "disabled" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
logTo(`开始备份(触发方式:${reason}${has("--dry-run") ? ",试运行" : ""})`);
|
|
324
|
+
const result = await runBackup(config, {
|
|
325
|
+
log: logTo,
|
|
326
|
+
agentDir,
|
|
327
|
+
stateFile: statePath(),
|
|
328
|
+
dryRun: has("--dry-run"),
|
|
329
|
+
reason,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (result.ok && !result.dryRun && reason !== "exit") {
|
|
333
|
+
try {
|
|
334
|
+
await pruneOldArchives(config, config.keepVersions, logTo);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
logTo(`清理旧版本失败(不影响本次备份):${e.message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!result.ok) logTo(`备份失败:${result.error}`);
|
|
341
|
+
finish(result, result.ok ? 0 : 1);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
logTo(`未知命令:${cmd}`);
|
|
346
|
+
finish({ ok: false, error: `未知命令:${cmd}` }, 1);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
main().catch((e) => {
|
|
350
|
+
logTo(`未捕获错误:${e?.stack ?? e?.message ?? String(e)}`);
|
|
351
|
+
finish({ ok: false, error: String(e?.message ?? e) }, 1);
|
|
352
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collect.mjs — 文件收集与 glob 排除
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdirSync, statSync, lstatSync } from "node:fs";
|
|
6
|
+
import { join, relative, sep, posix } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** glob -> RegExp(支持 **、*、?) */
|
|
9
|
+
export function globToRegExp(glob) {
|
|
10
|
+
let re = "";
|
|
11
|
+
const g = String(glob).replace(/\\/g, "/");
|
|
12
|
+
for (let i = 0; i < g.length; i++) {
|
|
13
|
+
const c = g[i];
|
|
14
|
+
if (c === "*") {
|
|
15
|
+
if (g[i + 1] === "*") {
|
|
16
|
+
// ** 跨目录
|
|
17
|
+
const after = g[i + 2];
|
|
18
|
+
if (after === "/") {
|
|
19
|
+
re += "(?:.*/)?";
|
|
20
|
+
i += 2;
|
|
21
|
+
} else {
|
|
22
|
+
re += ".*";
|
|
23
|
+
i += 1;
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
re += "[^/]*";
|
|
27
|
+
}
|
|
28
|
+
} else if (c === "?") {
|
|
29
|
+
re += "[^/]";
|
|
30
|
+
} else if ("\\^$.|+()[]{}".includes(c)) {
|
|
31
|
+
re += `\\${c}`;
|
|
32
|
+
} else {
|
|
33
|
+
re += c;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(`^${re}$`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function makeExcluder(patterns) {
|
|
40
|
+
const regs = (patterns ?? []).filter((p) => p && String(p).trim()).map((p) => globToRegExp(p));
|
|
41
|
+
return (relPath) => {
|
|
42
|
+
const p = posix.normalize(String(relPath).replace(/\\/g, "/"));
|
|
43
|
+
// 检查路径本身及其任意父目录是否命中
|
|
44
|
+
const segs = p.split("/");
|
|
45
|
+
const candidates = new Set([p]);
|
|
46
|
+
for (let i = 1; i < segs.length; i++) candidates.add(`${segs.slice(0, i).join("/")}/`);
|
|
47
|
+
for (const r of regs) {
|
|
48
|
+
for (const c of candidates) if (r.test(c)) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 收集文件。
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} opts.agentDir 基准目录
|
|
58
|
+
* @param {string[]} opts.scope 相对 agentDir 的路径列表
|
|
59
|
+
* @param {string[]} opts.exclude glob 排除规则
|
|
60
|
+
* @returns {{files: {abs: string, rel: string, size: number, mtimeMs: number}[], skipped: number, totalSize: number}}
|
|
61
|
+
*/
|
|
62
|
+
export function collectFiles({ agentDir, scope, exclude, followSymlinks = false }) {
|
|
63
|
+
const isExcluded = makeExcluder(exclude);
|
|
64
|
+
const files = [];
|
|
65
|
+
let skipped = 0;
|
|
66
|
+
|
|
67
|
+
const walk = (abs, relDir) => {
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const ent of entries) {
|
|
76
|
+
const absPath = join(abs, ent.name);
|
|
77
|
+
const relPath = relDir ? `${relDir}/${ent.name}` : ent.name;
|
|
78
|
+
|
|
79
|
+
if (isExcluded(relPath)) {
|
|
80
|
+
skipped++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let st;
|
|
85
|
+
try {
|
|
86
|
+
st = followSymlinks ? statSync(absPath) : lstatSync(absPath);
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (st.isSymbolicLink()) {
|
|
92
|
+
skipped++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (st.isDirectory()) {
|
|
96
|
+
walk(absPath, relPath);
|
|
97
|
+
} else if (st.isFile()) {
|
|
98
|
+
files.push({ abs: absPath, rel: relPath, size: st.size, mtimeMs: Math.floor(st.mtimeMs) });
|
|
99
|
+
} else {
|
|
100
|
+
skipped++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (const s of scope) {
|
|
106
|
+
const clean = String(s).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
107
|
+
const absBase = clean === "." || clean === "" ? agentDir : join(agentDir, clean);
|
|
108
|
+
let st;
|
|
109
|
+
try {
|
|
110
|
+
st = lstatSync(absBase);
|
|
111
|
+
} catch {
|
|
112
|
+
skipped++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (st.isDirectory()) walk(absBase, clean === "." || clean === "" ? "" : clean);
|
|
116
|
+
else if (st.isFile()) {
|
|
117
|
+
const rel = relative(agentDir, absBase).split(sep).join("/");
|
|
118
|
+
if (!isExcluded(rel)) files.push({ abs: absBase, rel, size: st.size, mtimeMs: Math.floor(st.mtimeMs) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
123
|
+
const totalSize = files.reduce((n, f) => n + f.size, 0);
|
|
124
|
+
return { files, skipped, totalSize };
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-paths.mjs — 路径解析(独立模块,避免 secrets.mjs 与 config.mjs 循环依赖)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
|
9
|
+
|
|
10
|
+
/** 解析 agent 目录(不依赖 pi 包,CLI 子进程也能用) */
|
|
11
|
+
export function resolveAgentDir() {
|
|
12
|
+
const envDir = process.env[AGENT_DIR_ENV];
|
|
13
|
+
if (envDir && envDir.trim()) return envDir.trim();
|
|
14
|
+
return join(homedir(), ".pi", "agent");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** getAgentDir 的本地等价实现 */
|
|
18
|
+
export function getAgentDir() {
|
|
19
|
+
return resolveAgentDir();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return join(getAgentDir(), "webdav-backup.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function statePath() {
|
|
27
|
+
return join(getAgentDir(), "webdav-backup-state.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function logPath() {
|
|
31
|
+
return join(getAgentDir(), "webdav-backup.log");
|
|
32
|
+
}
|