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,110 @@
1
+ /**
2
+ * 文件类分区共享 adapter 基类(skills / agentPresets / sessions 复用)。
3
+ * 基准目录与 core/backup.ts 的 FILE_BASES 保持一致(skills→'skills'、agentPresets→'.agent-presets'、sessions→'sessions'),
4
+ * 保证引擎通用快照/回滚(resolveFileTarget)与 applyItem 的写入路径完全一致。
5
+ *
6
+ * 文件内容以真实文件进入 ZIP(custom/skills/… 等前缀由 exporter 按 SECTION_FILE_PREFIXES 处理)。
7
+ * 幂等:相对路径 + 内容 SHA-256 hash 比对(重复导入 → Skip)。
8
+ */
9
+ import path from 'node:path';
10
+ import { sha256Hex } from '../utils/hashing.ts';
11
+ import type { FilesSection, SectionId } from '../schema/types.ts';
12
+ import type {
13
+ ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
14
+ ImportContext, PlanItem, Portability, ValidationResult,
15
+ } from '../core/types.ts';
16
+
17
+ export abstract class FileCollectionAdapter implements ConfigAdapter<FilesSection> {
18
+ abstract readonly id: SectionId;
19
+ abstract readonly displayName: string;
20
+ abstract readonly defaultIncluded: boolean;
21
+ abstract readonly portability: Portability;
22
+ /** 相对 homeDir 的基准目录(与 core/backup.ts FILE_BASES 一致) */
23
+ abstract readonly baseDir: string;
24
+
25
+ async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<FilesSection>> {
26
+ const files: FilesSection['files'] = [];
27
+ const warnings: string[] = [];
28
+ let rels: string[] = [];
29
+ try {
30
+ rels = await ctx.fs.listRecursive(this.baseDir);
31
+ } catch {
32
+ // 目录不存在视为空
33
+ }
34
+ for (const rel of rels) {
35
+ const data = await ctx.fs.readFile(rel);
36
+ const relPath = this.baseDir === '' ? rel : rel.replace(new RegExp(`^${escapeRegExp(this.baseDir)}[\\/]`), '');
37
+ files.push({ relativePath: relPath, data, contentHash: sha256Hex(data) });
38
+ }
39
+ if (rels.length === 0) warnings.push(`${this.displayName} 目录为空或不存在`);
40
+ return {
41
+ sectionId: this.id,
42
+ data: { version: 1, files },
43
+ counts: { files: files.length },
44
+ warnings,
45
+ };
46
+ }
47
+
48
+ async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
49
+ const items: PlanItem[] = [];
50
+ for (const file of data.files) {
51
+ const id = `${this.id}:${file.relativePath}`;
52
+ let current: Uint8Array | null = null;
53
+ try {
54
+ current = await ctx.target.fs.readFile(path.join(this.baseDir, file.relativePath));
55
+ } catch {
56
+ current = null;
57
+ }
58
+ if (current === null) {
59
+ items.push({
60
+ id, kind: 'Create', adapter: this.id,
61
+ description: `创建${this.displayName}文件 ${file.relativePath}`, severity: 'info',
62
+ target: { adapter: this.id, ref: file.relativePath },
63
+ });
64
+ } else if (sha256Hex(current) === file.contentHash) {
65
+ items.push({ id, kind: 'Skip', adapter: this.id, description: `文件 ${file.relativePath} 已一致`, severity: 'info' });
66
+ } else {
67
+ items.push({
68
+ id, kind: 'Conflict', adapter: this.id,
69
+ description: `文件 ${file.relativePath} 内容不同`, severity: 'warning',
70
+ target: { adapter: this.id, ref: file.relativePath },
71
+ });
72
+ }
73
+ }
74
+ return items;
75
+ }
76
+
77
+ async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
78
+ const ref = item.target?.ref;
79
+ if (!ref) return { ok: false, message: '缺少 target.ref' };
80
+ const data = ctx.sections.get(this.id) as FilesSection | undefined;
81
+ const file = data?.files.find((f) => f.relativePath === ref);
82
+ if (!file) return { ok: false, message: `导入数据缺少文件 ${ref}` };
83
+ await ctx.target.fs.writeFile(path.join(this.baseDir, ref), file.data);
84
+ return { ok: true };
85
+ }
86
+
87
+ async validate(data: FilesSection): Promise<ValidationResult> {
88
+ const issues: ValidationResult['issues'] = [];
89
+ if (data === null || typeof data !== 'object') {
90
+ return { valid: false, issues: [{ path: '$', message: '文件分区数据必须是对象', severity: 'error' }] };
91
+ }
92
+ if (data.version !== 1) {
93
+ issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
94
+ }
95
+ if (!Array.isArray(data.files)) {
96
+ issues.push({ path: 'files', message: 'files 必须是数组', severity: 'error' });
97
+ } else {
98
+ for (const f of data.files) {
99
+ if (f === null || typeof f !== 'object' || typeof f.relativePath !== 'string' || f.relativePath === '') {
100
+ issues.push({ path: 'files[]', message: '文件记录必须含非空 relativePath', severity: 'error' });
101
+ }
102
+ }
103
+ }
104
+ return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
105
+ }
106
+ }
107
+
108
+ function escapeRegExp(s: string): string {
109
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 文件类分区 adapter 测试(skills / agentPresets / pluginFiles / sessions):
3
+ * 导出收集文件、Create/Skip 分析、applyItem 写入、白名单与默认关闭语义。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { SkillsAdapter } from './skills.ts';
8
+ import { AgentPresetsAdapter } from './agent-presets.ts';
9
+ import { PluginFilesAdapter } from './plugin-files.ts';
10
+ import { SessionsAdapter } from './sessions.ts';
11
+ import { makeContext, makeImportContext, sha256Hex } from './test-helpers.ts';
12
+ import type { PlanItem } from '../core/types.ts';
13
+
14
+ test('skills: 导出收集文件 + 导入往返(hash 幂等)', async () => {
15
+ const src = makeContext('win32', 'C:\\Users\\alice');
16
+ await src.fs.writeFile('skills/coding.md', Buffer.from('# Coding skill\n', 'utf8'));
17
+ await src.fs.writeFile('skills/git.md', Buffer.from('# Git skill\n', 'utf8'));
18
+
19
+ const adapter = new SkillsAdapter();
20
+ const out = await adapter.export(src, { includeSecrets: false });
21
+ assert.equal(out.data.files.length, 2);
22
+ assert.equal(out.data.files[0]?.relativePath, 'coding.md');
23
+ assert.equal(out.data.files[0]?.contentHash, sha256Hex(Buffer.from('# Coding skill\n')));
24
+ assert.equal(out.counts.files, 2);
25
+
26
+ const sections = new Map([['skills', out.data]]);
27
+ const dst = makeContext('linux', '/home/bob');
28
+ let items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
29
+ assert.equal(items.length, 2);
30
+ assert.ok(items.every((i) => i.kind === 'Create'));
31
+ for (const item of items) {
32
+ const r = await adapter.applyItem(item, makeImportContext(dst, sections));
33
+ assert.equal(r.ok, true);
34
+ }
35
+ assert.equal(Buffer.from(await dst.fs.readFile('skills/coding.md')).toString(), '# Coding skill\n');
36
+
37
+ // 幂等:一致 → Skip
38
+ items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
39
+ assert.ok(items.every((i) => i.kind === 'Skip'));
40
+
41
+ // 内容不同 → Conflict
42
+ await dst.fs.writeFile('skills/coding.md', Buffer.from('# Changed\n', 'utf8'));
43
+ items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
44
+ assert.ok(items.some((i) => i.kind === 'Conflict'));
45
+ });
46
+
47
+ test('agentPresets: 目录 bundle 文件收集与写入(.agent-presets 基准目录)', async () => {
48
+ const src = makeContext('win32', 'C:\\Users\\alice');
49
+ await src.fs.writeFile('.agent-presets/work/agent.cordis.yml', Buffer.from('services:\n - name: work\n', 'utf8'));
50
+ await src.fs.writeFile('.agent-presets/work/preset.yml', Buffer.from('name: work\n', 'utf8'));
51
+
52
+ const adapter = new AgentPresetsAdapter();
53
+ const out = await adapter.export(src, { includeSecrets: false });
54
+ assert.equal(out.data.files.length, 2);
55
+ const rels = out.data.files.map((f) => f.relativePath).sort();
56
+ assert.deepEqual(rels, ['work/agent.cordis.yml', 'work/preset.yml']);
57
+
58
+ const sections = new Map([['agentPresets', out.data]]);
59
+ const dst = makeContext('linux', '/home/bob');
60
+ const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
61
+ assert.ok(items.every((i) => i.kind === 'Create'));
62
+ for (const item of items) {
63
+ await adapter.applyItem(item, makeImportContext(dst, sections));
64
+ }
65
+ assert.equal(
66
+ Buffer.from(await dst.fs.readFile('.agent-presets/work/agent.cordis.yml')).toString(),
67
+ 'services:\n - name: work\n',
68
+ );
69
+ });
70
+
71
+ test('pluginFiles: 白名单导出(不存在跳过)+ 默认不包含', async () => {
72
+ const src = makeContext('win32', 'C:\\Users\\alice');
73
+ await src.fs.writeFile('dsh-ssh.json', Buffer.from('{"hosts":[]}', 'utf8'));
74
+ await src.fs.writeFile('other-file.json', Buffer.from('{"x":1}', 'utf8'));
75
+
76
+ const adapter = new PluginFilesAdapter(['dsh-ssh.json', 'pet.json']);
77
+ assert.equal(adapter.defaultIncluded, false, 'pluginFiles 默认不导出');
78
+ const out = await adapter.export(src, { includeSecrets: false });
79
+ assert.equal(out.data.files.length, 1);
80
+ assert.equal(out.data.files[0]?.relativePath, 'dsh-ssh.json');
81
+ assert.ok(!out.data.files.some((f) => f.relativePath === 'other-file.json'), '白名单外文件不导出');
82
+
83
+ const sections = new Map([['pluginFiles', out.data]]);
84
+ const dst = makeContext('linux', '/home/bob');
85
+ const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
86
+ assert.equal(items[0]?.kind, 'Create');
87
+ await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
88
+ assert.equal(Buffer.from(await dst.fs.readFile('dsh-ssh.json')).toString(), '{"hosts":[]}');
89
+ });
90
+
91
+ test('sessions: 默认关 + 文件级复制(deviceSpecific)', async () => {
92
+ const src = makeContext('win32', 'C:\\Users\\alice');
93
+ await src.fs.writeFile('sessions/proj-a/s1/session.jsonl.zstd', Buffer.from('zstd-bytes', 'utf8'));
94
+ const adapter = new SessionsAdapter();
95
+ assert.equal(adapter.defaultIncluded, false, 'sessions 默认不导出');
96
+ assert.equal(adapter.portability, 'deviceSpecific');
97
+ const out = await adapter.export(src, { includeSecrets: false });
98
+ assert.equal(out.data.files.length, 1);
99
+ assert.equal(out.data.files[0]?.relativePath, 'proj-a/s1/session.jsonl.zstd');
100
+
101
+ const sections = new Map([['sessions', out.data]]);
102
+ const dst = makeContext('linux', '/home/bob');
103
+ const items = await adapter.analyzeImport(out.data, makeImportContext(dst, sections));
104
+ const r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
105
+ assert.equal(r.ok, true);
106
+ assert.equal(Buffer.from(await dst.fs.readFile('sessions/proj-a/s1/session.jsonl.zstd')).toString(), 'zstd-bytes');
107
+ });
108
+
109
+ test('文件类 validate', async () => {
110
+ const adapter = new SkillsAdapter();
111
+ assert.equal((await adapter.validate({ version: 1, files: [] })).valid, true);
112
+ assert.equal((await adapter.validate({ version: 1, files: [{ relativePath: '' } as never] })).valid, false);
113
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 适配器 registry(设计 §12.1 / §3.3):
3
+ * 组装全部 ConfigAdapter(导出顺序参考 APPLY_ORDER:settings → ui → providers → prompts
4
+ * → skills → agentPresets → workspaces → pluginFiles → mcp → plugins → credentialsStatus)。
5
+ *
6
+ * 明确不实现的 adapter(研究报告 §2.2 确认 DSH 无对应概念):keybindings / workflows / commands / rules ——
7
+ * manifest 中不出现这些 section,产品 UI 说明「DSH 当前无此配置」。
8
+ */
9
+ import type { ConfigAdapter, HostContext } from '../core/types.ts';
10
+ import { SettingsAdapter, type NamespaceProvider } from './settings.ts';
11
+ import { UiAdapter } from './ui.ts';
12
+ import { ProvidersAdapter } from './providers.ts';
13
+ import { PluginsAdapter } from './plugins.ts';
14
+ import { McpAdapter } from './mcp.ts';
15
+ import { PromptsAdapter } from './prompts.ts';
16
+ import { SkillsAdapter } from './skills.ts';
17
+ import { AgentPresetsAdapter } from './agent-presets.ts';
18
+ import { WorkspacesAdapter } from './workspaces.ts';
19
+ import { CredentialsAdapter, type CredentialRefsProvider } from './credentials.ts';
20
+ import { PluginFilesAdapter } from './plugin-files.ts';
21
+ import { SessionsAdapter } from './sessions.ts';
22
+
23
+ export interface AdapterRegistryOptions {
24
+ /** settings namespace 清单(宿主从 settings.yaml 顶层 key 解析注入;缺省 [] → settings/ui/credentials 为空) */
25
+ namespaces?: string[] | NamespaceProvider;
26
+ /** 凭据 ref 收集器(缺省从 settings secrets 标记 + llm apiKeyEnv 推断) */
27
+ credentialsRefs?: CredentialRefsProvider;
28
+ /** pluginFiles 白名单(相对 ~/.dsh 根;缺省 dsh-ssh.json + pet.json) */
29
+ pluginFiles?: string[];
30
+ /** 是否包含 sessions 分区(默认关,研究报告 §4.9) */
31
+ includeSessions?: boolean;
32
+ }
33
+
34
+ /** 组装默认 adapter 列表(pluginFiles/sessions 恒挂载但 defaultIncluded=false,用户勾选才导出) */
35
+ export function createAdapters(options: AdapterRegistryOptions = {}): ConfigAdapter[] {
36
+ const namespaces = options.namespaces ?? [];
37
+ const adapters: ConfigAdapter[] = [
38
+ new SettingsAdapter(namespaces),
39
+ new UiAdapter(namespaces),
40
+ new ProvidersAdapter(),
41
+ new PluginsAdapter(),
42
+ new McpAdapter(),
43
+ new PromptsAdapter(),
44
+ new SkillsAdapter(),
45
+ new AgentPresetsAdapter(),
46
+ new WorkspacesAdapter(),
47
+ new CredentialsAdapter({ namespaces, refs: options.credentialsRefs }),
48
+ new PluginFilesAdapter(options.pluginFiles),
49
+ ];
50
+ if (options.includeSessions) adapters.push(new SessionsAdapter());
51
+ return adapters;
52
+ }
53
+
54
+ export type { NamespaceProvider } from './settings.ts';
55
+ export type { CredentialRefsProvider } from './credentials.ts';
56
+
57
+ export { SettingsAdapter } from './settings.ts';
58
+ export { UiAdapter, isUiNamespace, KNOWN_UI_NAMESPACE_PREFIXES, UI_MIGRATION_NOTES } from './ui.ts';
59
+ export { ProvidersAdapter, DEFAULT_PROVIDER_NAMESPACES, type ProviderExportEntry, type ProviderExportSection } from './providers.ts';
60
+ export { PluginsAdapter, USER_PATCH_FILE } from './plugins.ts';
61
+ export { McpAdapter, extractMcpServers, buildMcpPatchLine, type McpExportEntry, type McpExportSection } from './mcp.ts';
62
+ export { PromptsAdapter, extractPrompts, mergePromptIntoLine, buildPromptLine, type PromptExportEntry, type PromptsExportSection } from './prompts.ts';
63
+ export { SkillsAdapter } from './skills.ts';
64
+ export { AgentPresetsAdapter } from './agent-presets.ts';
65
+ export { WorkspacesAdapter } from './workspaces.ts';
66
+ export { CredentialsAdapter, defaultCredentialRefs } from './credentials.ts';
67
+ export { PluginFilesAdapter, DEFAULT_PLUGIN_FILE_WHITELIST } from './plugin-files.ts';
68
+ export { SessionsAdapter } from './sessions.ts';
69
+ export { FileCollectionAdapter } from './file-collection.ts';
@@ -0,0 +1,96 @@
1
+ /**
2
+ * mcp adapter 测试:组合 patch 提取(单行 + insert 块)、Create/Skip/Conflict、
3
+ * applyItem 写 patch 行(needsRestart)、validate。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { McpAdapter, extractMcpServers, buildMcpPatchLine } from './mcp.ts';
8
+ import { makeContext, makeImportContext } from './test-helpers.ts';
9
+ import type { PlanItem } from '../core/types.ts';
10
+
11
+ test('mcp: extractMcpServers 兼容单行与 insert 块', () => {
12
+ const lines = [
13
+ {
14
+ lineId: 'mcp-fs',
15
+ raw: {
16
+ id: 'mcp-fs',
17
+ name: 'dsh-mcp-client',
18
+ config: { serverName: 'filesystem', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem'], cwd: 'C:\\Users\\alice\\projects' },
19
+ },
20
+ },
21
+ {
22
+ lineId: 'mcp-http',
23
+ raw: {
24
+ insert: [
25
+ { id: 'mcp-http', name: 'dsh-mcp-client', config: { serverName: 'remote', url: 'https://mcp.example.com/sse', headers: { Authorization: 'Bearer abc' } } },
26
+ ],
27
+ },
28
+ },
29
+ { lineId: 'other', raw: { id: 'other', name: '@deepseek-ai/dsh-web', config: { searchProvider: 'deepseek-official' } } },
30
+ ];
31
+ const servers = extractMcpServers(lines);
32
+ assert.equal(servers.length, 2);
33
+ const fs = servers.find((s) => s.serverName === 'filesystem');
34
+ assert.equal(fs?.type, 'stdio');
35
+ assert.equal(fs?.command, 'npx');
36
+ assert.equal(fs?.cwd, 'C:\\Users\\alice\\projects');
37
+ assert.equal(fs?.sourceLineId, 'mcp-fs');
38
+ const http = servers.find((s) => s.serverName === 'remote');
39
+ assert.equal(http?.type, 'streamable-http');
40
+ assert.equal(http?.url, 'https://mcp.example.com/sse');
41
+ assert.equal(http?.sourceLineId, 'mcp-http');
42
+ });
43
+
44
+ test('mcp: buildMcpPatchLine 构造写回行', () => {
45
+ const line = buildMcpPatchLine('mcp-fs', { serverName: 'filesystem', type: 'stdio', command: 'npx', args: ['-y', 'x'] });
46
+ assert.equal(line.id, 'mcp-fs');
47
+ assert.equal(line.name, 'dsh-mcp-client');
48
+ assert.equal((line.config as Record<string, unknown>)['command'], 'npx');
49
+ });
50
+
51
+ test('mcp: export 提取 + analyzeImport Create/Skip/Conflict + applyItem 写 patch(needsRestart)', async () => {
52
+ const src = makeContext('win32', 'C:\\Users\\alice');
53
+ src.patchFile.lines.set('mcp-fs', {
54
+ lineId: 'mcp-fs',
55
+ raw: { id: 'mcp-fs', name: 'dsh-mcp-client', config: { serverName: 'filesystem', command: 'npx', args: ['-y', 'x'] } },
56
+ });
57
+ const adapter = new McpAdapter();
58
+ const exported = await adapter.export(src, { includeSecrets: false });
59
+ assert.equal(exported.data.servers.length, 1);
60
+ assert.equal(exported.data.servers[0]?.serverName, 'filesystem');
61
+ const sections = new Map([['mcp', exported.data]]);
62
+
63
+ const dst = makeContext('linux', '/home/bob');
64
+ let items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
65
+ assert.equal(items.length, 1);
66
+ assert.equal(items[0]?.kind, 'Create');
67
+ assert.equal(items[0]?.target?.ref, 'mcp-fs');
68
+ let r = await adapter.applyItem(items[0]!, makeImportContext(dst, sections));
69
+ assert.equal(r.ok, true);
70
+ assert.equal(r.needsRestart, true, 'MCP 写 patch 必须重启生效');
71
+ assert.ok(dst.patchFile.lines.has('mcp-fs'));
72
+ assert.equal((dst.patchFile.lines.get('mcp-fs')?.raw as Record<string, unknown>)['name'], 'dsh-mcp-client');
73
+
74
+ // 幂等:同 serverName 同内容 → Skip
75
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
76
+ assert.equal(items[0]?.kind, 'Skip');
77
+
78
+ // 目标同 serverName 不同内容 → Conflict(target.ref = 目标行 id)
79
+ dst.patchFile.lines.set('mcp-fs', {
80
+ lineId: 'mcp-fs',
81
+ raw: { id: 'mcp-fs', name: 'dsh-mcp-client', config: { serverName: 'filesystem', command: 'npx', args: ['-y', 'other'] } },
82
+ });
83
+ items = await adapter.analyzeImport(exported.data, makeImportContext(dst, sections));
84
+ assert.equal(items[0]?.kind, 'Conflict');
85
+ const useItem: PlanItem = { ...items[0]!, kind: 'Update' };
86
+ r = await adapter.applyItem(useItem, makeImportContext(dst, sections));
87
+ assert.equal(r.ok, true);
88
+ const raw = dst.patchFile.lines.get('mcp-fs')?.raw as Record<string, unknown>;
89
+ assert.deepEqual((raw['config'] as Record<string, unknown>)['args'], ['-y', 'x'], 'Conflict useImported 应更新目标行');
90
+ });
91
+
92
+ test('mcp: validate 拒绝无 serverName 的条目', async () => {
93
+ const adapter = new McpAdapter();
94
+ const bad = await adapter.validate({ version: 1, servers: [{ serverName: '' } as never] });
95
+ assert.equal(bad.valid, false);
96
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * mcp 分区 adapter(设计 §3.3/§1.2/§15):
3
+ * 数据源 = 组合 patch 中 dsh-mcp-client 行的 config(研究报告 §2.2:MCP 无 settings 面/无管理 API)。
4
+ * 导入 = 写 profile cordis.patch.yml 插行(needsRestart 生效);依赖检测(npx 等)由 analyzer 的
5
+ * dependencyChecker 执行(§15:缺失不阻塞,标记 Requires Attention)。
6
+ *
7
+ * patch 行 raw 支持两种形态:单行 { id, name, config } 与块 { insert: [{ id, name, config }] }。
8
+ */
9
+ import { isDeepStrictEqual } from 'node:util';
10
+ import type { McpServerEntry, McpSection } from '../schema/types.ts';
11
+ import type {
12
+ ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
13
+ ImportContext, PlanItem, ValidationResult,
14
+ } from '../core/types.ts';
15
+ import { USER_PATCH_FILE } from './plugins.ts';
16
+
17
+ /** 导出记录:McpServerEntry 之外附加来源 patch 行 id(导入写回定位用) */
18
+ export interface McpExportEntry extends McpServerEntry {
19
+ sourceLineId: string;
20
+ }
21
+
22
+ export interface McpExportSection { version: 1; servers: McpExportEntry[]; }
23
+
24
+ /** 从 patch 行数组中提取 MCP server 条目(以 config.serverName 存在为判定) */
25
+ export function extractMcpServers(lines: { lineId: string; raw: unknown }[]): McpExportEntry[] {
26
+ const servers: McpExportEntry[] = [];
27
+ for (const line of lines) {
28
+ for (const entry of entriesOf(line.raw)) {
29
+ const config = entry.config;
30
+ if (config === null || typeof config !== 'object') continue;
31
+ const c = config as Record<string, unknown>;
32
+ const serverName = c['serverName'];
33
+ if (typeof serverName !== 'string' || serverName === '') continue;
34
+ const type = typeof c['url'] === 'string' && c['url'] !== '' ? 'streamable-http' : 'stdio';
35
+ servers.push({
36
+ serverName,
37
+ type,
38
+ command: typeof c['command'] === 'string' ? c['command'] : undefined,
39
+ args: Array.isArray(c['args']) ? (c['args'] as string[]) : undefined,
40
+ env: c['env'] !== null && typeof c['env'] === 'object' ? (c['env'] as Record<string, string>) : undefined,
41
+ cwd: typeof c['cwd'] === 'string' ? c['cwd'] : undefined,
42
+ url: typeof c['url'] === 'string' ? c['url'] : undefined,
43
+ headers: c['headers'] !== null && typeof c['headers'] === 'object' ? (c['headers'] as Record<string, string>) : undefined,
44
+ sourceLineId: line.lineId,
45
+ });
46
+ }
47
+ }
48
+ return servers;
49
+ }
50
+
51
+ /** patch 行 → 可枚举的 entry 列表(兼容单行与 insert 块) */
52
+ function entriesOf(raw: unknown): { id?: unknown; name?: unknown; config?: unknown }[] {
53
+ if (raw === null || typeof raw !== 'object') return [];
54
+ const obj = raw as Record<string, unknown>;
55
+ if (Array.isArray(obj['insert'])) {
56
+ return obj['insert']
57
+ .filter((e): e is Record<string, unknown> => e !== null && typeof e === 'object')
58
+ .map((e) => e as { id?: unknown; name?: unknown; config?: unknown });
59
+ }
60
+ if (obj['id'] !== undefined || obj['name'] !== undefined) {
61
+ return [obj as { id?: unknown; name?: unknown; config?: unknown }];
62
+ }
63
+ return [];
64
+ }
65
+
66
+ /** 由导出条目构造写回 patch 行 raw(单行形态,与 dsh-mcp-client 组合 config 语义一致) */
67
+ export function buildMcpPatchLine(lineId: string, server: McpServerEntry): Record<string, unknown> {
68
+ const config: Record<string, unknown> = { serverName: server.serverName };
69
+ if (server.type === 'streamable-http') {
70
+ config['url'] = server.url;
71
+ if (server.headers !== undefined) config['headers'] = server.headers;
72
+ } else {
73
+ config['command'] = server.command;
74
+ if (server.args !== undefined) config['args'] = server.args;
75
+ if (server.env !== undefined) config['env'] = server.env;
76
+ if (server.cwd !== undefined) config['cwd'] = server.cwd;
77
+ }
78
+ return { id: lineId, name: 'dsh-mcp-client', config };
79
+ }
80
+
81
+ function newLineId(serverName: string): string {
82
+ const slug = serverName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
83
+ return `mcp-${slug || 'server'}`;
84
+ }
85
+
86
+ export class McpAdapter implements ConfigAdapter<McpExportSection> {
87
+ readonly id = 'mcp' as const;
88
+ readonly displayName = 'MCP Servers';
89
+ readonly defaultIncluded = true;
90
+ readonly portability = 'platformSpecific' as const;
91
+
92
+ async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<McpExportSection>> {
93
+ const warnings: string[] = [];
94
+ let lines: { lineId: string; raw: unknown }[] = [];
95
+ try {
96
+ lines = await ctx.patchFile.readPatchLines(USER_PATCH_FILE);
97
+ } catch (err) {
98
+ warnings.push(`patch 文件读取失败,无法提取 MCP: ${err instanceof Error ? err.message : String(err)}`);
99
+ }
100
+ const servers = extractMcpServers(lines);
101
+ return {
102
+ sectionId: 'mcp',
103
+ data: { version: 1, servers },
104
+ counts: { servers: servers.length },
105
+ warnings,
106
+ };
107
+ }
108
+
109
+ async analyzeImport(data: McpExportSection, ctx: ImportContext): Promise<PlanItem[]> {
110
+ const items: PlanItem[] = [];
111
+ const targetLines = await ctx.target.patchFile.readPatchLines(USER_PATCH_FILE);
112
+ const targetServers = extractMcpServers(targetLines);
113
+ for (const server of data.servers) {
114
+ const id = `mcp:${server.serverName}`;
115
+ const existing = targetServers.find((s) => s.serverName === server.serverName);
116
+ const comparable = { ...server } as Record<string, unknown>;
117
+ delete comparable.sourceLineId;
118
+ if (!existing) {
119
+ const lineId = server.sourceLineId && targetLines.some((l) => l.lineId === server.sourceLineId)
120
+ ? `${server.sourceLineId}-imported`
121
+ : (server.sourceLineId ?? newLineId(server.serverName));
122
+ items.push({
123
+ id, kind: 'Create', adapter: 'mcp',
124
+ description: `创建 MCP server ${server.serverName}`,
125
+ detail: `${server.type === 'stdio' ? `${server.command} ${(server.args ?? []).join(' ')}` : server.url}`,
126
+ severity: 'info',
127
+ target: { adapter: 'mcp', ref: lineId },
128
+ });
129
+ } else {
130
+ const existingComparable = { ...existing } as Record<string, unknown>;
131
+ delete existingComparable.sourceLineId;
132
+ if (isDeepStrictEqual(existingComparable, comparable)) {
133
+ items.push({ id, kind: 'Skip', adapter: 'mcp', description: `MCP server ${server.serverName} 已一致`, severity: 'info' });
134
+ } else {
135
+ items.push({
136
+ id, kind: 'Conflict', adapter: 'mcp',
137
+ description: `MCP server ${server.serverName} 与目标不同`,
138
+ detail: `current=${JSON.stringify(existingComparable)} imported=${JSON.stringify(comparable)}`.slice(0, 200),
139
+ severity: 'warning',
140
+ target: { adapter: 'mcp', ref: existing.sourceLineId },
141
+ });
142
+ }
143
+ }
144
+ }
145
+ return items;
146
+ }
147
+
148
+ async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
149
+ const serverName = item.id.replace(/^mcp:/, '');
150
+ const data = ctx.sections.get('mcp') as McpExportSection | undefined;
151
+ const server = data?.servers.find((s) => s.serverName === serverName);
152
+ if (!server) return { ok: false, message: `导入数据缺少 MCP server ${serverName}` };
153
+ const ref = item.target?.ref;
154
+ if (!ref) return { ok: false, message: '缺少 target.ref' };
155
+ const raw = buildMcpPatchLine(ref, server);
156
+ await ctx.target.patchFile.applyPatchChanges(USER_PATCH_FILE, [
157
+ { lineId: ref, raw, action: item.kind === 'Create' ? 'insert' : 'update' },
158
+ ]);
159
+ return { ok: true, needsRestart: true, message: `MCP server ${serverName} 已写入 patch,重启后生效` };
160
+ }
161
+
162
+ async validate(data: McpExportSection): Promise<ValidationResult> {
163
+ const issues: ValidationResult['issues'] = [];
164
+ if (data === null || typeof data !== 'object') {
165
+ return { valid: false, issues: [{ path: '$', message: 'mcp 数据必须是对象', severity: 'error' }] };
166
+ }
167
+ if (data.version !== 1) {
168
+ issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
169
+ }
170
+ if (!Array.isArray(data.servers)) {
171
+ issues.push({ path: 'servers', message: 'servers 必须是数组', severity: 'error' });
172
+ } else {
173
+ for (const s of data.servers) {
174
+ if (s === null || typeof s !== 'object' || typeof s.serverName !== 'string' || s.serverName === '') {
175
+ issues.push({ path: 'servers[]', message: 'server 记录必须含非空 serverName', severity: 'error' });
176
+ }
177
+ }
178
+ }
179
+ return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
180
+ }
181
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * pluginFiles 分区 adapter(可选,设计 §3.3/§1.2):
3
+ * 数据源 = 插件自有配置文件白名单(dsh-ssh.json、pet.json 等,相对 ~/.dsh 根)。
4
+ * 白名单机制避免扫描整个主目录;默认关闭(defaultIncluded=false,用户显式勾选才导出)。
5
+ */
6
+ import { sha256Hex } from '../utils/hashing.ts';
7
+ import type { FilesSection } from '../schema/types.ts';
8
+ import type {
9
+ ApplyResult, ConfigAdapter, ExportOptions, ExportSection, HostContext,
10
+ ImportContext, PlanItem, ValidationResult,
11
+ } from '../core/types.ts';
12
+
13
+ export const DEFAULT_PLUGIN_FILE_WHITELIST: readonly string[] = ['dsh-ssh.json', 'pet.json'];
14
+
15
+ export class PluginFilesAdapter implements ConfigAdapter<FilesSection> {
16
+ readonly id = 'pluginFiles' as const;
17
+ readonly displayName = 'Plugin Files';
18
+ readonly defaultIncluded = false;
19
+ readonly portability = 'deviceSpecific' as const;
20
+ private readonly whitelist: string[];
21
+
22
+ constructor(whitelist: string[] = [...DEFAULT_PLUGIN_FILE_WHITELIST]) {
23
+ this.whitelist = whitelist;
24
+ }
25
+
26
+ async export(ctx: HostContext, _options: ExportOptions): Promise<ExportSection<FilesSection>> {
27
+ const files: FilesSection['files'] = [];
28
+ for (const rel of this.whitelist) {
29
+ try {
30
+ const data = await ctx.fs.readFile(rel);
31
+ files.push({ relativePath: rel, data, contentHash: sha256Hex(data) });
32
+ } catch {
33
+ // 白名单文件不存在则跳过(dsh-ssh.json 等为按需创建)
34
+ }
35
+ }
36
+ return {
37
+ sectionId: 'pluginFiles',
38
+ data: { version: 1, files },
39
+ counts: { files: files.length },
40
+ warnings: [],
41
+ };
42
+ }
43
+
44
+ async analyzeImport(data: FilesSection, ctx: ImportContext): Promise<PlanItem[]> {
45
+ const items: PlanItem[] = [];
46
+ for (const file of data.files) {
47
+ const id = `pluginFile:${file.relativePath}`;
48
+ let current: Uint8Array | null = null;
49
+ try {
50
+ current = await ctx.target.fs.readFile(file.relativePath);
51
+ } catch {
52
+ current = null;
53
+ }
54
+ if (current === null) {
55
+ items.push({
56
+ id, kind: 'Create', adapter: 'pluginFiles',
57
+ description: `创建插件配置文件 ${file.relativePath}`, severity: 'info',
58
+ target: { adapter: 'pluginFiles', ref: file.relativePath },
59
+ });
60
+ } else if (sha256Hex(current) === file.contentHash) {
61
+ items.push({ id, kind: 'Skip', adapter: 'pluginFiles', description: `文件 ${file.relativePath} 已一致`, severity: 'info' });
62
+ } else {
63
+ items.push({
64
+ id, kind: 'Conflict', adapter: 'pluginFiles',
65
+ description: `文件 ${file.relativePath} 内容不同`, severity: 'warning',
66
+ target: { adapter: 'pluginFiles', ref: file.relativePath },
67
+ });
68
+ }
69
+ }
70
+ return items;
71
+ }
72
+
73
+ async applyItem(item: PlanItem, ctx: ImportContext): Promise<ApplyResult> {
74
+ const ref = item.target?.ref;
75
+ if (!ref) return { ok: false, message: '缺少 target.ref' };
76
+ const data = ctx.sections.get('pluginFiles') as FilesSection | undefined;
77
+ const file = data?.files.find((f) => f.relativePath === ref);
78
+ if (!file) return { ok: false, message: `导入数据缺少文件 ${ref}` };
79
+ await ctx.target.fs.writeFile(ref, file.data);
80
+ return { ok: true };
81
+ }
82
+
83
+ async validate(data: FilesSection): Promise<ValidationResult> {
84
+ const issues: ValidationResult['issues'] = [];
85
+ if (data === null || typeof data !== 'object') {
86
+ return { valid: false, issues: [{ path: '$', message: 'pluginFiles 数据必须是对象', severity: 'error' }] };
87
+ }
88
+ if (data.version !== 1) {
89
+ issues.push({ path: 'version', message: `version 必须为 1(收到 ${String(data.version)})`, severity: 'error' });
90
+ }
91
+ if (!Array.isArray(data.files)) {
92
+ issues.push({ path: 'files', message: 'files 必须是数组', severity: 'error' });
93
+ }
94
+ return { valid: issues.filter((i) => i.severity === 'error').length === 0, issues };
95
+ }
96
+ }