dsh-config-manager 0.1.72 → 0.1.76

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.
Files changed (153) hide show
  1. package/README.md +40 -4
  2. package/README.zh-CN.md +40 -4
  3. package/lib/adapters/index.d.ts +1 -0
  4. package/lib/adapters/index.js +5 -0
  5. package/lib/adapters/messages.d.ts +3 -0
  6. package/lib/adapters/messages.js +7 -0
  7. package/lib/adapters/plugin-data.d.ts +114 -0
  8. package/lib/adapters/plugin-data.js +309 -0
  9. package/lib/adapters/self.d.ts +1 -1
  10. package/lib/adapters/self.js +2 -1
  11. package/lib/adapters/test-helpers.d.ts +9 -0
  12. package/lib/adapters/test-helpers.js +14 -0
  13. package/lib/client.d.ts +103 -10
  14. package/lib/client.js +10748 -10119
  15. package/lib/core/analyzer.d.ts +11 -2
  16. package/lib/core/analyzer.js +31 -8
  17. package/lib/core/backup-plan.d.ts +10 -0
  18. package/lib/core/backup-plan.js +21 -3
  19. package/lib/core/backup.d.ts +20 -1
  20. package/lib/core/backup.js +48 -3
  21. package/lib/core/command-rollback.d.ts +6 -2
  22. package/lib/core/command-rollback.js +6 -2
  23. package/lib/core/messages-core.d.ts +2 -0
  24. package/lib/core/messages-core.js +4 -0
  25. package/lib/core/messages.d.ts +2 -1
  26. package/lib/core/messages.js +4 -2
  27. package/lib/core/plugin-data-registry.d.ts +124 -0
  28. package/lib/core/plugin-data-registry.js +198 -0
  29. package/lib/core/rollback-gate.d.ts +33 -0
  30. package/lib/core/rollback-gate.js +57 -0
  31. package/lib/core/rollback-ledger.d.ts +274 -0
  32. package/lib/core/rollback-ledger.js +1400 -0
  33. package/lib/core/rollback-point.d.ts +406 -5
  34. package/lib/core/rollback-point.js +1453 -306
  35. package/lib/core/rollback.js +6 -4
  36. package/lib/core/types.d.ts +19 -0
  37. package/lib/foreign/registry.d.ts +1 -1
  38. package/lib/foreign/registry.js +1 -1
  39. package/lib/index.d.ts +5 -1
  40. package/lib/index.js +83 -13
  41. package/lib/routes/messages.d.ts +1 -0
  42. package/lib/routes/messages.js +2 -0
  43. package/lib/routes/prefs.js +26 -3
  44. package/lib/routes/recovery.d.ts +21 -0
  45. package/lib/routes/recovery.js +127 -7
  46. package/lib/routes/sync.js +11 -1
  47. package/lib/schema/section-registry.d.ts +1 -1
  48. package/lib/schema/section-registry.js +32 -3
  49. package/lib/schema/types.d.ts +1 -1
  50. package/lib/sync/messages.d.ts +1 -0
  51. package/lib/sync/messages.js +3 -0
  52. package/lib/sync/sync-engine.d.ts +21 -7
  53. package/lib/sync/sync-engine.js +108 -18
  54. package/lib/sync/ui-prefs.d.ts +22 -0
  55. package/lib/sync/ui-prefs.js +28 -0
  56. package/lib/ui/diff-view.d.ts +10 -0
  57. package/lib/ui/diff-view.js +17 -0
  58. package/lib/ui/export-flow.js +2 -0
  59. package/lib/ui/i18n.d.ts +5 -3
  60. package/lib/ui/i18n.js +10 -6
  61. package/lib/ui/rollback-view.d.ts +59 -0
  62. package/lib/ui/rollback-view.js +14 -0
  63. package/lib/ui/test-helpers.js +2 -1
  64. package/lib/utils/bundle-scan.d.ts +5 -3
  65. package/lib/utils/bundle-scan.js +5 -3
  66. package/lib/utils/line-diff.d.ts +7 -1
  67. package/lib/utils/line-diff.js +8 -2
  68. package/package.json +1 -1
  69. package/src/adapters/index.ts +5 -0
  70. package/src/adapters/messages.ts +7 -0
  71. package/src/adapters/plugin-data.test.ts +225 -0
  72. package/src/adapters/plugin-data.ts +351 -0
  73. package/src/adapters/self.ts +2 -1
  74. package/src/adapters/test-helpers.ts +15 -0
  75. package/src/client/chat/ChatEntryView.tsx +59 -10
  76. package/src/client/chat/RollbackDiffTab.tsx +49 -2
  77. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  78. package/src/client/chat/RollbackReviewDialog.tsx +20 -4
  79. package/src/client/chat/chat-entry-view.test.ts +164 -1
  80. package/src/client/chat/chat-entry-view.ts +127 -10
  81. package/src/client/chat/chat-node-registration.ts +40 -6
  82. package/src/client/chat/chat-point-window.ts +11 -1
  83. package/src/client/chat/rollback-diff-tab.ts +13 -3
  84. package/src/client/chat/rollback-entry-view.ts +5 -0
  85. package/src/client/chat/sidebar-slots.ts +24 -2
  86. package/src/client/common/DiffTable.tsx +54 -29
  87. package/src/client/common/client-route-parity.test.ts +6 -0
  88. package/src/client/common/host-primitives.ts +78 -0
  89. package/src/client/common/routes.ts +10 -0
  90. package/src/client/common/section-labels.test.ts +1 -0
  91. package/src/client/common/section-labels.ts +2 -0
  92. package/src/client/config-manager.module.css +138 -14
  93. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  94. package/src/client/index.ts +54 -2
  95. package/src/client/library/RestorePlanView.tsx +0 -1
  96. package/src/client/locales.ts +8 -6
  97. package/src/client/recovery/RollbackCard.tsx +201 -7
  98. package/src/client/recovery/RollbackDiffPane.tsx +26 -8
  99. package/src/client/recovery/recovery-api.ts +28 -0
  100. package/src/client/recovery/recovery-locales.ts +36 -6
  101. package/src/client/recovery/rollback-enabled.ts +41 -0
  102. package/src/client/recovery/rollback-file-changes.test.ts +77 -0
  103. package/src/client/sync/PushPreviewCard.tsx +57 -0
  104. package/src/client/sync/SyncPanel.tsx +2 -37
  105. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  106. package/src/client/sync/sync-push-preview.test.ts +14 -0
  107. package/src/client/sync/sync-view.test.ts +1 -1
  108. package/src/client/sync/sync-view.ts +9 -1
  109. package/src/core/analyzer.ts +31 -9
  110. package/src/core/backup-plan.test.ts +20 -5
  111. package/src/core/backup-plan.ts +22 -3
  112. package/src/core/backup.ts +50 -4
  113. package/src/core/command-rollback.ts +6 -2
  114. package/src/core/messages-core.ts +4 -0
  115. package/src/core/messages.ts +4 -2
  116. package/src/core/plugin-data-registry.test.ts +113 -0
  117. package/src/core/plugin-data-registry.ts +261 -0
  118. package/src/core/rollback-gate.test.ts +66 -0
  119. package/src/core/rollback-gate.ts +61 -0
  120. package/src/core/rollback-ledger.test.ts +655 -0
  121. package/src/core/rollback-ledger.ts +1430 -0
  122. package/src/core/rollback-point.test.ts +866 -41
  123. package/src/core/rollback-point.ts +1582 -291
  124. package/src/core/rollback.ts +5 -3
  125. package/src/core/types.ts +19 -0
  126. package/src/foreign/registry.test.ts +1 -1
  127. package/src/foreign/registry.ts +1 -1
  128. package/src/index.ts +73 -10
  129. package/src/routes/messages.ts +2 -0
  130. package/src/routes/prefs.ts +31 -2
  131. package/src/routes/recovery.ts +122 -7
  132. package/src/routes/sync.ts +15 -1
  133. package/src/schema/manifest-schema.test.ts +4 -4
  134. package/src/schema/registry.test.ts +9 -2
  135. package/src/schema/section-registry.ts +32 -3
  136. package/src/schema/types.ts +2 -1
  137. package/src/sync/messages.ts +3 -0
  138. package/src/sync/sync-credentials.test.ts +119 -0
  139. package/src/sync/sync-engine.ts +106 -18
  140. package/src/sync/sync-preview-push.test.ts +158 -0
  141. package/src/sync/transport.test.ts +1 -0
  142. package/src/sync/ui-prefs.test.ts +44 -1
  143. package/src/sync/ui-prefs.ts +39 -0
  144. package/src/ui/diff-view.test.ts +9 -1
  145. package/src/ui/diff-view.ts +22 -0
  146. package/src/ui/export-flow.ts +2 -0
  147. package/src/ui/i18n.ts +10 -6
  148. package/src/ui/rollback-view.test.ts +17 -0
  149. package/src/ui/rollback-view.ts +66 -0
  150. package/src/ui/test-helpers.ts +2 -1
  151. package/src/utils/bundle-scan.ts +5 -3
  152. package/src/utils/line-diff.test.ts +12 -0
  153. package/src/utils/line-diff.ts +9 -3
