pi-webdav-backup 1.0.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,13 @@
11
11
  一行命令(pi 官方包机制):
12
12
 
13
13
  ```bash
14
- pi install git:github.com/ddldxiacan/pi-webdav-backup@v1
14
+ pi install git:github.com/ddldxiacan/pi-webdav-backup@v1 # 永远装最新的 1.x 正式版
15
+ ```
16
+
17
+ `@v1` 是**移动主版本 tag**,始终指向最新的 1.x 正式版。以后升级不用改命令:
18
+
19
+ ```bash
20
+ pi update --extensions # 自动升到最新的 v1.x(然后 /reload)
15
21
  ```
16
22
 
17
23
  只想试一下、不安装(仅本次运行生效):
@@ -20,13 +26,20 @@ pi install git:github.com/ddldxiacan/pi-webdav-backup@v1
20
26
  pi -e git:github.com/ddldxiacan/pi-webdav-backup
21
27
  ```
22
28
 
23
- 其他方式:
29
+ 其他方式 / 更细的版本控制:
24
30
 
25
31
  ```bash
26
- pi install npm:pi-webdav-backup # 已发布到 npm 时
27
- pi install /path/to/pi-webdav-backup # 本地目录(下载 ZIP 解压后)
32
+ pi install git:github.com/ddldxiacan/pi-webdav-backup@v1.1.0 # 钉死某个版本(不自动升级)
33
+ pi install git:github.com/ddldxiacan/pi-webdav-backup # 不钉 ref,直接跟 main 分支
34
+ pi install npm:pi-webdav-backup # 已发布到 npm 时
35
+ pi install /path/to/pi-webdav-backup # 本地目录(下载 ZIP 解压后)
28
36
  ```
29
37
 
38
+ > `@<ref>` 可以是移动 tag(`@v1`)、具体版本 tag(`@v1.1.0`)、分支或 commit SHA:
39
+ > - `@v1` → `pi update --extensions` 会跟到最新的 1.x,**推荐**
40
+ > - `@v1.1.0` / commit SHA → 钉死不动;升级需重新 `pi install ...@<新版本>`
41
+ > - ref 必须真实存在,写错(如不存在的 tag)会 clone/checkout 失败
42
+
30
43
  手动安装:把 `extensions/webdav-backup.ts` 与 `extensions/webdav-backup/` 拷到 `~/.pi/agent/extensions/`。
31
44
 
32
45
  装好后在 pi 里运行 **`/reload`** 加载。
@@ -67,8 +80,12 @@ pi install /path/to/pi-webdav-backup # 本地目录(下载 ZIP 解压后
67
80
  | `/backup dry` | 试运行:只扫描统计,不上传 |
68
81
  | `/backup status` | 查看上次备份时间 |
69
82
  | `/backup prune` | 清理旧备份(保留 `keepVersions` 个) |
70
- | `/backup restore` | 交互选择备份并恢复到临时目录 |
83
+ | `/backup restore` | 交互选择备份并恢复;完成后询问是否**写回当前 pi**(换机一键还原);如含 `extra/` 数据会询问是否归位;未写回时自动体检恢复目录并询问补装插件 |
84
+ | `/backup restore-apply <目录>` | 把恢复目录写回当前 pi 的 agent 目录(覆盖前自动备份) |
85
+ | `/backup apply-extras <目录>` | 把恢复目录里 `agentDir` 之外的额外数据(`extra/`)归位到真实路径 |
71
86
  | `/backup verify` | 配置体检:密钥来源、明文告警、连接性 |
87
+ | `/backup plugins` | 插件体检:已声明的插件是否装齐(含逐个依赖查缺失) |
88
+ | `/backup repair-plugins` | 补装缺失的插件 / 依赖(`npm install` / `git clone`) |
72
89
  | `/backup keys list` | 列出已加密保存的密钥 |
73
90
  | `/backup keys set <名称>` | 用 DPAPI 加密保存密钥 |
74
91
  | `/backup keys rm <名称>` | 删除密钥 |
@@ -86,6 +103,11 @@ node ~/.pi/agent/extensions/webdav-backup/cli.mjs doctor --json
86
103
  node ~/.pi/agent/extensions/webdav-backup/cli.mjs migrate --method dpapi
87
104
  node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --list
88
105
  node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\restore
106
+ node ~/.pi/agent/extensions/webdav-backup/cli.mjs plugins --json [--dir D:\pi-restore]
107
+ node ~/.pi/agent/extensions/webdav-backup/cli.mjs repair-plugins --dry-run [--dir D:\pi-restore]
108
+ node ~/.pi/agent/extensions/webdav-backup/cli.mjs apply-extras --dir D:\pi-restore [--dry-run] [--force]
109
+ node ~/.pi/agent/extensions/webdav-backup/cli.mjs restore-apply --dir D:\pi-restore [--dry-run] [--force]
110
+ node ~/.pi/agent/extensions/webdav-backup/cli.mjs detect-legacy --json
89
111
  ```
90
112
 
91
113
  输出末行固定是 JSON 汇总(`--json` 时只输出该行,人类模式则前面还有日志行),脚本可直接解析;退出码 0 成功 / 1 失败。
