dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
@@ -18,10 +18,16 @@ import type { McpServerEntry } from '../schema/types.ts';
18
18
  import { sectionMeta } from '../schema/section-registry.ts';
19
19
  import { validateJsonSection } from './json-section.ts';
20
20
  import type {
21
- ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
21
+ ApplyResult, ConfigAdapter, ExportOptions, ExportSection, ExportUnit, HostContext,
22
22
  ImportContext, PlanItem, ValidationResult,
23
23
  } from '../core/types.ts';
24
24
  import { locatePatchLineLayer, readEffectivePatchLines, resolveWriteLayer } from '../core/patch-layers.ts';
25
+ import {
26
+ MCP_CONNECTOR_STORE_REL, buildConnectorRecord, comparableConnector, exportConnectorBlock,
27
+ findConnectorRecord, mergeConnectorIntoStoreText, pickConnectorKey, readConnectorStore,
28
+ refillFieldsOf,
29
+ } from '../core/mcp-connector-store.ts';
30
+ import type { ConnectorExportEntry, McpConnectorExport } from '../core/mcp-connector-store.ts';
25
31
 
26
32
  /** 导出记录:McpServerEntry 之外附加来源 patch 行 id 与所在层(导入写回定位用) */
27
33
  export interface McpExportEntry extends McpServerEntry {
@@ -33,7 +39,17 @@ export interface McpExportEntry extends McpServerEntry {
33
39
  sourceFile?: string;
34
40
  }
35
41
 
36
- export interface McpExportSection { version: 1; servers: McpExportEntry[]; }
42
+ export interface McpExportSection {
43
+ version: 1;
44
+ servers: McpExportEntry[];
45
+ /**
46
+ * MCP 连接器(\`dsh-mcp-connector\` 插件)的连接,第二个数据源。
47
+ *
48
+ * 为什么并列而不是新增分区:两条路径在 DSH 里都是「MCP 服务器」,用户心智里是一件事;
49
+ * 而备份格式是追加字段 ⇒ 旧备份没有它 = 空,读旧包的代码不受影响。
50
+ */
51
+ connectors?: McpConnectorExport;
52
+ }
37
53
 
38
54
  /**
39
55
  * 从 patch 行数组中提取 MCP server 条目(以 config.serverName 存在为判定)。
@@ -97,11 +113,60 @@ export function buildMcpPatchLine(lineId: string, server: McpServerEntry): Recor
97
113
  return { id: lineId, name: 'dsh-mcp-client', config };
98
114
  }
99
115
 
116
+ /** 连接器计划项的 id 前缀(导入时按前缀分派到 applyConnectorItem) */
117
+ const MCP_CONNECTOR_ITEM_PREFIX = 'mcp-connector:';
118
+
119
+ /** 计划项 detail:stdio 给命令 + 参数,http 给 URL(不打印任何 header/env 值,防凭据入日志) */
120
+ function describeConnector(entry: ConnectorExportEntry): string {
121
+ const head = entry.transport === 'stdio'
122
+ ? [entry.command ?? '', ...(entry.args ?? [])].filter((s) => s !== '').join(' ')
123
+ : (entry.url ?? '');
124
+ return `${entry.transport} ${head}`.trim().slice(0, 200);
125
+ }
126
+
100
127
  function newLineId(serverName: string): string {
101
128
  const slug = serverName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
102
129
  return `mcp-${slug || 'server'}`;
103
130
  }
104
131
 
132
+ /**
133
+ * 单元 id:两条数据源各自独立前缀。
134
+ *
135
+ * 为什么必须分开:patch 行的 `serverName` 与连接器的 `serverName` 完全可能同名(同一台服务器的两条
136
+ * 历史配置路径),共用 id 会让「勾了其中一个」变成「两个一起勾」,与选择器「逐项可勾」的前提冲突。
137
+ * 命名空间仍以 `mcp:` 开头 —— 与 ExportUnit 的「<section>:<单元>」约定一致。
138
+ */
139
+ export function mcpServerUnitId(serverName: string): string {
140
+ return 'mcp:patch:' + serverName
141
+ }
142
+
143
+ export function mcpConnectorUnitId(key: string): string {
144
+ return 'mcp:connector:' + key
145
+ }
146
+
147
+ /** 单元体积 = 该条目在包里的 UTF-8 字节数(与 /export-preview 对 JSON 分区的口径一致) */
148
+ function unitBytes(value: unknown): number {
149
+ return new TextEncoder().encode(JSON.stringify(value) ?? '').byteLength
150
+ }
151
+
152
+ /** 只读的 stdio 描述(命令 + 参数),不打印任何 env / header 值 */
153
+ function stdioDetail(entry: { command?: string; args?: string[] }): string {
154
+ return [entry.command ?? '', ...(entry.args ?? [])].filter((s) => s !== '').join(' ').slice(0, 200)
155
+ }
156
+
157
+ /**
158
+ * 连接器整块的条目级过滤。
159
+ *
160
+ * allow === undefined = 全量(旧调用方零改动);过滤后一条不剩 → 返回 undefined,
161
+ * 让 `connectors` 字段整个消失(而不是留一个空块冒充「连接器已备份」)。
162
+ */
163
+ function filterConnectorBlock(block: McpConnectorExport, allow: readonly string[] | undefined): McpConnectorExport | undefined {
164
+ if (allow === undefined) return block
165
+ const connections = block.connections.filter((c) => allow.includes(mcpConnectorUnitId(c.key)))
166
+ const oauthConnections = block.oauthConnections.filter((c) => allow.includes(mcpConnectorUnitId(c.key)))
167
+ if (connections.length === 0 && oauthConnections.length === 0) return undefined
168
+ return { ...block, connections, oauthConnections }
169
+ }
105
170
  export class McpAdapter implements ConfigAdapter<McpExportSection> {
106
171
  readonly id = 'mcp' as const;
107
172
  // 元数据唯一来源 = 注册表(t31):不再与 ui/export-flow.ts 的导出目录各写一份
@@ -109,7 +174,7 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
109
174
  readonly defaultIncluded = sectionMeta('mcp').defaultIncluded;
110
175
  readonly portability = sectionMeta('mcp').portability;
111
176
 
112
- async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<McpExportSection>> {
177
+ async export(ctx: HostContext, options: ExportOptions): Promise<ExportSection<McpExportSection>> {
113
178
  const warnings: string[] = [];
114
179
  // issue #71:两层都读(home 层优先),否则 profile 层里的 server 全部丢失
115
180
  const read = await readEffectivePatchLines(ctx.patchFile, ctx.profile);
@@ -117,14 +182,90 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
117
182
  warnings.push(msgOf(ctx)('adapter.patchReadFailedMCP', { reason: `${f.file}: ${f.reason}` }));
118
183
  }
119
184
  const servers = extractMcpServers(read.lines);
185
+ // 条目级白名单:undefined = 整分区全量(旧调用方零改动);空数组 = 一条都不带。
186
+ // 选择器把「部分勾选」翻译成 includeItems['mcp'],这里必须如实过滤,否则勾了等于没勾。
187
+ const allow = options.includeItems?.[this.id];
188
+ const pickedServers = allow === undefined
189
+ ? servers
190
+ : servers.filter((s) => allow.includes(mcpServerUnitId(s.serverName)));
191
+ // 第二个数据源:连接器插件的 `storages/mcp_connector.json`(用户实际配置 MCP 的地方)。
192
+ // 读不到 = 本机没装/没用连接器(正常,静默);读到了解析不了 = 异常,如实报 warning,
193
+ // **绝不**当作空表导出 —— 否则用户以为备份里有,恢复时才发现丢了。
194
+ let connectors: McpConnectorExport | undefined;
195
+ try {
196
+ const store = await readConnectorStore(ctx.fs);
197
+ if (store.exists && !store.parsed) {
198
+ warnings.push(msgOf(ctx)('adapter.mcpConnectorReadFailed', { reason: store.error ?? 'unknown' }));
199
+ } else if (store.exists && store.parsed) {
200
+ // 文件不存在 = 本机没有任何连接器:不写这个字段(旧备份也没有它,形状保持一致)
201
+ connectors = filterConnectorBlock(exportConnectorBlock(store.connections), allow);
202
+ }
203
+ } catch (err) {
204
+ warnings.push(msgOf(ctx)('adapter.mcpConnectorReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
205
+ }
206
+ const data: McpExportSection = { version: 1, servers: pickedServers };
207
+ if (connectors !== undefined) data.connectors = connectors;
120
208
  return {
121
209
  sectionId: 'mcp',
122
- data: { version: 1, servers },
123
- counts: { servers: servers.length },
210
+ data,
211
+ counts: {
212
+ servers: pickedServers.length,
213
+ ...(connectors !== undefined ? { connectors: connectors.connections.length } : {}),
214
+ },
124
215
  warnings,
125
216
  };
126
217
  }
127
218
 
219
+ /**
220
+ * 条目级单元清单(选择器靠它决定「能不能展开」)。
221
+ *
222
+ * 契约(core/types.ts:1211):纯函数、零 I/O,且必须与 export(includeItems) 自洽 ——
223
+ * 单元 id 就是 export 白名单里认的 id,两边共用 mcpServerUnitId / mcpConnectorUnitId。
224
+ *
225
+ * 单元 label 与 detail 只放「能帮用户认出是哪条」的信息:连接器的 detail 复用
226
+ * describeConnector(stdio 给命令 + 参数、http 给 URL),**绝不**带出 env / header 值。
227
+ */
228
+ listUnits(section: ExportSection<McpExportSection>): ExportUnit[] {
229
+ const units: ExportUnit[] = [];
230
+ const seen = new Set<string>();
231
+ for (const server of section.data.servers ?? []) {
232
+ const id = mcpServerUnitId(server.serverName);
233
+ if (seen.has(id)) continue;
234
+ seen.add(id);
235
+ units.push({
236
+ id,
237
+ label: server.serverName,
238
+ detail: server.type === 'stdio' ? stdioDetail(server) : (server.url ?? ''),
239
+ sizeBytes: unitBytes(server),
240
+ });
241
+ }
242
+ const connectors = section.data.connectors;
243
+ for (const connection of connectors?.connections ?? []) {
244
+ const id = mcpConnectorUnitId(connection.key);
245
+ if (seen.has(id)) continue;
246
+ seen.add(id);
247
+ units.push({
248
+ id,
249
+ label: connection.name,
250
+ detail: describeConnector(connection),
251
+ sizeBytes: unitBytes(connection),
252
+ });
253
+ }
254
+ for (const oauth of connectors?.oauthConnections ?? []) {
255
+ const id = mcpConnectorUnitId(oauth.key);
256
+ if (seen.has(id)) continue;
257
+ seen.add(id);
258
+ units.push({
259
+ id,
260
+ label: oauth.name,
261
+ // OAuth 连接只带引用(Token 永不进备份),detail 说明它靠重新授权才能用
262
+ detail: 'oauth ' + oauth.serverName,
263
+ sizeBytes: unitBytes(oauth),
264
+ });
265
+ }
266
+ return units;
267
+ }
268
+
128
269
  async analyzeImport(data: McpExportSection, ctx: ImportContext): Promise<PlanItem[]> {
129
270
  const msg = ctx.msg;
130
271
  const items: PlanItem[] = [];
@@ -166,10 +307,92 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
166
307
  }
167
308
  }
168
309
  }
310
+ items.push(...await this.analyzeConnectors(data, ctx));
311
+ return items;
312
+ }
313
+
314
+ /**
315
+ * 连接器来源的计划项。
316
+ *
317
+ * 三条纪律:
318
+ * ① 目标存储「存在但解析不了」→ 只报 warning、**零写入**(整文件改写会把 grants 等表一起抹掉);
319
+ * ② 条目里还有占位符 → 不写,产出「需重填凭据」的 warning(写进去 = 给用户一个起不来的 Server);
320
+ * ③ 同名 server 同时来自 patch 行与连接器 → 两条都保留,另加一条 warning(不隐式合并,
321
+ * 两者在 DSH 里是两份独立配置,合并会悄悄改变用户的生效结果)。
322
+ */
323
+ private async analyzeConnectors(data: McpExportSection, ctx: ImportContext): Promise<PlanItem[]> {
324
+ const msg = ctx.msg;
325
+ const block = data.connectors;
326
+ if (block === undefined) return [];
327
+ const items: PlanItem[] = [];
328
+ const target = await readConnectorStore(ctx.target.fs);
329
+ if (target.exists && !target.parsed) {
330
+ items.push({
331
+ id: 'mcp-connector:store',
332
+ kind: 'Warning',
333
+ adapter: 'mcp',
334
+ description: msg('adapter.mcpConnectorTargetUnreadable', { reason: target.error ?? 'unknown' }),
335
+ severity: 'warning',
336
+ });
337
+ }
338
+ const writable = target.parsed;
339
+ for (const entry of block.connections ?? []) {
340
+ const id = `mcp-connector:${entry.key}`;
341
+ // 双来源同名:告诉用户「这条连接和 patch 行里的同名 server 是两份」,不替用户做决定。
342
+ // 放在「需重填」判断**之前**——两条事实都要讲,不能因为一条要重填就吞掉另一条。
343
+ if (data.servers.some((s) => s.serverName === entry.serverName)) {
344
+ items.push({
345
+ id: `mcp-connector-name:${entry.key}`,
346
+ kind: 'Warning', adapter: 'mcp',
347
+ description: msg('adapter.mcpConnectorDuplicateName', { serverName: entry.serverName }),
348
+ severity: 'warning',
349
+ });
350
+ }
351
+ const refill = refillFieldsOf(entry);
352
+ if (refill.length > 0) {
353
+ items.push({
354
+ id, kind: 'Warning', adapter: 'mcp',
355
+ description: msg('adapter.mcpConnectorNeedsRefill', { name: entry.name }),
356
+ detail: refill.join(', '),
357
+ severity: 'warning',
358
+ });
359
+ continue;
360
+ }
361
+ if (!writable) continue;
362
+ const existing = findConnectorRecord(target.connections, entry);
363
+ const detail = describeConnector(entry);
364
+ if (existing === undefined) {
365
+ items.push({
366
+ id, kind: 'Create', adapter: 'mcp',
367
+ description: msg('adapter.mcpConnectorCreate', { name: entry.name }),
368
+ detail,
369
+ severity: 'info',
370
+ target: { adapter: 'mcp', ref: MCP_CONNECTOR_STORE_REL },
371
+ });
372
+ } else if (isDeepStrictEqual(comparableConnector(existing), comparableConnector(entry))) {
373
+ items.push({ id, kind: 'Skip', adapter: 'mcp', description: msg('adapter.mcpConnectorSame', { name: entry.name }), severity: 'info' });
374
+ } else {
375
+ items.push({
376
+ id, kind: 'Conflict', adapter: 'mcp',
377
+ description: msg('adapter.mcpConnectorDiff', { name: entry.name }),
378
+ detail: `current=${JSON.stringify(comparableConnector(existing))} imported=${JSON.stringify(comparableConnector(entry))}`.slice(0, 200),
379
+ severity: 'warning',
380
+ target: { adapter: 'mcp', ref: MCP_CONNECTOR_STORE_REL },
381
+ });
382
+ }
383
+ }
384
+ for (const oauth of block.oauthConnections ?? []) {
385
+ items.push({
386
+ id: `mcp-connector:${oauth.key}`, kind: 'Warning', adapter: 'mcp',
387
+ description: msg('adapter.mcpConnectorOauth', { name: oauth.name }),
388
+ severity: 'warning',
389
+ });
390
+ }
169
391
  return items;
170
392
  }
171
393
 
172
394
  async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
395
+ if (item.id.startsWith(MCP_CONNECTOR_ITEM_PREFIX)) return this.applyConnectorItem(item, ctx);
173
396
  const msg = ctx.msg;
174
397
  const serverName = item.id.replace(/^mcp:/, '');
175
398
  const data = ctx.sections.get('mcp') as McpExportSection | undefined;
@@ -189,6 +412,39 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
189
412
  return { ok: true, needsRestart: true, message: msg('adapter.mcpWritten', { serverName }) };
190
413
  }