@@ -169,7 +169,7 @@ export interface SectionMeta {
169
169
  readonly configSnapshot: boolean;
170
170
  }
171
171
 
172
- /** 分区载荷版本:当前 15 个分区统一为 1(唯一字面量;分区可独立升级时只改对应条目的引用) */
172
+ /** 分区载荷版本:当前 17 个分区统一为 1(唯一字面量;分区可独立升级时只改对应条目的引用) */
173
173
  export const SECTION_DATA_VERSION = 1 as const;
174
174
 
175
175
  /**
@@ -324,7 +324,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
324
324
  },
325
325
  pluginFiles: {
326
326
  id: 'pluginFiles',
327
- displayName: 'Plugin Files',
327
+ displayName: "Other Plugins' Loose Files",
328
328
  group: 'extensions',
329
329
  payload: { kind: 'files', filePrefix: 'plugin-files/' },
330
330
  dataVersion: SECTION_DATA_VERSION,
@@ -377,7 +377,7 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
377
377
  },
378
378
  self: {
379
379
  id: 'self',
380
- displayName: 'Plugin Self Config',
380
+ displayName: "This Plugin's Own Config",
381
381
  group: 'extensions',
382
382
  payload: { kind: 'files', filePrefix: 'self/' },
383
383
  dataVersion: SECTION_DATA_VERSION,
@@ -388,6 +388,35 @@ export const SECTION_REGISTRY: Record<SectionId, SectionMeta> = {
388
388
  defaultIncluded: true,
389
389
  configSnapshot: true,
390
390
  },
391
+ pluginData: {
392
+ id: 'pluginData',
393
+ displayName: "Other Plugins' Data",
394
+ group: 'extensions',
395
+ payload: { kind: 'files', filePrefix: 'plugin-data/' },
396
+ dataVersion: SECTION_DATA_VERSION,
397
+ applyOrder: 16,
398
+ riskTier: 'high',
399
+ // 第三方插件的数据目录不在 DSH 的三个核心存储里:路径是「机器的 home 布局」而非配置内容,
400
+ // 换机后可能整个目录不存在(未安装该插件)→ deviceSpecific + 不参与自动同步。
401
+ portability: 'deviceSpecific',
402
+ syncOptIn: 'off',
403
+ defaultIncluded: false,
404
+ // 体量由用户数据主导(可能几百 MB),与 sessions 同理不进「配置状态」采集口径
405
+ configSnapshot: false,
406
+ },
407
+ pluginAssets: {
408
+ id: 'pluginAssets',
409
+ displayName: "Other Plugins' Large Files",
410
+ group: 'extensions',
411
+ payload: { kind: 'files', filePrefix: 'plugin-assets/' },
412
+ dataVersion: SECTION_DATA_VERSION,
413
+ applyOrder: 17,
414
+ riskTier: 'high',
415
+ portability: 'deviceSpecific',
416
+ syncOptIn: 'off',
417
+ defaultIncluded: false,
418
+ configSnapshot: false,
419
+ },
391
420
  };
392
421
 
393
422
  /** 分区 id(按 applyOrder 升序)——历史上与 SECTION_IDS 数组逐项一致 */