@@ -171,21 +193,82 @@ setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
171
193
  |---|---|
172
194
  | `sessions/**` | 会话历史,通常很大(可用 `includeSessions` 打开) |
173
195
  | `tmp/**`、`web-search-cache/**` | 缓存 |
174
- | `**/node_modules/**` | 可重装 |
196
+ | `**/node_modules/**` | 可重装(但**开 `includePlugins` 后会一并备份**,见下文) |
197
+ | `bin/**` | pi 自动管理的 `fd`/`rg` 二进制,换机后会重新下载(约 8MB) |
175
198
  | `**/*.log` | 日志 |
176
199
 
177
200
  包含:`extensions/`(你写的扩展)、`models.json`、`models-store.json`、`settings.json`、`trust.json`、`auth.json`、`npm/package.json` 等。
178
201
 
202
+ > **想换机一键还原?** 打开 `includePlugins: true`,把插件实现代码(`node_modules`)也备份进去,
203
+ > 新电脑恢复后插件即可直接用,无需联网重装。详见下文「换机一键还原」。
204
+
205
+ ### 插件数据写在 `~/.pi/agent` 之外怎么办(`extraScope`)
206
+
207
+ 有些插件把配置/数据写在 agent 目录之外,默认 `scope: ["."]` 够不到:
208
+
209
+ | 路径 | 谁写在这里 |
210
+ |---|---|
211
+ | `~/.pi/settings.json` | pi 旧版本遗留的包声明(`~/.pi/npm/` 同理) |
212
+ | `~/.pi/web-search.json` | `pi-web-access` 的回退位置 |
213
+ | `~/.config/rpiv-*/config.json` | `@juicesharp/rpiv-*` 系列(XDG 配置) |
214
+
215
+ 用 `extraScope` 把这些**绝对路径(或 `~/...`)**一并备份;归档内统一放在 `extra/` 前缀下:
216
+
217
+ ```jsonc
218
+ {
219
+ "extraScope": [
220
+ "~/.pi/settings.json",
221
+ "~/.pi/web-search.json",
222
+ "~/.config/rpiv-todo"
223
+ ]
224
+ }
225
+ ```
226
+
227
+ - 备份时:`extra/` 内的 JSON/配置会按字段名(`key`/`token`/`password`/`apiKey`…)脱敏
228
+ - 恢复时:默认只解到恢复目录;`/backup restore` 会询问是否**归位**到真实路径,也可手动
229
+ `node cli.mjs apply-extras --dir <恢复目录>`
230
+ - **跨机可移植**:备份时会把每个文件的归属记成“锚点 + 尾巴”(`home` / `config`(XDG) / `appdata`),
231
+ 恢复时按**当前机器**的锚点重算目标。换用户名、换盘符、Linux↔Windows 都能正确归位:
232
+ - 旧机 `/home/alice/.pi/settings.json` → 新机 `/home/bob/.pi/settings.json`
233
+ - 旧机 `C:/Users/alice/.pi/settings.json` → 新机 `D:/Users/carol/.pi/settings.json`
234
+ - 不在任何锚点下的绝对路径(如 `/etc/foo`)保持原样,并在结果里标为 `unmapped` 以便你核对
235
+ - 安全:若本地已存在“真实值”、而归档里是 `__REDACTED__`,归位会**跳过**该文件(除非加 `--force`),
236
+ 以免把真密钥覆盖成占位符;覆盖前会把原文件存为 `<name>.bak-extras-<时间戳>`
237
+ - 探测遗留配置:`/backup-setup` 会自动检测并询问是否纳入;也可 `node cli.mjs detect-legacy`
238
+
239
+ > 归档里的 `extra-manifest.json` 记录了源机器的 `platform`/`hostname`/`home` 与各锚点,
240
+ > 归位时会打印出来,便于确认“这次恢复来自哪台机器”。
241
+
242
+ ### 插件(install 装的包)会怎样
243
+
244
+ 插件是否连同**实现代码(`node_modules`)**一起备份,由 `includePlugins` 决定:
245
+
246
+ | `includePlugins` | 备份里有什么 | 恢复后 |
247
+ |---|---|---|
248
+ | `false`(默认,瘦备份) | 声明 + `git:` 源码;npm 包只有 `package.json`/lockfile | 插件实现不在,需联网 `/backup repair-plugins` 补装 |
249
+ | `true`(推荐,换机一键还原) | 连同 `node_modules` 一起 | 插件直接可用,无需联网 |
250
+
251
+ 开启 `includePlugins: true` 时,会保留 `npm/node_modules/**`、`git/**/node_modules/**`,仅排除其中运行时用不到、
252
+ 体积大的内部数据(`**/.git/**`、`node_modules/.cache`、`.vite`、`.turbo`、`.bin`)。
253
+
254
+ > **取舍**:`true` 能实现真正的一键还原,代价是体积变大(有原生依赖时可能几百 MB,且同一份备份在
255
+ > 不同平台间可能带入不兼容的二进制)。`false` 体积小,但恢复后必须联网补装。
256
+ >
257
+ > 无论哪种模式,`/backup restore` 都不再强制联网:开启 `includePlugins` 后,恢复流程会检测到
258
+ > 备份里已含插件代码并跳过补装提示。
259
+
179
260
  ### 敏感文件脱敏(重要)