191
414
 
415
+ /**
416
+ * 写入一条连接:读全文件 → 只替换 `tables.connections[<key>]` → 整文件原子写。
417
+ *
418
+ * 为什么整文件写而不是走宿主 storage 服务:本机没装 `@deepseek-ai/dsh-storage-domain`,
419
+ * 我们拿不到 `mcp_connector` 域的表句柄;而直接改文件又会被运行中的宿主内存态覆盖 ⇒
420
+ * 计划项与本结果都显式要求重启(needsRestart),写之前先把原文件整体快照(引擎侧按
421
+ * `storages/` 前缀走文件类快照)。
422
+ */
423
+ private async applyConnectorItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
424
+ const msg = ctx.msg;
425
+ const key = item.id.slice(MCP_CONNECTOR_ITEM_PREFIX.length);
426
+ const data = ctx.sections.get('mcp') as McpExportSection | undefined;
427
+ const entry = data?.connectors?.connections.find((c) => c.key === key);
428
+ if (entry === undefined) return { ok: false, message: msg('adapter.mcpConnectorMissing', { name: key }) };
429
+ const refill = refillFieldsOf(entry);
430
+ if (refill.length > 0) {
431
+ return { ok: false, message: msg('adapter.mcpConnectorNeedsRefill', { name: entry.name }) };
432
+ }
433
+ const store = await readConnectorStore(ctx.target.fs);
434
+ if (store.exists && !store.parsed) {
435
+ return { ok: false, message: msg('adapter.mcpConnectorTargetUnreadable', { reason: store.error ?? 'unknown' }) };
436
+ }
437
+ const existing = findConnectorRecord(store.connections, entry);
438
+ const record = buildConnectorRecord(entry, {
439
+ existing,
440
+ key: pickConnectorKey(store.connections, entry),
441
+ now: Date.now(),
442
+ });
443
+ const text = mergeConnectorIntoStoreText(store, record);
444
+ await ctx.target.fs.writeFile(MCP_CONNECTOR_STORE_REL, new TextEncoder().encode(text));
445
+ return { ok: true, needsRestart: true, message: msg('adapter.mcpConnectorWritten', { name: entry.name }) };
446
+ }
447
+
192
448
  async validate(data: McpExportSection, msg: MsgFunc = zhMsg): Promise<ValidationResult> {
193
449
  return validateJsonSection<McpExportSection>('mcp', data, msg, (section, issues) => {
194
450
  if (!Array.isArray(section.servers)) {
@@ -200,6 +456,21 @@ export class McpAdapter implements ConfigAdapter<McpExportSection> {
200
456
  }
201
457
  }
202
458
  }
459
+ // connectors 是**可选追加字段**:旧备份没有它 = 合法;有它但形态不对才算错
460
+ const connectors = section.connectors;
461
+ if (connectors !== undefined) {
462
+ if (connectors === null || typeof connectors !== 'object' || !Array.isArray((connectors as { connections?: unknown }).connections)) {
463
+ issues.push({ path: 'connectors.connections', message: msg('adapter.validate.array', { subject: 'connectors.connections' }), severity: 'error' });
464
+ } else {
465
+ for (const c of (connectors as { connections: unknown[] }).connections) {
466
+ if (c === null || typeof c !== 'object'
467
+ || typeof (c as { key?: unknown }).key !== 'string' || (c as { key: string }).key === ''
468
+ || typeof (c as { serverName?: unknown }).serverName !== 'string' || (c as { serverName: string }).serverName === '') {
469
+ issues.push({ path: 'connectors.connections[]', message: msg('adapter.mcpConnectorInvalid'), severity: 'error' });
470
+ }
471
+ }
472
+ }
473
+ }
203
474
  });
