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
@@ -1,4 +1,4 @@
1
- /** 分区载荷版本:当前 15 个分区统一为 1(唯一字面量;分区可独立升级时只改对应条目的引用) */
1
+ /** 分区载荷版本:当前 17 个分区统一为 1(唯一字面量;分区可独立升级时只改对应条目的引用) */
2
2
  export const SECTION_DATA_VERSION = 1;
3
3
  /**
4
4
  * 分区注册表(**Record<SectionId, SectionMeta>**:漏注册/写错 id → 编译失败)。
@@ -152,7 +152,7 @@ export const SECTION_REGISTRY = {
152
152
  },
153
153
  pluginFiles: {
154
154
  id: 'pluginFiles',
155
- displayName: 'Plugin Files',
155
+ displayName: "Other Plugins' Loose Files",
156
156
  group: 'extensions',
157
157
  payload: { kind: 'files', filePrefix: 'plugin-files/' },
158
158
  dataVersion: SECTION_DATA_VERSION,
@@ -205,7 +205,7 @@ export const SECTION_REGISTRY = {
205
205
  },
206
206
  self: {
207
207
  id: 'self',
208
- displayName: 'Plugin Self Config',
208
+ displayName: "This Plugin's Own Config",
209
209
  group: 'extensions',
210
210
  payload: { kind: 'files', filePrefix: 'self/' },
211
211
  dataVersion: SECTION_DATA_VERSION,
@@ -216,6 +216,35 @@ export const SECTION_REGISTRY = {
216
216
  defaultIncluded: true,
217
217
  configSnapshot: true,
218
218
  },
219
+ pluginData: {
220
+ id: 'pluginData',
221
+ displayName: "Other Plugins' Data",
222
+ group: 'extensions',
223
+ payload: { kind: 'files', filePrefix: 'plugin-data/' },
224
+ dataVersion: SECTION_DATA_VERSION,
225
+ applyOrder: 16,
226
+ riskTier: 'high',
227
+ // 第三方插件的数据目录不在 DSH 的三个核心存储里:路径是「机器的 home 布局」而非配置内容,
228
+ // 换机后可能整个目录不存在(未安装该插件)→ deviceSpecific + 不参与自动同步。
229
+ portability: 'deviceSpecific',
230
+ syncOptIn: 'off',
231
+ defaultIncluded: false,
232
+ // 体量由用户数据主导(可能几百 MB),与 sessions 同理不进「配置状态」采集口径
233
+ configSnapshot: false,
234
+ },
235
+ pluginAssets: {
236
+ id: 'pluginAssets',
237
+ displayName: "Other Plugins' Large Files",
238
+ group: 'extensions',
239
+ payload: { kind: 'files', filePrefix: 'plugin-assets/' },
240
+ dataVersion: SECTION_DATA_VERSION,
241
+ applyOrder: 17,
242
+ riskTier: 'high',
243
+ portability: 'deviceSpecific',
244
+ syncOptIn: 'off',
245
+ defaultIncluded: false,
246
+ configSnapshot: false,
247
+ },
219
248
  };
220
249
  /** 分区 id(按 applyOrder 升序)——历史上与 SECTION_IDS 数组逐项一致 */
221
250
  export const SECTION_IDS = Object.keys(SECTION_REGISTRY)
@@ -11,7 +11,7 @@ export type Platform = 'win32' | 'darwin' | 'linux' | 'freebsd' | 'openbsd' | 'a
11
11
  * 明确不实现的分区(keybindings/workflows/commands)不在此列;
12
12
  * rules 已由 agentInstructions(~/.dsh/AGENTS.md,dsh-agent-instructions 全局指令)承接。
13
13
  */
14
- export type SectionId = 'settings' | 'ui' | 'providers' | 'plugins' | 'mcp' | 'prompts' | 'skills' | 'agentPresets' | 'agentInstructions' | 'workspaces' | 'pluginFiles' | 'credentialsStatus' | 'secrets' | 'sessions' | 'self';
14
+ export type SectionId = 'settings' | 'ui' | 'providers' | 'plugins' | 'mcp' | 'prompts' | 'skills' | 'agentPresets' | 'agentInstructions' | 'workspaces' | 'pluginFiles' | 'credentialsStatus' | 'secrets' | 'sessions' | 'self' | 'pluginData' | 'pluginAssets';
15
15
  /** 加密备份的算法参数(非秘密:salt/iv/authTag 仅用于解密与完整性校验) */