180
261
 
181
- **未加密**备份时,`auth.json` 与 `webdav-backup.json` 会先脱敏再上传:
262
+ 备份时(**加密与否都会**),`auth.json`、`webdav-backup.json` 以及 `extraScope` 下的 JSON/配置会先脱敏再上传:
182
263
 
183
- - 按字段名匹配:`key` / `token` / `secret` / `password` / `credential` / `cookie` / `private` …
184
- - **并按值的形状匹配**:`sk-…`、`ghp_…`、`AIza…`、JWT、32 位以上随机串
185
- - 命中替换为 `__REDACTED__`,结构保留
264
+ - 按字段名匹配:`key` / `token` / `secret` / `password` / `credential` / `cookie` / `apiKey` / `private` …
265
+ - **未加密时**还额外按值的形状匹配:`sk-…`、`ghp_…`、`AIza…`、JWT、32 位以上随机串
266
+ - 命中替换为 `__REDACTED__`,结构保留;密钥**引用**(`$ENV` / `dpapi:` / `file:`)不会被抹
186
267
  - 解析失败时整文件替换为占位符,**绝不原样上传**
187
268
 
188
- > 脱敏是「尽力而为」的防泄漏措施。真正的安全做法是开启 `encrypt`。
269
+ > 因为加密备份同样会脱敏,**用明文写的 API key / 密码无法从备份恢复**(会变成 `__REDACTED__`)。
270
+ > 建议一律用 `$ENV` / `dpapi:` 引用(引用会被保留,恢复后仍可用)。
271
+ > 脱敏是「尽力而为」的防泄漏措施,真正的安全做法是开启 `encrypt`。
189
272
 
190
273
  ---
191
274
 
@@ -205,6 +288,8 @@ setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
205
288
 
206
289
  "scope": ["."], // 相对 ~/.pi/agent 的路径,可写 ["extensions", "models.json"]
207
290
  "exclude": [], // 额外 glob 排除规则
291
+ "extraScope": [], // agentDir 之外的额外备份路径(绝对路径或 ~/...),见上文「插件数据」
292
+ "includePlugins": false, // 是否把插件实现(node_modules)一起备份(换机一键还原的关键),见上文
208
293
  "includeSessions": false, // 是否备份会话历史
209
294
  "snapshot": false, // 见下文「两种模式」
210
295
 
@@ -287,24 +372,95 @@ node "$env:USERPROFILE\.pi\agent\extensions\webdav-backup\cli.mjs" install-cron
287
372
  ## 恢复
288
373
 
289
374
  ```
290
- /backup restore # 选择备份 → 恢复到临时目录
375
+ /backup restore # 选择备份 → 恢复 → 询问是否写回当前 pi
291
376
  ```
292
377
 
293
- **默认恢复到临时目录,不会直接覆盖 `~/.pi/agent`** —— 因为恢复旧配置可能覆盖你当前的密钥。
378
+ 恢复分两步:先解到临时目录(不会直接动你的现有配置),再由你确认后才写回 `~/.pi/agent`。
379
+ 写回前每个被覆盖的旧文件都会另存为 `<名字>.bak-apply-<时间戳>`。
294
380
 
295
- 恢复后自行拷贝需要的文件:
381
+ **换机一键还原(推荐)**:新电脑装好 pi → `/backup-setup` 配好同一 WebDAV → `/backup restore -y`:
296
382
 