204
475
  }
205
476
  }
@@ -35,6 +35,10 @@ export const zh = {
35
35
  'adapter.pluginDataEnvOverridden': '{path} 的位置被环境变量 {env} 覆盖,本次已跳过(不猜它指向哪里)',
36
36
  'adapter.pluginDataPathNotAllowed': '路径不在第三方插件数据的允许范围内,已拒绝写入: {path}',
37
37
  'adapter.pluginDataNoOsHome': '第三方插件的数据在系统主目录下,但当前宿主没有提供该目录的文件通道(旧版本宿主):本次不读取也不导入 / third-party plugin data lives under the system home directory, but this host offers no file channel for it (older host): nothing is read or imported this time',
38
+ // ---------- pnpm 配置可移植净化(机器相关路径键) ----------
39
+ 'adapter.pwMachinePathStripped': '已剥离 {count} 条与本机路径绑定的 pnpm 设置(换机后会指向不存在的目录):{keys}',
40
+ 'adapter.pwMachinePathKept': '保留本机原有的 {count} 条 pnpm 路径设置(不搬运来源机的路径):{keys}',
41
+ 'adapter.pwLocalReadFailed': '读取本机 pnpm-workspace.yaml 失败:{reason}(未写入 —— 避免把本机自己的 pnpm 路径设置清掉)',
38
42
  } as const;