@@ -18,7 +18,8 @@ export type Platform =
18
18
  export type SectionId =
19
19
  | 'settings' | 'ui' | 'providers' | 'plugins' | 'mcp' | 'prompts'
20
20
  | 'skills' | 'agentPresets' | 'agentInstructions' | 'workspaces' | 'pluginFiles'
21
- | 'credentialsStatus' | 'secrets' | 'sessions' | 'self';
21
+ | 'credentialsStatus' | 'secrets' | 'sessions' | 'self'
22
+ | 'pluginData' | 'pluginAssets';
22
23
 
23
24
  /** 加密备份的算法参数(非秘密:salt/iv/authTag 仅用于解密与完整性校验) */
24
25
  export interface EncryptionInfo {
@@ -85,6 +85,8 @@ export const zh = {
85
85
  'sync.backupHistoryDone': '{trigger}完成:{zip}',
86
86
  'sync.backupHistorySkipped': '{trigger}跳过:{reason}',
87
87
  'sync.backupHistoryFailed': '{trigger}失败',
88
+ // ---------- 推送前只读预览(issue #91) ----------
89
+ 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
88
90
  } as const;
89
91
 
90
92
  export const en: Record<keyof typeof zh, string> = {
@@ -145,6 +147,7 @@ export const en: Record<keyof typeof zh, string> = {
145
147
  'sync.backupHistoryDone': '{trigger} finished: {zip}',
146
148
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
147
149
  'sync.backupHistoryFailed': '{trigger} failed',
150
+ 'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
148
151
  };
149
152
 
150
153
  registerMessageCatalog({ id: 'sync-domain', zh, en });
@@ -409,3 +409,122 @@ test('散文件布局不承载凭据载荷(issue #38 防御:绝不静默丢
409
409
  await fs.rm(tmp, { recursive: true, force: true });
410
410
  }
411
411
  });