16
16
  export interface EncryptionInfo {
17
17
  algorithm: 'aes-256-gcm';
@@ -57,6 +57,7 @@ export declare const zh: {
57
57
  readonly 'sync.backupHistoryDone': '{trigger}完成:{zip}';
58
58
  readonly 'sync.backupHistorySkipped': '{trigger}跳过:{reason}';
59
59
  readonly 'sync.backupHistoryFailed': '{trigger}失败';
60
+ readonly 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示';
60
61
  };
61
62
  export declare const en: Record<keyof typeof zh, string>;
62
63
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -83,6 +83,8 @@ export const zh = {
83
83
  'sync.backupHistoryDone': '{trigger}完成:{zip}',
84
84
  'sync.backupHistorySkipped': '{trigger}跳过:{reason}',
85
85
  'sync.backupHistoryFailed': '{trigger}失败',
86
+ // ---------- 推送前只读预览(issue #91) ----------
87
+ 'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
86
88
  };
87
89
  export const en = {
88
90
  'sync.ancestorSnapshotIdRequired': 'loadAncestor: snapshotId must not be empty',
@@ -142,6 +144,7 @@ export const en = {
142
144
  'sync.backupHistoryDone': '{trigger} finished: {zip}',
143
145
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
144
146
  'sync.backupHistoryFailed': '{trigger} failed',
147
+ 'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
145
148
  };
146
149
  registerMessageCatalog({ id: 'sync-domain', zh, en });
147
150
  /** 本域活翻译器(与 core 的 engineMsg 同一实现;导入本模块即完成目录注册)。 */
@@ -331,20 +331,34 @@ export declare class SyncEngine {
331
331
  */
332
332
  private decryptSnapshotCredentials;
333
333
  /**
334
- * 把「随加密快照迁移的凭据」补进导入计划:每个解密出的 ref 生成一条 MissingSecret 项。
334
+ * 把「随加密快照迁移的凭据」补进导入计划:**需要写回**的 ref 生成 MissingSecret 项,
335
+ * 本机值已与快照逐字节相同的 ref 生成 Skip(info)——写回是 no-op,不该再让用户确认。
335
336
  * 执行阶段由 credentials adapter 经 ImportContext.decryptedCredentials(仅内存)
336
337
  * 调用 credentials.set(ref, value) 写回本机 —— 与手动补录走同一条已验证路径。
337
338
  *
338
- * 判定与导入路径同规则(**值的有无优先于本机状态**):
339
- * - 本函数只处理**有值**的 ref → 一律生成 MissingSecret(确认后写回)。
340
- * **不因本机已配置而跳过**:快照显式带了值(用户勾了「导出密钥」),跳过就等于
341
- * 「密钥没同步过来」;要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
339
+ * 判定与导入路径同规则(**值的有无优先于本机状态**),并多做一步「值是否已经相同」:
340
+ * - 有值 + 本机不同 / 本机没有 → MissingSecret(确认后写回)。**不因本机已配置而跳过**:
341
+ * 快照显式带了值(用户勾了「导出密钥」),跳过就等于「密钥没同步过来」;
342
+ * 要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
343
+ * - 有值 + 本机逐字节相同 → Skip(info)。这是对上一轮「有值一律 MissingSecret」的**收窄**
344
+ * (真机反馈:本机 refs 一条不缺,列表却全标「缺密钥」+ 黄色警告):只有真的会改动本机的
345
+ * ref 才需要人工确认。
346
+ * - 本机凭据文件读不到 → 无法比较 → 保守按 MissingSecret 处理,绝不静默跳过。
342
347
  * - 「只有 ref 名、没有值」的情形由 createImportPlan → buildCredentialPlanItems 处理
343
348
  * (那里才做本机已配置 → Skip 的判定)。
344
- * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ **升级**为 MissingSecret:
345
- * 否则有值也会被 Skip 挡住,值永远不会写回。
349
+ * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ 值不同时**升级**为
350
+ * MissingSecret:否则有值也会被 Skip 挡住,值永远不会写回;值相同时保持 Skip。
346
351
  */
347
352
  private appendCredentialPlanItems;
353
+ /**
354
+ * 读本机 `$DSH_HOME/.credentials.yaml` → `Map<ref, value>`,**只用于**「值是否已经相同」比对。
355
+ * 与 push 侧 buildCredentialsPayload 读同一个文件、同一解析口径(credentialsMapFromYaml);
356
+ * 不新增任何能力,也不回读凭据值到 UI。
357
+ * 读不到 / 解析失败 → undefined = **无法比较**:调用方必须按「未知」保守处理(照旧要求确认),
358
+ * 绝不因为读失败就静默跳过写回。
359
+ * 安全:值只在进程内存里比较 —— 不落盘、不进日志/报告/响应体。
360
+ */
361
+ private readLocalCredentials;
348
362
  /**
349
363
  * sessions 分区的导出选项(opt-in 分区):显式勾选清单优先于「最新 N 个」。
350
364
  *
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
  import { createAdapters } from '../adapters/index.js';
23
23
  import { defaultSecretScanner } from '../core/exporter.js';
24
24
  import { Importer } from '../core/importer.js';
25
+ // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
26
+ import { isLocalPluginSpec } from '../core/local-plugin-pack.js';
25
27
  import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.js';
26
28
  import { buildManifest, CHECKSUMS_FILE, MANIFEST_FILE } from '../schema/manifest.js';
27
29
  import { CURRENT_SCHEMA_VERSION } from '../schema/versions.js';
@@ -83,6 +85,24 @@ const FORBIDDEN_SECTIONS = ['credentialsStatus', 'secrets'];
83
85
  * 保留导出是为了让既有调用方/测试仍能引用「缺省保留份数」这一事实,不再作为唯一判据。
84
86
  */
85
87
  export const MAX_REMOTE_SNAPSHOTS = DEFAULT_RETENTION_POLICY.keepLast;
88
+ /**
89
+ * issue #91:走只读 `preview()` 拿到的产物,能否直接与该分区**基线 hash**(push 时按 export() 产物记录)比对。
90
+ *
91
+ * 为什么需要这个判定:`plugins` 适配器的 `preview()` 走 `collect(…, pack=false)` —— 刻意**不做**
92
+ * 本地源(`link:` / `file:`)插件的 `npm pack`。于是当 profile 里存在本地源插件时,预览产物既没有
93
+ * `localTarballs`、插件 spec 也保持原样(export() 会改写成 `file:<tarball>`),与基线**不同口径**:
94
+ * 此时既不能断言「无变化」(本地插件源码改了体积/内容,预览看不见),也不该为了比对去打包
95
+ * (那正是本 issue 的缺陷本身)。
96
+ *
97
+ * 返回 true = 该分区本趟**不可比** ⇒ 调用方只能保守报「有变化」并如实说明原因。
98
+ * 其余分区的 preview() 产物与 export() 逐字节相同(文件类分区不走 preview(),见 previewPush)。
99
+ */
100
+ function previewCaliberDiffersFromExport(sectionId, data) {
101
+ if (sectionId !== 'plugins')
102
+ return false;
103
+ const plugins = data.plugins;
104
+ return Array.isArray(plugins) && plugins.some((p) => isLocalPluginSpec(p?.spec));
105
+ }
86
106
  export class SyncEngine {
87
107
  ctx;
88
108
  transport;
@@ -282,26 +302,49 @@ export class SyncEngine {
282
302
  return credentialsMapFromYaml(await decryptCredentialsPayload(payload, password));
283
303
  }
284
304
  /**
285
- * 把「随加密快照迁移的凭据」补进导入计划:每个解密出的 ref 生成一条 MissingSecret 项。
305
+ * 把「随加密快照迁移的凭据」补进导入计划:**需要写回**的 ref 生成 MissingSecret 项,
306
+ * 本机值已与快照逐字节相同的 ref 生成 Skip(info)——写回是 no-op,不该再让用户确认。
286
307
  * 执行阶段由 credentials adapter 经 ImportContext.decryptedCredentials(仅内存)
287
308
  * 调用 credentials.set(ref, value) 写回本机 —— 与手动补录走同一条已验证路径。
288
309
  *
289
- * 判定与导入路径同规则(**值的有无优先于本机状态**):
290
- * - 本函数只处理**有值**的 ref → 一律生成 MissingSecret(确认后写回)。
291
- * **不因本机已配置而跳过**:快照显式带了值(用户勾了「导出密钥」),跳过就等于
292
- * 「密钥没同步过来」;要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
310
+ * 判定与导入路径同规则(**值的有无优先于本机状态**),并多做一步「值是否已经相同」:
311
+ * - 有值 + 本机不同 / 本机没有 → MissingSecret(确认后写回)。**不因本机已配置而跳过**:
312
+ * 快照显式带了值(用户勾了「导出密钥」),跳过就等于「密钥没同步过来」;
313
+ * 要逐项放过用确认列表的批量按钮(sync-view 的 isBulkDecidable)。
314
+ * - 有值 + 本机逐字节相同 → Skip(info)。这是对上一轮「有值一律 MissingSecret」的**收窄**
315
+ * (真机反馈:本机 refs 一条不缺,列表却全标「缺密钥」+ 黄色警告):只有真的会改动本机的
316
+ * ref 才需要人工确认。
317
+ * - 本机凭据文件读不到 → 无法比较 → 保守按 MissingSecret 处理,绝不静默跳过。
293
318
  * - 「只有 ref 名、没有值」的情形由 createImportPlan → buildCredentialPlanItems 处理
294
319
  * (那里才做本机已配置 → Skip 的判定)。
295
- * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ **升级**为 MissingSecret:
296
- * 否则有值也会被 Skip 挡住,值永远不会写回。
320
+ * - 同 id 已是 **Skip**(上面那条规则在「无值」时先判成了 Skip)→ 值不同时**升级**为
321
+ * MissingSecret:否则有值也会被 Skip 挡住,值永远不会写回;值相同时保持 Skip。
297
322
  */
298
- appendCredentialPlanItems(plan, credentials) {
323
+ async appendCredentialPlanItems(plan, credentials) {
299
324
  if (credentials === undefined || credentials.size === 0)
300
325
  return;
326
+ const local = await this.readLocalCredentials();
301
327
  const byId = new Map(plan.items.map((i) => [i.id, i]));
302
- for (const ref of credentials.keys()) {
328
+ for (const [ref, value] of credentials) {
303
329
  const id = `secret:${ref}`;
304
330
  const prev = byId.get(id);
331
+ if (local?.get(ref) === value) {
332
+ const description = this.msg('sync.credentialsItemSame', { ref });
333
+ if (prev === undefined) {
334
+ const same = {
335
+ id, kind: 'Skip', adapter: 'credentialsStatus', description,
336
+ severity: 'info', target: { adapter: 'credentialsStatus', ref },
337
+ };
338
+ plan.items.push(same);
339
+ byId.set(id, same);
340
+ }
341
+ else if (prev.kind === 'Skip' || prev.kind === 'MissingSecret') {
342
+ prev.kind = 'Skip';
343
+ prev.severity = 'info';
344
+ prev.description = description;
345
+ }
346
+ continue;
347
+ }
305
348
  if (prev !== undefined) {
306
349
  if (prev.kind !== 'Skip')
307
350
  continue; // 已有可执行项 → 不重复
@@ -326,6 +369,24 @@ export class SyncEngine {
326
369
  .filter((i) => i.kind === 'MissingSecret')
327
370
  .map((i) => ({ ref: i.id.replace(/^secret:/, ''), required: true }));
328
371
  }
372
+ /**
373
+ * 读本机 `$DSH_HOME/.credentials.yaml` → `Map<ref, value>`,**只用于**「值是否已经相同」比对。
374
+ * 与 push 侧 buildCredentialsPayload 读同一个文件、同一解析口径(credentialsMapFromYaml);
375
+ * 不新增任何能力,也不回读凭据值到 UI。
376
+ * 读不到 / 解析失败 → undefined = **无法比较**:调用方必须按「未知」保守处理(照旧要求确认),
377
+ * 绝不因为读失败就静默跳过写回。
378
+ * 安全:值只在进程内存里比较 —— 不落盘、不进日志/报告/响应体。
379
+ */
380
+ async readLocalCredentials() {
381
+ try {
382
+ const file = path.join(this.ctx.homeDir, '.credentials.yaml');
383
+ const raw = Buffer.from(await this.ctx.fs.readFile(file)).toString('utf8');
384
+ return credentialsMapFromYaml(raw);
385
+ }
386
+ catch {
387
+ return undefined;
388
+ }
389
+ }
329
390
  /**
330
391
  * sessions 分区的导出选项(opt-in 分区):显式勾选清单优先于「最新 N 个」。
331
392
  *
@@ -475,14 +536,29 @@ export class SyncEngine {
475
536
  const targets = this.pushTargets(opts.sections, warnings);
476
537
  const plainSections = {};
477
538
  const counts = {};
539
+ /** issue #91:本趟只读预览省掉了本地源打包 ⇒ 与基线不可比的分区(不可断言「无变化」) */
540
+ const unpackedSections = new Set();
478
541
  for (const adapter of targets) {
542
+ const exportOptions = {
543
+ includeSecrets,
544
+ // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
545
+ ...this.sessionExportOptions(adapter.id, opts.sessions),
546
+ };
479
547
  let section;
480
548
  try {
481
- section = await adapter.export(this.ctx, {
482
- includeSecrets,
483
- // 与 push 同口径:显式勾选清单 > 「最新 N 个」上限
484
- ...this.sessionExportOptions(adapter.id, opts.sessions),
485
- });
549
+ // issue #91:**非文件类**分区优先走 adapter.preview()。只读预览必须零进程副作用,而
550
+ // plugins 的 export() 会对每个 `link:`/`file:` 插件真的跑一次 `npm pack`
551
+ // (真机 2 GB 本地源插件 = 297 s;这段时间全局写锁被该请求占满 ⇒ 界面上所有写操作静默 423)。
552
+ // 为什么文件类分区**不能**走 preview():其预览产物是「无内容」形态(条目 data 为空、
553
+ // contentHash 为空串),既不能与基线 hash 比对,也不能进 assertNoForbiddenSections
554
+ // —— 那会让预览谎报「每个文件分区都变了」。
555
+ const summary = !isFileSection(adapter.id) && adapter.preview !== undefined
556
+ ? await adapter.preview(this.ctx, exportOptions)
557
+ : undefined;
558
+ section = summary?.section ?? await adapter.export(this.ctx, exportOptions);
559
+ if (summary !== undefined && previewCaliberDiffersFromExport(adapter.id, section.data)) {
560
+ unpackedSections.add(adapter.id);
561
+ }
486
562
  }
487
563
  catch (err) {
488
564
  warnings.push(this.msg('sync.sectionFailed', { adapter: adapter.id, reason: err instanceof Error ? err.message : String(err) }));
@@ -503,15 +579,29 @@ export class SyncEngine {
503
579
  if (!encrypted) {
504
580
  try {
505
581
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
506
- baselineHashes = state.sections;
582
+ // 基线条目是 SyncSectionState({hash, updatedAt}),**不是**裸 hash 字符串。
583
+ // 这里曾把整个对象 as Record<string,string> 直接比对 ⇒ 对象 !== 字符串 恒真
584
+ // ⇒ 预览对**每一个**分区都报「有变化」,「N 个有变化」这句永远是「全都有变化」。
585
+ for (const [sid, rec] of Object.entries(state.sections)) {
586
+ if (rec !== undefined && typeof rec.hash === 'string')
587
+ baselineHashes[sid] = rec.hash;
588
+ }
507
589
  }
508
590
  catch {
509
591
  baselineHashes = {};
510
592
  }
511
593
  }
594
+ if (unpackedSections.size > 0) {
595
+ // 如实说明:本次预览没有为本地源插件执行 npm pack,故这些分区的基线比对不可得
596
+ warnings.push(this.msg('sync.pushPreviewUnpackedLocalSources', { sections: [...unpackedSections].join(', ') }));
597
+ }
512
598
  const sections = Object.keys(plainSections).map((sid) => {
513
599
  const id = sid;
514
- const changed = encrypted || baselineHashes[id] === undefined || baselineHashes[id] !== hashSection(plainSections[id]);
600
+ const changed = encrypted
601
+ // issue #91:只读预览省掉了本地源打包 ⇒ 产物与基线不同口径,**绝不**据此断言「无变化」
602
+ || unpackedSections.has(id)
603
+ || baselineHashes[id] === undefined
604
+ || baselineHashes[id] !== hashSection(plainSections[id]);
515
605
  return { section: id, count: counts[id] ?? 0, changed };
516
606
  });
517
607
  let remoteSnapshotCount = 0;
@@ -659,7 +749,7 @@ export class SyncEngine {
659
749
  pathMappings: opts.pathMappings ?? [],
660
750
  });
661
751
  // 凭据迁移项(ref 名进计划/报告,值绝不进)
662
- this.appendCredentialPlanItems(plan, credentials);
752
+ await this.appendCredentialPlanItems(plan, credentials);
663
753
  const changes = plan.items.map((i) => ({
664
754
  id: i.id,
665
755
  adapter: i.adapter,
@@ -831,7 +921,7 @@ export class SyncEngine {
831
921
  // issue #45:自动重定基 + 用户映射(与 pull 同口径;一键同步的 apply-items 复用本 plan)
832
922
  pathMappings: opts.pathMappings ?? [],
833
923
  });
834
- this.appendCredentialPlanItems(plan, credentials);
924
+ await this.appendCredentialPlanItems(plan, credentials);
835
925
  return {
836
926
  ok: analysis.valid, zipPath, plan, analysis, snapshotId: targetId, credentials,
837
927
  message: tombstoned.length === 0
@@ -29,11 +29,33 @@ export interface UiPrefs {
29
29
  * 「用户主动改配置」或「非本插件客户端」时起作用。
30
30
  */
31
31
  sessionFormatDisposition?: SessionFormatDisposition;
32
+ /**
33
+ * 回合自动留点(回滚点)总开关。**缺省 / 任何非 true 值 = 关闭**(issue #88 / #89:
34
+ * 该功能与「配置备份 / 同步」是两件事,却随 bundle 默认启用且没有关闭入口;
35
+ * 默认启用时账本每回合整份读改写,真机上把宿主拖到 V8 heap OOM)。
36
+ *
37
+ * 关闭语义(宿主侧 gate,见 src/core/rollback-gate.ts):
38
+ * · 不订阅 `session/event`(不是「订阅了什么都不做」);
39
+ * · 不留点、不写台账;
40
+ * · 聊天内的「↺ 回退」入口收起(不是置灰)。
41
+ * 台账 / 回退路由本身仍可用:已有回滚点仍能查看、回退、删除、清理 —— 关的是「自动留点」。
42
+ */
43
+ rollbackEnabled?: boolean;
32
44
  }
33
45
  /** 缺省配置(首次无文件 / 损坏 / 不支持 schema 时回退) */
34
46
  export declare function defaultUiPrefs(): UiPrefs;
35
47
  /** 读取 UI 偏好;文件不存在 / 损坏 / 不支持 schema → 缺省值(不抛错)。 */
36
48
  export declare function readUiPrefs(dir: string): Promise<UiPrefs>;
49
+ /**
50
+ * 同步读取「回滚点开关」(启动期专用)。
51
+ *
52
+ * 为什么必须有同步版本:宿主 `apply()` 是同步函数(`src/index.ts`),而开关必须在
53
+ * **注册订阅之前**就定下来 —— 否则「关闭」只等于「订阅了什么都不做」,与 issue 要求的
54
+ * 「关闭后不再订阅 `session/event`」不符。读一个几百字节的小文件,启动期一次,代价可忽略。
55
+ *
56
+ * 任何异常(不存在 / 损坏 / 形状不符)一律回 false = 关闭。
57
+ */
58
+ export declare function readRollbackEnabledSync(dir: string): boolean;
37
59
  /** 写入 UI 偏好(原子写:临时文件 + rename;自动创建目录)。 */
38
60
  export declare function writeUiPrefs(dir: string, prefs: UiPrefs): Promise<void>;
39
61
  /**
@@ -21,6 +21,7 @@
21
21
  * 避免「sync/ui-prefs 写 lastSyncChannel」与「star-prompt 写弹窗状态」互相覆盖。
22
22
  */
23
23
  import fs from 'node:fs/promises';
24
+ import fsSync from 'node:fs';
24
25
  import path from 'node:path';
25
26
  import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.js';
26
27
  import { atomicWriteFile } from '../utils/atomic-write.js';
@@ -84,8 +85,34 @@ export async function readUiPrefs(dir) {
84
85
  if (disposition === 'abort' || disposition === 'skip' || disposition === 'guide') {
85
86
  prefs.sessionFormatDisposition = disposition;
86
87
  }
88
+ // 开关只认字面 true:false / 缺省 / 形状不符一律 = 关闭(fail-closed,绝不猜用户想开)
89
+ if (obj['rollbackEnabled'] === true) {
90
+ prefs.rollbackEnabled = true;
91
+ }
87
92
  return prefs;
88
93
  }
94
+ /**
95
+ * 同步读取「回滚点开关」(启动期专用)。
96
+ *
97
+ * 为什么必须有同步版本:宿主 `apply()` 是同步函数(`src/index.ts`),而开关必须在
98
+ * **注册订阅之前**就定下来 —— 否则「关闭」只等于「订阅了什么都不做」,与 issue 要求的
99
+ * 「关闭后不再订阅 `session/event`」不符。读一个几百字节的小文件,启动期一次,代价可忽略。
100
+ *
101
+ * 任何异常(不存在 / 损坏 / 形状不符)一律回 false = 关闭。
102
+ */
103
+ export function readRollbackEnabledSync(dir) {
104
+ try {
105
+ const raw = fsSync.readFileSync(path.join(dir, UI_PREFS_FILE), 'utf8');
106
+ const parsed = JSON.parse(raw);
107
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
108
+ return false;
109
+ const obj = parsed;
110
+ return obj['schemaVersion'] === UI_PREFS_SCHEMA_VERSION && obj['rollbackEnabled'] === true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
89
116
  /** 写入 UI 偏好(原子写:临时文件 + rename;自动创建目录)。 */
90
117
  export async function writeUiPrefs(dir, prefs) {
91
118
  await fs.mkdir(dir, { recursive: true });
@@ -98,6 +125,7 @@ export async function writeUiPrefs(dir, prefs) {
98
125
  ...(prefs.releaseNotesLastSeenVersion !== undefined ? { releaseNotesLastSeenVersion: prefs.releaseNotesLastSeenVersion } : {}),
99
126
  ...(prefs.releaseNotesDismissed === true ? { releaseNotesDismissed: true } : {}),
100
127
  ...(prefs.sessionFormatDisposition !== undefined ? { sessionFormatDisposition: prefs.sessionFormatDisposition } : {}),
128
+ ...(prefs.rollbackEnabled === true ? { rollbackEnabled: true } : {}),
101
129
  };
102
130
  const target = path.join(dir, UI_PREFS_FILE);
103
131
  const data = stringifyJsonSafe(payload, { space: 2 });
@@ -32,3 +32,13 @@ export declare function toSideBySide(hunks: readonly DiffHunk[]): SideBySideHunk
32
32
  export declare function maxLineDigits(hunks: readonly DiffHunk[]): number;
33
33
  /** 行对总数(渲染层做「超长截断」提示时的分母)。 */
34
34
  export declare function countSideRows(groups: readonly SideBySideHunk[]): number;
35
+ /** 增删行数(编辑器式文件头的 `+A` / `−R` 用;上下文行不计)。 */
36
+ export interface DiffLineCounts {
37
+ added: number;
38
+ removed: number;
39
+ }
40
+ /**
41
+ * 一侧的行数统计:**只数 unidiff 语义的增删行**(与 git 的 `+A −R` 同口径)。
42
+ * 成对修改在 unidiff 里本来就是「一删一增」,所以这里不需要按行对再折算。
43
+ */
44
+ export declare function diffLineCounts(hunks: readonly DiffHunk[]): DiffLineCounts;
@@ -63,4 +63,21 @@ export function maxLineDigits(hunks) {
63
63
  export function countSideRows(groups) {
64
64
  return groups.reduce((sum, group) => sum + group.rows.length, 0);
65
65
  }
66
+ /**
67
+ * 一侧的行数统计:**只数 unidiff 语义的增删行**(与 git 的 `+A −R` 同口径)。
68
+ * 成对修改在 unidiff 里本来就是「一删一增」,所以这里不需要按行对再折算。
69
+ */
70
+ export function diffLineCounts(hunks) {
71
+ let added = 0;
72
+ let removed = 0;
73
+ for (const hunk of hunks) {
74
+ for (const line of hunk.lines) {
75
+ if (line.kind === 'add')
76
+ added += 1;
77
+ else if (line.kind === 'del')
78
+ removed += 1;
79
+ }
80
+ }
81
+ return { added, removed };
82
+ }
66
83
  //# sourceMappingURL=diff-view.js.map
@@ -51,6 +51,8 @@ const CATEGORY_DESCRIPTION_KEY = {
51
51
  ui: 'export.category.ui',
52
52
  credentialsStatus: 'export.category.credentialsStatus',
53
53
  sessions: 'export.category.sessions',
54
+ pluginData: 'export.category.pluginData',
55
+ pluginAssets: 'export.category.pluginAssets',
54
56
  };
55
57
  /** 需在 UI 标注安全提示、但绝不显示值的分区(凭据状态只有 configured 标记) */
56
58
  const SENSITIVE_SECTIONS = new Set(['credentialsStatus']);
package/lib/ui/i18n.d.ts CHANGED
@@ -92,7 +92,7 @@ export declare const uiZh: {
92
92
  readonly 'error.downloadFailed': '下载失败:HTTP {status}';
93
93
  readonly 'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态';
94
94
  readonly 'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试';
95
- readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态';
95
+ readonly 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试';
96
96
  readonly 'error.settingsConflict.title': '配置已被并发修改';
97
97
  readonly 'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。';
98
98
  readonly 'error.notConfirmed.title': '导入未确认';
@@ -162,7 +162,7 @@ export declare const uiZh: {
162
162
  readonly 'sync.kind.skip': '跳过';
163
163
  readonly 'sync.kind.conflict': '冲突';
164
164
  readonly 'sync.kind.install': '安装';
165
- readonly 'sync.kind.missingSecret': '缺密钥';
165
+ readonly 'sync.kind.missingSecret': '凭据迁移';
166
166
  readonly 'sync.kind.missingDependency': '缺依赖';
167
167
  readonly 'sync.kind.pathMapping': '路径映射';
168
168
  readonly 'sync.kind.warning': '警告';
@@ -517,8 +517,10 @@ export declare const uiZh: {
517
517
  readonly 'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)';
518
518
  readonly 'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL';
519
519
  readonly 'export.category.plugins': '已安装插件清单与启用状态(不含二进制)';
520
- readonly 'export.category.pluginFiles': '插件自有配置文件(白名单 + plugin-config/ 目录,整文件复制)';
520
+ readonly 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)';
521
521
  readonly 'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)';
522
+ readonly 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)';
523
+ readonly 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)';
522
524
  readonly 'export.category.mcp': 'MCP 服务器组合配置(需重启生效)';
523
525
  readonly 'export.category.prompts': 'System Prompt / Plan Mode 提示';
524
526
  readonly 'export.category.skills': '用户技能文件(~/.dsh/skills)';
package/lib/ui/i18n.js CHANGED
@@ -99,7 +99,7 @@ export const uiZh = {
99
99
  'error.downloadFailed': '下载失败:HTTP {status}',
100
100
  'error.exportTimeout': '导出超时({minutes} 分钟):导出过程未完成,请重试;若反复超时请检查 DSH 状态',
101
101
  'error.syncTimeout': '同步请求超时({minutes} 分钟):请检查网络与仓库可达性后重试',
102
- 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):恢复过程未完成,请重试;若反复超时请检查 DSH 状态',
102
+ 'error.recoveryTimeout': '恢复请求超时({minutes} 分钟):过程可能仍在后台进行,请先刷新预览确认;确实未完成再重试',
103
103
  'error.settingsConflict.title': '配置已被并发修改',
104
104
  'error.settingsConflict.action': '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。',
105
105
  'error.notConfirmed.title': '导入未确认',
@@ -172,7 +172,7 @@ export const uiZh = {
172
172
  'sync.kind.skip': '跳过',
173
173
  'sync.kind.conflict': '冲突',
174
174
  'sync.kind.install': '安装',
175
- 'sync.kind.missingSecret': '缺密钥',
175
+ 'sync.kind.missingSecret': '凭据迁移',
176
176
  'sync.kind.missingDependency': '缺依赖',
177
177
  'sync.kind.pathMapping': '路径映射',
178
178
  'sync.kind.warning': '警告',
@@ -547,8 +547,10 @@ export const uiZh = {
547
547
  'export.category.settings': 'DSH 全局设置(namespace 分区,redacted)',
548
548
  'export.category.providers': 'LLM Provider / Model / 默认模型 / BaseURL',
549
549
  'export.category.plugins': '已安装插件清单与启用状态(不含二进制)',
550
- 'export.category.pluginFiles': '插件自有配置文件(白名单 + plugin-config/ 目录,整文件复制)',
550
+ 'export.category.pluginFiles': '其他插件放在 ~/.dsh 根下的零散配置文件(白名单 + plugin-config/ 目录,整文件复制)',
551
551
  'export.category.self': '本插件自身配置(同步/自动同步/分区选择/UI 偏好/市场;sync-*.json 等,不含凭据值)',
552
+ 'export.category.pluginData': '其他插件在系统主目录下的数据(如 ~/.dsh-wallpaper-engine、~/.mnemon 的配置与状态;可再生成缓存绝不收录)',
553
+ 'export.category.pluginAssets': '其他插件的大体积文件(上传的壁纸、本地数据库等;默认不勾选)',
552
554
  'export.category.mcp': 'MCP 服务器组合配置(需重启生效)',
553
555
  'export.category.prompts': 'System Prompt / Plan Mode 提示',
554
556
  'export.category.skills': '用户技能文件(~/.dsh/skills)',
@@ -660,7 +662,7 @@ export const uiEn = {
660
662
  'error.downloadFailed': 'Download failed: HTTP {status}',
661
663
  'error.exportTimeout': 'Export timed out ({minutes} min): the export did not complete, please retry; if it keeps timing out, check the DSH state',
662
664
  'error.syncTimeout': 'Sync request timed out ({minutes} min): check network and repository reachability, then retry',
663
- 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): the recovery did not complete, please retry; if it keeps timing out, check the DSH state',
665
+ 'error.recoveryTimeout': 'Recovery request timed out ({minutes} min): it may still be running in the background — refresh the preview to confirm, then retry if it did not finish',
664
666
  'error.settingsConflict.title': 'Configuration changed concurrently',
665
667
  'error.settingsConflict.action': 'This configuration on the target DSH was modified by another process during import. To avoid overwriting it, re-export or review it manually, then retry.',
666
668
  'error.notConfirmed.title': 'Import not confirmed',
@@ -730,7 +732,7 @@ export const uiEn = {
730
732
  'sync.kind.skip': 'Skip',
731
733
  'sync.kind.conflict': 'Conflict',
732
734
  'sync.kind.install': 'Install',
733
- 'sync.kind.missingSecret': 'Secret',
735
+ 'sync.kind.missingSecret': 'Credential',
734
736
  'sync.kind.missingDependency': 'Dependency',
735
737
  'sync.kind.pathMapping': 'Path',
736
738
  'sync.kind.warning': 'Warning',
@@ -1091,8 +1093,10 @@ export const uiEn = {
1091
1093
  'export.category.settings': 'Global DSH settings (namespace sections, redacted)',
1092
1094
  'export.category.providers': 'LLM providers / models / default model / base URL',
1093
1095
  'export.category.plugins': 'Installed plugin list and enablement (binaries excluded)',
1094
- 'export.category.pluginFiles': "Plugins' own config files (allowlist + plugin-config/, copied whole)",
1096
+ 'export.category.pluginFiles': "Other plugins' loose config files at the ~/.dsh root (allowlist + plugin-config/, copied whole)",
1095
1097
  'export.category.self': "This plugin's own config (sync / autosync / section selection / UI prefs / market; sync-*.json etc., no credential values)",
1098
+ 'export.category.pluginData': "Other plugins' data under the system home directory (e.g. the config/state of ~/.dsh-wallpaper-engine and ~/.mnemon; regenerable caches are never included)",
1099
+ 'export.category.pluginAssets': "Other plugins' large files (uploaded wallpapers, local databases, ...; not selected by default)",
1096
1100
  'export.category.mcp': 'MCP server composition (restart required)',
1097
1101
  'export.category.prompts': 'System prompt / Plan Mode prompts',
1098
1102
  'export.category.skills': 'Your skill files (~/.dsh/skills)',