pi-webdav-backup 1.0.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -357
- 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/packages.mjs +397 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -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
|
@@ -1,766 +1,1024 @@
|
|
|
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
|
|
13
|
-
* /backup
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* /backup-
|
|
19
|
-
* /backup-
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
child.on("
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
if (sub === "
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
ctx.ui.notify(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
);
|
|
508
|
-
|
|
509
|
-
ctx
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
if (
|
|
615
|
-
ctx.ui.notify("
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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 apply-extras 把恢复目录里 agentDir 之外的数据(extra/)归位到真实路径
|
|
13
|
+
* /backup verify 配置体检(密钥来源、明文告警、连接性)
|
|
14
|
+
* /backup keys 管理密钥:列表 / 设置 / 删除 / 迁移明文
|
|
15
|
+
*
|
|
16
|
+
* 密钥不明文落盘:remote.password 与 encryptKey 支持
|
|
17
|
+
* dpapi:名称(Windows 用户级加密,推荐)、$ENV_VAR、file:路径、!命令
|
|
18
|
+
* /backup-setup 交互式向导:生成/修改 ~/.pi/agent/webdav-backup.json
|
|
19
|
+
* /backup-cron 安装/移除每日定时备份(Windows 计划任务)
|
|
20
|
+
* /backup-log 查看最近日志
|
|
21
|
+
*
|
|
22
|
+
* 自动行为:
|
|
23
|
+
* 退出 pi(session_shutdown, reason=quit)时,若 backupOnExit 为 true
|
|
24
|
+
* 且距上次自动备份超过 backupOnExitMinIntervalMinutes,则启动一个
|
|
25
|
+
* 【分离的子进程】执行上传 —— 不阻塞 pi 退出,也不怕终端被关。
|
|
26
|
+
*
|
|
27
|
+
* 安装位置:~/.pi/agent/extensions/webdav-backup.ts
|
|
28
|
+
* 首次加载后运行 /reload
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import { parseCliJson } from "./webdav-backup/json.mjs";
|
|
38
|
+
import { pickCommand } from "./webdav-backup/args.mjs";
|
|
39
|
+
import { detectLegacyConfig } from "./webdav-backup/extras.mjs";
|
|
40
|
+
|
|
41
|
+
// bundler/jiti 下 import.meta.url 可能不可用,做兜底
|
|
42
|
+
const EXT_DIR = (() => {
|
|
43
|
+
try {
|
|
44
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
} catch {
|
|
46
|
+
return join(getAgentDir(), "extensions", "webdav-backup");
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
const CLI = [
|
|
51
|
+
join(EXT_DIR, "cli.mjs"),
|
|
52
|
+
join(EXT_DIR, "webdav-backup", "cli.mjs"),
|
|
53
|
+
].find((p) => existsSync(p)) ?? join(EXT_DIR, "webdav-backup", "cli.mjs");
|
|
54
|
+
|
|
55
|
+
function configFile() {
|
|
56
|
+
return join(getAgentDir(), "webdav-backup.json");
|
|
57
|
+
}
|
|
58
|
+
function stateFile() {
|
|
59
|
+
return join(getAgentDir(), "webdav-backup-state.json");
|
|
60
|
+
}
|
|
61
|
+
function logFile() {
|
|
62
|
+
return join(getAgentDir(), "webdav-backup.log");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 探测 agentDir 之外的遗留配置(包一层,便于将来加缓存/容错) */
|
|
66
|
+
function detectLegacyConfigLocal(): { path: string; kind: string; note: string }[] {
|
|
67
|
+
try {
|
|
68
|
+
return detectLegacyConfig(getAgentDir());
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readJson<T = unknown>(path: string): T | null {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(path)) return null;
|
|
77
|
+
return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")) as T;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type BackupState = { lastBackupAt?: string; lastReason?: string };
|
|
84
|
+
|
|
85
|
+
function lastAutoBackupMs(): number {
|
|
86
|
+
const s = readJson<BackupState>(stateFile());
|
|
87
|
+
if (!s?.lastBackupAt) return 0;
|
|
88
|
+
const t = Date.parse(s.lastBackupAt);
|
|
89
|
+
return Number.isNaN(t) ? 0 : t;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 运行 cli.mjs 并把输出实时回显(同步等待) */
|
|
93
|
+
function runCli(
|
|
94
|
+
args: string[],
|
|
95
|
+
ctx: ExtensionCommandContext,
|
|
96
|
+
{ timeoutMs = 10 * 60 * 1000 }: { timeoutMs?: number } = {},
|
|
97
|
+
): Promise<{ ok: boolean; code: number | null; stdout: string; stderr: string }> {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const child = spawn(process.execPath, [CLI, ...args], {
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
env: { ...process.env },
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
let stdout = "";
|
|
106
|
+
let stderr = "";
|
|
107
|
+
let settled = false;
|
|
108
|
+
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
if (!settled) {
|
|
111
|
+
ctx.ui.notify(`备份超时(${Math.round(timeoutMs / 1000)}s),已终止`, "error");
|
|
112
|
+
child.kill();
|
|
113
|
+
}
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
|
|
116
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
117
|
+
const text = d.toString("utf8");
|
|
118
|
+
stdout += text;
|
|
119
|
+
for (const line of text.split("\n")) {
|
|
120
|
+
const t = line.trim();
|
|
121
|
+
// 末行 JSON 汇总是给程序读的,不刷到状态栏
|
|
122
|
+
if (t && !t.startsWith("{")) ctx.ui.setStatus("webdav-backup", line.slice(0, 90));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
126
|
+
stderr += d.toString("utf8");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
child.on("error", (e) => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
settled = true;
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
resolve({ ok: false, code: null, stdout, stderr: `${stderr}\n${e.message}` });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
child.on("close", (code) => {
|
|
137
|
+
if (settled) return;
|
|
138
|
+
settled = true;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
ctx.ui.setStatus("webdav-backup", undefined);
|
|
141
|
+
resolve({ ok: code === 0, code, stdout, stderr });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 分离子进程执行退出备份:不阻塞 pi 退出 */
|
|
147
|
+
function spawnDetachedBackup(timeoutMs: number): { ok: boolean; error?: string } {
|
|
148
|
+
try {
|
|
149
|
+
if (!existsSync(CLI)) return { ok: false, error: `找不到 ${CLI}` };
|
|
150
|
+
const child = spawn(process.execPath, [CLI, "backup", "--reason", "exit"], {
|
|
151
|
+
detached: true,
|
|
152
|
+
stdio: "ignore",
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
env: { ...process.env, PI_WEBDAV_BACKUP_EXIT_TIMEOUT_MS: String(timeoutMs) },
|
|
155
|
+
});
|
|
156
|
+
child.unref();
|
|
157
|
+
return { ok: true };
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fmtSize(n: number): string {
|
|
164
|
+
if (!n) return "0 B";
|
|
165
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
166
|
+
let i = 0;
|
|
167
|
+
let v = n;
|
|
168
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
169
|
+
v /= 1024;
|
|
170
|
+
i++;
|
|
171
|
+
}
|
|
172
|
+
return `${v.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 体检并(在用户同意时)补装插件。
|
|
177
|
+
* dir 指向要体检的目录——恢复流程里必须传「刚恢复出来的目录」:
|
|
178
|
+
* 恢复默认解到临时目录,声明还在那里,看当前 agent 目录会永远报“没声明插件”。
|
|
179
|
+
*/
|
|
180
|
+
async function repairPluginsFlow(
|
|
181
|
+
ctx: ExtensionCommandContext,
|
|
182
|
+
dir: string | null,
|
|
183
|
+
note: string,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const dirArgs = dir ? ["--dir", dir] : [];
|
|
186
|
+
const pl = await runCli(["plugins", "--json", ...dirArgs], ctx, { timeoutMs: 60_000 });
|
|
187
|
+
const pa = parseCliJson(pl) as { issues?: { source: string }[] } | null;
|
|
188
|
+
if (!pa?.issues?.length) return;
|
|
189
|
+
const names = pa.issues.map((i) => i.source).join("、");
|
|
190
|
+
const fix = await ctx.ui.confirm(
|
|
191
|
+
"插件未安装完整",
|
|
192
|
+
`检测到 ${pa.issues.length} 个已声明但未装好的插件:\n${names}\n\n${note}\n\n现在补装吗?`,
|
|
193
|
+
);
|
|
194
|
+
if (!fix) return;
|
|
195
|
+
ctx.ui.setStatus("webdav-backup", "补装插件中…");
|
|
196
|
+
const rp = await runCli(["repair-plugins", "--json", ...dirArgs], ctx, { timeoutMs: 30 * 60 * 1000 });
|
|
197
|
+
ctx.ui.setStatus("webdav-backup", undefined);
|
|
198
|
+
const pr = parseCliJson(rp) as { repaired?: unknown[]; failed?: unknown[] } | null;
|
|
199
|
+
if (rp.ok) ctx.ui.notify(`插件补装完成(${pr?.repaired?.length ?? 0} 个)`, "info");
|
|
200
|
+
else ctx.ui.notify(`插件补装未全部成功(失败 ${pr?.failed?.length ?? 0} 个)`, "warning");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 恢复后把恢复目录写回当前 agent 目录(换机一键还原)。
|
|
205
|
+
* 默认【合并模式】:保护本机已装插件(已存在的不被覆盖),settings.json 只合并 packages。
|
|
206
|
+
* 覆盖前自动备份旧文件;归档里是 __REDACTED__ 而本地有真实值时会跳过。
|
|
207
|
+
* 返回是否写回成功,以及备份里是否已含插件实现代码。
|
|
208
|
+
*/
|
|
209
|
+
async function applyRestoredFlow(
|
|
210
|
+
ctx: ExtensionCommandContext,
|
|
211
|
+
dir: string,
|
|
212
|
+
): Promise<{ applied: boolean; pluginReady: boolean }> {
|
|
213
|
+
const dry = await runCli(["restore-apply", "--dir", dir, "--dry-run", "--json"], ctx, { timeoutMs: 120_000 });
|
|
214
|
+
const d = parseCliJson(dry) as {
|
|
215
|
+
total?: number;
|
|
216
|
+
applied?: unknown[];
|
|
217
|
+
unchanged?: unknown[];
|
|
218
|
+
skipped?: { rel: string; target?: string }[];
|
|
219
|
+
failed?: unknown[];
|
|
220
|
+
} | null;
|
|
221
|
+
const total = d?.total ?? 0;
|
|
222
|
+
if (total === 0) return { applied: false, pluginReady: false };
|
|
223
|
+
|
|
224
|
+
// 本机已装了插件?让用户选合并(默认,保本机)还是完全替换(旧机原样)
|
|
225
|
+
const curPlugins = countLocalPackages();
|
|
226
|
+
let mode = "merge";
|
|
227
|
+
if (curPlugins > 0) {
|
|
228
|
+
const pick = await ctx.ui.select(
|
|
229
|
+
`写回当前 pi?本机已装 ${curPlugins} 个插件`,
|
|
230
|
+
[
|
|
231
|
+
"合并(推荐)— 保留本机已装插件,只补本机没有的;不删除/不降级本机插件",
|
|
232
|
+
"替换 — 完全还原成备份里的状态(可能删掉/降级本机插件)",
|
|
233
|
+
],
|
|
234
|
+
);
|
|
235
|
+
if (!pick) return { applied: false, pluginReady: false };
|
|
236
|
+
mode = pick.startsWith("替换") ? "replace" : "merge";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const modeNote =
|
|
240
|
+
mode === "merge"
|
|
241
|
+
? "合并模式:本机已装的插件实现(node_modules / git)保留不被覆盖;settings.json 只合并 packages,保留本机已有插件声明。"
|
|
242
|
+
: "替换模式:完全还原成备份里的状态,同名插件会被降级/覆盖,本机多装的插件声明会被移除(不会删插件代码)。";
|
|
243
|
+
const ok = await ctx.ui.confirm(
|
|
244
|
+
`写回当前 pi 配置目录(${mode === "merge" ? "合并" : "替换"})?`,
|
|
245
|
+
`将把恢复出来的 ${total} 个文件写回:\n${dir}\n→ ${getAgentDir()}\n\n${modeNote}\n` +
|
|
246
|
+
`已存在且内容不同的文件会先备份为 <名字>.bak-apply-<时间戳>;` +
|
|
247
|
+
`归档里是 __REDACTED__ 而本地有真实值的文件会被跳过(避免把真密钥覆盖成占位符)。\n\n继续吗?`,
|
|
248
|
+
);
|
|
249
|
+
if (!ok) return { applied: false, pluginReady: false };
|
|
250
|
+
|
|
251
|
+
ctx.ui.setStatus("webdav-backup", "写回配置中…");
|
|
252
|
+
const r = await runCli(["restore-apply", "--dir", dir, "--mode", mode, "--json"], ctx, { timeoutMs: 5 * 60 * 1000 });
|
|
253
|
+
ctx.ui.setStatus("webdav-backup", undefined);
|
|
254
|
+
const res = parseCliJson(r) as {
|
|
255
|
+
applied?: unknown[];
|
|
256
|
+
unchanged?: unknown[];
|
|
257
|
+
skipped?: { rel: string }[];
|
|
258
|
+
failed?: unknown[];
|
|
259
|
+
merged?: unknown[];
|
|
260
|
+
pluginReady?: boolean;
|
|
261
|
+
} | null;
|
|
262
|
+
if (!r.ok) {
|
|
263
|
+
ctx.ui.notify(`写回未全部成功(失败 ${res?.failed?.length ?? 0} 个)`, "warning");
|
|
264
|
+
return { applied: false, pluginReady: false };
|
|
265
|
+
}
|
|
266
|
+
const skippedN = res?.skipped?.length ?? 0;
|
|
267
|
+
ctx.ui.notify(
|
|
268
|
+
`已写回 ${res?.applied?.length ?? 0} 个文件到 ${getAgentDir()}(${mode === "merge" ? "合并" : "替换"})` +
|
|
269
|
+
(res?.merged?.length ? "\nsettings.json 已合并 packages,保留本机插件声明" : "") +
|
|
270
|
+
(skippedN > 0 ? `\n${skippedN} 个因本机已有版本或本地真实值被跳过` : "") +
|
|
271
|
+
(res?.pluginReady ? "\n插件实现代码已一并恢复,重启 pi 后即可使用。" : ""),
|
|
272
|
+
skippedN > 0 ? "warning" : "info",
|
|
273
|
+
);
|
|
274
|
+
return { applied: true, pluginReady: res?.pluginReady === true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 本机 settings.json 里声明了多少个插件(仅用于写回前的提示) */
|
|
278
|
+
function countLocalPackages(): number {
|
|
279
|
+
try {
|
|
280
|
+
const s = readJson<{ packages?: unknown[] }>(join(getAgentDir(), "settings.json"));
|
|
281
|
+
return Array.isArray(s?.packages) ? s.packages.length : 0;
|
|
282
|
+
} catch {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 恢复后把 extra/(agentDir 之外的数据)归位到真实路径。
|
|
289
|
+
* 这些文件默认只解到恢复目录,需用户确认后才写回 ~/.pi/settings.json、~/.config/... 等。
|
|
290
|
+
*/
|
|
291
|
+
async function applyExtrasFlow(ctx: ExtensionCommandContext, dir: string): Promise<void> {
|
|
292
|
+
const dry = await runCli(["apply-extras", "--dir", dir, "--dry-run", "--json"], ctx, { timeoutMs: 60_000 });
|
|
293
|
+
const d = parseCliJson(dry) as {
|
|
294
|
+
applied?: unknown[];
|
|
295
|
+
skipped?: { rel: string; target?: string }[];
|
|
296
|
+
unparsed?: { rel: string }[];
|
|
297
|
+
unmapped?: { rel: string; orig?: string; target?: string }[];
|
|
298
|
+
failed?: { rel: string; target?: string; error?: string }[];
|
|
299
|
+
source?: { hostname?: string; platform?: string; home?: string } | null;
|
|
300
|
+
} | null;
|
|
301
|
+
const toWrite = (d?.applied?.length ?? 0) + (d?.skipped?.length ?? 0);
|
|
302
|
+
if (toWrite === 0) return;
|
|
303
|
+
const srcNote = d?.source
|
|
304
|
+
? `来源机器:${d.source.hostname ?? "?"}(${d.source.platform ?? "?"}),home=${d.source.home ?? "?"}\n`
|
|
305
|
+
: "";
|
|
306
|
+
const ok = await ctx.ui.confirm(
|
|
307
|
+
"归位 agentDir 之外的数据?",
|
|
308
|
+
`恢复目录里有 ${toWrite} 个 agentDir 之外的配置文件(如 ~/.pi/settings.json、~/.config/rpiv-*):\n` +
|
|
309
|
+
srcNote +
|
|
310
|
+
`${(d?.skipped ?? []).slice(0, 5).map((x) => ` ${x.target}`).join("\n")}\n\n` +
|
|
311
|
+
`将写回真实路径(跨机时会按家目录/XDG 锚点重映射;已存在且内容相同的会跳过)。继续吗?`,
|
|
312
|
+
);
|
|
313
|
+
if (!ok) return;
|
|
314
|
+
ctx.ui.setStatus("webdav-backup", "归位额外数据中…");
|
|
315
|
+
// 不加 --force:applyExtraFiles 会跳过“本地已有真实值、而归档里是 __REDACTED__”的文件,
|
|
316
|
+
// 以免把真密钥覆盖成占位符。被跳过的会以告警列出,用户可手动处理。
|
|
317
|
+
const r = await runCli(["apply-extras", "--dir", dir, "--json"], ctx, { timeoutMs: 60_000 });
|
|
318
|
+
ctx.ui.setStatus("webdav-backup", undefined);
|
|
319
|
+
const res = parseCliJson(r) as {
|
|
320
|
+
applied?: unknown[];
|
|
321
|
+
failed?: unknown[];
|
|
322
|
+
skipped?: { rel: string }[];
|
|
323
|
+
unmapped?: { rel: string }[];
|
|
324
|
+
} | null;
|
|
325
|
+
if (r.ok) {
|
|
326
|
+
const skippedN = res?.skipped?.length ?? 0;
|
|
327
|
+
const unmappedN = res?.unmapped?.length ?? 0;
|
|
328
|
+
const extraNote =
|
|
329
|
+
(skippedN > 0 ? `;${skippedN} 个因本地已有真实值被跳过(避免用 __REDACTED__ 覆盖,如需强制用 /backup apply-extras <目录> --force)` : "") +
|
|
330
|
+
(unmappedN > 0 ? `;${unmappedN} 个未匹配家目录/XDG 锚点,按原路径归位,请核对` : "");
|
|
331
|
+
ctx.ui.notify(`已归位 ${res?.applied?.length ?? 0} 个文件${extraNote}`, skippedN > 0 || unmappedN > 0 ? "warning" : "info");
|
|
332
|
+
} else {
|
|
333
|
+
ctx.ui.notify(`归位未全部成功(失败 ${res?.failed?.length ?? 0} 个)`, "warning");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export default function webdavBackupExtension(pi: ExtensionAPI) {
|
|
338
|
+
// ---------------------------------------------------------------- 退出自动备份
|
|
339
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
340
|
+
if (event.reason !== "quit") return;
|
|
341
|
+
|
|
342
|
+
const cfg = readJson<{
|
|
343
|
+
enabled?: boolean;
|
|
344
|
+
backupOnExit?: boolean;
|
|
345
|
+
backupOnExitMinIntervalMinutes?: number;
|
|
346
|
+
backupOnExitTimeoutMs?: number;
|
|
347
|
+
}>(configFile());
|
|
348
|
+
|
|
349
|
+
if (!cfg || cfg.enabled === false || cfg.backupOnExit === false) return;
|
|
350
|
+
const remote = (cfg as { remote?: { url?: string } }).remote;
|
|
351
|
+
if (!remote?.url) return;
|
|
352
|
+
|
|
353
|
+
const minInterval = Number(cfg.backupOnExitMinIntervalMinutes ?? 30) * 60_000;
|
|
354
|
+
const since = Date.now() - lastAutoBackupMs();
|
|
355
|
+
if (lastAutoBackupMs() > 0 && minInterval > 0 && since < minInterval) {
|
|
356
|
+
const mins = Math.ceil((minInterval - since) / 60_000);
|
|
357
|
+
ctx.ui.notify(`WebDAV 备份跳过:距上次不足 ${mins} 分钟`, "info");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const timeoutMs = Number(cfg.backupOnExitTimeoutMs ?? 10 * 60 * 1000);
|
|
362
|
+
const r = spawnDetachedBackup(timeoutMs);
|
|
363
|
+
if (r.ok) {
|
|
364
|
+
ctx.ui.notify("WebDAV 备份已在后台开始上传,可安全退出", "info");
|
|
365
|
+
} else {
|
|
366
|
+
ctx.ui.notify(`WebDAV 备份启动失败:${r.error}`, "error");
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------- /backup
|
|
371
|
+
pi.registerCommand("backup", {
|
|
372
|
+
description: "WebDAV 备份:备份插件与配置(子命令见 /backup help)",
|
|
373
|
+
getArgumentCompletions: (prefix) => {
|
|
374
|
+
const subs = [
|
|
375
|
+
"run",
|
|
376
|
+
"check",
|
|
377
|
+
"list",
|
|
378
|
+
"prune",
|
|
379
|
+
"dry",
|
|
380
|
+
"status",
|
|
381
|
+
"restore",
|
|
382
|
+
"restore-apply",
|
|
383
|
+
"apply-extras",
|
|
384
|
+
"verify",
|
|
385
|
+
"plugins",
|
|
386
|
+
"repair-plugins",
|
|
387
|
+
"keys",
|
|
388
|
+
"help",
|
|
389
|
+
];
|
|
390
|
+
const f = subs.filter((s) => s.startsWith(prefix));
|
|
391
|
+
return f.length ? f.map((s) => ({ value: s, label: s })) : null;
|
|
392
|
+
},
|
|
393
|
+
handler: async (args, ctx) => {
|
|
394
|
+
// 注意:不能写 `split(/\s+/)[0] ?? "run"`——空串会绕过 ?? 兜底(见 args.mjs)
|
|
395
|
+
const sub = pickCommand(args.trim().split(/\s+/), "run");
|
|
396
|
+
|
|
397
|
+
if (sub === "help") {
|
|
398
|
+
ctx.ui.notify(
|
|
399
|
+
[
|
|
400
|
+
"/backup 立即备份",
|
|
401
|
+
"/backup check 测试 WebDAV 连接与认证",
|
|
402
|
+
"/backup list 列出远端已有备份",
|
|
403
|
+
"/backup prune 清理旧备份",
|
|
404
|
+
"/backup dry 试运行(只扫描不上传)",
|
|
405
|
+
"/backup status 查看上次备份状态",
|
|
406
|
+
"/backup restore 查看可恢复的备份列表",
|
|
407
|
+
" (/backup restore -y 恢复到临时目录后询问是否写回当前 pi)",
|
|
408
|
+
"/backup restore-apply <目录> 写回当前 pi(默认合并,保留本机已装插件)",
|
|
409
|
+
"/backup apply-extras 把恢复目录里 agentDir 之外的数据归位到真实路径",
|
|
410
|
+
"/backup verify 配置体检(密钥来源 / 明文告警 / 连接性)",
|
|
411
|
+
"/backup plugins 体检已声明插件是否装齐(含缺失依赖)",
|
|
412
|
+
"/backup repair-plugins 补装缺失的插件 / 依赖",
|
|
413
|
+
"/backup keys 管理密钥(列表 / 设置 / 删除 / 迁移明文)",
|
|
414
|
+
].join("\n"),
|
|
415
|
+
"info",
|
|
416
|
+
);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!existsSync(configFile())) {
|
|
421
|
+
const yes = await ctx.ui.confirm(
|
|
422
|
+
"尚未配置 WebDAV",
|
|
423
|
+
`配置文件不存在:\n${configFile()}\n\n现在用向导创建吗?`,
|
|
424
|
+
);
|
|
425
|
+
if (yes) {
|
|
426
|
+
await ctx.ui.notify("请运行 /backup-setup 完成配置", "info");
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (sub === "apply-extras") {
|
|
432
|
+
const dir = args.trim().split(/\s+/)[1];
|
|
433
|
+
if (!dir) {
|
|
434
|
+
ctx.ui.notify("用法:/backup apply-extras <恢复目录>", "warning");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await applyExtrasFlow(ctx, dir);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (sub === "restore-apply" || sub === "apply-restore") {
|
|
442
|
+
const dir = args.trim().split(/\s+/)[1];
|
|
443
|
+
if (!dir) {
|
|
444
|
+
ctx.ui.notify("用法:/backup restore-apply <恢复目录>(把恢复结果写回当前 agent 目录)", "warning");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
await applyRestoredFlow(ctx, dir);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── 体检:报告密钥来源与明文告警
|
|
452
|
+
if (sub === "verify") {
|
|
453
|
+
const r = await runCli(["doctor", "--json"], ctx, { timeoutMs: 60_000 });
|
|
454
|
+
const d = parseCliJson(r) as {
|
|
455
|
+
ok?: boolean;
|
|
456
|
+
url?: string;
|
|
457
|
+
username?: string;
|
|
458
|
+
password?: { source?: string; plaintext?: boolean };
|
|
459
|
+
encryptKey?: { configured?: boolean; source?: string; plaintext?: boolean };
|
|
460
|
+
dpapiAvailable?: boolean;
|
|
461
|
+
connectivity?: { ok?: boolean; error?: string } | null;
|
|
462
|
+
errors?: string[];
|
|
463
|
+
warnings?: string[];
|
|
464
|
+
};
|
|
465
|
+
const rows = [
|
|
466
|
+
`WebDAV: ${d.url || "(未设置)"}`,
|
|
467
|
+
`用户: ${d.username || "(未设置)"}`,
|
|
468
|
+
`密码来源: ${d.password?.source ?? "空"}${d.password?.plaintext ? " ⚠️ 明文" : ""}`,
|
|
469
|
+
`加密密钥: ${
|
|
470
|
+
d.encryptKey?.configured ? `${d.encryptKey.source}${d.encryptKey.plaintext ? " ⚠️ 明文" : ""}` : "(未配置)"
|
|
471
|
+
}`,
|
|
472
|
+
`DPAPI: ${d.dpapiAvailable ? "可用" : "不可用(非 Windows)"}`,
|
|
473
|
+
`连接: ${d.connectivity ? (d.connectivity.ok ? "通过 ✅" : `失败 — ${d.connectivity.error}`) : "未测"}`,
|
|
474
|
+
];
|
|
475
|
+
if (d.warnings?.length) rows.push(`告警: ${d.warnings.join(";")}`);
|
|
476
|
+
if (d.errors?.length) rows.push(`错误: ${d.errors.join(";")}`);
|
|
477
|
+
ctx.ui.notify(rows.join("\n"), d.ok ? "info" : "error");
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── 密钥管理
|
|
482
|
+
if (sub === "keys") {
|
|
483
|
+
const action = (args.trim().split(/\s+/)[1] ?? "list").toLowerCase();
|
|
484
|
+
|
|
485
|
+
if (action === "list") {
|
|
486
|
+
const r = await runCli(["list-secrets", "--json"], ctx, { timeoutMs: 60_000 });
|
|
487
|
+
const d = parseCliJson(r) as {
|
|
488
|
+
available?: boolean;
|
|
489
|
+
secrets?: { name: string; updatedAt?: string }[];
|
|
490
|
+
};
|
|
491
|
+
const items = d.secrets ?? [];
|
|
492
|
+
ctx.ui.notify(
|
|
493
|
+
items.length === 0
|
|
494
|
+
? `DPAPI 存储中没有密钥${d.available ? "" : "(DPAPI 不可用:非 Windows)"}`
|
|
495
|
+
: `已保存的密钥:\n${items.map((s) => ` ${s.name} ${s.updatedAt ?? ""}`).join("\n")}`,
|
|
496
|
+
"info",
|
|
497
|
+
);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (action === "set") {
|
|
502
|
+
const name = args.trim().split(/\s+/)[2];
|
|
503
|
+
if (!name) {
|
|
504
|
+
ctx.ui.notify("用法:/backup keys set <名称>,例如 /backup keys set webdav-password", "warning");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const value = await ctx.ui.input(`密钥值(${name})`, "输入后将以 DPAPI 加密保存");
|
|
508
|
+
if (value === undefined || value === "") return;
|
|
509
|
+
const r = await runCli(["set-secret", "--name", name, "--value", value, "--json"], ctx, {
|
|
510
|
+
timeoutMs: 60_000,
|
|
511
|
+
});
|
|
512
|
+
if (r.ok) {
|
|
513
|
+
ctx.ui.notify(
|
|
514
|
+
`已加密保存「${name}」\n在配置里写 dpapi:${name} 即可引用`,
|
|
515
|
+
"info",
|
|
516
|
+
);
|
|
517
|
+
} else {
|
|
518
|
+
const line = r.stdout.trim().split("\n").pop() ?? "{}";
|
|
519
|
+
let msg = r.stderr.trim() || `退出码 ${r.code}`;
|
|
520
|
+
try {
|
|
521
|
+
msg = (JSON.parse(line) as { error?: string }).error ?? msg;
|
|
522
|
+
} catch {
|
|
523
|
+
/* ignore */
|
|
524
|
+
}
|
|
525
|
+
ctx.ui.notify(`保存失败:${msg}`, "error");
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (action === "rm" || action === "delete") {
|
|
531
|
+
const name = args.trim().split(/\s+/)[2];
|
|
532
|
+
if (!name) {
|
|
533
|
+
ctx.ui.notify("用法:/backup keys rm <名称>", "warning");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const ok = await ctx.ui.confirm("删除密钥?", `将删除 DPAPI 密钥「${name}」,引用它的配置会失效。`);
|
|
537
|
+
if (!ok) return;
|
|
538
|
+
const r = await runCli(["rm-secret", "--name", name, "--json"], ctx, { timeoutMs: 60_000 });
|
|
539
|
+
ctx.ui.notify(r.ok ? `已删除「${name}」` : `删除失败:${r.stderr.trim()}`, r.ok ? "info" : "error");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (action === "migrate") {
|
|
544
|
+
const raw = readFileSync(configFile(), "utf8");
|
|
545
|
+
const hasPlain =
|
|
546
|
+
/"password"\s*:\s*"(?!dpapi:|file:|plain:|!|\$)[^"]+"/.test(raw) ||
|
|
547
|
+
/"encryptKey"\s*:\s*"(?!dpapi:|file:|plain:|!|\$)[^"]+"/.test(raw);
|
|
548
|
+
|
|
549
|
+
if (!hasPlain) {
|
|
550
|
+
ctx.ui.notify("配置里没有明文密钥,无需迁移", "info");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const method = await ctx.ui.select("迁移方式", [
|
|
555
|
+
"dpapi — Windows 用户级加密(推荐,无需主密码)",
|
|
556
|
+
"env — 写入用户环境变量",
|
|
557
|
+
"file — 写入 0600 权限的独立文件",
|
|
558
|
+
]);
|
|
559
|
+
if (!method) return;
|
|
560
|
+
const m = method.split(" ")[0];
|
|
561
|
+
|
|
562
|
+
const ok = await ctx.ui.confirm(
|
|
563
|
+
"确认迁移?",
|
|
564
|
+
`将把明文密钥转为 ${m} 引用,并自动备份原配置。`,
|
|
565
|
+
);
|
|
566
|
+
if (!ok) return;
|
|
567
|
+
|
|
568
|
+
const r = await runCli(["migrate", "--method", m, "--json"], ctx, { timeoutMs: 120_000 });
|
|
569
|
+
const line = r.stdout.trim().split("\n").pop() ?? "{}";
|
|
570
|
+
let msg = r.ok ? "迁移完成" : "迁移失败";
|
|
571
|
+
try {
|
|
572
|
+
const parsed = JSON.parse(line) as {
|
|
573
|
+
migrated?: number;
|
|
574
|
+
message?: string;
|
|
575
|
+
error?: string;
|
|
576
|
+
changes?: { field: string; to: string }[];
|
|
577
|
+
};
|
|
578
|
+
if (parsed.error) msg = parsed.error;
|
|
579
|
+
else if (parsed.message) msg = parsed.message;
|
|
580
|
+
else if (parsed.changes?.length)
|
|
581
|
+
msg = `已迁移 ${parsed.migrated} 项:\n${parsed.changes.map((c) => ` ${c.field} → ${c.to}`).join("\n")}`;
|
|
582
|
+
} catch {
|
|
583
|
+
/* ignore */
|
|
584
|
+
}
|
|
585
|
+
ctx.ui.notify(msg, r.ok ? "info" : "error");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
ctx.ui.notify(
|
|
590
|
+
"用法:\n/backup keys list\n/backup keys set <名称>\n/backup keys rm <名称>\n/backup keys migrate",
|
|
591
|
+
"info",
|
|
592
|
+
);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const cliArgs: string[] =
|
|
597
|
+
sub === "run" ? ["backup"] : sub === "dry" ? ["backup", "--dry-run"] : [sub];
|
|
598
|
+
if (sub === "check" || sub === "list" || sub === "status") {
|
|
599
|
+
cliArgs.push("--json");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// restore:先用列表展示,用户确认后才真正下载
|
|
603
|
+
if (sub === "restore") {
|
|
604
|
+
const autoYes = /(^|\s)(-y|--yes)(\s|$)/.test(args);
|
|
605
|
+
const listRun = await runCli(["restore", "--list", "--json"], ctx, { timeoutMs: 60_000 });
|
|
606
|
+
const listing = parseCliJson(listRun) as {
|
|
607
|
+
archives?: { name: string; size: number }[];
|
|
608
|
+
snapshot?: { name: string; fileCount: number } | null;
|
|
609
|
+
};
|
|
610
|
+
const options: string[] = [
|
|
611
|
+
...(listing.archives ?? []).map((a) => `${a.name} (${fmtSize(a.size)})`),
|
|
612
|
+
...(listing.snapshot ? [`${listing.snapshot.name} [快照, ${listing.snapshot.fileCount} 个文件]`] : []),
|
|
613
|
+
];
|
|
614
|
+
if (options.length === 0) {
|
|
615
|
+
ctx.ui.notify("远端没有可恢复的备份", "warning");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const picked = autoYes
|
|
620
|
+
? options[0]
|
|
621
|
+
: await ctx.ui.select("选择要恢复的备份", options);
|
|
622
|
+
if (!picked) return;
|
|
623
|
+
const fileName = picked.split(" (")[0].replace(/\/$/, "");
|
|
624
|
+
|
|
625
|
+
const ok = autoYes
|
|
626
|
+
? true
|
|
627
|
+
: await ctx.ui.confirm(
|
|
628
|
+
"确认恢复?",
|
|
629
|
+
`将从 ${fileName} 恢复文件。\n先解到一个临时目录,之后会询问是否写回当前 pi 配置目录(${getAgentDir()})。\n写回时覆盖前会自动备份旧文件。`,
|
|
630
|
+
);
|
|
631
|
+
if (!ok) return;
|
|
632
|
+
|
|
633
|
+
const restoreArgs = ["restore", "--file", fileName];
|
|
634
|
+
ctx.ui.setStatus("webdav-backup", "恢复中…");
|
|
635
|
+
const rr = await runCli(restoreArgs, ctx, { timeoutMs: 30 * 60 * 1000 });
|
|
636
|
+
ctx.ui.setStatus("webdav-backup", undefined);
|
|
637
|
+
const last = rr.stdout.trim().split("\n").pop() ?? "{}";
|
|
638
|
+
let info: { ok?: boolean; dest?: string; files?: number; error?: string } = {};
|
|
639
|
+
try {
|
|
640
|
+
info = JSON.parse(last) as typeof info;
|
|
641
|
+
} catch {
|
|
642
|
+
/* ignore */
|
|
643
|
+
}
|
|
644
|
+
if (rr.ok && info.ok) {
|
|
645
|
+
ctx.ui.notify(`恢复完成:${info.files} 个文件 → ${info.dest}`, "info");
|
|
646
|
+
const dest = info.dest;
|
|
647
|
+
if (!dest) return;
|
|
648
|
+
|
|
649
|
+
// 换机一键还原:把恢复结果写回当前 agent 目录(覆盖前自动备份)
|
|
650
|
+
const applied = await applyRestoredFlow(ctx, dest);
|
|
651
|
+
|
|
652
|
+
// agentDir 之外的额外数据(~/.pi/settings.json 等)单独归位
|
|
653
|
+
const extrasInDest = (info as { extras?: unknown[] }).extras?.length ?? 0;
|
|
654
|
+
if (extrasInDest > 0) {
|
|
655
|
+
await applyExtrasFlow(ctx, dest);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (applied.applied) {
|
|
659
|
+
// 已写回当前目录:只有备份不含插件代码时才需要联网补装
|
|
660
|
+
if (!applied.pluginReady) {
|
|
661
|
+
await repairPluginsFlow(
|
|
662
|
+
ctx,
|
|
663
|
+
null,
|
|
664
|
+
"备份里不含插件实现代码(node_modules)。这台机器正在用的 agent 目录里,声明的插件没装齐。",
|
|
665
|
+
);
|
|
666
|
+
} else {
|
|
667
|
+
ctx.ui.notify("配置已全部还原。建议 /reload 或重启 pi 让插件生效。", "info");
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
// 未写回:针对刚恢复出来的目录体检并补装,用户再自行拷贝
|
|
671
|
+
await repairPluginsFlow(
|
|
672
|
+
ctx,
|
|
673
|
+
dest,
|
|
674
|
+
`插件实现代码不在备份里(node_modules 被排除)。补装会装进恢复目录:${dest}\n装完把这些文件拷到 ~/.pi/agent 即可直接使用。`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
ctx.ui.notify(`恢复失败:${info.error || rr.stderr.trim() || `退出码 ${rr.code}`}`, "error");
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
ctx.ui.setStatus("webdav-backup", "备份中…");
|
|
684
|
+
const r = await runCli(cliArgs, ctx, { timeoutMs: 30 * 60 * 1000 });
|
|
685
|
+
|
|
686
|
+
// status/list/check 用 JSON 结果做友好展示
|
|
687
|
+
if (sub === "status") {
|
|
688
|
+
const parsed = parseCliJson(r) as { state?: BackupState };
|
|
689
|
+
ctx.ui.notify(
|
|
690
|
+
parsed.state?.lastBackupAt
|
|
691
|
+
? `上次备份:${new Date(parsed.state.lastBackupAt).toLocaleString()}(${parsed.state.lastReason ?? "?"})`
|
|
692
|
+
: "还没有备份记录",
|
|
693
|
+
"info",
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (sub === "check" || sub === "list") {
|
|
699
|
+
const parsed = parseCliJson(r) as {
|
|
700
|
+
ok?: boolean;
|
|
701
|
+
error?: string;
|
|
702
|
+
status?: number;
|
|
703
|
+
files?: { name: string; size: number }[];
|
|
704
|
+
};
|
|
705
|
+
if (sub === "check") {
|
|
706
|
+
ctx.ui.notify(
|
|
707
|
+
parsed.ok ? "WebDAV 连接正常,认证通过 ✅" : `连接失败:${parsed.error ?? parsed.status}`,
|
|
708
|
+
parsed.ok ? "info" : "error",
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
const files = parsed.files ?? [];
|
|
712
|
+
ctx.ui.notify(
|
|
713
|
+
files.length === 0
|
|
714
|
+
? "远端暂无备份文件"
|
|
715
|
+
: `远端 ${files.length} 个文件:\n${files
|
|
716
|
+
.slice(0, 10)
|
|
717
|
+
.map((f) => `${f.name} ${fmtSize(f.size)}`)
|
|
718
|
+
.join("\n")}${files.length > 10 ? `\n…还有 ${files.length - 10} 个` : ""}`,
|
|
719
|
+
"info",
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 备份/试运行/清理:解析最后一行 JSON 汇总
|
|
726
|
+
const line = r.stdout.trim().split("\n").pop() ?? "{}";
|
|
727
|
+
let summary: Record<string, unknown> = {};
|
|
728
|
+
try {
|
|
729
|
+
summary = JSON.parse(line) as Record<string, unknown>;
|
|
730
|
+
} catch {
|
|
731
|
+
/* 非 JSON 输出,忽略 */
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (r.ok) {
|
|
735
|
+
if (summary.dryRun) {
|
|
736
|
+
ctx.ui.notify(
|
|
737
|
+
`试运行完成:${summary.files} 个文件,${fmtSize(Number(summary.totalSize ?? 0))},跳过 ${summary.skipped} 项`,
|
|
738
|
+
"info",
|
|
739
|
+
);
|
|
740
|
+
} else if (summary.mode === "snapshot") {
|
|
741
|
+
ctx.ui.notify(
|
|
742
|
+
`备份完成:上传 ${summary.uploaded} 个,跳过 ${summary.skippedSame} 个未变化文件`,
|
|
743
|
+
"info",
|
|
744
|
+
);
|
|
745
|
+
} else if (summary.remotePath) {
|
|
746
|
+
ctx.ui.notify(
|
|
747
|
+
`备份完成 ✅\n${summary.remotePath}\n${summary.files} 个文件,归档 ${fmtSize(Number(summary.archiveSize ?? 0))}`,
|
|
748
|
+
"info",
|
|
749
|
+
);
|
|
750
|
+
} else if (Array.isArray(summary.deleted)) {
|
|
751
|
+
ctx.ui.notify(
|
|
752
|
+
(summary.deleted as string[]).length > 0
|
|
753
|
+
? `已清理 ${(summary.deleted as string[]).length} 个旧备份`
|
|
754
|
+
: "没有需要清理的旧备份",
|
|
755
|
+
"info",
|
|
756
|
+
);
|
|
757
|
+
} else {
|
|
758
|
+
ctx.ui.notify("完成", "info");
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
ctx.ui.notify(`失败:${summary.error || r.stderr.trim() || `退出码 ${r.code}`}`, "error");
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// ---------------------------------------------------------------- /backup-setup
|
|
767
|
+
pi.registerCommand("backup-setup", {
|
|
768
|
+
description: "交互式配置 WebDAV 备份",
|
|
769
|
+
handler: async (_args, ctx) => {
|
|
770
|
+
const existing = readJson<Record<string, unknown>>(configFile()) ?? {};
|
|
771
|
+
const cur = (existing.remote ?? {}) as Record<string, string>;
|
|
772
|
+
|
|
773
|
+
ctx.ui.notify("WebDAV 备份配置向导(直接回车保留原值)", "info");
|
|
774
|
+
|
|
775
|
+
const url = await ctx.ui.input("WebDAV 地址", cur.url ?? "https://dav.jianguoyun.com/dav/");
|
|
776
|
+
if (url === undefined) return;
|
|
777
|
+
|
|
778
|
+
const username = await ctx.ui.input("用户名 / 邮箱", cur.username ?? "");
|
|
779
|
+
if (username === undefined) return;
|
|
780
|
+
|
|
781
|
+
const password = await ctx.ui.input(
|
|
782
|
+
"密码(坚果云请用【应用密码】)。留空则用环境变量或稍后 /backup keys set",
|
|
783
|
+
"",
|
|
784
|
+
);
|
|
785
|
+
if (password === undefined) return;
|
|
786
|
+
|
|
787
|
+
// 密码优先用 DPAPI 加密存储,配置文件里只留引用
|
|
788
|
+
let passwordRef = cur.password ?? "";
|
|
789
|
+
let dpapiNote = "";
|
|
790
|
+
if (password) {
|
|
791
|
+
const safe = await ctx.ui.confirm(
|
|
792
|
+
"用 DPAPI 加密保存密码?",
|
|
793
|
+
"是(推荐):密码经 Windows 用户级加密存入 secrets.dpapi.json,配置文件里只写 dpapi:webdav-password,无需主密码。\n\n否:密码会以明文写进 webdav-backup.json。",
|
|
794
|
+
);
|
|
795
|
+
if (safe) {
|
|
796
|
+
const sr = await runCli(
|
|
797
|
+
["set-secret", "--name", "webdav-password", "--value", password, "--json"],
|
|
798
|
+
ctx,
|
|
799
|
+
{ timeoutMs: 60_000 },
|
|
800
|
+
);
|
|
801
|
+
if (sr.ok) {
|
|
802
|
+
passwordRef = "dpapi:webdav-password";
|
|
803
|
+
dpapiNote = "密码已用 DPAPI 加密保存";
|
|
804
|
+
} else {
|
|
805
|
+
const line = sr.stdout.trim().split("\n").pop() ?? "{}";
|
|
806
|
+
let msg = sr.stderr.trim();
|
|
807
|
+
try {
|
|
808
|
+
msg = (JSON.parse(line) as { error?: string }).error ?? msg;
|
|
809
|
+
} catch {
|
|
810
|
+
/* ignore */
|
|
811
|
+
}
|
|
812
|
+
const goPlain = await ctx.ui.confirm(
|
|
813
|
+
"DPAPI 保存失败",
|
|
814
|
+
`${msg || "未知错误"}\n\n改为明文写入配置吗?(不推荐)`,
|
|
815
|
+
);
|
|
816
|
+
if (!goPlain) return;
|
|
817
|
+
passwordRef = password;
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
passwordRef = password;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const remoteDir = await ctx.ui.input("远端目录", cur.remoteDir ?? "pi-backup");
|
|
825
|
+
if (remoteDir === undefined) return;
|
|
826
|
+
|
|
827
|
+
const remoteName = await ctx.ui.input("备份名前缀", cur.remoteName ?? "pi-agent");
|
|
828
|
+
if (remoteName === undefined) return;
|
|
829
|
+
|
|
830
|
+
const includePlugins = await ctx.ui.confirm(
|
|
831
|
+
"把插件实现代码一并备份?",
|
|
832
|
+
"是(推荐):连同 node_modules 一起备份,换机后恢复即可直接用,无需联网重装。\n体积会明显变大(可能几百 MB)。\n\n否:只备份声明,恢复后用 /backup repair-plugins 联网补装。",
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const includeSessions = await ctx.ui.confirm("包含会话历史?", "会显著增大备份体积(可能几百 MB)");
|
|
836
|
+
const encrypt = await ctx.ui.confirm(
|
|
837
|
+
"加密备份?",
|
|
838
|
+
"用 AES-256-GCM 加密,防止 auth.json 里的密钥泄露到云端",
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
let encryptKey = (existing.encryptKey as string) ?? "";
|
|
842
|
+
let keyNote = "";
|
|
843
|
+
if (encrypt) {
|
|
844
|
+
const k1 = await ctx.ui.input("设置加密口令(至少 16 位)", "");
|
|
845
|
+
if (k1 === undefined) return;
|
|
846
|
+
if (k1.length < 16) {
|
|
847
|
+
ctx.ui.notify("加密口令至少 16 位,已取消", "error");
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const k2 = await ctx.ui.input("再输一次确认", "");
|
|
851
|
+
if (k2 !== k1) {
|
|
852
|
+
ctx.ui.notify("两次输入不一致,已取消", "error");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
encryptKey = k1;
|
|
856
|
+
|
|
857
|
+
const safeKey = await ctx.ui.confirm(
|
|
858
|
+
"用 DPAPI 加密保存口令?",
|
|
859
|
+
"是(推荐):配置里只写 dpapi:backup-key,避免明文落盘。",
|
|
860
|
+
);
|
|
861
|
+
if (safeKey) {
|
|
862
|
+
const sr = await runCli(
|
|
863
|
+
["set-secret", "--name", "backup-key", "--value", k1, "--json"],
|
|
864
|
+
ctx,
|
|
865
|
+
{ timeoutMs: 60_000 },
|
|
866
|
+
);
|
|
867
|
+
if (sr.ok) {
|
|
868
|
+
encryptKey = "dpapi:backup-key";
|
|
869
|
+
keyNote = "加密口令已用 DPAPI 加密保存";
|
|
870
|
+
} else {
|
|
871
|
+
const line = sr.stdout.trim().split("\n").pop() ?? "{}";
|
|
872
|
+
let msg = sr.stderr.trim();
|
|
873
|
+
try {
|
|
874
|
+
msg = (JSON.parse(line) as { error?: string }).error ?? msg;
|
|
875
|
+
} catch {
|
|
876
|
+
/* ignore */
|
|
877
|
+
}
|
|
878
|
+
const goPlainKey = await ctx.ui.confirm(
|
|
879
|
+
"DPAPI 保存失败",
|
|
880
|
+
`${msg || "未知错误"}\n\n改为明文写入配置吗?(不推荐)`,
|
|
881
|
+
);
|
|
882
|
+
if (!goPlainKey) {
|
|
883
|
+
ctx.ui.notify("已取消配置向导(未写入任何更改)", "warning");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} else {
|
|
888
|
+
const goPlainKey = await ctx.ui.confirm(
|
|
889
|
+
"口令将以明文保存",
|
|
890
|
+
"加密口令会明文写进 webdav-backup.json。确定继续吗?",
|
|
891
|
+
);
|
|
892
|
+
if (!goPlainKey) return;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 保留已有的引用形式,避免向导把 dpapi: 引用覆盖成空
|
|
897
|
+
const finalPassword = password ? passwordRef : (cur.password ?? "");
|
|
898
|
+
|
|
899
|
+
// 探测 agentDir 之外、可能影响插件加载的遗留配置(~/.pi/settings.json 等),
|
|
900
|
+
// 让用户决定是否一并纳入 extraScope。
|
|
901
|
+
const existingExtra: string[] = Array.isArray(existing.extraScope)
|
|
902
|
+
? (existing.extraScope as string[]).filter((x) => typeof x === "string")
|
|
903
|
+
: [];
|
|
904
|
+
let extraScope = existingExtra;
|
|
905
|
+
const legacy = detectLegacyConfigLocal();
|
|
906
|
+
if (legacy.length > 0) {
|
|
907
|
+
const want = await ctx.ui.confirm(
|
|
908
|
+
"发现 agentDir 之外的配置",
|
|
909
|
+
`检测到这些文件不在默认备份范围(~/.pi/agent)内:\n${legacy
|
|
910
|
+
.map((l) => ` ${l.path}`)
|
|
911
|
+
.join("\n")}\n\n是否把它们一并备份(extraScope)?恢复时可用 /backup apply-extras 归位。`,
|
|
912
|
+
);
|
|
913
|
+
if (want) {
|
|
914
|
+
extraScope = [...new Set([...existingExtra, ...legacy.map((l) => l.path)])];
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const cfg = {
|
|
919
|
+
_说明: "pi WebDAV 备份配置,由 /backup-setup 生成",
|
|
920
|
+
enabled: existing.enabled !== false,
|
|
921
|
+
remote: {
|
|
922
|
+
url,
|
|
923
|
+
username,
|
|
924
|
+
password: finalPassword,
|
|
925
|
+
remoteDir,
|
|
926
|
+
remoteName,
|
|
927
|
+
},
|
|
928
|
+
scope: ["."],
|
|
929
|
+
exclude: [],
|
|
930
|
+
extraScope,
|
|
931
|
+
includePlugins,
|
|
932
|
+
includeSessions,
|
|
933
|
+
snapshot: existing.snapshot === true,
|
|
934
|
+
encrypt,
|
|
935
|
+
encryptKey,
|
|
936
|
+
backupOnExit: existing.backupOnExit !== false,
|
|
937
|
+
backupOnExitMinIntervalMinutes: existing.backupOnExitMinIntervalMinutes ?? 30,
|
|
938
|
+
backupOnExitTimeoutMs: 10 * 60 * 1000,
|
|
939
|
+
keepVersions: existing.keepVersions ?? 10,
|
|
940
|
+
timeoutMs: 60_000,
|
|
941
|
+
insecureTls: false,
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
try {
|
|
945
|
+
mkdirSync(dirname(configFile()), { recursive: true });
|
|
946
|
+
writeFileSync(configFile(), JSON.stringify(cfg, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
947
|
+
} catch (e) {
|
|
948
|
+
ctx.ui.notify(`写入配置失败:${e instanceof Error ? e.message : String(e)}`, "error");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
ctx.ui.notify(
|
|
953
|
+
[`配置已保存:${configFile()}`, dpapiNote, keyNote].filter(Boolean).join("\n") + "\n正在测试连接…",
|
|
954
|
+
"info",
|
|
955
|
+
);
|
|
956
|
+
const r = await runCli(["check", "--json"], ctx, { timeoutMs: 60_000 });
|
|
957
|
+
if (r.ok) {
|
|
958
|
+
ctx.ui.notify("WebDAV 连接正常 ✅ 可以运行 /backup 了", "info");
|
|
959
|
+
} else {
|
|
960
|
+
const line = r.stdout.trim().split("\n").pop() ?? "{}";
|
|
961
|
+
let msg = "连接测试失败";
|
|
962
|
+
try {
|
|
963
|
+
msg = (JSON.parse(line) as { error?: string }).error ?? msg;
|
|
964
|
+
} catch {
|
|
965
|
+
/* ignore */
|
|
966
|
+
}
|
|
967
|
+
ctx.ui.notify(`${msg}\n配置已保存,修正后重试 /backup check`, "error");
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// ---------------------------------------------------------------- /backup-cron
|
|
973
|
+
pi.registerCommand("backup-cron", {
|
|
974
|
+
description: "安装/移除每日定时备份(Windows 计划任务)",
|
|
975
|
+
handler: async (args, ctx) => {
|
|
976
|
+
const sub = args.trim().toLowerCase();
|
|
977
|
+
if (sub === "remove" || sub === "uninstall" || sub === "off") {
|
|
978
|
+
const r = await runCli(["uninstall-cron"], ctx);
|
|
979
|
+
ctx.ui.notify(r.ok ? "已移除计划任务" : `移除失败:${r.stderr.trim()}`, r.ok ? "info" : "error");
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const time = /^\d{1,2}:\d{2}$/.test(sub) ? sub : "03:00";
|
|
984
|
+
const yes = await ctx.ui.confirm(
|
|
985
|
+
"安装每日定时备份?",
|
|
986
|
+
`将创建 Windows 计划任务,每天 ${time} 执行一次备份。\n需要管理员权限(若失败请以管理员身份运行 pi)。`,
|
|
987
|
+
);
|
|
988
|
+
if (!yes) return;
|
|
989
|
+
|
|
990
|
+
const r = await runCli(["install-cron", "--time", time, "--json"], ctx);
|
|
991
|
+
const line = r.stdout.trim().split("\n").pop() ?? "{}";
|
|
992
|
+
let msg = r.ok ? `已创建计划任务,每天 ${time} 备份` : "创建失败";
|
|
993
|
+
try {
|
|
994
|
+
const parsed = JSON.parse(line) as { error?: string };
|
|
995
|
+
if (!r.ok && parsed.error) msg = parsed.error;
|
|
996
|
+
} catch {
|
|
997
|
+
/* ignore */
|
|
998
|
+
}
|
|
999
|
+
ctx.ui.notify(msg, r.ok ? "info" : "error");
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// ---------------------------------------------------------------- /backup-log
|
|
1004
|
+
pi.registerCommand("backup-log", {
|
|
1005
|
+
description: "查看 WebDAV 备份日志尾部",
|
|
1006
|
+
handler: async (_args, ctx) => {
|
|
1007
|
+
const f = logFile();
|
|
1008
|
+
if (!existsSync(f)) {
|
|
1009
|
+
ctx.ui.notify("暂无日志", "info");
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
const text = readFileSync(f, "utf8");
|
|
1014
|
+
const lines = text.trimEnd().split("\n");
|
|
1015
|
+
ctx.ui.notify(lines.slice(-20).join("\n"), "info");
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
ctx.ui.notify(
|
|
1018
|
+
`读取日志失败:${e instanceof Error ? e.message : String(e)}`,
|
|
1019
|
+
"error",
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
}
|