412
+
413
+ /* ---------------- issue #38 补充:本机值已与快照一致 → 不再列为待写入 ---------------- */
414
+
415
+ /** 目标机 + 本机已有 `.credentials.yaml`(「值是否已经相同」比对的唯一数据源,与 push 侧同文件) */
416
+ async function makeTargetWithLocalCredentials(yaml: string): Promise<Ctx> {
417
+ const ctx = makeTarget();
418
+ await ctx.fs.writeFile('.credentials.yaml', Buffer.from(yaml, 'utf8'));
419
+ return ctx;
420
+ }
421
+
422
+ /** 与源机同一份凭据快照(加密 + 导出密钥) */
423
+ async function pushCredentialSnapshot(transport: MemSyncTransport, tmp: string, id: string): Promise<void> {
424
+ const srcCtx = makeContext('win32', 'C:\\Users\\alice');
425
+ await makeSource(srcCtx);
426
+ await makeEngine(srcCtx, transport, tmp).push({ snapshotId: id, encrypt: true, password: PASSWORD, includeSecrets: true });
427
+ }
428
+
429
+ test('本机值与快照逐字节相同 → Skip(info),不进待确认/补录清单(真机:本机一条不缺却全标「缺密钥」)', async () => {
430
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cred-same-'));
431
+ try {
432
+ const transport = new MemSyncTransport();
433
+ await pushCredentialSnapshot(transport, tmp, 'cred-remote');
434
+
435
+ const dstCtx = await makeTargetWithLocalCredentials(CRED_YAML);
436
+ const engine = makeEngine(dstCtx, transport, tmp);
437
+
438
+ const pull = await engine.pull({ password: PASSWORD });
439
+ assert.deepEqual(
440
+ pull.changes.filter((c) => c.id.startsWith('secret:')).map((c) => [c.id, c.kind, c.severity]),
441
+ [['secret:DEEPSEEK_API_KEY', 'Skip', 'info'], ['secret:GITHUB_TOKEN', 'Skip', 'info']],
442
+ '值一致 → Skip(info):写回是 no-op,不该要求人工确认',
443
+ );
444
+ assert.equal(pull.needsReview, false, '无任何待决策项');
445
+ assert.ok(!JSON.stringify(pull).includes('sk-real-cred-0001'), '报告不得含凭据值');
446
+
447
+ const preview = await engine.preview({ password: PASSWORD });
448
+ assert.deepEqual(preview.plan!.missingSecrets, [], '值一致不得计入 missingSecrets');
449
+ const item = preview.plan!.items.find((i) => i.id === 'secret:DEEPSEEK_API_KEY');
450
+ assert.equal(item?.kind, 'Skip');
451
+ assert.equal(item?.severity, 'info');
452
+ assert.ok(!JSON.stringify(preview.plan).includes('sk-real-cred-0001'), '计划不得含凭据值');
453
+ } finally {
454
+ await fs.rm(tmp, { recursive: true, force: true });
455
+ }
456
+ });
457
+
458
+ test('本机值不同 / 本机缺该 ref → 一律 MissingSecret 并写回(「导出密钥」语义不放宽)', async () => {
459
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cred-diff-'));
460
+ try {
461
+ const transport = new MemSyncTransport();
462
+ await pushCredentialSnapshot(transport, tmp, 'cred-remote');
463
+
464
+ // 本机只有 GITHUB_TOKEN 且是旧值;DEEPSEEK_API_KEY 本机没有
465
+ const dstCtx = await makeTargetWithLocalCredentials('GITHUB_TOKEN: ghp_local_old\n');
466
+ const engine = makeEngine(dstCtx, transport, tmp);
467
+
468
+ const preview = await engine.preview({ password: PASSWORD });
469
+ assert.deepEqual(
470
+ preview.plan!.items.filter((i) => i.id.startsWith('secret:')).map((i) => [i.id, i.kind, i.severity]),
471
+ [['secret:DEEPSEEK_API_KEY', 'MissingSecret', 'warning'], ['secret:GITHUB_TOKEN', 'MissingSecret', 'warning']],
472
+ '值不同或本机没有 → 仍须人工确认后写回',
473
+ );
474
+ assert.deepEqual(
475
+ preview.plan!.missingSecrets.map((s) => s.ref),
476
+ ['DEEPSEEK_API_KEY', 'GITHUB_TOKEN'],
477
+ 'missingSecrets 与 items 同源',
478
+ );
479
+
480
+ const plan = {
481
+ ...preview.plan!,
482
+ items: preview.plan!.items.filter((i) => i.id.startsWith('secret:')),
483
+ };
484
+ const report = await engine.applyItems(preview.zipPath, plan, { credentials: preview.credentials });
485
+ assert.deepEqual(report.failed, []);
486
+ assert.equal(dstCtx.credentials.values.get('GITHUB_TOKEN'), 'ghp_realcred0002', '值不同 → 写回快照里的值');
487
+ assert.equal(dstCtx.credentials.values.get('DEEPSEEK_API_KEY'), 'sk-real-cred-0001');
488
+ assert.ok(!JSON.stringify(report).includes('sk-real-cred-0001'), '执行报告不得含凭据值');
489
+ } finally {
490
+ await fs.rm(tmp, { recursive: true, force: true });
491
+ }
492
+ });
493
+
494
+ test('本机凭据文件读不到 → 无法比较:保守按待确认处理,绝不静默跳过', async () => {
495
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cred-nolocal-'));
496
+ try {
497
+ const transport = new MemSyncTransport();
498
+ await pushCredentialSnapshot(transport, tmp, 'cred-remote');
499
+
500
+ const dstCtx = makeTarget(); // 本机没有 .credentials.yaml
501
+ const preview = await makeEngine(dstCtx, transport, tmp).preview({ password: PASSWORD });
502
+ assert.deepEqual(
503
+ preview.plan!.items.filter((i) => i.id.startsWith('secret:')).map((i) => [i.id, i.kind]),
504
+ [['secret:DEEPSEEK_API_KEY', 'MissingSecret'], ['secret:GITHUB_TOKEN', 'MissingSecret']],
505
+ '读不到 = 未知 → 照旧要求确认(跳过就等于密钥没同步过来)',
506
+ );
507
+ } finally {
508
+ await fs.rm(tmp, { recursive: true, force: true });
509
+ }
510
+ });
511
+
512
+ test('本机凭据文件是 DSH v1 布局(refs: 块)→ 同样识别「值已一致」', async () => {
513
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-cred-v1-'));
514
+ try {
515
+ const transport = new MemSyncTransport();
516
+ await pushCredentialSnapshot(transport, tmp, 'cred-remote');
517
+
518
+ const localV1 = 'version: 1\nrefs:\n DEEPSEEK_API_KEY: sk-real-cred-0001\n GITHUB_TOKEN: ghp_realcred0002\n';
519
+ const dstCtx = await makeTargetWithLocalCredentials(localV1);
520
+ const preview = await makeEngine(dstCtx, transport, tmp).preview({ password: PASSWORD });
521
+ assert.deepEqual(
522
+ preview.plan!.items.filter((i) => i.id.startsWith('secret:')).map((i) => [i.id, i.kind]),
523
+ [['secret:DEEPSEEK_API_KEY', 'Skip'], ['secret:GITHUB_TOKEN', 'Skip']],
524
+ 'v1 refs 块与扁平布局读出的值相同 → 一律 Skip',
525
+ );
526
+ assert.deepEqual(preview.plan!.missingSecrets, []);
527
+ } finally {
528
+ await fs.rm(tmp, { recursive: true, force: true });
529
+ }
530
+ });
@@ -23,6 +23,8 @@ import path from 'node:path';
23
23
  import { createAdapters } from '../adapters/index.ts';
24
24
  import { defaultSecretScanner } from '../core/exporter.ts';
25
25
  import { Importer } from '../core/importer.ts';
