dsh-config-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/README.md +216 -0
  2. package/README.zh-CN.md +218 -0
  3. package/cordis.patch.yml +45 -0
  4. package/dsh.bundle.patch +21 -0
  5. package/dsh.client +39 -0
  6. package/lib/adapters/agent-presets.d.ts +13 -0
  7. package/lib/adapters/agent-presets.js +14 -0
  8. package/lib/adapters/agent-presets.js.map +1 -0
  9. package/lib/adapters/credentials.d.ts +36 -0
  10. package/lib/adapters/credentials.js +121 -0
  11. package/lib/adapters/credentials.js.map +1 -0
  12. package/lib/adapters/file-collection.d.ts +14 -0
  13. package/lib/adapters/file-collection.js +102 -0
  14. package/lib/adapters/file-collection.js.map +1 -0
  15. package/lib/adapters/index.d.ts +38 -0
  16. package/lib/adapters/index.js +46 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/mcp.d.ts +27 -0
  19. package/lib/adapters/mcp.js +175 -0
  20. package/lib/adapters/mcp.js.map +1 -0
  21. package/lib/adapters/plugin-files.d.ts +15 -0
  22. package/lib/adapters/plugin-files.js +91 -0
  23. package/lib/adapters/plugin-files.js.map +1 -0
  24. package/lib/adapters/plugins.d.ts +13 -0
  25. package/lib/adapters/plugins.js +177 -0
  26. package/lib/adapters/plugins.js.map +1 -0
  27. package/lib/adapters/prompts.d.ts +29 -0
  28. package/lib/adapters/prompts.js +228 -0
  29. package/lib/adapters/prompts.js.map +1 -0
  30. package/lib/adapters/providers.d.ts +33 -0
  31. package/lib/adapters/providers.js +143 -0
  32. package/lib/adapters/providers.js.map +1 -0
  33. package/lib/adapters/sessions.d.ts +14 -0
  34. package/lib/adapters/sessions.js +15 -0
  35. package/lib/adapters/sessions.js.map +1 -0
  36. package/lib/adapters/settings.d.ts +23 -0
  37. package/lib/adapters/settings.js +142 -0
  38. package/lib/adapters/settings.js.map +1 -0
  39. package/lib/adapters/skills.d.ts +13 -0
  40. package/lib/adapters/skills.js +14 -0
  41. package/lib/adapters/skills.js.map +1 -0
  42. package/lib/adapters/test-helpers.d.ts +99 -0
  43. package/lib/adapters/test-helpers.js +192 -0
  44. package/lib/adapters/test-helpers.js.map +1 -0
  45. package/lib/adapters/ui.d.ts +26 -0
  46. package/lib/adapters/ui.js +88 -0
  47. package/lib/adapters/ui.js.map +1 -0
  48. package/lib/adapters/workspaces.d.ts +12 -0
  49. package/lib/adapters/workspaces.js +92 -0
  50. package/lib/adapters/workspaces.js.map +1 -0
  51. package/lib/client.d.ts +377 -0
  52. package/lib/client.js +2598 -0
  53. package/lib/client.js.map +1 -0
  54. package/lib/core/analyzer.d.ts +35 -0
  55. package/lib/core/analyzer.js +563 -0
  56. package/lib/core/analyzer.js.map +1 -0
  57. package/lib/core/backup.d.ts +29 -0
  58. package/lib/core/backup.js +184 -0
  59. package/lib/core/backup.js.map +1 -0
  60. package/lib/core/exporter.d.ts +37 -0
  61. package/lib/core/exporter.js +222 -0
  62. package/lib/core/exporter.js.map +1 -0
  63. package/lib/core/importer.d.ts +42 -0
  64. package/lib/core/importer.js +25 -0
  65. package/lib/core/importer.js.map +1 -0
  66. package/lib/core/index.d.ts +12 -0
  67. package/lib/core/index.js +11 -0
  68. package/lib/core/index.js.map +1 -0
  69. package/lib/core/rollback.d.ts +9 -0
  70. package/lib/core/rollback.js +136 -0
  71. package/lib/core/rollback.js.map +1 -0
  72. package/lib/core/types.d.ts +331 -0
  73. package/lib/core/types.js +18 -0
  74. package/lib/core/types.js.map +1 -0
  75. package/lib/core/validator.d.ts +18 -0
  76. package/lib/core/validator.js +77 -0
  77. package/lib/core/validator.js.map +1 -0
  78. package/lib/index.d.ts +51 -0
  79. package/lib/index.js +875 -0
  80. package/lib/index.js.map +1 -0
  81. package/lib/migrations/index.d.ts +29 -0
  82. package/lib/migrations/index.js +56 -0
  83. package/lib/migrations/index.js.map +1 -0
  84. package/lib/migrations/v1-to-v2.d.ts +9 -0
  85. package/lib/migrations/v1-to-v2.js +10 -0
  86. package/lib/migrations/v1-to-v2.js.map +1 -0
  87. package/lib/profiles/index.d.ts +4 -0
  88. package/lib/profiles/index.js +5 -0
  89. package/lib/profiles/index.js.map +1 -0
  90. package/lib/profiles/profile-manager.d.ts +102 -0
  91. package/lib/profiles/profile-manager.js +522 -0
  92. package/lib/profiles/profile-manager.js.map +1 -0
  93. package/lib/schema/config.d.ts +28 -0
  94. package/lib/schema/config.js +136 -0
  95. package/lib/schema/config.js.map +1 -0
  96. package/lib/schema/manifest.d.ts +28 -0
  97. package/lib/schema/manifest.js +109 -0
  98. package/lib/schema/manifest.js.map +1 -0
  99. package/lib/schema/types.d.ts +169 -0
  100. package/lib/schema/types.js +8 -0
  101. package/lib/schema/types.js.map +1 -0
  102. package/lib/schema/versions.d.ts +27 -0
  103. package/lib/schema/versions.js +50 -0
  104. package/lib/schema/versions.js.map +1 -0
  105. package/lib/security/encryption.d.ts +41 -0
  106. package/lib/security/encryption.js +152 -0
  107. package/lib/security/encryption.js.map +1 -0
  108. package/lib/security/index.d.ts +16 -0
  109. package/lib/security/index.js +17 -0
  110. package/lib/security/index.js.map +1 -0
  111. package/lib/security/integrity.d.ts +30 -0
  112. package/lib/security/integrity.js +78 -0
  113. package/lib/security/integrity.js.map +1 -0
  114. package/lib/security/redaction.d.ts +26 -0
  115. package/lib/security/redaction.js +95 -0
  116. package/lib/security/redaction.js.map +1 -0
  117. package/lib/security/secret-scanner.d.ts +48 -0
  118. package/lib/security/secret-scanner.js +253 -0
  119. package/lib/security/secret-scanner.js.map +1 -0
  120. package/lib/security/zip-security.d.ts +27 -0
  121. package/lib/security/zip-security.js +154 -0
  122. package/lib/security/zip-security.js.map +1 -0
  123. package/lib/ui/conflict-view.d.ts +28 -0
  124. package/lib/ui/conflict-view.js +49 -0
  125. package/lib/ui/conflict-view.js.map +1 -0
  126. package/lib/ui/errors.d.ts +17 -0
  127. package/lib/ui/errors.js +76 -0
  128. package/lib/ui/errors.js.map +1 -0
  129. package/lib/ui/export-flow.d.ts +62 -0
  130. package/lib/ui/export-flow.js +79 -0
  131. package/lib/ui/export-flow.js.map +1 -0
  132. package/lib/ui/import-wizard.d.ts +67 -0
  133. package/lib/ui/import-wizard.js +169 -0
  134. package/lib/ui/import-wizard.js.map +1 -0
  135. package/lib/ui/path-mapping.d.ts +38 -0
  136. package/lib/ui/path-mapping.js +75 -0
  137. package/lib/ui/path-mapping.js.map +1 -0
  138. package/lib/ui/progress.d.ts +27 -0
  139. package/lib/ui/progress.js +76 -0
  140. package/lib/ui/progress.js.map +1 -0
  141. package/lib/ui/report.d.ts +21 -0
  142. package/lib/ui/report.js +146 -0
  143. package/lib/ui/report.js.map +1 -0
  144. package/lib/ui/test-helpers.d.ts +62 -0
  145. package/lib/ui/test-helpers.js +147 -0
  146. package/lib/ui/test-helpers.js.map +1 -0
  147. package/lib/ui/types.d.ts +129 -0
  148. package/lib/ui/types.js +21 -0
  149. package/lib/ui/types.js.map +1 -0
  150. package/lib/utils/hashing.d.ts +17 -0
  151. package/lib/utils/hashing.js +41 -0
  152. package/lib/utils/hashing.js.map +1 -0
  153. package/lib/utils/json.d.ts +24 -0
  154. package/lib/utils/json.js +76 -0
  155. package/lib/utils/json.js.map +1 -0
  156. package/lib/utils/logger.d.ts +39 -0
  157. package/lib/utils/logger.js +82 -0
  158. package/lib/utils/logger.js.map +1 -0
  159. package/lib/utils/paths.d.ts +31 -0
  160. package/lib/utils/paths.js +132 -0
  161. package/lib/utils/paths.js.map +1 -0
  162. package/lib/utils/zip.d.ts +54 -0
  163. package/lib/utils/zip.js +285 -0
  164. package/lib/utils/zip.js.map +1 -0
  165. package/package.json +109 -0
  166. package/src/adapters/agent-presets.ts +14 -0
  167. package/src/adapters/credentials.test.ts +90 -0
  168. package/src/adapters/credentials.ts +141 -0
  169. package/src/adapters/file-collection.ts +110 -0
  170. package/src/adapters/files.test.ts +113 -0
  171. package/src/adapters/index.ts +69 -0
  172. package/src/adapters/mcp.test.ts +96 -0
  173. package/src/adapters/mcp.ts +181 -0
  174. package/src/adapters/plugin-files.ts +96 -0
  175. package/src/adapters/plugins.test.ts +72 -0
  176. package/src/adapters/plugins.ts +176 -0
  177. package/src/adapters/prompts.test.ts +110 -0
  178. package/src/adapters/prompts.ts +226 -0
  179. package/src/adapters/providers.test.ts +125 -0
  180. package/src/adapters/providers.ts +161 -0
  181. package/src/adapters/roundtrip.test.ts +188 -0
  182. package/src/adapters/sessions.ts +15 -0
  183. package/src/adapters/settings.test.ts +93 -0
  184. package/src/adapters/settings.ts +165 -0
  185. package/src/adapters/skills.ts +14 -0
  186. package/src/adapters/test-helpers.ts +207 -0
  187. package/src/adapters/ui.test.ts +56 -0
  188. package/src/adapters/ui.ts +106 -0
  189. package/src/adapters/workspaces.test.ts +79 -0
  190. package/src/adapters/workspaces.ts +95 -0
  191. package/src/client/CLIENT_DEPENDENCIES.md +101 -0
  192. package/src/client/ConfigManagerSection.tsx +60 -0
  193. package/src/client/api.ts +270 -0
  194. package/src/client/client-types.ts +27 -0
  195. package/src/client/common/ErrorBanner.tsx +68 -0
  196. package/src/client/common/ProgressBar.tsx +49 -0
  197. package/src/client/common/ReportView.tsx +117 -0
  198. package/src/client/common/ui.tsx +165 -0
  199. package/src/client/config-manager.module.css +759 -0
  200. package/src/client/css-modules.d.ts +8 -0
  201. package/src/client/export/ExportView.tsx +222 -0
  202. package/src/client/import/ConflictList.tsx +72 -0
  203. package/src/client/import/ImportWizardView.tsx +406 -0
  204. package/src/client/import/PathMappingForm.tsx +77 -0
  205. package/src/client/index.ts +65 -0
  206. package/src/client/locales.ts +182 -0
  207. package/src/core/analyzer.ts +631 -0
  208. package/src/core/backup.ts +212 -0
  209. package/src/core/exporter.ts +246 -0
  210. package/src/core/importer.ts +66 -0
  211. package/src/core/index.ts +39 -0
  212. package/src/core/rollback.ts +140 -0
  213. package/src/core/smoke.test.ts +717 -0
  214. package/src/core/types.ts +343 -0
  215. package/src/core/validator.ts +84 -0
  216. package/src/index.ts +942 -0
  217. package/src/migrations/index.ts +71 -0
  218. package/src/migrations/v1-to-v2.ts +17 -0
  219. package/src/profiles/index.ts +8 -0
  220. package/src/profiles/profile-manager.test.ts +280 -0
  221. package/src/profiles/profile-manager.ts +606 -0
  222. package/src/schema/config.ts +137 -0
  223. package/src/schema/manifest.ts +124 -0
  224. package/src/schema/types.ts +179 -0
  225. package/src/schema/versions.ts +55 -0
  226. package/src/security/encryption.ts +180 -0
  227. package/src/security/index.ts +16 -0
  228. package/src/security/integrity.ts +102 -0
  229. package/src/security/redaction.ts +101 -0
  230. package/src/security/secret-scanner.ts +275 -0
  231. package/src/security/security.test.ts +796 -0
  232. package/src/security/zip-security.ts +180 -0
  233. package/src/ui/conflict-view.test.ts +62 -0
  234. package/src/ui/conflict-view.ts +65 -0
  235. package/src/ui/errors.test.ts +44 -0
  236. package/src/ui/errors.ts +95 -0
  237. package/src/ui/export-flow.test.ts +75 -0
  238. package/src/ui/export-flow.ts +128 -0
  239. package/src/ui/import-wizard.test.ts +128 -0
  240. package/src/ui/import-wizard.ts +208 -0
  241. package/src/ui/path-mapping.test.ts +67 -0
  242. package/src/ui/path-mapping.ts +103 -0
  243. package/src/ui/progress.test.ts +42 -0
  244. package/src/ui/progress.ts +90 -0
  245. package/src/ui/report.test.ts +93 -0
  246. package/src/ui/report.ts +140 -0
  247. package/src/ui/test-helpers.ts +177 -0
  248. package/src/ui/types.ts +193 -0
  249. package/src/utils/hashing.ts +53 -0
  250. package/src/utils/json.ts +77 -0
  251. package/src/utils/logger.ts +114 -0
  252. package/src/utils/paths.ts +122 -0
  253. package/src/utils/zip.ts +332 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * providers adapter 测试:llm-deepseek(单 provider)与 llm-pi-ai(多 provider)导出、
