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.
@@ -0,0 +1,766 @@
1
+ /**
2
+ * webdav-backup.ts — pi 扩展:把插件/配置/扩展自动备份到 WebDAV
3
+ *
4
+ * 命令:
5
+ * /backup 立即备份(同步等待,输出实时日志)
6
+ * /backup check 测试 WebDAV 连通性与认证
7
+ * /backup list 列出远端已有备份
8
+ * /backup prune 清理旧备份(保留 keepVersions 个)
9
+ * /backup dry 试运行:只扫描不传
10
+ * /backup status 查看上次备份状态
11
+ * /backup restore 列出可恢复的备份;加 -y 直接恢复到临时目录
12
+ * /backup verify 配置体检(密钥来源、明文告警、连接性)
13
+ * /backup keys 管理密钥:列表 / 设置 / 删除 / 迁移明文
14
+ *
15
+ * 密钥不明文落盘:remote.password 与 encryptKey 支持
16
+ * dpapi:名称(Windows 用户级加密,推荐)、$ENV_VAR、file:路径、!命令
17
+ * /backup-setup 交互式向导:生成/修改 ~/.pi/agent/webdav-backup.json
18
+ * /backup-cron 安装/移除每日定时备份(Windows 计划任务)
19
+ * /backup-log 查看最近日志
20
+ *
21
+ * 自动行为:
22
+ * 退出 pi(session_shutdown, reason=quit)时,若 backupOnExit 为 true
23
+ * 且距上次自动备份超过 backupOnExitMinIntervalMinutes,则启动一个
24
+ * 【分离的子进程】执行上传 —— 不阻塞 pi 退出,也不怕终端被关。
25
+ *
26
+ * 安装位置:~/.pi/agent/extensions/webdav-backup.ts
27
+ * 首次加载后运行 /reload
28
+ */
29
+
30
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
31
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ // bundler/jiti 下 import.meta.url 可能不可用,做兜底
38
+ const EXT_DIR = (() => {
39
+ try {
40
+ return dirname(fileURLToPath(import.meta.url));
41
+ } catch {
42
+ return join(getAgentDir(), "extensions", "webdav-backup");
43
+ }
44
+ })();
45
+
46
+ const CLI = [
47
+ join(EXT_DIR, "cli.mjs"),
48
+ join(EXT_DIR, "webdav-backup", "cli.mjs"),
49
+ ].find((p) => existsSync(p)) ?? join(EXT_DIR, "webdav-backup", "cli.mjs");
50
+
51
+ function configFile() {
52
+ return join(getAgentDir(), "webdav-backup.json");
53
+ }
54
+ function stateFile() {
55
+ return join(getAgentDir(), "webdav-backup-state.json");
56
+ }
57
+ function logFile() {
58
+ return join(getAgentDir(), "webdav-backup.log");
59
+ }
60
+
61
+ function readJson<T = unknown>(path: string): T | null {
62
+ try {
63
+ if (!existsSync(path)) return null;
64
+ return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) as T;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ type BackupState = { lastBackupAt?: string; lastReason?: string };
71
+
72
+ function lastAutoBackupMs(): number {
73
+ const s = readJson<BackupState>(stateFile());
74
+ if (!s?.lastBackupAt) return 0;
75
+ const t = Date.parse(s.lastBackupAt);
76
+ return Number.isNaN(t) ? 0 : t;
77
+ }
78
+
79
+ /** 运行 cli.mjs 并把输出实时回显(同步等待) */
80
+ function runCli(
81
+ args: string[],
82
+ ctx: ExtensionCommandContext,
83
+ { timeoutMs = 10 * 60 * 1000 }: { timeoutMs?: number } = {},
84
+ ): Promise<{ ok: boolean; code: number | null; stdout: string; stderr: string }> {
85
+ return new Promise((resolve) => {
86
+ const child = spawn(process.execPath, [CLI, ...args], {
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ windowsHide: true,
89
+ env: { ...process.env },
90
+ });
91
+
92
+ let stdout = "";
93
+ let stderr = "";
94
+ let settled = false;
95
+
96
+ const timer = setTimeout(() => {
97
+ if (!settled) {
98
+ ctx.ui.notify(`备份超时(${Math.round(timeoutMs / 1000)}s),已终止`, "error");
99
+ child.kill();
100
+ }
101
+ }, timeoutMs);
102
+
103
+ child.stdout?.on("data", (d: Buffer) => {
104
+ const text = d.toString("utf8");
105
+ stdout += text;
106
+ for (const line of text.split("\n")) {
107
+ if (line.trim()) ctx.ui.setStatus("webdav-backup", line.slice(0, 90));
108
+ }
109
+ });
110
+ child.stderr?.on("data", (d: Buffer) => {
111
+ stderr += d.toString("utf8");
112
+ });
113
+
114
+ child.on("error", (e) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ resolve({ ok: false, code: null, stdout, stderr: `${stderr}\n${e.message}` });
119
+ });
120
+
121
+ child.on("close", (code) => {
122
+ if (settled) return;
123
+ settled = true;
124
+ clearTimeout(timer);
125
+ ctx.ui.setStatus("webdav-backup", undefined);
126
+ resolve({ ok: code === 0, code, stdout, stderr });
127
+ });
128
+ });
129
+ }
130
+
131
+ /** 分离子进程执行退出备份:不阻塞 pi 退出 */
132
+ function spawnDetachedBackup(timeoutMs: number): { ok: boolean; error?: string } {
133
+ try {
134
+ if (!existsSync(CLI)) return { ok: false, error: `找不到 ${CLI}` };
135
+ const child = spawn(process.execPath, [CLI, "backup", "--reason", "exit"], {
136
+ detached: true,
137
+ stdio: "ignore",
138
+ windowsHide: true,
139
+ env: { ...process.env, PI_WEBDAV_BACKUP_EXIT_TIMEOUT_MS: String(timeoutMs) },
140
+ });
141
+ child.unref();
142
+ return { ok: true };
143
+ } catch (e) {
144
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
145
+ }
146
+ }
147
+
148
+ function fmtSize(n: number): string {
149
+ if (!n) return "0 B";
150
+ const units = ["B", "KB", "MB", "GB"];
151
+ let i = 0;
152
+ let v = n;
153
+ while (v >= 1024 && i < units.length - 1) {
154
+ v /= 1024;
155
+ i++;
156
+ }
157
+ return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
158
+ }
159
+
160
+ export default function webdavBackupExtension(pi: ExtensionAPI) {
161
+ // ---------------------------------------------------------------- 退出自动备份
162
+ pi.on("session_shutdown", async (event, ctx) => {
163
+ if (event.reason !== "quit") return;
164
+
165
+ const cfg = readJson<{
166
+ enabled?: boolean;
167
+ backupOnExit?: boolean;
168
+ backupOnExitMinIntervalMinutes?: number;
169
+ backupOnExitTimeoutMs?: number;
170
+ }>(configFile());
171
+
172
+ if (!cfg || cfg.enabled === false || cfg.backupOnExit === false) return;
173
+ const remote = (cfg as { remote?: { url?: string } }).remote;
174
+ if (!remote?.url) return;
175
+
176
+ const minInterval = Number(cfg.backupOnExitMinIntervalMinutes ?? 30) * 60_000;
177
+ const since = Date.now() - lastAutoBackupMs();
178
+ if (lastAutoBackupMs() > 0 && minInterval > 0 && since < minInterval) {
179
+ const mins = Math.ceil((minInterval - since) / 60_000);
180
+ ctx.ui.notify(`WebDAV 备份跳过:距上次不足 ${mins} 分钟`, "info");
181
+ return;
182
+ }
183
+
184
+ const timeoutMs = Number(cfg.backupOnExitTimeoutMs ?? 10 * 60 * 1000);
185
+ const r = spawnDetachedBackup(timeoutMs);
186
+ if (r.ok) {
187
+ ctx.ui.notify("WebDAV 备份已在后台开始上传,可安全退出", "info");
188
+ } else {
189
+ ctx.ui.notify(`WebDAV 备份启动失败:${r.error}`, "error");
190
+ }
191
+ });
192
+
193
+ // ---------------------------------------------------------------- /backup
194
+ pi.registerCommand("backup", {
195
+ description: "WebDAV 备份:备份插件与配置(子命令见 /backup help)",
196
+ getArgumentCompletions: (prefix) => {
197
+ const subs = [
198
+ "run",
199
+ "check",
200
+ "list",
201
+ "prune",
202
+ "dry",
203
+ "status",
204
+ "restore",
205
+ "verify",
206
+ "keys",
207
+ "help",
208
+ ];
209
+ const f = subs.filter((s) => s.startsWith(prefix));
210
+ return f.length ? f.map((s) => ({ value: s, label: s })) : null;
211
+ },
212
+ handler: async (args, ctx) => {
213
+ const sub = (args.trim().split(/\s+/)[0] ?? "run").toLowerCase();
214
+
215
+ if (sub === "help") {
216
+ ctx.ui.notify(
217
+ [
218
+ "/backup 立即备份",
219
+ "/backup check 测试 WebDAV 连接与认证",
220
+ "/backup list 列出远端已有备份",
221
+ "/backup prune 清理旧备份",
222
+ "/backup dry 试运行(只扫描不上传)",
223
+ "/backup status 查看上次备份状态",
224
+ "/backup restore 查看可恢复的备份列表",
225
+ " (/backup restore -y 恢复到临时目录)",
226
+ "/backup verify 配置体检(密钥来源 / 明文告警 / 连接性)",
227
+ "/backup keys 管理密钥(列表 / 设置 / 删除 / 迁移明文)",
228
+ ].join("\n"),
229
+ "info",
230
+ );
231
+ return;
232
+ }
233
+
234
+ if (!existsSync(configFile())) {
235
+ const yes = await ctx.ui.confirm(
236
+ "尚未配置 WebDAV",
237
+ `配置文件不存在:\n${configFile()}\n\n现在用向导创建吗?`,
238
+ );
239
+ if (yes) {
240
+ await ctx.ui.notify("请运行 /backup-setup 完成配置", "info");
241
+ }
242
+ return;
243
+ }
244
+
245
+ // ── 体检:报告密钥来源与明文告警
246
+ if (sub === "verify") {
247
+ const r = await runCli(["doctor", "--json"], ctx, { timeoutMs: 60_000 });
248
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
249
+ const d = JSON.parse(line) as {
250
+ ok?: boolean;
251
+ url?: string;
252
+ username?: string;
253
+ password?: { source?: string; plaintext?: boolean };
254
+ encryptKey?: { configured?: boolean; source?: string; plaintext?: boolean };
255
+ dpapiAvailable?: boolean;
256
+ connectivity?: { ok?: boolean; error?: string } | null;
257
+ errors?: string[];
258
+ warnings?: string[];
259
+ };
260
+ const rows = [
261
+ `WebDAV: ${d.url || "(未设置)"}`,
262
+ `用户: ${d.username || "(未设置)"}`,
263
+ `密码来源: ${d.password?.source ?? "空"}${d.password?.plaintext ? " ⚠️ 明文" : ""}`,
264
+ `加密密钥: ${
265
+ d.encryptKey?.configured ? `${d.encryptKey.source}${d.encryptKey.plaintext ? " ⚠️ 明文" : ""}` : "(未配置)"
266
+ }`,
267
+ `DPAPI: ${d.dpapiAvailable ? "可用" : "不可用(非 Windows)"}`,
268
+ `连接: ${d.connectivity ? (d.connectivity.ok ? "通过 ✅" : `失败 — ${d.connectivity.error}`) : "未测"}`,
269
+ ];
270
+ if (d.warnings?.length) rows.push(`告警: ${d.warnings.join(";")}`);
271
+ if (d.errors?.length) rows.push(`错误: ${d.errors.join(";")}`);
272
+ ctx.ui.notify(rows.join("\n"), d.ok ? "info" : "error");
273
+ return;
274
+ }
275
+
276
+ // ── 密钥管理
277
+ if (sub === "keys") {
278
+ const action = (args.trim().split(/\s+/)[1] ?? "list").toLowerCase();
279
+
280
+ if (action === "list") {
281
+ const r = await runCli(["list-secrets", "--json"], ctx, { timeoutMs: 60_000 });
282
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
283
+ const d = JSON.parse(line) as {
284
+ available?: boolean;
285
+ secrets?: { name: string; updatedAt?: string }[];
286
+ };
287
+ const items = d.secrets ?? [];
288
+ ctx.ui.notify(
289
+ items.length === 0
290
+ ? `DPAPI 存储中没有密钥${d.available ? "" : "(DPAPI 不可用:非 Windows)"}`
291
+ : `已保存的密钥:\n${items.map((s) => ` ${s.name} ${s.updatedAt ?? ""}`).join("\n")}`,
292
+ "info",
293
+ );
294
+ return;
295
+ }
296
+
297
+ if (action === "set") {
298
+ const name = args.trim().split(/\s+/)[2];
299
+ if (!name) {
300
+ ctx.ui.notify("用法:/backup keys set <名称>,例如 /backup keys set webdav-password", "warning");
301
+ return;
302
+ }
303
+ const value = await ctx.ui.input(`密钥值(${name})`, "输入后将以 DPAPI 加密保存");
304
+ if (value === undefined || value === "") return;
305
+ const r = await runCli(["set-secret", "--name", name, "--value", value, "--json"], ctx, {
306
+ timeoutMs: 60_000,
307
+ });
308
+ if (r.ok) {
309
+ ctx.ui.notify(
310
+ `已加密保存「${name}」\n在配置里写 dpapi:${name} 即可引用`,
311
+ "info",
312
+ );
313
+ } else {
314
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
315
+ let msg = r.stderr.trim() || `退出码 ${r.code}`;
316
+ try {
317
+ msg = (JSON.parse(line) as { error?: string }).error ?? msg;
318
+ } catch {
319
+ /* ignore */
320
+ }
321
+ ctx.ui.notify(`保存失败:${msg}`, "error");
322
+ }
323
+ return;
324
+ }
325
+
326
+ if (action === "rm" || action === "delete") {
327
+ const name = args.trim().split(/\s+/)[2];
328
+ if (!name) {
329
+ ctx.ui.notify("用法:/backup keys rm <名称>", "warning");
330
+ return;
331
+ }
332
+ const ok = await ctx.ui.confirm("删除密钥?", `将删除 DPAPI 密钥「${name}」,引用它的配置会失效。`);
333
+ if (!ok) return;
334
+ const r = await runCli(["rm-secret", "--name", name, "--json"], ctx, { timeoutMs: 60_000 });
335
+ ctx.ui.notify(r.ok ? `已删除「${name}」` : `删除失败:${r.stderr.trim()}`, r.ok ? "info" : "error");
336
+ return;
337
+ }
338
+
339
+ if (action === "migrate") {
340
+ const raw = readFileSync(configFile(), "utf8");
341
+ const hasPlain =
342
+ /"password"\s*:\s*"(?!dpapi:|file:|plain:|!|\$)[^"]+"/.test(raw) ||
343
+ /"encryptKey"\s*:\s*"(?!dpapi:|file:|plain:|!|\$)[^"]+"/.test(raw);
344
+
345
+ if (!hasPlain) {
346
+ ctx.ui.notify("配置里没有明文密钥,无需迁移", "info");
347
+ return;
348
+ }
349
+
350
+ const method = await ctx.ui.select("迁移方式", [
351
+ "dpapi — Windows 用户级加密(推荐,无需主密码)",
352
+ "env — 写入用户环境变量",
353
+ "file — 写入 0600 权限的独立文件",
354
+ ]);
355
+ if (!method) return;
356
+ const m = method.split(" ")[0];
357
+
358
+ const ok = await ctx.ui.confirm(
359
+ "确认迁移?",
360
+ `将把明文密钥转为 ${m} 引用,并自动备份原配置。`,
361
+ );
362
+ if (!ok) return;
363
+
364
+ const r = await runCli(["migrate", "--method", m, "--json"], ctx, { timeoutMs: 120_000 });
365
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
366
+ let msg = r.ok ? "迁移完成" : "迁移失败";
367
+ try {
368
+ const parsed = JSON.parse(line) as {
369
+ migrated?: number;
370
+ message?: string;
371
+ error?: string;
372
+ changes?: { field: string; to: string }[];
373
+ };
374
+ if (parsed.error) msg = parsed.error;
375
+ else if (parsed.message) msg = parsed.message;
376
+ else if (parsed.changes?.length)
377
+ msg = `已迁移 ${parsed.migrated} 项:\n${parsed.changes.map((c) => ` ${c.field} → ${c.to}`).join("\n")}`;
378
+ } catch {
379
+ /* ignore */
380
+ }
381
+ ctx.ui.notify(msg, r.ok ? "info" : "error");
382
+ return;
383
+ }
384
+
385
+ ctx.ui.notify(
386
+ "用法:\n/backup keys list\n/backup keys set <名称>\n/backup keys rm <名称>\n/backup keys migrate",
387
+ "info",
388
+ );
389
+ return;
390
+ }
391
+
392
+ const cliArgs: string[] =
393
+ sub === "run" ? ["backup"] : sub === "dry" ? ["backup", "--dry-run"] : [sub];
394
+ if (sub === "check" || sub === "list" || sub === "status") {
395
+ cliArgs.push("--json");
396
+ }
397
+
398
+ // restore:先用列表展示,用户确认后才真正下载
399
+ if (sub === "restore") {
400
+ const autoYes = /(^|\s)(-y|--yes)(\s|$)/.test(args);
401
+ const listRun = await runCli(["restore", "--list", "--json"], ctx, { timeoutMs: 60_000 });
402
+ const raw = listRun.stdout.trim().split("\n").pop() ?? "{}";
403
+ const listing = JSON.parse(raw) as {
404
+ archives?: { name: string; size: number }[];
405
+ snapshot?: { name: string; fileCount: number } | null;
406
+ };
407
+ const options: string[] = [
408
+ ...(listing.archives ?? []).map((a) => `${a.name} (${fmtSize(a.size)})`),
409
+ ...(listing.snapshot ? [`${listing.snapshot.name} [快照, ${listing.snapshot.fileCount} 个文件]`] : []),
410
+ ];
411
+ if (options.length === 0) {
412
+ ctx.ui.notify("远端没有可恢复的备份", "warning");
413
+ return;
414
+ }
415
+
416
+ const picked = autoYes
417
+ ? options[0]
418
+ : await ctx.ui.select("选择要恢复的备份", options);
419
+ if (!picked) return;
420
+ const fileName = picked.split(" (")[0].replace(/\/$/, "");
421
+
422
+ const ok = autoYes
423
+ ? true
424
+ : await ctx.ui.confirm(
425
+ "确认恢复?",
426
+ `将从 ${fileName} 恢复到一个临时目录(不会直接覆盖 ~/.pi/agent)。\n恢复后你可以自行拷贝需要的文件。`,
427
+ );
428
+ if (!ok) return;
429
+
430
+ const restoreArgs = ["restore", "--file", fileName];
431
+ ctx.ui.setStatus("webdav-backup", "恢复中…");
432
+ const rr = await runCli(restoreArgs, ctx, { timeoutMs: 30 * 60 * 1000 });
433
+ ctx.ui.setStatus("webdav-backup", undefined);
434
+ const last = rr.stdout.trim().split("\n").pop() ?? "{}";
435
+ let info: { ok?: boolean; dest?: string; files?: number; error?: string } = {};
436
+ try {
437
+ info = JSON.parse(last) as typeof info;
438
+ } catch {
439
+ /* ignore */
440
+ }
441
+ if (rr.ok && info.ok) {
442
+ ctx.ui.notify(`恢复完成:${info.files} 个文件 → ${info.dest}`, "info");
443
+ } else {
444
+ ctx.ui.notify(`恢复失败:${info.error ?? rr.stderr.trim()}`, "error");
445
+ }
446
+ return;
447
+ }
448
+
449
+ ctx.ui.setStatus("webdav-backup", "备份中…");
450
+ const r = await runCli(cliArgs, ctx, { timeoutMs: 30 * 60 * 1000 });
451
+
452
+ // status/list/check 用 JSON 结果做友好展示
453
+ if (sub === "status") {
454
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
455
+ const parsed = JSON.parse(line) as { state?: BackupState };
456
+ ctx.ui.notify(
457
+ parsed.state?.lastBackupAt
458
+ ? `上次备份:${new Date(parsed.state.lastBackupAt).toLocaleString()}(${parsed.state.lastReason ?? "?"})`
459
+ : "还没有备份记录",
460
+ "info",
461
+ );
462
+ return;
463
+ }
464
+
465
+ if (sub === "check" || sub === "list") {
466
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
467
+ const parsed = JSON.parse(line) as {
468
+ ok?: boolean;
469
+ error?: string;
470
+ status?: number;
471
+ files?: { name: string; size: number }[];
472
+ };
473
+ if (sub === "check") {
474
+ ctx.ui.notify(
475
+ parsed.ok ? "WebDAV 连接正常,认证通过 ✅" : `连接失败:${parsed.error ?? parsed.status}`,
476
+ parsed.ok ? "info" : "error",
477
+ );
478
+ } else {
479
+ const files = parsed.files ?? [];
480
+ ctx.ui.notify(
481
+ files.length === 0
482
+ ? "远端暂无备份文件"
483
+ : `远端 ${files.length} 个文件:\n${files
484
+ .slice(0, 10)
485
+ .map((f) => `${f.name} ${fmtSize(f.size)}`)
486
+ .join("\n")}${files.length > 10 ? `\n…还有 ${files.length - 10} 个` : ""}`,
487
+ "info",
488
+ );
489
+ }
490
+ return;
491
+ }
492
+
493
+ // 备份/试运行/清理:解析最后一行 JSON 汇总
494
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
495
+ let summary: Record<string, unknown> = {};
496
+ try {
497
+ summary = JSON.parse(line) as Record<string, unknown>;
498
+ } catch {
499
+ /* 非 JSON 输出,忽略 */
500
+ }
501
+
502
+ if (r.ok) {
503
+ if (summary.dryRun) {
504
+ ctx.ui.notify(
505
+ `试运行完成:${summary.files} 个文件,${fmtSize(Number(summary.totalSize ?? 0))},跳过 ${summary.skipped} 项`,
506
+ "info",
507
+ );
508
+ } else if (summary.mode === "snapshot") {
509
+ ctx.ui.notify(
510
+ `备份完成:上传 ${summary.uploaded} 个,跳过 ${summary.skippedSame} 个未变化文件`,
511
+ "info",
512
+ );
513
+ } else if (summary.remotePath) {
514
+ ctx.ui.notify(
515
+ `备份完成 ✅\n${summary.remotePath}\n${summary.files} 个文件,归档 ${fmtSize(Number(summary.archiveSize ?? 0))}`,
516
+ "info",
517
+ );
518
+ } else if (Array.isArray(summary.deleted)) {
519
+ ctx.ui.notify(
520
+ (summary.deleted as string[]).length > 0
521
+ ? `已清理 ${(summary.deleted as string[]).length} 个旧备份`
522
+ : "没有需要清理的旧备份",
523
+ "info",
524
+ );
525
+ } else {
526
+ ctx.ui.notify("完成", "info");
527
+ }
528
+ } else {
529
+ ctx.ui.notify(`失败:${summary.error ?? r.stderr.trim() ?? `退出码 ${r.code}`}`, "error");
530
+ }
531
+ },
532
+ });
533
+
534
+ // ---------------------------------------------------------------- /backup-setup
535
+ pi.registerCommand("backup-setup", {
536
+ description: "交互式配置 WebDAV 备份",
537
+ handler: async (_args, ctx) => {
538
+ const existing = readJson<Record<string, unknown>>(configFile()) ?? {};
539
+ const cur = (existing.remote ?? {}) as Record<string, string>;
540
+
541
+ ctx.ui.notify("WebDAV 备份配置向导(直接回车保留原值)", "info");
542
+
543
+ const url = await ctx.ui.input("WebDAV 地址", cur.url ?? "https://dav.jianguoyun.com/dav/");
544
+ if (url === undefined) return;
545
+
546
+ const username = await ctx.ui.input("用户名 / 邮箱", cur.username ?? "");
547
+ if (username === undefined) return;
548
+
549
+ const password = await ctx.ui.input(
550
+ "密码(坚果云请用【应用密码】)。留空则用环境变量或稍后 /backup keys set",
551
+ "",
552
+ );
553
+ if (password === undefined) return;
554
+
555
+ // 密码优先用 DPAPI 加密存储,配置文件里只留引用
556
+ let passwordRef = cur.password ?? "";
557
+ let dpapiNote = "";
558
+ if (password) {
559
+ const safe = await ctx.ui.confirm(
560
+ "用 DPAPI 加密保存密码?",
561
+ "是(推荐):密码经 Windows 用户级加密存入 secrets.dpapi.json,配置文件里只写 dpapi:webdav-password,无需主密码。\n\n否:密码会以明文写进 webdav-backup.json。",
562
+ );
563
+ if (safe) {
564
+ const sr = await runCli(
565
+ ["set-secret", "--name", "webdav-password", "--value", password, "--json"],
566
+ ctx,
567
+ { timeoutMs: 60_000 },
568
+ );
569
+ if (sr.ok) {
570
+ passwordRef = "dpapi:webdav-password";
571
+ dpapiNote = "密码已用 DPAPI 加密保存";
572
+ } else {
573
+ const line = sr.stdout.trim().split("\n").pop() ?? "{}";
574
+ let msg = sr.stderr.trim();
575
+ try {
576
+ msg = (JSON.parse(line) as { error?: string }).error ?? msg;
577
+ } catch {
578
+ /* ignore */
579
+ }
580
+ const goPlain = await ctx.ui.confirm(
581
+ "DPAPI 保存失败",
582
+ `${msg || "未知错误"}\n\n改为明文写入配置吗?(不推荐)`,
583
+ );
584
+ if (!goPlain) return;
585
+ passwordRef = password;
586
+ }
587
+ } else {
588
+ passwordRef = password;
589
+ }
590
+ }
591
+
592
+ const remoteDir = await ctx.ui.input("远端目录", cur.remoteDir ?? "pi-backup");
593
+ if (remoteDir === undefined) return;
594
+
595
+ const remoteName = await ctx.ui.input("备份名前缀", cur.remoteName ?? "pi-agent");
596
+ if (remoteName === undefined) return;
597
+
598
+ const includeSessions = await ctx.ui.confirm("包含会话历史?", "会显著增大备份体积(可能几百 MB)");
599
+ const encrypt = await ctx.ui.confirm(
600
+ "加密备份?",
601
+ "用 AES-256-GCM 加密,防止 auth.json 里的密钥泄露到云端",
602
+ );
603
+
604
+ let encryptKey = (existing.encryptKey as string) ?? "";
605
+ let keyNote = "";
606
+ if (encrypt) {
607
+ const k1 = await ctx.ui.input("设置加密口令(至少 16 位)", "");
608
+ if (k1 === undefined) return;
609
+ if (k1.length < 16) {
610
+ ctx.ui.notify("加密口令至少 16 位,已取消", "error");
611
+ return;
612
+ }
613
+ const k2 = await ctx.ui.input("再输一次确认", "");
614
+ if (k2 !== k1) {
615
+ ctx.ui.notify("两次输入不一致,已取消", "error");
616
+ return;
617
+ }
618
+ encryptKey = k1;
619
+
620
+ const safeKey = await ctx.ui.confirm(
621
+ "用 DPAPI 加密保存口令?",
622
+ "是(推荐):配置里只写 dpapi:backup-key,避免明文落盘。",
623
+ );
624
+ if (safeKey) {
625
+ const sr = await runCli(
626
+ ["set-secret", "--name", "backup-key", "--value", k1, "--json"],
627
+ ctx,
628
+ { timeoutMs: 60_000 },
629
+ );
630
+ if (sr.ok) {
631
+ encryptKey = "dpapi:backup-key";
632
+ keyNote = "加密口令已用 DPAPI 加密保存";
633
+ } else {
634
+ const line = sr.stdout.trim().split("\n").pop() ?? "{}";
635
+ let msg = sr.stderr.trim();
636
+ try {
637
+ msg = (JSON.parse(line) as { error?: string }).error ?? msg;
638
+ } catch {
639
+ /* ignore */
640
+ }
641
+ const goPlainKey = await ctx.ui.confirm(
642
+ "DPAPI 保存失败",
643
+ `${msg || "未知错误"}\n\n改为明文写入配置吗?(不推荐)`,
644
+ );
645
+ if (!goPlainKey) {
646
+ ctx.ui.notify("已取消配置向导(未写入任何更改)", "warning");
647
+ return;
648
+ }
649
+ }
650
+ } else {
651
+ const goPlainKey = await ctx.ui.confirm(
652
+ "口令将以明文保存",
653
+ "加密口令会明文写进 webdav-backup.json。确定继续吗?",
654
+ );
655
+ if (!goPlainKey) return;
656
+ }
657
+ }
658
+
659
+ // 保留已有的引用形式,避免向导把 dpapi: 引用覆盖成空
660
+ const finalPassword = password ? passwordRef : (cur.password ?? "");
661
+
662
+ const cfg = {
663
+ _说明: "pi WebDAV 备份配置,由 /backup-setup 生成",
664
+ enabled: existing.enabled !== false,
665
+ remote: {
666
+ url,
667
+ username,
668
+ password: finalPassword,
669
+ remoteDir,
670
+ remoteName,
671
+ },
672
+ scope: ["."],
673
+ exclude: [],
674
+ includeSessions,
675
+ snapshot: existing.snapshot === true,
676
+ encrypt,
677
+ encryptKey,
678
+ backupOnExit: existing.backupOnExit !== false,
679
+ backupOnExitMinIntervalMinutes: existing.backupOnExitMinIntervalMinutes ?? 30,
680
+ backupOnExitTimeoutMs: 10 * 60 * 1000,
681
+ keepVersions: existing.keepVersions ?? 10,
682
+ timeoutMs: 60_000,
683
+ insecureTls: false,
684
+ };
685
+
686
+ try {
687
+ mkdirSync(dirname(configFile()), { recursive: true });
688
+ writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { encoding: "utf8", mode: 0o600 });
689
+ } catch (e) {
690
+ ctx.ui.notify(`写入配置失败:${e instanceof Error ? e.message : String(e)}`, "error");
691
+ return;
692
+ }
693
+
694
+ ctx.ui.notify(
695
+ [`配置已保存:${configFile()}`, dpapiNote, keyNote].filter(Boolean).join("\n") + "\n正在测试连接…",
696
+ "info",
697
+ );
698
+ const r = await runCli(["check", "--json"], ctx, { timeoutMs: 60_000 });
699
+ if (r.ok) {
700
+ ctx.ui.notify("WebDAV 连接正常 ✅ 可以运行 /backup 了", "info");
701
+ } else {
702
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
703
+ let msg = "连接测试失败";
704
+ try {
705
+ msg = (JSON.parse(line) as { error?: string }).error ?? msg;
706
+ } catch {
707
+ /* ignore */
708
+ }
709
+ ctx.ui.notify(`${msg}\n配置已保存,修正后重试 /backup check`, "error");
710
+ }
711
+ },
712
+ });
713
+
714
+ // ---------------------------------------------------------------- /backup-cron
715
+ pi.registerCommand("backup-cron", {
716
+ description: "安装/移除每日定时备份(Windows 计划任务)",
717
+ handler: async (args, ctx) => {
718
+ const sub = args.trim().toLowerCase();
719
+ if (sub === "remove" || sub === "uninstall" || sub === "off") {
720
+ const r = await runCli(["uninstall-cron"], ctx);
721
+ ctx.ui.notify(r.ok ? "已移除计划任务" : `移除失败:${r.stderr.trim()}`, r.ok ? "info" : "error");
722
+ return;
723
+ }
724
+
725
+ const time = /^\d{1,2}:\d{2}$/.test(sub) ? sub : "03:00";
726
+ const yes = await ctx.ui.confirm(
727
+ "安装每日定时备份?",
728
+ `将创建 Windows 计划任务,每天 ${time} 执行一次备份。\n需要管理员权限(若失败请以管理员身份运行 pi)。`,
729
+ );
730
+ if (!yes) return;
731
+
732
+ const r = await runCli(["install-cron", "--time", time, "--json"], ctx);
733
+ const line = r.stdout.trim().split("\n").pop() ?? "{}";
734
+ let msg = r.ok ? `已创建计划任务,每天 ${time} 备份` : "创建失败";
735
+ try {
736
+ const parsed = JSON.parse(line) as { error?: string };
737
+ if (!r.ok && parsed.error) msg = parsed.error;
738
+ } catch {
739
+ /* ignore */
740
+ }
741
+ ctx.ui.notify(msg, r.ok ? "info" : "error");
742
+ },
743
+ });
744
+
745
+ // ---------------------------------------------------------------- /backup-log
746
+ pi.registerCommand("backup-log", {
747
+ description: "查看 WebDAV 备份日志尾部",
748
+ handler: async (_args, ctx) => {
749
+ const f = logFile();
750
+ if (!existsSync(f)) {
751
+ ctx.ui.notify("暂无日志", "info");
752
+ return;
753
+ }
754
+ try {
755
+ const text = readFileSync(f, "utf8");
756
+ const lines = text.trimEnd().split("\n");
757
+ ctx.ui.notify(lines.slice(-20).join("\n"), "info");
758
+ } catch (e) {
759
+ ctx.ui.notify(
760
+ `读取日志失败:${e instanceof Error ? e.message : String(e)}`,
761
+ "error",
762
+ );
763
+ }
764
+ },
765
+ });
766
+ }