26
+ // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
27
+ import { isLocalPluginSpec } from '../core/local-plugin-pack.ts';
26
28
  import type {
27
29
  ConfigAdapter, ExportSection, GlobalConflictStrategy, HostContext,
28
30
  ImportAnalysis, ImportPlan, ImportResult, PathMapping, PlanItem, PlanItemKind, RollbackReport, SecretScanner,
@@ -340,6 +342,24 @@ const FORBIDDEN_SECTIONS: readonly SectionId[] = ['credentialsStatus', 'secrets'
340
342
  */
341
343
  export const MAX_REMOTE_SNAPSHOTS = DEFAULT_RETENTION_POLICY.keepLast;
342
344
 
345
+ /**
346
+ * issue #91:走只读 `preview()` 拿到的产物,能否直接与该分区**基线 hash**(push 时按 export() 产物记录)比对。
347
+ *
348
+ * 为什么需要这个判定:`plugins` 适配器的 `preview()` 走 `collect(…, pack=false)` —— 刻意**不做**
349
+ * 本地源(`link:` / `file:`)插件的 `npm pack`。于是当 profile 里存在本地源插件时,预览产物既没有
350
+ * `localTarballs`、插件 spec 也保持原样(export() 会改写成 `file:<tarball>`),与基线**不同口径**:
351
+ * 此时既不能断言「无变化」(本地插件源码改了体积/内容,预览看不见),也不该为了比对去打包
352
+ * (那正是本 issue 的缺陷本身)。
353
+ *
354
+ * 返回 true = 该分区本趟**不可比** ⇒ 调用方只能保守报「有变化」并如实说明原因。
355
+ * 其余分区的 preview() 产物与 export() 逐字节相同(文件类分区不走 preview(),见 previewPush)。
356
+ */
357
+ function previewCaliberDiffersFromExport(sectionId: SectionId, data: SectionData): boolean {
358
+ if (sectionId !== 'plugins') return false;
359
+ const plugins = (data as { plugins?: { spec?: string }[] }).plugins;
360
+ return Array.isArray(plugins) && plugins.some((p) => isLocalPluginSpec(p?.spec));
361
+ }
362
+
343
363
  export class SyncEngine {
344
364
  private readonly ctx: HostContext;
345
365
  private readonly transport: SyncTransport;
@@ -553,25 +573,47 @@ export class SyncEngine {
553
573
  }
554
574
 
555
575
  /**
556
- * 把「随加密快照迁移的凭据」补进导入计划:每个解密出的 ref 生成一条 MissingSecret 项。
576
+ * 把「随加密快照迁移的凭据」补进导入计划:**需要写回**的 ref 生成 MissingSecret 项,
577
+ * 本机值已与快照逐字节相同的 ref 生成 Skip(info)——写回是 no-op,不该再让用户确认。
557
578
  * 执行阶段由 credentials adapter 经 ImportContext.decryptedCredentials(仅内存)
558
579
  * 调用 credentials.set(ref, value) 写回本机 —— 与手动补录走同一条已验证路径。
559
580
  *
560
- * 判定与导入路径同规则(**值的有无优先于本机状态**):
561
- * - 本函数只处理**有值**的 ref → 一律生成 MissingSecret(确认后写回)。
562
- * **不因本机已配置而跳过**:快照显式带了值(用户勾了「导出密钥」),跳过就等于
563
- * 「密钥没同步过来」;要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
581
+ * 判定与导入路径同规则(**值的有无优先于本机状态**),并多做一步「值是否已经相同」:
582
+ * - 有值 + 本机不同 / 本机没有 → MissingSecret(确认后写回)。**不因本机已配置而跳过**:
583
+ * 快照显式带了值(用户勾了「导出密钥」),跳过就等于「密钥没同步过来」;
584
+ * 要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
585
+ * - 有值 + 本机逐字节相同 → Skip(info)。这是对上一轮「有值一律 MissingSecret」的**收窄**
586
+ * (真机反馈:本机 refs 一条不缺,列表却全标「缺密钥」+ 黄色警告):只有真的会改动本机的
587
+ * ref 才需要人工确认。
588
+ * - 本机凭据文件读不到 → 无法比较 → 保守按 MissingSecret 处理,绝不静默跳过。
564
589
  * - 「只有 ref 名、没有值」的情形由 createImportPlan → buildCredentialPlanItems 处理
565
590
  * (那里才做本机已配置 → Skip 的判定)。
566
- * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ **升级**为 MissingSecret:
567
- * 否则有值也会被 Skip 挡住,值永远不会写回。
591
+ * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ 值不同时**升级**为
592
+ * MissingSecret:否则有值也会被 Skip 挡住,值永远不会写回;值相同时保持 Skip。
568
593
  */
569
- private appendCredentialPlanItems(plan: ImportPlan, credentials: Map<string, string> | undefined): void {
594
+ private async appendCredentialPlanItems(plan: ImportPlan, credentials: Map<string, string> | undefined): Promise<void> {
570
595
  if (credentials === undefined || credentials.size === 0) return;
596
+ const local = await this.readLocalCredentials();
571
597
  const byId = new Map(plan.items.map((i) => [i.id, i] as const));
572
- for (const ref of credentials.keys()) {
598
+ for (const [ref, value] of credentials) {
573
599
  const id = `secret:${ref}`;
574
600
  const prev = byId.get(id);
601
+ if (local?.get(ref) === value) {
602
+ const description = this.msg('sync.credentialsItemSame', { ref });
603
+ if (prev === undefined) {
604
+ const same: PlanItem = {
605
+ id, kind: 'Skip', adapter: 'credentialsStatus', description,
606
+ severity: 'info', target: { adapter: 'credentialsStatus', ref },
607
+ };
608
+ plan.items.push(same);
609
+ byId.set(id, same);
610
+ } else if (prev.kind === 'Skip' || prev.kind === 'MissingSecret') {
611
+ prev.kind = 'Skip';
612
+ prev.severity = 'info';
613
+ prev.description = description;
614
+ }
615
+ continue;
616
+ }
575
617
  if (prev !== undefined) {
576
618
  if (prev.kind !== 'Skip') continue; // 已有可执行项 → 不重复
577
619
  prev.kind = 'MissingSecret';
@@ -596,6 +638,24 @@ export class SyncEngine {
596
638
  .map((i) => ({ ref: i.id.replace(/^secret:/, ''), required: true }));
597
639
  }
598
640
 
641
+ /**
642
+ * 读本机 `$DSH_HOME/.credentials.yaml` → `Map<ref, value>`,**只用于**「值是否已经相同」比对。
643
+ * 与 push 侧 buildCredentialsPayload 读同一个文件、同一解析口径(credentialsMapFromYaml);
644
+ * 不新增任何能力,也不回读凭据值到 UI。
645
+ * 读不到 / 解析失败 → undefined = **无法比较**:调用方必须按「未知」保守处理(照旧要求确认),
646
+ * 绝不因为读失败就静默跳过写回。
647
+ * 安全:值只在进程内存里比较 —— 不落盘、不进日志/报告/响应体。
648
+ */
649
+ private async readLocalCredentials(): Promise<Map<string, string> | undefined> {
650
+ try {
651
+ const file = path.join(this.ctx.homeDir, '.credentials.yaml');
652
+ const raw = Buffer.from(await this.ctx.fs.readFile(file)).toString('utf8');
653
+ return credentialsMapFromYaml(raw);
654
+ } catch {
655
+ return undefined;
656
+ }
657
+ }
658
+
599
659
  /**
600
660
  * sessions 分区的导出选项(opt-in 分区):显式勾选清单优先于「最新 N 个」。
601
661
  *
@@ -754,14 +814,29 @@ export class SyncEngine {
754
814
  const targets = this.pushTargets(opts.sections, warnings);
755
815
  const plainSections: Partial<Record<SectionId, SectionData>> = {};
756
816
  const counts: Partial<Record<SectionId, number>> = {};
817
+ /** issue #91:本趟只读预览省掉了本地源打包 ⇒ 与基线不可比的分区(不可断言「无变化」) */
818
+ const unpackedSections = new Set<SectionId>();
757
819
  for (const adapter of targets) {
820
+ const exportOptions = {
821
+ includeSecrets,
822
+ // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
823
+ ...this.sessionExportOptions(adapter.id, opts.sessions),
824
+ };
758
825
  let section: ExportSection;
759
826
  try {
760
- section = await adapter.export(this.ctx, {
761
- includeSecrets,
762
- // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
763
- ...this.sessionExportOptions(adapter.id, opts.sessions),
764
- });
827
+ // issue #91:**非文件类**分区优先走 adapter.preview()。只读预览必须零进程副作用,而
828
+ // plugins 的 export() 会对每个 `link:`/`file:` 插件真的跑一次 `npm pack`
829
+ // (真机 2 GB 本地源插件 = 297 s;这段时间全局写锁被该请求占满 ⇒ 界面上所有写操作静默 423)。
830
+ // 为什么文件类分区**不能**走 preview():其预览产物是「无内容」形态(条目 data 为空、
831
+ // contentHash 为空串),既不能与基线 hash 比对,也不能进 assertNoForbiddenSections
832
+ // —— 那会让预览谎报「每个文件分区都变了」。
833
+ const summary = !isFileSection(adapter.id) && adapter.preview !== undefined
834
+ ? await adapter.preview(this.ctx, exportOptions)
835
+ : undefined;
836
+ section = summary?.section ?? await adapter.export(this.ctx, exportOptions);
837
+ if (summary !== undefined && previewCaliberDiffersFromExport(adapter.id, section.data as SectionData)) {
838
+ unpackedSections.add(adapter.id);
839
+ }
765
840
  } catch (err) {
766
841
  warnings.push(this.msg('sync.sectionFailed', { adapter: adapter.id, reason: err instanceof Error ? err.message : String(err) }));
767
842
  continue;
@@ -782,14 +857,27 @@ export class SyncEngine {
782
857
  if (!encrypted) {
783
858
  try {
784
859
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
785
- baselineHashes = state.sections as Record<string, string>;
860
+ // 基线条目是 SyncSectionState({hash, updatedAt}),**不是**裸 hash 字符串。
861
+ // 这里曾把整个对象 as Record<string,string> 直接比对 ⇒ 对象 !== 字符串 恒真
862
+ // ⇒ 预览对**每一个**分区都报「有变化」,「N 个有变化」这句永远是「全都有变化」。
863
+ for (const [sid, rec] of Object.entries(state.sections)) {
864
+ if (rec !== undefined && typeof rec.hash === 'string') baselineHashes[sid] = rec.hash;
865
+ }
786
866
  } catch {
787
867
  baselineHashes = {};
788
868
  }
789
869
  }
870
+ if (unpackedSections.size > 0) {
871
+ // 如实说明:本次预览没有为本地源插件执行 npm pack,故这些分区的基线比对不可得
872
+ warnings.push(this.msg('sync.pushPreviewUnpackedLocalSources', { sections: [...unpackedSections].join(', ') }));
873
+ }
790
874
  const sections: SyncPushPreviewSection[] = Object.keys(plainSections).map((sid) => {
791
875
  const id = sid as SectionId;
792
- const changed = encrypted || baselineHashes[id] === undefined || baselineHashes[id] !== hashSection(plainSections[id] as SectionData);
876
+ const changed = encrypted
877
+ // issue #91:只读预览省掉了本地源打包 ⇒ 产物与基线不同口径,**绝不**据此断言「无变化」
878
+ || unpackedSections.has(id)
879
+ || baselineHashes[id] === undefined
880
+ || baselineHashes[id] !== hashSection(plainSections[id] as SectionData);
793
881
  return { section: id, count: counts[id] ?? 0, changed };
794
882
  });
795
883
 
@@ -944,7 +1032,7 @@ export class SyncEngine {
944
1032
  pathMappings: opts.pathMappings ?? [],
945
1033
  });
946
1034
  // 凭据迁移项(ref 名进计划/报告,值绝不进)
947
- this.appendCredentialPlanItems(plan, credentials);
1035
+ await this.appendCredentialPlanItems(plan, credentials);
948
1036
  const changes: PullChange[] = plan.items.map((i) => ({
949
1037
  id: i.id,
950
1038
  adapter: i.adapter,
@@ -1116,7 +1204,7 @@ export class SyncEngine {
1116
1204
  // issue #45:自动重定基 + 用户映射(与 pull 同口径;一键同步的 apply-items 复用本 plan)
1117
1205
  pathMappings: opts.pathMappings ?? [],
1118
1206
  });
1119
- this.appendCredentialPlanItems(plan, credentials);
1207
+ await this.appendCredentialPlanItems(plan, credentials);
1120
1208
  return {
1121
1209
  ok: analysis.valid, zipPath, plan, analysis, snapshotId: targetId, credentials,
1122
1210
  message: tombstoned.length === 0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * issue #91 回归:**推送预览(previewPush)不得为本地源插件执行 `npm pack`**。
3
+ *
4
+ * 缺陷:`previewPush` 走的是 `adapter.export()`,而 `plugins` 适配器只有 `preview()`(PR #68)
5
+ * 才跳过打包 —— 于是「推送前只读预览」会真的对每个 `link:`/`file:` 插件 spawn 一次 `npm pack`
6
+ * (真机 2 GB 本地源插件 = 297 s),这段时间该请求占着全局写锁,界面所有写操作静默 423。
7
+ *
8
+ * 本文件钉死三件事:
9
+ * ① 预览不打包(真推送仍然打包 —— 本地源插件换机可用不能被牺牲);
10
+ * ② 预览与基线口径不同时**绝不断言「无变化」**(保守报「有变化」+ 如实说明);
11
+ * ③ 文件类分区仍走 `export()`:其 `preview()` 产物是「无内容」形态,拿它比对 hash 会误报
12
+ * 「每个文件分区都变了」。
13
+ */
14
+ import test from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs/promises';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ import { SyncEngine } from './sync-engine.ts';
21
+ import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
22
+ import { computeSnapshotMeta } from './transport.ts';
23
+ import { createAdapters } from '../adapters/index.ts';
24
+ import type { LocalPluginPackHook } from '../adapters/index.ts';
25
+ import { makeContext, MemSnapshotStore } from '../adapters/test-helpers.ts';
26
+ import { Importer } from '../core/importer.ts';
27
+
28
+ const NS = ['general', 'theme'];
29
+
30
+ /** 内存传输(与 sync-engine.test.ts 同款最小实现;本仓库各同步测试各自持有一份)。 */
31
+ class MemSyncTransport implements SyncTransport {
32
+ readonly type = 'memory';
33
+ snapshots = new Map<string, SyncSnapshot>();
34
+ metas: SyncSnapshotMeta[] = [];
35
+ async list(): Promise<SyncSnapshotMeta[]> {
36
+ return [...this.metas].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
37
+ }
38
+ async upload(snapshot: SyncSnapshot): Promise<SyncSnapshotMeta> {
39
+ this.snapshots.set(snapshot.id, snapshot);
40
+ this.metas.push(computeSnapshotMeta(snapshot));
41
+ return computeSnapshotMeta(snapshot);
42
+ }
43
+ async download(id: string): Promise<SyncSnapshot> {
44
+ const s = this.snapshots.get(id);
45
+ if (!s) throw new Error('快照不存在: ' + id);
46
+ return s;
47
+ }
48
+ async delete(id: string): Promise<void> {
49
+ this.snapshots.delete(id);
50
+ this.metas = this.metas.filter((m) => m.id !== id);
51
+ }
52
+ }
53
+
54
+ /** 假打包钩子:记录被调用次数,并按需产出「本地源插件已打包」的结果。 */
55
+ function makePackSpy(withLocal: boolean): { hook: LocalPluginPackHook; calls: () => number } {
56
+ let calls = 0;
57
+ const isLocal = (spec: string | undefined): boolean =>
58
+ typeof spec === 'string' && (spec.startsWith('file:') || spec.startsWith('link:'));
59
+ const hook: LocalPluginPackHook = async (plugins) => {
60
+ calls += 1;
61
+ if (!withLocal) return { packed: [], rewritten: {}, warnings: [] };
62
+ const locals = plugins.filter((p) => isLocal(p.spec));
63
+ return {
64
+ packed: locals.map((p) => ({
65
+ packageName: p.name,
66
+ version: p.version,
67
+ relativePath: 'local-plugins/' + p.name + '-1.0.0.tgz',
68
+ rewrittenSpec: 'file:local-plugins/' + p.name + '-1.0.0.tgz',
69
+ data: new Uint8Array([1, 2, 3, 4]),
70
+ })),
71
+ rewritten: Object.fromEntries(locals.map((p) => [p.name, 'file:local-plugins/' + p.name + '-1.0.0.tgz'])),
72
+ warnings: [],
73
+ };
74
+ };
75
+ return { hook, calls: () => calls };
76
+ }
77
+
78
+ function makeEngine(ctx: ReturnType<typeof makeContext>, transport: SyncTransport, stateDir: string, adapters: ReturnType<typeof createAdapters>) {
79
+ return new SyncEngine({
80
+ ctx,
81
+ transport,
82
+ stateDir,
83
+ adapters,
84
+ importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
85
+ now: () => new Date('2026-08-16T12:00:00.000Z'),
86
+ } as ConstructorParameters<typeof SyncEngine>[0]);
87
+ }
88
+
89
+ test('issue #91:推送预览不走 npm pack(真推送仍然打包)', async () => {
90
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-preview-local-'));
91
+ try {
92
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
93
+ ctx.plugins.installed.set('local-thing', { name: 'local-thing', version: '1.0.0', enabled: true, spec: 'file:C:/tmp/local-thing' });
94
+ const spy = makePackSpy(true);
95
+ const adapters = createAdapters({ namespaces: NS, localPluginPack: spy.hook });
96
+ const transport = new MemSyncTransport();
97
+ const engine = makeEngine(ctx, transport, tmp, adapters);
98
+
99
+ // 真推送:必须打包(本地源插件否则换机即丢),同时建立「按 export() 产物」的基线
100
+ const pushed = await engine.push({ snapshotId: 'sync-local-1', sections: ['plugins'] });
101
+ assert.equal(pushed.ok, true);
102
+ assert.equal(spy.calls(), 1, '真推送必须打包本地源插件');
103
+
104
+ // 只读预览:修复前会再打一次包(真机 297 s,占满全局写锁)
105
+ const preview = await engine.previewPush({ sections: ['plugins'] });
106
+ assert.equal(spy.calls(), 1, '只读预览绝不能再执行 npm pack');
107
+ assert.equal(preview.ok, true);
108
+ const row = preview.sections.find((s) => s.section === 'plugins');
109
+ assert.ok(row !== undefined, 'plugins 出现在预览分区清单里');
110
+ assert.equal(row.changed, true, '预览产物与基线不同口径 ⇒ 保守报「有变化」,绝不断言「无变化」');
111
+ assert.ok((preview.message ?? '').length > 0, '并如实说明该分区为何不做基线比对');
112
+ } finally {
113
+ await fs.rm(tmp, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ test('issue #91:无本地源插件时预览与 export() 同口径 —— 仍能精确报「无变化」', async () => {
118
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-preview-plain-'));
119
+ try {
120
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
121
+ ctx.plugins.installed.set('@deepseek-ai/dsh-ssh', { name: '@deepseek-ai/dsh-ssh', version: '1.0.0', enabled: true });
122
+ const spy = makePackSpy(true);
123
+ const adapters = createAdapters({ namespaces: NS, localPluginPack: spy.hook });
124
+ const transport = new MemSyncTransport();
125
+ const engine = makeEngine(ctx, transport, tmp, adapters);
126
+
127
+ await engine.push({ snapshotId: 'sync-plain-1', sections: ['plugins'] });
128
+ assert.equal(spy.calls(), 1);
129
+
130
+ const preview = await engine.previewPush({ sections: ['plugins'] });
131
+ assert.equal(spy.calls(), 1, '预览不打包');
132
+ const row = preview.sections.find((s) => s.section === 'plugins');
133
+ assert.ok(row !== undefined);
134
+ assert.equal(row.changed, false, '没有本地源 ⇒ 预览产物与基线逐字节可比,必须精确报「无变化」');
135
+ assert.equal(preview.message, undefined, '无可比性问题时不产生告警');
136
+ } finally {
137
+ await fs.rm(tmp, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test('issue #91:文件类分区仍走 export()(内容形态),不因预览形态误报「有变化」', async () => {
142
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-preview-files-'));
143
+ try {
144
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
145
+ await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding\n', 'utf8'));
146
+ const adapters = createAdapters({ namespaces: NS });
147
+ const transport = new MemSyncTransport();
148
+ const engine = makeEngine(ctx, transport, tmp, adapters);
149
+
150
+ await engine.push({ snapshotId: 'sync-skills-1', sections: ['skills'] });
151
+ const preview = await engine.previewPush({ sections: ['skills'] });
152
+ const row = preview.sections.find((s) => s.section === 'skills');
153
+ assert.ok(row !== undefined);
154
+ assert.equal(row.changed, false, 'skills 的预览必须与基线同口径(内容形态),否则永远误报有变化');
155
+ } finally {
156
+ await fs.rm(tmp, { recursive: true, force: true });
157
+ }
158
+ });
@@ -70,6 +70,7 @@ test('manifestSummaryFrom: 从导出 Manifest 提取摘要(只含启用分区
70
70
  settings: true, ui: true, providers: true, plugins: false, mcp: false,
71
71
  prompts: false, skills: true, agentPresets: false, agentInstructions: false, workspaces: false,
72
72
  pluginFiles: false, credentialsStatus: false, secrets: false, sessions: false, self: false,
73
+ pluginData: false, pluginAssets: false,
73
74
  },
74
75
  security: { containsSecrets: true, encrypted: false, encryption: null },
75
76
  };
@@ -9,7 +9,7 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
 
11
11
  import {
12
- defaultUiPrefs, readUiPrefs, writeUiPrefs, updateUiPrefs,
12
+ defaultUiPrefs, readUiPrefs, writeUiPrefs, updateUiPrefs, readRollbackEnabledSync,
13
13
  UI_PREFS_FILE, UI_PREFS_SCHEMA_VERSION,
14
14
  } from './ui-prefs.ts';
15
15
 
@@ -174,3 +174,46 @@ test('更新内容弹窗状态:写入字段 → 读回一致 + 原始文件校
174
174
  } finally { await fs.rm(dir, { recursive: true, force: true }); }
175
175
  });
176
176
 
177
+ /**
178
+ * issue #88 / #89:回合自动留点开关(`rollbackEnabled`)。
179
+ *
180
+ * 钉住四条判据:① 缺省 = 关闭(fail-closed);②「开」才落字段、「关」即字段消失;
181
+ * ③ 任何非法输入(非布尔 / 坏 JSON / 不支持 schema)一律回关闭 —— 绝不猜用户想开;
182
+ * ④ 本地与同步两个读法一致(宿主启动期必须能同步读到同一个判据)。
183
+ */
184
+ test('issue #88:rollbackEnabled 往返 + 缺省与非法值一律关闭(fail-closed)', async () => {
185
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-ui-prefs-rollback-'));
186
+ const file = path.join(dir, UI_PREFS_FILE);
187
+ try {
188
+ // ① 文件不存在 → 关闭
189
+ assert.equal(readRollbackEnabledSync(dir), false, '文件不存在 = 关闭');
190
+ assert.equal((await readUiPrefs(dir)).rollbackEnabled, undefined);
191
+
192
+ // ② 开 → 落字段 true;两个读法都认
193
+ await writeUiPrefs(dir, { schemaVersion: 1, rollbackEnabled: true });
194
+ assert.equal((await readUiPrefs(dir)).rollbackEnabled, true);
195
+ assert.equal(readRollbackEnabledSync(dir), true);
196
+ assert.equal(JSON.parse(await fs.readFile(file, 'utf8')).rollbackEnabled, true);
197
+
198
+ // ③ 关 → 字段消失(缺省即关),同步读法回 false
199
+ await writeUiPrefs(dir, { schemaVersion: 1, rollbackEnabled: false });
200
+ assert.equal('rollbackEnabled' in JSON.parse(await fs.readFile(file, 'utf8')), false, '关闭 = 字段消失');
201
+ assert.equal(readRollbackEnabledSync(dir), false);
202
+
203
+ // ④ 非法输入一律回关闭
204
+ for (const raw of ['{"schemaVersion":1,"rollbackEnabled":"yes"}', '{not-json', '{"schemaVersion":99,"rollbackEnabled":true}']) {
205
+ await fs.writeFile(file, raw, 'utf8');
206
+ assert.equal(readRollbackEnabledSync(dir), false, '非法输入必须回关闭:' + raw);
207
+ assert.equal((await readUiPrefs(dir)).rollbackEnabled, undefined, '非法输入必须回关闭:' + raw);
208
+ }
209
+
210
+ // ⑤ updateUiPrefs 局部合并:改开关不得冲掉其它字段(多端点共写同一文件)
211
+ await writeUiPrefs(dir, { schemaVersion: 1, lastSyncChannel: 'git' });
212
+ await updateUiPrefs(dir, { rollbackEnabled: true });
213
+ const merged = await readUiPrefs(dir);
214
+ assert.equal(merged.lastSyncChannel, 'git', '局部更新不得冲掉其它字段');
215
+ assert.equal(merged.rollbackEnabled, true);
216
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
217
+ });
218
+
219
+