3
+ * Create/Skip/Conflict 分析、整体 namespace 写回(raw + 乐观锁)。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { ProvidersAdapter } from './providers.ts';
8
+ import { createSecretScanner } from '../security/secret-scanner.ts';
9
+ import { makeContext, makeImportContext } from './test-helpers.ts';
10
+ import type { PlanItem } from '../core/types.ts';
11
+
12
+ test('providers: 导出单/多 provider section(含 revision 与 raw)', async () => {
13
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
14
+ ctx.settings.ns.set('llm-deepseek', {
15
+ value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', apiKey: 'sk-secret' },
16
+ revision: 5,
17
+ secrets: [{ path: ['apiKey'], set: true }],
18
+ });
19
+ ctx.settings.ns.set('llm-pi-ai', {
20
+ value: {
21
+ providers: {
22
+ openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'] },
23
+ local: { apiKeyEnv: 'LOCAL_KEY', baseURL: 'http://127.0.0.1:11434' },
24
+ },
25
+ },
26
+ revision: 2,
27
+ secrets: [],
28
+ });
29
+
30
+ const adapter = new ProvidersAdapter();
31
+ const out = await adapter.export(ctx, { includeSecrets: false });
32
+ assert.equal(out.data.version, 1);
33
+ const routes = Object.keys(out.data.providers).sort();
34
+ assert.deepEqual(routes, ['llm-deepseek', 'local', 'openai']);
35
+ const deepseek = out.data.providers['llm-deepseek'];
36
+ assert.equal(deepseek?.namespace, 'llm-deepseek');
37
+ assert.equal(deepseek?.revision, 5);
38
+ assert.equal(deepseek?.apiKeyEnv, 'DEEPSEEK_API_KEY');
39
+ assert.ok(deepseek?.raw, 'raw 保存完整 redacted namespace 值');
40
+ const openai = out.data.providers['openai'];
41
+ assert.equal(openai?.namespace, 'llm-pi-ai');
42
+ assert.equal(openai?.baseURL, 'https://api.openai.com');
43
+ assert.equal(out.counts.providers, 3);
44
+
45
+ const v = await adapter.validate(out.data);
46
+ assert.equal(v.valid, true);
47
+ });
48
+
49
+ test('providers: analyzeImport Create/Skip/Conflict + applyItem 写回整个 namespace', async () => {
50
+ const src = makeContext('win32', 'C:\\Users\\alice');
51
+ src.settings.ns.set('llm-pi-ai', {
52
+ value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } },
53
+ revision: 2,
54
+ secrets: [],
55
+ });
56
+ const adapter = new ProvidersAdapter();
57
+ const exported = await adapter.export(src, { includeSecrets: false });
58
+ const sections = new Map([['providers', exported.data]]);
59
+
60
+ const dst = makeContext('linux', '/home/bob');
61
+ let items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
62
+ assert.equal(items.length, 1);
63
+ assert.equal(items[0]?.kind, 'Create');
64
+ assert.equal(items[0]?.id, 'provider:openai');
65
+ assert.equal(items[0]?.target?.ref, 'llm-pi-ai', '快照定位 namespace');
66
+ let r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
67
+ assert.equal(r.ok, true);
68
+ assert.deepEqual(dst.settings.ns.get('llm-pi-ai')?.value, { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } });
69
+
70
+ // 幂等:一致 → Skip
71
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
72
+ assert.equal(items[0]?.kind, 'Skip');
73
+
74
+ // 目标不同 → Conflict;useImported 覆盖
75
+ dst.settings.ns.set('llm-pi-ai', {
76
+ value: { providers: { openai: { apiKeyEnv: 'OTHER_KEY', baseURL: 'https://other.example.com' } } },
77
+ revision: 8,
78
+ secrets: [],
79
+ });
80
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
81
+ assert.equal(items[0]?.kind, 'Conflict');
82
+ const useItem: PlanItem = { ...items[0]!, kind: 'Update' };
83
+ await adapter.applyItem(useItem, makeImportContext(dst, sections));
84
+ assert.deepEqual(dst.settings.ns.get('llm-pi-ai')?.value, { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com' } } });
85
+ });
86
+
87
+ test('providers: validate 拒绝缺 namespace/revision 的记录', async () => {
88
+ const adapter = new ProvidersAdapter();
89
+ const bad = await adapter.validate({ version: 1, providers: { x: { route: 'x' } as never } });
90
+ assert.equal(bad.valid, false);
91
+ });
92
+
93
+ test('providers: 多 route 共享 secrets 引用不触发强化 scanner 循环误报(m4 createSecretScanner)', async () => {
94
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
95
+ ctx.settings.ns.set('llm-pi-ai', {
96
+ value: {
97
+ providers: {
98
+ openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'], apiKey: 'sk-openai-123' },
99
+ local: { apiKeyEnv: 'LOCAL_KEY', baseURL: 'http://127.0.0.1:11434', token: 'local-token-456' },
100
+ },
101
+ },
102
+ revision: 2,
103
+ secrets: [{ path: ['apiKey'], set: true }],
104
+ });
105
+ const adapter = new ProvidersAdapter();
106
+ const exported = await adapter.export(ctx, { includeSecrets: false });
107
+ assert.equal(Object.keys(exported.data.providers).length, 2);
108
+
109
+ // 隔离断言:route 间 secrets / raw 必须独立引用(共享引用会被 visited 循环检测误判)
110
+ const openai = exported.data.providers['openai'];
111
+ const local = exported.data.providers['local'];
112
+ assert.notStrictEqual(openai?.secrets, local?.secrets, 'route 间 secrets 数组必须独立');
113
+ assert.notStrictEqual(openai?.raw, local?.raw, 'route 间 raw 必须独立');
114
+
115
+ // m4 强化版 scanner 扫描导出数据:不得抛「检测到循环引用」,且敏感字段被剥离
116
+ const scanner = createSecretScanner();
117
+ let scanned: { sanitized: unknown; hits: import('../core/types.ts').SensitiveHit[] };
118
+ assert.doesNotThrow(() => { scanned = scanner.scanAndRedact(exported.data); }, '共享引用不得被误判为循环');
119
+ assert.ok(scanned!.hits.length >= 2, `apiKey/token 字段应被剥离(实际 ${scanned!.hits.length})`);
120
+ const sanitizedProviders = (scanned!.sanitized as { providers: Record<string, Record<string, unknown>> }).providers;
121
+ assert.equal(sanitizedProviders['openai']?.['apiKey'], '', 'apiKey 值剥离');
122
+ assert.equal(sanitizedProviders['local']?.['token'], '', 'token 值剥离');
123
+ assert.equal(sanitizedProviders['openai']?.['apiKeyEnv'], 'OPENAI_API_KEY', '引用字段 apiKeyEnv 保留');
124
+ assert.equal(sanitizedProviders['openai']?.['baseURL'], 'https://api.openai.com', '非敏感字段保留');
125
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * providers 分区 adapter(设计 §3.3/§13.1):
3
+ * 数据源 = settings.yaml 的 llm-deepseek / llm-pi-ai section(providers 与 models 天然同 section,不拆文件)。
4
+ *
5
+ * 导入走 settings 写通道(replace llm-* namespace + 乐观锁):
6
+ * 每项携带 raw(该 namespace 的完整 redacted 值)用于整体写回,apiKey 值经 credentials 状态占位(不导出值)。
7
+ */
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import type { ProviderEntry } from '../schema/types.ts';
10
+ import type {
11
+ ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
12
+ ImportContext, NamespaceInfo, PlanItem, ValidationResult,
13
+ } from '../core/types.ts';
14
+ import { resolveNamespaces, type NamespaceProvider } from './settings.ts';
15
+
16
+ /** 导出记录:ProviderEntry 之外附加 namespace 级元数据(导入写回用),满足 ProvidersSection 形状 */
17
+ export interface ProviderExportEntry extends ProviderEntry {
18
+ /** 来源 settings namespace(llm-deepseek / llm-pi-ai) */
19
+ namespace: string;
20
+ /** 乐观锁 revision(导入时检测并发修改) */
21
+ revision: number;
22
+ secrets?: { path: string[]; set: boolean }[];
23
+ /** 该 namespace 的完整 redacted 值(导入整体写回,避免按字段重建丢失结构) */
24
+ raw?: Record<string, unknown>;
25
+ }
26
+
27
+ export interface ProviderExportSection { version: 1; providers: Record<string, ProviderExportEntry>; }
28
+
29
+ export const DEFAULT_PROVIDER_NAMESPACES: readonly string[] = ['llm-deepseek', 'llm-pi-ai'];
30
+
31
+ /** 无 raw 时的兜底重建:只取 ProviderEntry 已知字段 */
32
+ function stripEntry(entry: ProviderExportEntry): Record<string, unknown> {
33
+ const out: Record<string, unknown> = {};
34
+ for (const key of ['apiKeyEnv', 'displayName', 'baseURL', 'models', 'modelOverrides', 'reasoning', 'transport', 'retryPolicy'] as const) {
35
+ if (entry[key] !== undefined) out[key] = entry[key];
36
+ }
37
+ return out;
38
+ }
39
+
40
+ export class ProvidersAdapter implements ConfigAdapter<ProviderExportSection> {
41
+ readonly id = 'providers' as const;
42
+ readonly displayName = 'Providers & Models';
43
+ readonly defaultIncluded = true;
44
+ readonly portability = 'portable' as const;
45
+ private readonly namespaces: string[] | NamespaceProvider;
46
+
47
+ constructor(namespaces: string[] | NamespaceProvider = DEFAULT_PROVIDER_NAMESPACES as unknown as string[]) {
48
+ this.namespaces = namespaces;
49
+ }
50
+
51
+ async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<ProviderExportSection>> {
52
+ const providers: Record<string, ProviderExportEntry> = {};
53
+ const warnings: string[] = [];
54
+ for (const ns of await resolveNamespaces(this.namespaces, ctx)) {
55
+ let info: NamespaceInfo;
56
+ try {
57
+ info = await ctx.settings.describe(ns, { redactSecrets: true });
58
+ } catch (err) {
59
+ warnings.push(`provider namespace ${ns} 读取失败(可能未配置): ${err instanceof Error ? err.message : String(err)}`);
60
+ continue;
61
+ }
62
+ const value = (info.value ?? {}) as Record<string, unknown>;
63
+ // llm-pi-ai 形态:value.providers = { route: {...} };llm-deepseek 形态:value 即单 provider 配置
64
+ const routes: [string, unknown][] =
65
+ value.providers !== null && typeof value.providers === 'object'
66
+ ? Object.entries(value.providers as Record<string, unknown>)
67
+ : [[ns, value]];
68
+ for (const [route, pv] of routes) {
69
+ // 每个 route 独立深拷贝 secrets / pv / raw:同一 namespace 的多 route 若共享引用,
70
+ // m4 强化版 createSecretScanner 的 visited 循环检测会误判「检测到循环引用」(见 providers.test.ts)。
71
+ providers[route] = {
72
+ route,
73
+ namespace: ns,
74
+ revision: info.revision,
75
+ secrets: structuredClone(info.secrets),
76
+ ...structuredClone(pv as Record<string, unknown>),
77
+ raw: structuredClone(value),
78
+ };
79
+ }
80
+ }
81
+ return {
82
+ sectionId: 'providers',
83
+ data: { version: 1, providers },
84
+ counts: { providers: Object.keys(providers).length },
85
+ warnings,
86
+ };
87
+ }
88
+
89
+ async analyzeImport(data: ProviderExportSection, ctx: ImportContext): Promise<PlanItem[]> {
90
+ const items: PlanItem[] = [];
91
+ for (const [route, entry] of Object.entries(data.providers)) {
92
+ const id = `provider:${route}`;
93
+ let current: NamespaceInfo | null = null;
94
+ try {
95
+ current = await ctx.target.settings.describe(entry.namespace, { redactSecrets: true });
96
+ } catch {
97
+ current = null;
98
+ }
99
+ if (current === null) {
100
+ items.push({
101
+ id, kind: 'Create', adapter: 'providers',
102
+ description: `创建 Provider ${route}(namespace ${entry.namespace})`, severity: 'info',
103
+ target: { adapter: 'providers', ref: entry.namespace },
104
+ });
105
+ } else if (isDeepStrictEqual(current.value, entry.raw ?? stripEntry(entry))) {
106
+ items.push({ id, kind: 'Skip', adapter: 'providers', description: `Provider ${route} 已一致`, severity: 'info' });
107
+ } else {
108
+ items.push({
109
+ id, kind: 'Conflict', adapter: 'providers',
110
+ description: `Provider ${route} 与目标不同`,
111
+ detail: `namespace=${entry.namespace} current=${JSON.stringify(current.value)} imported=${JSON.stringify(entry.raw ?? stripEntry(entry))}`.slice(0, 200),
112
+ severity: 'warning', target: { adapter: 'providers', ref: entry.namespace },
113
+ });
114
+ }
115
+ }
116
+ return items;
117
+ }
118
+
119
+ async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
120
+ const route = item.id.replace(/^provider:/, '');
121
+ const data = ctx.sections.get('providers') as ProviderExportSection | undefined;
122
+ const entry = data?.providers[route];
123
+ if (!entry) return { ok: false, message: `导入数据缺少 provider ${route}` };
124
+ const value = entry.raw ?? stripEntry(entry);
125
+ let expected: number | undefined;
126
+ try {
127
+ expected = (await ctx.target.settings.describe(entry.namespace, { redactSecrets: true })).revision;
128
+ } catch {
129
+ expected = undefined; // 目标 namespace 不存在(Create)
130
+ }
131
+ await ctx.target.settings.replace(entry.namespace, value, expected);
132
+ return { ok: true };
133
+ }
134
+
135
+ async validate(data: ProviderExportSection): Promise<ValidationResult> {
136
+ const issues: ValidationResult['issues'] = [];
137
+ if (data === null || typeof data !== 'object') {
138
+ return { valid: false, issues: [{ path: '$', message: 'providers 数据必须是对象', severity: 'error' }] };
139
+ }
140
+ if (data.version !== 1) {
141
+ issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
142
+ }
143
+ if (data.providers === null || typeof data.providers !== 'object') {
144
+ issues.push({ path: 'providers', message: '缺少 providers 对象', severity: 'error' });
145
+ } else {
146
+ for (const [route, entry] of Object.entries(data.providers)) {
147
+ if (entry === null || typeof entry !== 'object') {
148
+ issues.push({ path: `providers.${route}`, message: 'provider 记录必须是对象', severity: 'error' });
149
+ continue;
150
+ }
151
+ if (typeof entry.namespace !== 'string' || entry.namespace === '') {
152
+ issues.push({ path: `providers.${route}.namespace`, message: '缺少来源 namespace', severity: 'error' });
153
+ }
154
+ if (typeof entry.revision !== 'number') {
155
+ issues.push({ path: `providers.${route}.revision`, message: 'revision 必须是数字', severity: 'error' });
156
+ }
157
+ }
158
+ }
159
+ return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
160
+ }
161
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * 适配器集成往返测试:真实 adapter 列表(createAdapters)+ 核心引擎
3
+ * Exporter → ZIP → Importer(win32 → linux),验证:
4
+ * - 全分区导出、Secret 值不进入 ZIP、路径映射、凭据补录报告、
5
+ * - MCP/插件写 patch 的 needsRestart、重复导入幂等。
6
+ */
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import { Exporter } from '../core/exporter.ts';
14
+ import { Importer } from '../core/importer.ts';
15
+ import { parseZip } from '../utils/zip.ts';
16
+ import { normalizePath } from '../utils/paths.ts';
17
+ import { createAdapters } from './index.ts';
18
+ import { makeContext, MemSnapshotStore, type MockHostContext } from './test-helpers.ts';
19
+
20
+ const NS = ['general', 'theme', 'llm-deepseek', 'llm-pi-ai'];
21
+
22
+ async function seedSource(ctx: MockHostContext): Promise<void> {
23
+ ctx.settings.ns.set('general', { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] });
24
+ ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] });
25
+ ctx.settings.ns.set('llm-deepseek', {
26
+ value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', apiKey: 'sk-super-secret-value-123' },
27
+ revision: 5,
28
+ secrets: [{ path: ['apiKey'], set: true }],
29
+ });
30
+ ctx.settings.ns.set('llm-pi-ai', {
31
+ value: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://api.openai.com', models: ['gpt-4o'] } } },
32
+ revision: 2,
33
+ secrets: [],
34
+ });
35
+ ctx.credentials.values.set('DEEPSEEK_API_KEY', 'sk-super-secret-value-123');
36
+ ctx.credentials.values.set('OPENAI_API_KEY', 'sk-openai-456');
37
+
38
+ await ctx.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\nUse deepseek.\n', 'utf8'));
39
+ await ctx.fs.writeFile('.agent-presets/work/agent.cordis.yml', Buffer.from('services:\n - name: work\n', 'utf8'));
40
+ await ctx.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":[]}', 'utf8'));
41
+
42
+ ctx.workspace.records.set('ws-ops', {
43
+ id: 'ws-ops', path: 'C:\\Users\\alice\\projects\\ops', title: 'OpsFlow', sessionIds: [],
44
+ });
45
+ ctx.patchFile.lines.set('mcp-fs', {
46
+ lineId: 'mcp-fs',
47
+ raw: { id: 'mcp-fs', name: 'dsh-mcp-client', config: { serverName: 'filesystem', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], cwd: 'C:\\Users\\alice\\projects' } },
48
+ });
49
+ ctx.patchFile.lines.set('persona-line', {
50
+ lineId: 'persona-line',
51
+ raw: { id: 'persona-line', name: '@deepseek-ai/dsh-web', config: { systemPrompt: { persona: 'You are a helpful assistant.' } } },
52
+ });
53
+ }
54
+
55
+ test('集成往返:win32 → linux(路径映射 + Secret 不泄 + 补录报告 + needsRestart + 幂等)', async () => {
56
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-adapters-'));
57
+ try {
58
+ const src = makeContext('win32', 'C:\\Users\\alice');
59
+ await seedSource(src);
60
+ const adapters = createAdapters({ namespaces: NS });
61
+ const zipPath = path.join(tmp, 'dsh-config-roundtrip.zip');
62
+ const exporter = new Exporter({
63
+ ctx: src, adapters, exporterVersion: '0.1.0',
64
+ now: () => new Date('2026-08-14T12:00:00.000Z'),
65
+ });
66
+ const result = await exporter.export({ includeSecrets: false, outPath: zipPath });
67
+ assert.equal(result.manifest.schemaVersion, 1);
68
+ assert.equal(result.manifest.security.containsSecrets, false);
69
+ assert.equal(result.report.security.redactedHits >= 1, true, 'apiKey 字段应被 scanner 剥离计数');
70
+
71
+ // ZIP 内容检查:secret 值绝不出现;插件二进制未打包
72
+ const archive = parseZip(await fs.readFile(zipPath));
73
+ const allText = archive.names()
74
+ .filter((n) => n !== 'integrity/checksums.json')
75
+ .map((n) => Buffer.from(archive.readEntry(n)).toString('utf8'))
76
+ .join('\n');
77
+ assert.ok(!allText.includes('sk-super-secret-value-123'), 'secret 值不得写入导出');
78
+ assert.ok(archive.has('config/settings.json'));
79
+ assert.ok(archive.has('config/ui.json'));
80
+ assert.ok(archive.has('ai/providers.json'));
81
+ assert.ok(archive.has('plugins/plugins.json'));
82
+ assert.ok(archive.has('mcp/servers.json'));
83
+ assert.ok(archive.has('custom/prompts.json'));
84
+ assert.ok(archive.has('custom/skills/coding.md'));
85
+ assert.ok(archive.has('agents/presets/work/agent.cordis.yml'));
86
+ assert.ok(archive.has('workspaces/workspaces.json'));
87
+ assert.ok(archive.has('security/credentials.json'));
88
+
89
+ // —— 目标机器(linux)导入 ——
90
+ const dst = makeContext('linux', '/home/bob');
91
+ const importer = new Importer({ ctx: dst, adapters, snapshotStore: new MemSnapshotStore() });
92
+
93
+ const analysis = await importer.analyzeImport(zipPath);
94
+ assert.equal(analysis.valid, true);
95
+ assert.equal(analysis.secretCount, 2, '两个已配置凭据(DEEPSEEK/OPENAI)需补录');
96
+ assert.equal(analysis.compatibility, 'partial'); // 跨平台
97
+ assert.ok(analysis.pathIssues.some((p) => p.kind === 'platformMismatch' && p.value.includes('C:\\Users\\alice')), '跨平台路径检测');
98
+
99
+ const plan = await importer.createImportPlan(zipPath, {
100
+ strategy: 'merge',
101
+ resolutions: {},
102
+ pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
103
+ });
104
+ assert.ok(plan.pathMappings.length >= 1);
105
+ assert.equal(plan.needsRestart, true, 'MCP/patch 行写入需要重启');
106
+
107
+ // Dry Run 零写入
108
+ assert.equal(dst.settings.ns.size, 0);
109
+ assert.equal(dst.patchFile.lines.size, 0);
110
+
111
+ const r = await importer.executeImportPlan(zipPath, plan, {
112
+ confirm: true,
113
+ secretInputs: { DEEPSEEK_API_KEY: 'sk-reentered' },
114
+ });
115
+ assert.equal(r.ok, true);
116
+
117
+ // settings/ui/providers 写回
118
+ assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
119
+ assert.deepEqual(dst.settings.ns.get('theme')?.value, { mode: 'dark' });
120
+ const llm = dst.settings.ns.get('llm-deepseek')?.value as Record<string, unknown>;
121
+ assert.equal(llm.apiKeyEnv, 'DEEPSEEK_API_KEY');
122
+ assert.equal(llm.apiKey, '', 'secret 字段剥离为空串');
123
+
124
+ // 文件类写回
125
+ assert.equal(Buffer.from(await dst.fs.readFile('skills/coding.md')).toString(), '# Coding skill\nUse deepseek.\n');
126
+ assert.equal(Buffer.from(await dst.fs.readFile('.agent-presets/work/agent.cordis.yml')).toString(), 'services:\n - name: work\n');
127
+
128
+ // workspaces 路径映射
129
+ const ws = dst.workspace.records.get('ws-ops');
130
+ assert.ok(ws);
131
+ assert.equal(normalizePath(ws.path), '/home/bob/projects/ops', '绝对路径被映射');
132
+
133
+ // MCP / prompt patch 行写入(needsRestart 语义)
134
+ assert.ok(dst.patchFile.lines.has('mcp-fs'), 'MCP patch 行写入');
135
+ const mcpLine = dst.patchFile.lines.get('mcp-fs')?.raw as Record<string, unknown>;
136
+ assert.equal((mcpLine['config'] as Record<string, unknown>)['cwd'], '/home/bob/projects', 'MCP cwd 被映射');
137
+ const persona = dst.patchFile.lines.get('persona-line')?.raw as Record<string, unknown>;
138
+ assert.equal(((persona['config'] as Record<string, unknown>)['systemPrompt'] as Record<string, unknown>)['persona'], 'You are a helpful assistant.');
139
+
140
+ // 凭据:补录写入 + 未补录如实报告
141
+ assert.equal(dst.credentials.values.get('DEEPSEEK_API_KEY'), 'sk-reentered');
142
+ assert.ok(r.missingSecrets.includes('OPENAI_API_KEY'), '未补录的凭据进入报告');
143
+ assert.ok(!dst.credentials.values.has('OPENAI_API_KEY'), '未提供值不得写入');
144
+
145
+ // 幂等:再次导入 → 无新写入项(除 MissingSecret 占位)
146
+ const plan2 = await importer.createImportPlan(zipPath, {
147
+ strategy: 'merge',
148
+ resolutions: {},
149
+ pathMappings: [{ oldPrefix: 'C:\\Users\\alice', newPrefix: '/home/bob', appliesTo: ['workspaces', 'mcp', 'pluginConfig', 'skills'] }],
150
+ });
151
+ assert.ok(
152
+ !plan2.items.some((i) => i.kind === 'Create' || i.kind === 'Update' || i.kind === 'Install'),
153
+ '重复导入不产生新写入项',
154
+ );
155
+ const r2 = await importer.executeImportPlan(zipPath, plan2, { confirm: true, secretInputs: {} });
156
+ assert.ok(r2.executed.every((e) => e.status !== 'failed'), '第二次导入无失败项');
157
+ assert.equal(dst.patchFile.lines.size, 2, 'patch 行未被重复插入');
158
+ } finally {
159
+ await fs.rm(tmp, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ test('集成:pluginFiles 默认不导出 / sessions 默认不导出(manifest 标记 false)', async () => {
164
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-adapters-opt-'));
165
+ try {
166
+ const src = makeContext('win32', 'C:\\Users\\alice');
167
+ await src.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":[]}', 'utf8'));
168
+ await src.fs.writeFile('sessions/p/s/s.jsonl.zstd', Buffer.from('x', 'utf8'));
169
+ const adapters = createAdapters({ namespaces: NS, includeSessions: true });
170
+ const zipPath = path.join(tmp, 'opt.zip');
171
+ const exporter = new Exporter({ ctx: src, adapters, exporterVersion: '0.1.0' });
172
+ const result = await exporter.export({ includeSecrets: false, outPath: zipPath });
173
+ assert.equal(result.manifest.sections.pluginFiles, false, 'pluginFiles 默认不导出');
174
+ assert.equal(result.manifest.sections.sessions, false, 'sessions 默认不导出');
175
+ const archive = parseZip(await fs.readFile(zipPath));
176
+ assert.ok(!archive.has('plugin-files/dsh-ssh.json'));
177
+ assert.ok(!archive.has('sessions/p/s/s.jsonl.zstd'));
178
+
179
+ // 显式勾选后导出
180
+ const zipPath2 = path.join(tmp, 'opt2.zip');
181
+ await exporter.export({ includeSecrets: false, only: ['pluginFiles', 'sessions'], outPath: zipPath2 });
182
+ const archive2 = parseZip(await fs.readFile(zipPath2));
183
+ assert.ok(archive2.has('plugin-files/dsh-ssh.json'));
184
+ assert.ok(archive2.has('sessions/p/s/s.jsonl.zstd'));
185
+ } finally {
186
+ await fs.rm(tmp, { recursive: true, force: true });
187
+ }
188
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * sessions 分区 adapter(默认关,设计 §3.3/§15):
3
+ * 数据源 = ~/.dsh/sessions/<projectKey>/<sessionId>/…(zstd jsonl,含敏感信息)。
4
+ * defaultIncluded=false:Quick Export 不包含,用户显式勾选才导出(v1 文件级复制)。
5
+ * 研究报告 §4.9:DSH 无会话批量导出 API,逐会话文件复制是唯一通道。
6
+ */
7
+ import { FileCollectionAdapter } from './file-collection.ts';
8
+
9
+ export class SessionsAdapter extends FileCollectionAdapter {
10
+ readonly id = 'sessions' as const;
11
+ readonly displayName = 'Sessions';
12
+ readonly defaultIncluded = false;
13
+ readonly portability = 'deviceSpecific' as const;
14
+ readonly baseDir = 'sessions';
15
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * settings adapter 测试:非 UI namespace 导出(redact+revision)/ Create·Skip·Conflict 分析 /
3
+ * applyItem 乐观锁写回 / validate。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { SettingsAdapter } from './settings.ts';
8
+ import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import type { PlanItem } from '../core/types.ts';
10
+
11
+ const NS = ['general', 'theme', 'llm-deepseek'];
12
+
13
+ test('settings: 导出只含非 UI namespace(redacted + revision + secrets 标记)', async () => {
14
+ const ctx = makeContext('win32', 'C:\\Users\\alice');
15
+ ctx.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
16
+ ctx.settings.ns.set('theme', { value: { mode: 'dark' }, revision: 1, secrets: [] }); // UI 类 → 排除
17
+ ctx.settings.ns.set('llm-deepseek', {
18
+ value: { apiKeyEnv: 'DEEPSEEK_API_KEY' },
19
+ revision: 5,
20
+ secrets: [{ path: ['apiKey'], set: true }],
21
+ });
22
+
23
+ const adapter = new SettingsAdapter(NS);
24
+ const out = await adapter.export(ctx, { includeSecrets: false });
25
+ assert.equal(out.data.version, 1);
26
+ assert.ok(out.data.namespaces['general']);
27
+ assert.ok(out.data.namespaces['llm-deepseek']);
28
+ assert.ok(!out.data.namespaces['theme'], 'theme 属 UI 类,应被 settings 排除');
29
+ assert.equal(out.data.namespaces['llm-deepseek']?.revision, 5);
30
+ assert.deepEqual(out.data.namespaces['llm-deepseek']?.secrets, [{ path: ['apiKey'], set: true }]);
31
+ assert.equal(out.counts.namespaces, 2);
32
+
33
+ const v = await adapter.validate(out.data);
34
+ assert.equal(v.valid, true);
35
+ });
36
+
37
+ test('settings: analyzeImport Create/Skip/Conflict + applyItem 写回(幂等)', async () => {
38
+ const src = makeContext('win32', 'C:\\Users\\alice');
39
+ src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
40
+ const adapter = new SettingsAdapter(NS);
41
+ const exported = await adapter.export(src, { includeSecrets: false });
42
+ const sections = new Map([['settings', exported.data]]);
43
+
44
+ const dst = makeContext('linux', '/home/bob');
45
+ // 目标无 → Create 并写入
46
+ let items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
47
+ assert.equal(items.length, 1);
48
+ assert.equal(items[0]?.kind, 'Create');
49
+ assert.equal(items[0]?.target?.ref, 'general');
50
+ const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
51
+ assert.equal(r.ok, true);
52
+ assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark' });
53
+
54
+ // 重复导入 → Skip(幂等)
55
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
56
+ assert.equal(items[0]?.kind, 'Skip');
57
+
58
+ // 目标不同 → Conflict;useImported(Update)后覆盖
59
+ dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 9, secrets: [] });
60
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
61
+ assert.equal(items[0]?.kind, 'Conflict');
62
+ const useItem: PlanItem = { ...items[0]!, kind: 'Update' };
63
+ await adapter.applyItem(useItem, makeImportContext(dst, sections));
64
+ assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark' });
65
+ });
66
+
67
+ test('settings: applyItem 读时锁 — 目标 revision 不同时仍按当前值提交(不覆盖并发修改)', async () => {
68
+ const src = makeContext('win32', 'C:\\Users\\alice');
69
+ src.settings.ns.set('general', { value: { theme: 'dark' }, revision: 3, secrets: [] });
70
+ const adapter = new SettingsAdapter(NS);
71
+ const exported = await adapter.export(src, { includeSecrets: false });
72
+
73
+ const dst = makeContext('linux', '/home/bob');
74
+ dst.settings.ns.set('general', { value: { theme: 'light' }, revision: 9, secrets: [] });
75
+ const sections = new Map([['settings', exported.data]]);
76
+ const item: PlanItem = {
77
+ id: 'settings:general', kind: 'Update', adapter: 'settings',
78
+ description: '更新 general', severity: 'info',
79
+ target: { adapter: 'settings', ref: 'general' },
80
+ };
81
+ const r = await adapter.applyItem(item, makeImportContext(dst, sections));
82
+ assert.equal(r.ok, true);
83
+ assert.deepEqual(dst.settings.ns.get('general')?.value, { theme: 'dark' }, 'useImported 应覆盖目标');
84
+ assert.equal(dst.settings.ns.get('general')?.revision, 10, '提交后 revision 递增');
85
+ });
86
+
87
+ test('settings: validate 拒绝非法结构', async () => {
88
+ const adapter = new SettingsAdapter(NS);
89
+ const bad = await adapter.validate({ version: 2, namespaces: {} } as never);
90
+ assert.equal(bad.valid, false);
91
+ const noNs = await adapter.validate({ version: 1, namespaces: null as never });
92
+ assert.equal(noNs.valid, false);
93
+ });