pi-webdav-backup 1.3.1 → 1.3.3

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
@@ -120,14 +120,17 @@ node ~/.pi/agent/extensions/webdav-backup/cli.mjs detect-legacy --json
120
120
 
121
121
  | 语法 | 说明 | 适用 |
122
122
  |---|---|---|
123
- | `dpapi:名称` | **推荐**。Windows DPAPI 用户级加密存入 `secrets.dpapi.json`,本机本用户可解,**无需主密码** | Windows |
124
- | `$ENV_VAR` / `${ENV_VAR}` | 环境变量(也支持 `$A-$B` 拼接,`$$` 转义) | 跨平台 |
125
- | `file:路径` | 从文件读取(自动去换行;相对路径基于 `~/.pi/agent`) | 跨平台 |
126
- | `!命令` | 执行命令取 stdout(可对接密码管理器 CLI) | 跨平台 |
123
+ | `dpapi:名称` | Windows DPAPI 用户级加密存入 `secrets.dpapi.json`,本机本用户可解,**无需主密码** | 仅 Windows |
124
+ | `$ENV_VAR` / `${ENV_VAR}` | 环境变量(也支持 `$A-$B` 拼接,`$$` 转义) | **跨平台** |
125
+ | `file:路径` | 从文件读取(自动去换行;相对路径基于 `~/.pi/agent`) | **跨平台** |
126
+ | `!命令` | 执行命令取 stdout(可对接密码管理器 CLI) | **跨平台** |
127
127
  | `plain:值` | 显式声明明文(会告警,仅调试用) | — |
128
128
  | 其他值 | 视为明文(**会告警**) | — |
129
129
 
130
130
  > 该语法与 pi 自身的 `resolve-config-value` 约定一致,`auth.json` 的 `key` 字段也支持 `$ENV_VAR` 与 `!命令`。
131
+ >
132
+ > ⚠️ **要跨 Windows / Linux / macOS 多机同步,用 `file:` 或 `$ENV`,不要用 `dpapi:`**——同 DPAPI 密文只能在本机本用户解密。
133
+ > 详见下文「跨平台密钥(多机同步)」。
131
134
 
132
135
  ### 推荐做法
133
136
 
@@ -183,6 +186,46 @@ setx PI_WEBDAV_BACKUP_PASSWORD "你的应用密码"
183
186
  setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