297
- ```powershell
298
- # 归档恢复结果示例
299
- # C:\Users\...\Temp\pi-restore-XXXX\restored\extensions\...
300
- copy "$env:TEMP\pi-restore-XXXX\restored\extensions\*.ts" "$env:USERPROFILE\.pi\agent\extensions\"
383
+ ```
384
+ /backup restore
385
+ ├─ 选备份(`-y` 直接取最新)→ 解压到临时目录
386
+ ├─ 本机已装插件时选【合并 / 替换】(见下)
387
+ ├─ 询问是否【写回当前 pi】(restore-apply)
388
+ │ ├─ 是:把配置/扩展/插件实现写回 ~/.pi/agent(覆盖前自动备份)
389
+ │ └─ 否:只留在临时目录,你自己拷
390
+ ├─ 如含 extra/ 额外数据(~/.pi/settings.json 等)→ 询问是否归位
391
+ └─ 若备份已含插件代码 → 完成;否则体检并询问联网补装
392
+ /reload # 重启 pi 后插件即可用
301
393
  ```
302
394
 
303
- 指定目标目录(谨慎):
395
+ 前置条件:备份时开启了 `includePlugins: true`,插件实现才会在备份里;否则写回后仍需联网补装。
396
+
397
+ #### 本机已经装了插件时,写回(合并 vs 替换)
398
+
399
+ 新电脑往往已经装过一些插件——可能和备份里同名但版本不同,也可能多装了备份里没有的。
400
+ 直接覆盖会**把同名插件降级、把本机多装的声明抹掉、留下版本混合的“缝合”目录**。所以写回有两种模式:
401
+
402
+ | 模式 | settings.json 的 packages | 本机已装的插件实现 | 本机多装的插件 |
403
+ |---|---|---|---|
404
+ | **合并**(默认,推荐) | 本机声明优先,追加备份里本机没有的 | **保留本机版本**,不覆盖 | 保留(声明与代码都留) |
405
+ | 替换(旧机原样) | 完全用备份版 | 被备份覆盖(同名降级) | 声明被移除(代码不删) |
406
+
407
+ - 交互式 `/backup restore`:本机有插件声明时会先让你选(默认合并)
408
+ - 命令行:`--mode merge`(默认)/ `--mode replace`
409
+
410
+ > “合并”对**已安装**的插件只保声明与代码、不升级也不降级;备份里本机没有的插件则连代码一起补入。
411
+ > 想完全回到旧机器状态(含同名降级)用“替换”。两种模式都不会删除本机多装的插件**代码**,
412
+ > 只会(在替换时)从其 `settings.json` 声明里移除。
413
+
414
+ 手动写回某个已恢复目录(不重新下载):
415
+
416
+ ```
417
+ /backup restore-apply <目录>
418
+ # 或命令行:
419
+ node cli.mjs restore-apply --dir D:\pi-restore [--dry-run] [--force] [--mode merge|replace]
420
+ ```
421
+
422
+ 写回的安全规则:
423
+
424
+ - 只写回 agent 目录内的内容;`extra/`(agentDir 之外)由 `apply-extras` 单独处理
425
+ - 自动跳过备份/恢复产生的元数据(manifest、state、log)
426
+ - 目标已存在且内容不同时,先备份旧文件再覆盖(`.bak-apply-*`)
427
+ - 归档里是 `__REDACTED__` 而本地有真实值时跳过(除非 `--force`),避免把真密钥抹掉
428
+ - 合并模式下,本机已装插件的目录整体保留,不写入备份里的同名文件(避免版本混合)
429
+
430
+ > 若只想解到指定目录、不写回:
431
+ > ```bash
432
+ > node cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\pi-restore
433
+ > ```
434
+
435
+ ### 恢复后把插件装回来(仅瘦备份需要)
436
+
437
+ 如果备份时**没有**开启 `includePlugins`,插件实现代码 `node_modules` 不在备份里,需要联网补装。
438
+ 恢复后跑(`--dir` 指向刚恢复出来的目录,不加则看当前 `~/.pi/agent`):
304
439
 
305
- ```bash
306
- node cli.mjs restore --file pi-agent-20260924-120000.tar.gz --to D:\pi-restore
307
440
  ```
441
+ /backup plugins # 看哪些插件没装好
442
+ /backup repair-plugins # 补装
443
+
444
+ node cli.mjs plugins --json --dir D:\pi-restore # 命令行同理
445
+ node cli.mjs repair-plugins --dir D:\pi-restore
446
+ ```
447
+
448
+ 会做的事(安装命令与 pi 自身的安装逻辑保持一致):
449
+
450
+ | 情况 | 动作 |
451
+ |---|---|
452
+ | npm 包装在 `settings.json` 里、但 `npm/node_modules` 里没有 | `npm install <包>[@版本] --legacy-peer-deps`(在 `<目录>/npm`) |
453
+ | git 包目录不存在 | `git clone --depth 1 [--branch <ref>]`;ref 不是分支/标签(如 commit SHA)时自动回退为完整 clone + `git checkout` |
454
+ | git 包目录在、但缺依赖(逐个依赖名检测,拷了一半也能查出来) | 在该包目录内 `npm install --omit=dev` |
455
+
456
+ `--dry-run` 只列将执行的命令,不落盘;单个包失败不影响其余,结果里会分开报 `repaired` / `failed`。
457
+
458
+ > 来源解析与 pi 的 `parseGitUrl` 完全对齐:支持 `git:` 简写、`https://…` URL、`git@host:path` scp 形式,
459
+ > ref 可含 `/`(如 `feature/x`),`.git` 后缀自动去掉(否则会装到 pi 找不到的目录)。
460
+
461
+ > `/backup restore` 在**未写回**时会自动体检**刚恢复出来的目录**并询问是否立即补装(体检的是临时目录,
462
+ > 而不是当前 `~/.pi/agent`,因为声明还在临时目录里);若已写回当前目录,则看点目录。
463
+ > `/backup restore -y` 只略过「恢复哪个」的询问,写回与补装仍会各自询问一次。
308
464
 
309
465
  ### 加密备份的恢复
310
466
 
@@ -328,13 +484,16 @@ npm test
328
484
  node extensions/webdav-backup/verify-load.mjs