39
43
 
40
44
  export const en: Record<keyof typeof zh, string> = {
@@ -53,6 +57,9 @@ export const en: Record<keyof typeof zh, string> = {
53
57
  'adapter.pluginDataEnvOverridden': "the location of {path} is overridden by the {env} environment variable; skipped this time (the new location is never guessed)",
54
58
  'adapter.pluginDataPathNotAllowed': 'the path is outside the range allowed for third-party plugin data and was refused: {path}',
55
59
  'adapter.pluginDataNoOsHome': 'third-party plugin data lives under the system home directory, but this host offers no file channel for it (older host): nothing is read or imported this time',
60
+ 'adapter.pwMachinePathStripped': 'Stripped {count} pnpm setting(s) bound to machine-specific paths (they would point to non-existent directories on another machine): {keys}',
61
+ 'adapter.pwMachinePathKept': "Kept this machine's own {count} pnpm path setting(s) (the source machine's paths are never carried over): {keys}",
62
+ 'adapter.pwLocalReadFailed': "Failed to read the local pnpm-workspace.yaml: {reason} (nothing was written, so this machine's own pnpm path settings are not dropped)",
56
63
  };
57
64
 
58
65
  registerMessageCatalog({ id: 'adapters-domain', zh, en });
@@ -110,7 +110,8 @@ export class PluginFilesAdapter implements ConfigAdapter<FilesSection> {
110
110
 
111
111
  /** 单元清单(零 I/O):逐文件单元,id 与导入侧 `pluginFiles:<relPath>` 一致。 */
112
112
  listUnits(section: ExportSection<FilesSection>): ExportUnit[] {
113
- return unitsFromFiles(this.id, section.data.files, (rel) => rel);
113
+ // files 可能缺省(空分区 / 手写 section):按空表处理,不抛错 —— 选择器对任何分区都会调 listUnits
114
+ return unitsFromFiles(this.id, section.data.files ?? [], (rel) => rel);
114
115
  }
115
116
 
116
117
  async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
@@ -292,7 +292,7 @@ test('plugins: applyItem Install → needsRestart(官方机制,不打包二
292
292
  assert.ok(dst.plugins.installed.has('need-install'), '经 installPlugin 门面写入');
293
293
  });
294
294
 
295
- test('plugins: 非 registry spec(github:)随导入传给 install,registry 包不传 spec', async () => {
295
+ test('plugins: 安装目标按 pluginInstallTarget 推导 —— github: 原样、registry 精确版本(issue #101)', async () => {
296
296
  const src = makeContext('win32', 'C:\\Users\\alice');
297
297
  src.plugins.installed.set('dsh-memory-evolve', { name: 'dsh-memory-evolve', version: '1.0.0', enabled: true, spec: 'github:csyangwen/dsh-memory-evolve' });
298
298
  src.plugins.installed.set('dshmarket', { name: 'dshmarket', version: '1.0.3', enabled: true, spec: '^1.0.3' });
@@ -306,7 +306,7 @@ test('plugins: 非 registry spec(github:)随导入传给 install,registry
306
306
  assert.equal(dst.plugins.lastSpec, 'github:csyangwen/dsh-memory-evolve', 'github: spec 必须原样传给安装通道');
307
307
 
308
308
  await adapter.applyItem(items.find((i) => i.id === 'plugin:dshmarket')!, makeImportContext(dst, sections));
309
- assert.equal(dst.plugins.lastSpec, '^1.0.3', 'registry 版本区间也透传(由门面决定按裸名装最新)');
309
+ assert.equal(dst.plugins.lastSpec, 'dshmarket@1.0.3', '备份里有具体版本 → 精确安装(裸包名会被目标机既有 spec 挡住,issue #101)');
310
310
  });
311
311
 
312
312
  test('plugins: 安装失败 → 非致命 warning(§34.17,不触发整体回滚)', async () => {
@@ -355,6 +355,42 @@ test('plugins: 版本冲突 useImported → Update 走安装通道,失败同
355
355
  assert.equal(rFail.ok, false);
356
356
  assert.equal(rFail.warning, true, '更新失败同样为非致命 warning');
357
357
  });
358
+ test('plugins: 安装后回读版本与备份不一致 → warning + 可复制的精确安装命令(issue #101)', async () => {
359
+ const src = makeContext('win32', 'C:\\Users\\alice');
360
+ src.plugins.installed.set('pkg-b', { name: 'pkg-b', version: '2.0.0', enabled: true });
361
+ const adapter = new PluginsAdapter();
362
+ const exported = await adapter.export(src, { includeSecrets: false });
363
+ const sections = new Map([['plugins', exported.data]]);
364
+
365
+ const dst = makeContext('linux', '/home/bob', 'web');
366
+ dst.plugins.installed.set('pkg-b', { name: 'pkg-b', version: '1.5.0', enabled: true });
367
+ const items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
368
+ const conflict = items.find((i) => i.id === 'plugin:pkg-b');
369
+ assert.equal(conflict?.kind, 'Conflict');
370
+ const updateItem: PlanItem = { ...conflict!, kind: 'Update', conflict: { itemId: conflict!.id, resolution: 'useImported' } };
371
+
372
+ // 子进程退出 0,但 pnpm 尊重了目标机既有 spec、什么都没换(真机空转形态)
373
+ dst.plugins.install = async (): Promise<{ needsRestart: boolean; installedVersion?: string | null }> => ({
374
+ needsRestart: true,
375
+ installedVersion: '1.5.0',
376
+ });
377
+ const stale = await adapter.applyItem(updateItem, makeImportContext(dst, sections));
378
+ assert.equal(stale.ok, false, '版本没落地就不能报 done');
379
+ assert.equal(stale.warning, true, '非致命:其余配置已导入');
380
+ assert.match(stale.message ?? '', /1\.5\.0/, '必须写出实际装成的版本');
381
+ assert.match(stale.message ?? '', /2\.0\.0/, '必须写出备份记录的版本');
382
+ assert.match(stale.message ?? '', /dsh plugin --profile web add pkg-b@2\.0\.0/, '给可复制的手动精确安装命令');
383
+
384
+ // 同一个计划项在版本真的落地时应判成功并写明版本
385
+ dst.plugins.install = async (): Promise<{ needsRestart: boolean; installedVersion?: string | null }> => ({
386
+ needsRestart: true,
387
+ installedVersion: '2.0.0',
388
+ });
389
+ const done = await adapter.applyItem(updateItem, makeImportContext(dst, sections));
390
+ assert.equal(done.ok, true);
391
+ assert.match(done.message ?? '', /2\.0\.0/, '成功文案必须带实际版本,便于用户核对');
392
+ });
393
+
358
394
  /* ---------------- preview:只读预览不得触发任何本地源打包(总览慢的根因) ---------------- */
359
395
 
360
396
  test('plugins: preview 不调用打包钩子,但条目/白名单/告警与 export 同口径', async () => {
@@ -729,3 +765,141 @@ test('issue #95:目标清单缺失 / JSON 坏 / 形态不符 → 只告警,
729
765
  assert.equal(itemsC[0]?.kind, 'Warning');
730
766
  assert.match(itemsC[0]?.description ?? '', /形态/);
731
767
  });
768
+
769
+ /* ---------------- ④ 可移植性净化:机器相关 pnpm 路径不随快照搬家 ---------------- */
770
+
771
+ const MACHINE_WS = [
772
+ 'storeDir: D:/pnpm-store',
773
+ 'cacheDir: D:/pnpm-cache',
774
+ 'nodeLinker: hoisted',
775
+ 'allowBuilds:',
776
+ ' ssh2: true',
777
+ '',
778
+ ].join('\n');
779
+ /** 剥离机器路径键之后应当逐字剩下的内容 */
780
+ const PORTABLE_WS = ['nodeLinker: hoisted', 'allowBuilds:', ' ssh2: true', ''].join('\n');
781
+
782
+ test('④ 可移植性:导出剥离机器相关 pnpm 路径键(其余逐字不变),且剥离可见', async () => {
783
+ const src = makeContext('win32', 'C:\\Users\\alice', 'web');
784
+ await src.fs.writeFile('profiles/web/pnpm-workspace.yaml', new TextEncoder().encode(MACHINE_WS));
785
+ const adapter = new PluginsAdapter();
786
+ const out = await adapter.export(src, { includeSecrets: false });
787
+ assert.equal(out.data.pnpmWorkspace, PORTABLE_WS, '机器路径键整行剥离,其余内容逐字不变');
788
+ assert.ok(
789
+ out.warnings.some((w) => w.includes('storeDir') && w.includes('cacheDir')),
790
+ `剥离必须走既有告警通道(不静默改写): ${out.warnings.join(' | ')}`,
791
+ );
792
+
793
+ // 目标机全新(没有本机设置可保留)→ Create,且回填的文本里没有导出机路径
794
+ const dst = makeContext('linux', '/home/bob', 'web');
795
+ const sections = new Map([['plugins', out.data]]);
796
+ const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
797
+ const wsItem = items.find((i) => i.id === 'plugins:pnpm-workspace');
798
+ assert.equal(wsItem?.kind, 'Create');
799
+ const applied = await adapter.applyItem(wsItem!, makeImportContext(dst, sections));
800
+ assert.equal(applied.ok, true);
801
+ const written = new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/pnpm-workspace.yaml')!);
802
+ assert.equal(written, PORTABLE_WS);
803
+ assert.equal(written.includes('D:/pnpm-store'), false, '回填不得写入导出机路径');
804
+ });
805
+
806
+ test('④ 可移植性:老载荷仍带导出机路径 → 写回侧再剥一次;本机自己的设置保留、导出机路径不落地', async () => {
807
+ // 老版本备份(收集侧还没有净化)里带着来源机的路径键
808
+ const data: PluginsSection = {
809
+ version: 1,
810
+ plugins: [],
811
+ patch: [],
812
+ pnpmWorkspace: 'storeDir: /home/alice/pnpm-store\ncacheDir: /home/alice/.cache/pnpm\nnodeLinker: hoisted\n',
813
+ };
814
+ const adapter = new PluginsAdapter();
815
+ const dst = makeContext('linux', '/home/bob', 'web');
816
+ await dst.fs.writeFile(
817
+ 'profiles/web/pnpm-workspace.yaml',
818
+ new TextEncoder().encode('storeDir: /home/bob/pnpm-store\nnodeLinker: isolated\n'),
819
+ );
820
+ const sections = new Map([['plugins', data]]);
821
+ const items = await adapter.analyzeImport(data, makeImportContext(dst, sections));
822
+ const wsItem = items.find((i) => i.id === 'plugins:pnpm-workspace');
823
+ assert.equal(wsItem?.kind, 'Update');
824
+ assert.ok(wsItem?.detail?.includes('已剥离'), `剥离必须走既有可见通道(detail): ${wsItem?.detail}`);
825
+ assert.ok(wsItem?.detail?.includes('storeDir'), '保留本机设置也要说清');
826
+ const applied = await adapter.applyItem(wsItem!, makeImportContext(dst, sections));
827
+ assert.equal(applied.ok, true);
828
+ const written = new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/pnpm-workspace.yaml')!);
829
+ assert.equal(written, 'nodeLinker: hoisted\nstoreDir: /home/bob/pnpm-store\n');
830
+ assert.equal(written.includes('/home/alice'), false, '导出机的机器路径绝不落地');
831
+ assert.equal(written.includes('cacheDir'), false, '导出机独有的键不得带到本机');
832
+ assert.ok(applied.message?.includes('storeDir'), `写入结果也要说出来: ${applied.message}`);
833
+ });
834
+
835
+ test('④ 可移植性:只差机器路径行(本机设置接回后与现文本同义)→ 不生成计划项、零写入', async () => {
836
+ const dst = makeContext('linux', '/home/bob', 'web');
837
+ await dst.fs.writeFile(
838
+ 'profiles/web/pnpm-workspace.yaml',
839
+ new TextEncoder().encode('nodeLinker: hoisted\nstoreDir: /home/bob/pnpm-store\n'),
840
+ );
841
+ const data: PluginsSection = {
842
+ version: 1,
843
+ plugins: [],
844
+ patch: [],
845
+ pnpmWorkspace: 'storeDir: /home/alice/pnpm-store\nnodeLinker: hoisted\n',
846
+ };
847
+ const adapter = new PluginsAdapter();
848
+ const items = await adapter.analyzeImport(data, makeImportContext(dst, new Map([['plugins', data]])));
849
+ assert.equal(
850
+ items.some((i) => i.id === 'plugins:pnpm-workspace'),
851
+ false,
852
+ '语义一致 ⇒ 不打扰用户、也不产生无意义的改写',
853
+ );
854
+ });
855
+
856
+ test('④ 可移植性:载荷剥完只剩空内容(来源机配置全是机器路径)→ 不产出空文件项', async () => {
857
+ const dst = makeContext('linux', '/home/bob', 'web');
858
+ const data: PluginsSection = { version: 1, plugins: [], patch: [], pnpmWorkspace: 'storeDir: /home/alice/pnpm-store\n' };
859
+ const adapter = new PluginsAdapter();
860
+ const items = await adapter.analyzeImport(data, makeImportContext(dst, new Map([['plugins', data]])));
861
+ assert.equal(items.some((i) => i.id === 'plugins:pnpm-workspace'), false, '没有可移植内容 ⇒ 不写空文件');
862
+ assert.equal(await dst.fs.exists('profiles/web/pnpm-workspace.yaml'), false, '零写入');
863
+ });
864
+
865
+ test('④ 可移植性:CRLF 的 pnpm-workspace.yaml 剥离后仍是 CRLF(无 EOL 漂移)', async () => {
866
+ const src = makeContext('win32', 'C:\\Users\\alice', 'web');
867
+ await src.fs.writeFile(
868
+ 'profiles/web/pnpm-workspace.yaml',
869
+ new TextEncoder().encode('storeDir: D:/pnpm-store\r\nnodeLinker: hoisted\r\n'),
870
+ );
871
+ const out = await new PluginsAdapter().export(src, { includeSecrets: false });
872
+ assert.equal(out.data.pnpmWorkspace, 'nodeLinker: hoisted\r\n');
873
+ });
874
+
875
+ test('④ 可移植性:本机 pnpm-workspace.yaml 存在但读不出来 → 显式失败且零写入(不静默丢本机设置)', async () => {
876
+ const dst = makeContext('linux', '/home/bob', 'web');
877
+ const local = 'storeDir: /home/bob/pnpm-store\n';
878
+ await dst.fs.writeFile('profiles/web/pnpm-workspace.yaml', new TextEncoder().encode(local));
879
+ const data: PluginsSection = { version: 1, plugins: [], patch: [], pnpmWorkspace: 'nodeLinker: hoisted\n' };
880
+ const adapter = new PluginsAdapter();
881
+ const ctx = makeImportContext(dst, new Map([['plugins', data]]));
882
+ ctx.target.fs.exists = async () => true;
883
+ ctx.target.fs.readFile = async () => {
884
+ const e = new Error('EACCES: permission denied') as Error & { code?: string };
885
+ e.code = 'EACCES';
886
+ throw e;
887
+ };
888
+ const item: PlanItem = {
889
+ id: 'plugins:pnpm-workspace',
890
+ kind: 'Update',
891
+ adapter: 'plugins',
892
+ description: 'x',
893
+ severity: 'info',
894
+ target: { adapter: 'plugins', ref: 'pnpm-workspace.yaml' },
895
+ };
896
+ const applied = await adapter.applyItem(item, ctx);
897
+ assert.equal(applied.ok, false);
898
+ assert.equal(applied.warning, true);
899
+ assert.ok(applied.message?.includes('EACCES'), `失败原因要如实回报: ${applied.message}`);
900
+ assert.equal(
901
+ new TextDecoder().decode(dst.fs.files.get('/home/bob/profiles/web/pnpm-workspace.yaml')!),
902
+ local,
903
+ '读不到就不写:本机文件逐字不动',
904
+ );
905
+ });