184
187
  ```
185
188
 
189
+ ### 跨平台密钥(多机同步)
190
+
191
+ `dpapi:` 只能在 Windows 本机本用户解密,**同一份配置在 Linux 上会解析失败**。要在 Windows/Linux/macOS 多机共用一套配置,改用 `file:`(推荐,无需改 shell)或 `$ENV`。
192
+
193
+ **方案:`file:` 相对引用**(配置里写相对 `agentDir` 的路径,各平台写法一致)
194
+
195
+ ```jsonc
196
+ {
197
+ "remote": { "password": "file:secrets/webdav-password.txt" },
198
+ "encrypt": true,
199
+ "encryptKey": "file:secrets/backup-key.txt"
200
+ }
201
+ ```
202
+
203
+ 每台机器各创建一次密钥文件(内容相同):
204
+
205
+ ```bash
206
+ mkdir -p ~/.pi/agent/secrets
207
+ printf '%s' '你的加密口令' > ~/.pi/agent/secrets/backup-key.txt
208
+ printf '%s' '你的应用密码' > ~/.pi/agent/secrets/webdav-password.txt
209
+ chmod 600 ~/.pi/agent/secrets/*.txt # Windows 可忽略
210
+ ```
211
+
212
+ > `secrets/**` 默认被排除,**密钥不会随备份上传**。所以新机器必须手动做一次这步(这是唯一的手动环节)。
213
+ > 读取时会自动去掉行尾换行,`echo`/`printf` 都可以。
214
+
215
+ **方案:`$ENV`**
216
+
217
+ 配置里写 `"encryptKey": "$PI_WEBDAV_BACKUP_KEY"`,然后各机器设同名环境变量(值相同):
218
+
219
+ ```bash
220
+ # Linux / macOS:~/.bashrc 或 ~/.zshrc
221
+ export PI_WEBDAV_BACKUP_KEY="你的加密口令"
222
+
223
+ # Windows PowerShell(需新开终端生效)
224
+ setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
225
+ ```
226
+
227
+ 从现有 `dpapi:` 迁移:`node cli.mjs migrate --method file`(写入 `secrets/`)或 `--method env`(写入用户环境变量)。
228
+
186
229
  ---
187
230
 
188
231
  ## 备份什么
@@ -294,7 +337,7 @@ setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
294
337
  "snapshot": false, // 见下文「两种模式」
295
338
 
296
339
  "encrypt": false, // AES-256-GCM 加密
297
- "encryptKey": "dpapi:backup-key", // 密钥引用(≥16 位),或 $PI_WEBDAV_BACKUP_KEY
340
+ "encryptKey": "dpapi:backup-key", // 密钥引用(≥16 位):dpapi:名称 / $ENV_VAR / file:路径;见上文「密钥」
298
341
 
299
342
  "backupOnExit": true, // 退出 pi 时自动备份
300
343
  "backupOnExitMinIntervalMinutes": 30, // 两次自动备份最小间隔
@@ -315,10 +358,10 @@ setx PI_WEBDAV_BACKUP_KEY "你的加密口令"
315
358
  | **群晖 WebDAV** | `https://NAS地址:5006/<共享文件夹>` | DSM 账号 | DSM 密码 |
316
359
  | **自建 rclone serve** | `http://主机:端口/` | `--user` 指定的值 | `--pass` 指定的值 |
317
360
 
318
- > ⚠️ 密码建议用 `dpapi:` 引用而非明文,见下方「密钥不明文落盘」:
361
+ > ⚠️ 密码建议用引用而非明文(**多机同步请用 `file:` / `$ENV`,不要用 `dpapi:`**):
319
362
  > ```
320
- > /backup keys set webdav-password
321
- > # 然后在配置里写 "password": "dpapi:webdav-password"
363
+ > /backup keys set webdav-password # Windows DPAPI
364
+ > # 跨平台:"password": "file:secrets/webdav-password.txt"
322
365
  > ```
323
366
  > 也可用环境变量:`setx PI_WEBDAV_BACKUP_PASSWORD "你的应用密码"`
324
367
 
@@ -399,10 +442,13 @@ node "$env:USERPROFILE\.pi\agent\extensions\webdav-backup\cli.mjs" install-cron
399
442
  新电脑往往已经装过一些插件——可能和备份里同名但版本不同,也可能多装了备份里没有的。
400
443
  直接覆盖会**把同名插件降级、把本机多装的声明抹掉、留下版本混合的“缝合”目录**。所以写回有两种模式:
401
444
 
402
- | 模式 | settings.json 的 packages | 本机已装的插件实现 | 本机多装的插件 |
403
- |---|---|---|---|
404
- | **合并**(默认,推荐) | 本机声明优先,追加备份里本机没有的 | **保留本机版本**,不覆盖 | 保留(声明与代码都留) |
405
- | 替换(旧机原样) | 完全用备份版 | 被备份覆盖(同名降级) | 声明被移除(代码不删) |
445
+ | 模式 | settings.json 的 packages | npm/package.json 的 dependencies | 本机已装的插件实现 | 本机多装的插件 |
446
+ |---|---|---|---|---|
447
+ | **合并**(默认,推荐) | 本机声明优先,追加备份里本机没有的 | 本机依赖优先,追加备份里本机没有的 | **保留本机版本**,不覆盖 | 保留(声明与代码都留) |
448
+ | 替换(旧机原样) | 完全用备份版 | 完全用备份版 | 被备份覆盖(同名降级) | 声明被移除(代码不删) |
449
+
450
+ > **为何要合并 `npm/package.json`**:pi 启动时会拿它与 `node_modules` 做协调(npm install)。
451
+ > 若直接覆盖成备份版,本机独有的插件会被 npm 当成多余包【卸载】。所以合并模式会保留本机已有依赖。
406
452
 
407
453
  - 交互式 `/backup restore`:本机有插件声明时会先让你选(默认合并)
408
454
  - 命令行:`--mode merge`(默认)/ `--mode replace`
@@ -470,6 +516,90 @@ node cli.mjs repair-plugins --dir D:\pi-restore
470
516
 
471
517
  ---
472
518
 
519
+ ## 多机同步(Windows ↔ Linux)
520
+
521
+ ⚠️ 先认清本质:**这是"备份/恢复",不是实时双向同步。**
522
+
523
+ | 命令 | 方向 |
524
+ |---|---|
525
+ | `/backup` | 本机 → 云端(上传) |
526
+ | `/backup restore` | 云端 → 本机(下载,默认合并) |
527
+
528
+ 两台机器各自 `/backup` 会互相覆盖,建议**以一台为主机**,其他机器只做 `/backup restore`。
529
+
530
+ ### 一、旧机(主机):先备份
531
+
532
+ ```
533
+ /backup dry # 确认扫描结果(开了 includePlugins 会含几百 MB 插件代码)
534
+ /backup # 正式备份
535
+ ```
536
+
537
+ ### 二、新机:安装 + 恢复
538
+
539
+ ```bash
540
+ # 1. 装插件(GitHub / npm 二选一)
541
+ pi install git:github.com/ddldxiacan/pi-webdav-backup@v1
542
+ # 然后重启 pi
543
+
544
+ # 2. 创建密钥文件(内容与旧机一致;secrets/ 不入备份,故需手动一次)
545
+ mkdir -p ~/.pi/agent/secrets
546
+ printf '%s' '你的加密口令' > ~/.pi/agent/secrets/backup-key.txt
547
+ printf '%s' '你的应用密码' > ~/.pi/agent/secrets/webdav-password.txt
548
+ chmod 600 ~/.pi/agent/secrets/*.txt
549
+
550
+ # 3. 写配置 ~/.pi/agent/webdav-backup.json(与旧机相同,密钥用 file:)
551
+ ```
552
+
553
+ 配置文件模板(Windows/Linux 通用):
554
+
555
+ ```jsonc
556
+ {
557
+ "enabled": true,
558
+ "remote": {
559
+ "url": "https://你的.webdav/dav",
560
+ "username": "you@example.com",
561
+ "password": "file:secrets/webdav-password.txt",
562
+ "remoteDir": "pi-backup",
563
+ "remoteName": "pi"
564
+ },
565
+ "scope": ["."],
566
+ "includePlugins": true,
567
+ "extraScope": ["~/.pi/settings.json"],
568
+ "encrypt": true,
569
+ "encryptKey": "file:secrets/backup-key.txt",
570
+ "includeSessions": false
571
+ }
572
+ ```
573
+
574
+ ```
575
+ # 4. 测连接 + 恢复
576
+ /backup check
577
+ /backup restore -y # 选备份 → 解密解压 → 选合并/替换 → 写回 → 归位 extra → 完成
578
+ /reload
579
+ ```
580
+
581
+ ### 三、关键约束
582
+
583
+ | 项 | 要求 |
584
+ |---|---|
585
+ | `encryptKey` | **两台机器值必须一致**,否则解不开加密备份 |
586
+ | 密钥引用 | 多机一律用 `file:` 或 `$ENV`,**不要用 `dpapi:`**(只能本机解密) |
587
+ | `remote.url` / `remoteDir` / `remoteName` | 两台机器必须完全一致,否则找不到备份 |
588
+ | 插件还原 | 旧机需开 `includePlugins: true`;否则新机恢复后要联网 `/backup repair-plugins` |
589
+ | 合并/替换 | 新机已装插件时,默认"合并"(保留本机已装的,只补缺失) |
590
+
591
+ ### 四、常见坑
592
+
593
+ | 症状 | 处理 |
594
+ |---|---|
595
+ | 新机报"加密密钥解析失败" | 用了 `dpapi:` 但非 Windows;改用 `file:` / `$ENV` |
596
+ | 解密失败 | `backup-key` 与旧机不一致(或旧备份用了更早的口令) |
597
+ | 恢复后插件不生效 | 旧机未开 `includePlugins` → `/backup repair-plugins` 联网补装 |
598
+ | 恢复后缺插件声明 | 插件声明在 `~/.pi/settings.json`(非 agent)→ 加入 `extraScope` |
599
+ | 连接 401 | WebDAV 密码错(坚果云要用**应用密码**) |
600
+
601
+ ---
602
+
473
603
  ## 测试
474
604
 
475
605
  ```bash
@@ -506,6 +636,9 @@ node extensions/webdav-backup/verify-load.mjs
506
636
 
507
637
  | 版本 | 内容 |
508
638
  |---|---|
639
+ | **v1.3.3** | **修复合并模式会卸载本机插件**:`restore-apply` 合并模式此前只合并 `settings.json`,却用备份版**覆盖** `npm/package.json`——pi 下次启动的 npm 协调会把本机独有插件当成多余包卸载(实测于 Windows→Linux 同步)。现改为同时合并 `npm/package.json` 的 `dependencies`(本机优先),结果里标 `npm-dependencies`。另:文档补充「多机同步」与「跨平台密钥」流程 |
640
+ | **v1.3.2** | **跨平台多机同步**:依赖体检修正(npm 提升到父级的依赖不再误报为缺失,按 Node 模块解析算法沿祖先 `node_modules` 查找);文档新增「跨平台密钥(多机同步)」与「多机同步(Windows ↔ Linux)」流程,明确多机用 `file:` / `$ENV` 而非 `dpapi:`,并给出常见坑 |
641
+ | **v1.3.1** | 依赖体检误报修正(npm 提升的依赖被当成缺失) |
509
642
  | **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
643
  | **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
644
  | **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 重定向到对象存储自动跟随(不外泄凭据)、完成通知拿不到汇总的回归 |
@@ -64,6 +64,11 @@ export function isPluginImplementationPath(rel) {
64
64
  return r.startsWith("git/") || r.includes("/node_modules/");
65
65
  }
66
66
 
67
+ /** agentDir 下的 npm 包清单(决定 pi 启动时 npm 装/卸哪些插件) */
68
+ export function isNpmManifestPath(rel) {
69
+ return String(rel).replace(/\\/g, "/") === "npm/package.json";
70
+ }
71
+
67
72
  /**
68
73
  * 求一个插件实现文件所属的「插件根目录」相对路径,用于判定本机是否已装该插件:
69
74
  * npm/node_modules/foo/lib/x.js → npm/node_modules/foo
@@ -145,6 +150,46 @@ export function mergeSettingsPackages(backupText, localText) {
145
150
  return JSON.stringify({ ...backup, packages: [...localPkgs, ...extra] }, null, 2);
146
151
  }
147
152
 
153
+ /**
154
+ * 合并 npm/package.json 的 dependencies:本机已有优先(保留版本),追加备份里本机没有的。
155
+ *
156
+ * 为何必须合并:pi 启动时会拿 npm/package.json 与 node_modules 做协调(npm install)。
157
+ * 若 merge 写回时用备份版直接覆盖,本机独有的插件会被 npm 当成多余包【卸载】。
158
+ * 返回合并后的 JSON 文本;无可合并时返回 null(调用方按普通逻辑处理)。
159
+ */
160
+ export function mergeNpmDependencies(backupText, localText) {
161
+ let backup;
162
+ let local;
163
+ try {
164
+ backup = JSON.parse(String(backupText).replace(/^\uFEFF/, ""));
165
+ } catch {
166
+ return null;
167
+ }
168
+ try {
169
+ local = JSON.parse(String(localText).replace(/^\uFEFF/, ""));
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (!backup || typeof backup !== "object") return null;
174
+
175
+ const backupDeps = backup.dependencies && typeof backup.dependencies === "object" ? backup.dependencies : {};
176
+ const localDeps = local?.dependencies && typeof local.dependencies === "object" ? local.dependencies : {};
177
+ if (Object.keys(localDeps).length === 0) return null;
178
+
179
+ // 本机已声明的依赖保留本机版本;备份独有的追加进来
180
+ const merged = { ...localDeps };
181
+ let added = 0;
182
+ for (const [name, range] of Object.entries(backupDeps)) {
183
+ if (!(name in merged)) {
184
+ merged[name] = range;
185
+ added++;
186
+ }
187
+ }
188
+ // 本机声明已包含备份全部依赖且无新增 → 无需改写
189
+ if (added === 0 && Object.keys(merged).length === Object.keys(localDeps).length) return null;
190
+ return JSON.stringify({ ...backup, dependencies: merged }, null, 2);
191
+ }
192
+
148
193
  /** 递归列出恢复目录下、应写回 agentDir 的文件(跳过 extra/ 与元数据) */
149
194
  export function listRestoredFiles(restoredDir, { extraPrefix = "extra/" } = {}) {
150
195
  const root = resolve(restoredDir);
@@ -224,6 +269,21 @@ export function applyRestoredFiles(restoredDir, agentDir, opts = {}) {
224
269
  }
225
270
  }
226
271
 
272
+ // merge:npm/package.json 只合并 dependencies,保留本机独有插件。
273
+ // 否则覆盖后,pi 下次启动的 npm 协调会把本机独有的包当成多余包卸载。
274
+ let mergedNpm = false;
275
+ if (merge && isNpmManifestPath(f.rel) && existsSync(target)) {
276
+ try {
277
+ const mergedText = mergeNpmDependencies(srcBuf.toString("utf8"), readFileSync(target, "utf8"));
278
+ if (mergedText !== null) {
279
+ srcBuf = Buffer.from(mergedText, "utf8");
280
+ mergedNpm = true;
281
+ }
282
+ } catch {
283
+ /* 合并失败则退回普通处理 */
284
+ }
285
+ }
286
+
227
287
  // merge:本机已装该插件(插件根目录已存在)→ 跳过整个插件,避免降级与版本混合。
228
288
  // 但若该文件与本地完全一致,则归为 unchanged(保证重复写回幂等)。
229
289
  if (merge) {
@@ -281,11 +341,13 @@ export function applyRestoredFiles(restoredDir, agentDir, opts = {}) {
281
341
  let backup = null;
282
342
  if (existsSync(target)) backup = backupExisting(target, "apply");
283
343
  writeFileSync(target, srcBuf);
284
- applied.push({ rel: f.rel, target, backup, merged: mergedSettings });
285
- if (mergedSettings) merged.push({ rel: f.rel, target });
344
+ applied.push({ rel: f.rel, target, backup, merged: mergedSettings || mergedNpm });
345
+ if (mergedSettings) merged.push({ rel: f.rel, target, kind: "settings-packages" });
346
+ if (mergedNpm) merged.push({ rel: f.rel, target, kind: "npm-dependencies" });
286
347
  log(
287
348
  ` ${backup ? "已覆盖" : "已写入"} ${f.rel} → ${target}` +
288
349
  (mergedSettings ? "(已合并 packages,保留本机插件声明)" : "") +
350
+ (mergedNpm ? "(已合并 dependencies,保留本机插件依赖)" : "") +
289
351
  (backup ? `(原文件备份为 ${backup})` : ""),
290
352
  );
291
353
  } catch (e) {
@@ -246,7 +246,11 @@ async function main() {
246
246
  if (!has("--dry-run")) {
247
247
  logTo(`已写回 agent 目录:${agentDir}(${mergeMode ? "合并模式" : "替换模式"})`);
248
248
  logTo(` 写入/覆盖 ${r.applied.length} 个,内容一致 ${r.unchanged.length} 个,失败 ${r.failed.length} 个`);
249
- if (r.merged?.length) logTo(` 🔀 settings.json 已合并 packages,保留本机已有插件声明`);
249
+ if (r.merged?.length) {
250
+ const kinds = r.merged.map((m) => m.kind).filter(Boolean);
251
+ if (kinds.includes("settings-packages")) logTo(` 🔀 settings.json 已合并 packages,保留本机已有插件声明`);
252
+ if (kinds.includes("npm-dependencies")) logTo(` 🔀 npm/package.json 已合并 dependencies,保留本机独有插件依赖`);
253
+ }
250
254
  for (const x of r.skipped) logTo(` ➖ ${x.rel}(${x.reason})`);
251
255
  for (const x of r.failed) logTo(` ❌ ${x.rel} — ${x.error}`);
252
256
  if (pluginReady) logTo("插件实现代码已随备份恢复,无需联网重装。");
@@ -298,7 +298,7 @@ async function main() {
298
298
  rmSync(live2, { recursive: true, force: true });
299
299
  }
300
300
 
301
- // ── 8. mergeSettingsPackages:本机无声明时返回 null(用备份原样)
301
+ // 8. mergeSettingsPackages:本机无声明时返回 null(用备份原样)
302
302
  {
303
303
  check("本机无 packages 声明时不合并", mergeSettingsPackages('{"packages":["npm:A"]}', '{"theme":"x"}') === null);
304
304
  const m = mergeSettingsPackages('{"packages":["npm:A","npm:B"],"theme":"light"}', '{"packages":["npm:B@2"],"theme":"dark"}');
@@ -306,6 +306,50 @@ async function main() {
306
306
  check("合并保留本机声明优先、追加备份独有", JSON.stringify(o.packages) === JSON.stringify(["npm:B@2", "npm:A"]) && o.theme === "light", String(m));
307
307
  }
308
308
 
309
+ // ── 9. 回归:npm/package.json 必须合并 dependencies(否则 pi 会卸载本机独有插件)
310
+ {
311
+ // 备份来源(Windows):只依赖 A、B
312
+ const backup = mkdtempSync(join(tmpdir(), "pi-npm-merge-bk-"));
313
+ mkdirSync(join(backup, "npm"), { recursive: true });
314
+ writeFileSync(join(backup, "settings.json"), JSON.stringify({ packages: ["npm:A", "npm:B"] }));
315
+ writeFileSync(
316
+ join(backup, "npm", "package.json"),
317
+ JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^1.0.0", B: "^2.0.0" } }),
318
+ );
319
+
320
+ // 目标机器(Linux):已装 A(本机版本)+ 本机独有的 Z
321
+ const live = mkdtempSync(join(tmpdir(), "pi-npm-merge-live-"));
322
+ mkdirSync(join(live, "npm"), { recursive: true });
323
+ writeFileSync(join(live, "settings.json"), JSON.stringify({ packages: ["npm:A@9", "npm:Z"] }));
324
+ writeFileSync(
325
+ join(live, "npm", "package.json"),
326
+ JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^9.0.0", Z: "^0.1.0" } }),
327
+ );
328
+
329
+ const r = applyRestoredFiles(backup, live, { merge: true });
330
+ const deps = JSON.parse(readFileSync(join(live, "npm", "package.json"), "utf8")).dependencies;
331
+ check("merge 后 npm 依赖保留本机 A@9", deps.A === "^9.0.0", JSON.stringify(deps));
332
+ check("merge 后 npm 依赖保留本机独有 Z", deps.Z === "^0.1.0", JSON.stringify(deps));
333
+ check("merge 后 npm 依赖追加备份独有的 B", deps.B === "^2.0.0", JSON.stringify(deps));
334
+ check("merged 列表标出 npm-dependencies", r.merged?.some((m) => m.kind === "npm-dependencies"), JSON.stringify(r.merged));
335
+
336
+ // replace:完全用备份版(Z 会从 package.json 消失,由 pi 下次启动自行卸载)
337
+ const live2 = mkdtempSync(join(tmpdir(), "pi-npm-merge-live2-"));
338
+ mkdirSync(join(live2, "npm"), { recursive: true });
339
+ writeFileSync(join(live2, "settings.json"), JSON.stringify({ packages: ["npm:A@9", "npm:Z"] }));
340
+ writeFileSync(
341
+ join(live2, "npm", "package.json"),
342
+ JSON.stringify({ name: "pi-extensions", private: true, dependencies: { A: "^9.0.0", Z: "^0.1.0" } }),
343
+ );
344
+ applyRestoredFiles(backup, live2, { merge: false });
345
+ const deps2 = JSON.parse(readFileSync(join(live2, "npm", "package.json"), "utf8")).dependencies;
346
+ check("replace 后 npm 依赖完全变回备份版", JSON.stringify(deps2) === JSON.stringify({ A: "^1.0.0", B: "^2.0.0" }), JSON.stringify(deps2));
347
+
348
+ rmSync(backup, { recursive: true, force: true });
349
+ rmSync(live, { recursive: true, force: true });
350
+ rmSync(live2, { recursive: true, force: true });
351
+ }
352
+
309
353
  console.log(lines.join("\n"));
310
354
  console.log(`\n结果:${pass} 通过,${fail} 失败`);
311
355
  process.exit(fail === 0 ? 0 : 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-webdav-backup",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "pi 配置与插件数据自动备份到 WebDAV(坚果云 / Nextcloud / 群晖 / 自建):归档与快照两种模式、AES-256-GCM 加密、DPAPI 密钥管理、敏感字段脱敏、agentDir 之外的额外数据(extraScope)、插件实现代码随备份(includePlugins)实现换机一键还原、退出自动备份与每日定时备份。",
5
5
  "keywords": [
6
6
  "pi-package",