329
485
  ```
330
486
 
331
- 8 个测试文件、310 项断言,用内置的内存 WebDAV 服务端做真实 HTTP 往返:
487
+ 11 个测试文件、450+ 项断言,用内置的内存 WebDAV 服务端做真实 HTTP 往返:
332
488
 
333
489
  | 文件 | 覆盖 |
334
490
  |---|---|
335
491
  | `test.mjs` | 收集/排除、归档、加密往返、快照增量、prune |
336
492
  | `test-cli.mjs` | 真实子进程调用 CLI、退出备份、DPAPI 端到端、doctor、日志、状态 |
337
- | `test-restore.mjs` | 归档/加密/快照恢复、目录穿越防护 |
493
+ | `test-restore.mjs` | 归档/加密/快照恢复、目录穿越防护、302 重定向(对象存储)恢复 |
494
+ | `test-plugins.mjs` | 插件声明解析(与 pi 官方 parseGitUrl 对齐:`.git` 后缀、ref 含 `/`、URL/scp 来源)、恢复后体检(缺安装/逐个依赖查缺依赖)、补装、SHA ref 回退、dry-run、真实恢复往返 |
495
+ | `test-apply.mjs` | 换机一键还原:`includePlugins` 排除组装与收集、含插件归档往返恢复、`restore-apply` 写回/覆盖前备份/幂等/dry-run/防止 `__REDACTED__` 覆盖真值、跳过 `extra/` 与元数据 |
496
+ | `test-extras.mjs` | `agentDir` 之外的额外数据(`extraScope`):路径映射互逆、**跨机锚点重映射**(换用户名/盘符/平台)、收集、脱敏、恢复归位、幂等、`--force` 安全防护、遗留配置探测 |
338
497
  | `test-redact.mjs` | 脱敏(含「字段名无关但值是密钥」的回归用例) |
339
498
  | `test-json.mjs` | CLI 输出 JSON 解析容错(回归:空输出曾抛裸 JSON 错误吞掉 stderr) |
340
499
  | `test-empty-cmd.mjs` | 空参数/子命令解析、输出契约(回归:`/backup` 不带参数曾报「未知命令:」、完成提醒拿不到 JSON 汇总) |
@@ -343,6 +502,20 @@ node extensions/webdav-backup/verify-load.mjs
343
502
 
344
503
  ---
345
504
 
505
+ ## 版本历史
506
+
507
+ | 版本 | 内容 |
508
+ |---|---|
509
+ | **v1.3.0** | **换机一键还原**:新增 `includePlugins`(把插件实现 `node_modules` 一并备份,重机恢复即可直接用,无需联网重装);新增 `restore-apply` / `/backup restore-apply`(把恢复结果写回当前 `~/.pi/agent`,覆盖前备份、幂等、防止 `__REDACTED__` 覆盖真值);**写回默认【合并模式】**:本机已装插件保留不被覆盖、`settings.json` 只合并 `packages`(避免删掉/降级本机插件),可选 `--mode replace` 完全还原;`/backup restore` 默认询问是否写回当前 pi,已含插件时不再提示联网补装;`/backup-setup` 向导新增插件代码选项;脱敏审计跳过第三方插件代码(避免误报);排除规则重构(`includePlugins` 开关控制 node_modules) |
510
+ | **v1.2.0** | **插件数据跨机同步补齐**:新增 `extraScope`(备份 `agentDir` 之外的路径,如 `~/.pi/settings.json`、`~/.config/rpiv-*`,归档内放 `extra/` 前缀);归档写入 `extra-manifest.json`,按 `home`/`config`(XDG)/`appdata` 锚点记录归属,恢复时按当前机器重算目标(换用户名/盘符/平台均可);`/backup apply-extras` 归位(幂等、覆盖前备份、防止 `__REDACTED__` 覆盖真密钥、支持 `--force`、未映射路径单独提醒);新增 `detect-legacy` 探测遗留配置;`/backup-setup` 引导纳入;默认排除新增 `bin/**`;README 澄清加密备份同样会脱敏 |
511
+ | **v1.1.0** | **插件备份/恢复修好**:恢复后自动体检「恢复出来的目录」并补装(此前看错目录,补装提示永不出现);`plugins`/`repair-plugins` 新增 `--dir`;git 来源解析与 pi 完全对齐(`.git` 后缀、ref 含 `/`、`https://`/scp 形式);commit SHA ref 自动回退为完整 clone + checkout;缺依赖逐个检测;安装参数对齐 pi(`--legacy-peer-deps` / `--omit=dev`);Windows 路径含空格不再报错。另含 v1.0.2 之后的修复:恢复遇 302 重定向到对象存储自动跟随(不外泄凭据)、完成通知拿不到汇总的回归 |
512
+ | **v1.0.2** | 初始发布:归档/快照两种备份模式、AES-256-GCM 加密、DPAPI/环境变量/命令密钥、明文脱敏、退出自动备份与每日定时备份、恢复到临时目录 |
513
+
514
+ > 发新版时:打新 tag(如 `v1.1.1`)后把移动 tag `v1` 一并指过去,让 `pi install …@v1` 始终装最新:
515
+ > `git tag -f v1 v1.1.1 && git push -f origin v1`
516
+
517
+ ---
518
+
346
519
  ## 故障排查
347
520
 
348
521
  | 现象 | 处理 |
@@ -356,4 +529,9 @@ node extensions/webdav-backup/verify-load.mjs
356
529
  | 想立刻看到日志 | `/backup-log` |
357
530
  | DPAPI 解密失败 | 密文只能在本机本用户下解;换机器/换用户需重新 `/backup keys set` |
358
531
  | 提示「环境变量未设置」 | `/backup keys set` 改用 dpapi,或先设好该环境变量 |
532
+ | 恢复报 `HTTP 302` | Cloudreve/群晖等把下载重定向到对象存储(腾讯云 COS 等);客户端已自动跟随跨主机 302(不携带凭据),请升级到 v1.1.0+ |
533
+ | 恢复后插件不生效 | 若备份时未开启 `includePlugins`,插件代码(`node_modules`)不在备份里,需联网补装:`/backup repair-plugins`(可带 `--dir`)。推荐下次备份开启 `includePlugins: true`,下次即可一键还原 |
534
+ | 写回时提示跳过某些文件 | 归档里是 `__REDACTED__` 而本地已有真实密钥时会被跳过(避免把真密钥抹掉);确认要覆盖可加 `--force` |
535
+ | 补装报 `git clone` 失败 | 确认能访问 github.com 等源;ref 是 commit SHA 时会自动回退为完整 clone + checkout,仍失败则看日志里的 git 输出 |
536
+ | 插件体检一直报「缺少安装」 | 多半是 `.git` 后缀 / ref 形式的声明被装到了 pi 找不到的目录;v1.1.0+ 已与 pi 的解析对齐,升级后跑一次 `/backup repair-plugins` |
359
537
  | 配置体检 | `/backup verify` 一次看全部状态 |
@@ -0,0 +1,319 @@
1
+ /**
2
+ * apply.mjs — 把「恢复出来的目录」写回当前 pi 的 agent 目录(换机一键还原)
3
+ *
4
+ * 背景:restore 默认只把归档解到一个临时目录,用户还得手动拷贝。换机场景下
5
+ * 这正是最痛的一步。本模块负责把恢复结果安全地写回 ~/.pi/agent:
6
+ *
7
+ * - 跳过 agentDir 之外的额外数据(extra/),它们由 apply-extras 单独归位
8
+ * - 跳过工具自身生成的文件(manifest / extra-manifest / state / log)
9
+ * - 覆盖前把现有文件另存为 <name>.bak-apply-<时间戳>,避免不可逆丢失
10
+ * - 源文件含 __REDACTED__ 而本地已有真实值时默认跳过(防止把真密钥覆盖成占位符)
11
+ * - 返回逐项结果,供上层汇总提示
12
+ *
13
+ * 恢复策略(merge):
14
+ * 新电脑往往已经装了一些插件。若直接覆盖,会丢声明、把同名插件降级、留下版本混合
15
+ * 的“缝合”目录。所以默认走【合并】:
16
+ * - settings.json 只合并 packages:本机已有声明优先,追加备份里本机没有的插件
17
+ * - 本机已存在的插件实现(node_modules、git)保留本机版本,不用备份覆盖
18
+ * - 本机没有的插件则从备份补入
19
+ * 想完全还原成旧机器(含删掉本机多装的插件、降级同名插件)用 merge=false / --mode replace。
20
+ *
21
+ * 只依赖 node 内置模块。
22
+ */
23
+
24
+ import {
25
+ existsSync,
26
+ mkdirSync,
27
+ lstatSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { join, dirname, resolve } from "node:path";
34
+ import { hasRedaction, backupExisting } from "./extras.mjs";
35
+ import { parsePackageSource } from "./packages.mjs";
36
+
37
+ /** 这些是备份/恢复流程自己产生的元数据,不应写回 agent 目录 */
38
+ const SKIP_NAMES = new Set([
39
+ ".pi-backup-manifest.json",
40
+ "extra-manifest.json",
41
+ "webdav-backup-state.json",
42
+ "webdav-backup.log",
43
+ ]);
44
+
45
+ function safeJoin(destDir, rel) {
46
+ const parts = String(rel)
47
+ .replace(/\\/g, "/")
48
+ .split("/")
49
+ .filter((s) => s && s !== "." && s !== "..");
50
+ return join(destDir, ...parts);
51
+ }
52
+
53
+ function fileEqualsBuffer(target, buf) {
54
+ try {
55
+ return readFileSync(target).equals(buf);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /** 该相对路径是否属于「插件实现代码」(node_modules / git 包) */
62
+ export function isPluginImplementationPath(rel) {
63
+ const r = String(rel).replace(/\\/g, "/");
64
+ return r.startsWith("git/") || r.includes("/node_modules/");
65
+ }
66
+
67
+ /**
68
+ * 求一个插件实现文件所属的「插件根目录」相对路径,用于判定本机是否已装该插件:
69
+ * npm/node_modules/foo/lib/x.js → npm/node_modules/foo
70
+ * npm/node_modules/@scope/pkg/x.js → npm/node_modules/@scope/pkg
71
+ * git/host/u/r/node_modules/dep/x.js → git/host/u/r/node_modules/dep
72
+ * git/host/u/r/src/x.ts → git/host/u/r(整仓一起判定)
73
+ * 不在插件实现范围内则返回 null。
74
+ */
75
+ export function pluginRootRel(rel) {
76
+ const r = String(rel).replace(/\\/g, "/");
77
+ const marker = "/node_modules/";
78
+ const idx = r.lastIndexOf(marker);
79
+ if (idx >= 0) {
80
+ const after = r.slice(idx + marker.length).split("/");
81
+ const pkg = after[0]?.startsWith("@") && after[1] ? `${after[0]}/${after[1]}` : after[0];
82
+ if (!pkg) return null;
83
+ return r.slice(0, idx + marker.length) + pkg;
84
+ }
85
+ if (r.startsWith("git/")) {
86
+ const segs = r.split("/");
87
+ // git/<host>/<user>/<repo...>:至少保留 host/user/repo 三段
88
+ if (segs.length >= 4) return segs.slice(0, 4).join("/");
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** 取 packages 声明的来源字符串(兼容字符串与 { source, ... } 对象) */
94
+ function packageSourceString(p) {
95
+ if (typeof p === "string") return p;
96
+ if (p && typeof p === "object" && typeof p.source === "string") return p.source;
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * 计算一条 packages 声明的「身份」:npm 看包名、git 看 host/path、local 看路径。
102
+ * 用于合并时按身份去重(同身份视为同一个插件,不因版本号不同而重复)。
103
+ */
104
+ export function packageIdentity(p) {
105
+ const s = packageSourceString(p);
106
+ if (!s) return null;
107
+ const parsed = parsePackageSource(s);
108
+ if (!parsed) return `raw:${s}`;
109
+ if (parsed.type === "npm") return `npm:${parsed.name}`;
110
+ if (parsed.type === "git") return `git:${parsed.host}/${parsed.path}`;
111
+ return `local:${parsed.path}`;
112
+ }
113
+
114
+ /**
115
+ * 合并 settings.json 的 packages 字段:本机已有声明优先,追加备份里本机没有的。
116
+ * 返回合并后的 JSON 文本;无需合并(本机没有 packages)时返回 null,由调用方按普通逻辑处理。
117
+ * 除 packages 外的字段以备份为准(这正是“恢复配置”的语义)。
118
+ */
119
+ export function mergeSettingsPackages(backupText, localText) {
120
+ let backup;
121
+ let local;
122
+ try {
123
+ backup = JSON.parse(String(backupText).replace(/^\uFEFF/, ""));
124
+ } catch {
125
+ return null;
126
+ }
127
+ try {
128
+ local = JSON.parse(String(localText).replace(/^\uFEFF/, ""));
129
+ } catch {
130
+ return null;
131
+ }
132
+ if (!backup || typeof backup !== "object") return null;
133
+
134
+ const backupPkgs = Array.isArray(backup.packages) ? backup.packages : [];
135
+ const localPkgs = Array.isArray(local?.packages) ? local.packages : [];
136
+ // 本机没有任何声明 → 直接用备份版即可(全新机器场景)
137
+ if (localPkgs.length === 0) return null;
138
+
139
+ const localIds = new Set(localPkgs.map(packageIdentity).filter(Boolean));
140
+ const extra = backupPkgs.filter((p) => {
141
+ const id = packageIdentity(p);
142
+ return id && !localIds.has(id);
143
+ });
144
+
145
+ return JSON.stringify({ ...backup, packages: [...localPkgs, ...extra] }, null, 2);
146
+ }
147
+
148
+ /** 递归列出恢复目录下、应写回 agentDir 的文件(跳过 extra/ 与元数据) */
149
+ export function listRestoredFiles(restoredDir, { extraPrefix = "extra/" } = {}) {
150
+ const root = resolve(restoredDir);
151
+ const out = [];
152
+ const walk = (dir, relDir) => {
153
+ let entries;
154
+ try {
155
+ entries = readdirSync(dir, { withFileTypes: true });
156
+ } catch {
157
+ return;
158
+ }
159
+ for (const ent of entries) {
160
+ const abs = join(dir, ent.name);
161
+ const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
162
+ if (ent.isSymbolicLink()) continue;
163
+ if (ent.isDirectory()) {
164
+ walk(abs, rel);
165
+ } else if (ent.isFile()) {
166
+ // agentDir 之外的额外数据走 apply-extras,不在这里重复写
167
+ if (rel === extraPrefix.replace(/\/$/, "") || rel.startsWith(extraPrefix)) continue;
168
+ if (SKIP_NAMES.has(ent.name)) continue;
169
+ out.push({ rel, abs });
170
+ }
171
+ }
172
+ };
173
+ walk(root, "");
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * 把恢复目录写回 agentDir。
179
+ * @param {string} restoredDir 恢复出来的目录
180
+ * @param {string} agentDir 目标 agent 目录(通常 ~/.pi/agent)
181
+ * @param {{log?: Function, dryRun?: boolean, force?: boolean, merge?: boolean, only?: string[]}} opts
182
+ * merge 默认 true:保护本机已装插件;false=完全覆盖替换(旧机器原样)。
183
+ * @returns {{ok:boolean, applied:Array, unchanged:Array, skipped:Array, failed:Array, total:number, merged:Array}}
184
+ */
185
+ export function applyRestoredFiles(restoredDir, agentDir, opts = {}) {
186
+ const log = opts.log ?? (() => {});
187
+ const dryRun = opts.dryRun === true;
188
+ const force = opts.force === true;
189
+ const merge = opts.merge !== false; // 默认合并,保护本机已装插件
190
+ const only = opts.only ? new Set(opts.only) : null;
191
+
192
+ const files = listRestoredFiles(restoredDir);
193
+ const applied = [];
194
+ const unchanged = [];
195
+ const skipped = [];
196
+ const failed = [];
197
+ const merged = [];
198
+
199
+ if (!existsSync(agentDir) && !dryRun) mkdirSync(agentDir, { recursive: true });
200
+
201
+ for (const f of files) {
202
+ if (only && !only.has(f.rel)) continue;
203
+ const target = safeJoin(agentDir, f.rel);
204
+
205
+ let srcBuf;
206
+ try {
207
+ srcBuf = readFileSync(f.abs);
208
+ } catch (e) {
209
+ failed.push({ rel: f.rel, target, error: e instanceof Error ? e.message : String(e) });
210
+ continue;
211
+ }
212
+
213
+ // merge:settings.json 只合并 packages,保留本机已有的插件声明(避免删掉/降级本机插件)
214
+ let mergedSettings = false;
215
+ if (merge && f.rel === "settings.json" && existsSync(target)) {
216
+ try {
217
+ const mergedText = mergeSettingsPackages(srcBuf.toString("utf8"), readFileSync(target, "utf8"));
218
+ if (mergedText !== null) {
219
+ srcBuf = Buffer.from(mergedText, "utf8");
220
+ mergedSettings = true;
221
+ }
222
+ } catch {
223
+ /* 合并失败则退回普通处理 */
224
+ }
225
+ }
226
+
227
+ // merge:本机已装该插件(插件根目录已存在)→ 跳过整个插件,避免降级与版本混合。
228
+ // 但若该文件与本地完全一致,则归为 unchanged(保证重复写回幂等)。
229
+ if (merge) {
230
+ const root = pluginRootRel(f.rel);
231
+ if (root) {
232
+ try {
233
+ const localRoot = safeJoin(agentDir, root);
234
+ if (existsSync(localRoot) && lstatSync(localRoot).isDirectory()) {
235
+ if (existsSync(target) && fileEqualsBuffer(target, srcBuf)) unchanged.push({ rel: f.rel, target });
236
+ else skipped.push({ rel: f.rel, target, reason: "merge:本机已装该插件,保留本机版本" });
237
+ continue;
238
+ }
239
+ } catch {
240
+ /* ignore */
241
+ }
242
+ }
243
+ }
244
+
245
+ if (existsSync(target)) {
246
+ // 已一致:无需处理
247
+ if (fileEqualsBuffer(target, srcBuf)) {
248
+ unchanged.push({ rel: f.rel, target });
249
+ continue;
250
+ }
251
+ // 安全防护:归档里是脱敏占位符,而本地已有真实值 → 跳过,别把真密钥抹了
252
+ if (!force && hasRedaction(srcBuf)) {
253
+ const local = readFileSync(target);
254
+ if (!hasRedaction(local)) {
255
+ skipped.push({
256
+ rel: f.rel,
257
+ target,
258
+ reason: "本地已有真实值,跳过以免用 __REDACTED__ 覆盖(加 --force 强制)",
259
+ });
260
+ continue;
261
+ }
262
+ }
263
+ // 目标是目录:不覆盖
264
+ try {
265
+ if (lstatSync(target).isDirectory()) {
266
+ failed.push({ rel: f.rel, target, error: "目标是一个目录,拒绝覆盖" });
267
+ continue;
268
+ }
269
+ } catch {
270
+ /* ignore */
271
+ }
272
+ }
273
+
274
+ if (dryRun) {
275
+ skipped.push({ rel: f.rel, target, reason: "dry-run" });
276
+ continue;
277
+ }
278
+
279
+ try {
280
+ mkdirSync(dirname(target), { recursive: true });
281
+ let backup = null;
282
+ if (existsSync(target)) backup = backupExisting(target, "apply");
283
+ writeFileSync(target, srcBuf);
284
+ applied.push({ rel: f.rel, target, backup, merged: mergedSettings });
285
+ if (mergedSettings) merged.push({ rel: f.rel, target });
286
+ log(
287
+ ` ${backup ? "已覆盖" : "已写入"} ${f.rel} → ${target}` +
288
+ (mergedSettings ? "(已合并 packages,保留本机插件声明)" : "") +
289
+ (backup ? `(原文件备份为 ${backup})` : ""),
290
+ );
291
+ } catch (e) {
292
+ failed.push({ rel: f.rel, target, error: e instanceof Error ? e.message : String(e) });
293
+ }
294
+ }
295
+
296
+ return {
297
+ ok: failed.length === 0,
298
+ applied,
299
+ unchanged,
300
+ skipped,
301
+ failed,
302
+ merged,
303
+ total: files.length,
304
+ };
305
+ }
306
+
307
+ /** 目标目录里是否已经装好了插件实现(node_modules 是否非空) */
308
+ export function hasPluginCode(restoredDir) {
309
+ const roots = [join(restoredDir, "npm", "node_modules"), join(restoredDir, "git")];
310
+ return roots.some((r) => {
311
+ try {
312
+ if (!existsSync(r)) return false;
313
+ if (statSync(r).isFile()) return true;
314
+ return readdirSync(r).length > 0;
315
+ } catch {
316
+ return false;
317
+ }
318
+ });
319
+ }