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,180 @@
1
+ /**
2
+ * ZIP 解压安全(规范 §19 / 设计 §9 / core utils/zip.ts 的强化层)。
3
+ *
4
+ * 分工(避免与 core 重复):
5
+ * - core `utils/zip.ts` 已实现:条目名 isPathSafe、条目数/压缩体积/解压体积/单条/压缩比限额、
6
+ * CRC32 与尺寸校验、ZipArchive.readEntry 预算、safeExtract 基础解压。
7
+ * - 本模块在其上强化 core 未覆盖的攻击面:
8
+ * 1. **symlink 条目拒绝**:解析中央目录 external attrs 的 Unix mode(S_IFLNK 0xA000)→ 拒绝;
9
+ * 2. **重复条目名拒绝**:同名条目(含目录/文件互撞)→ 拒绝(防读取歧义攻击);
10
+ * 3. **安全解压强化**:解压后逐条 lstat 复查「必须是普通文件」,任何异常 → 中止并**完整清理**目标目录;
11
+ * 4. **可执行文件告警**:扩展名黑名单 → warnings(本插件从不执行 ZIP 内文件)。
12
+ *
13
+ * 与 core 注入点对齐:`parseZipHardened` / `createHardenedZipParser` 满足
14
+ * `ImporterOptions.parseZipOverride` 签名 `(buf, limits?) => ZipArchive`。
15
+ */
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { isPathSafe, isSameOrChild } from '../utils/paths.ts';
19
+ import {
20
+ DEFAULT_ZIP_SAFETY_LIMITS,
21
+ ZipArchive,
22
+ ZipSafetyError,
23
+ type ZipEntryMeta,
24
+ type ZipSafetyLimits,
25
+ } from '../utils/zip.ts';
26
+
27
+ export { isPathSafe } from '../utils/paths.ts';
28
+
29
+ /* ---------------- 常量 ---------------- */
30
+
31
+ const EOCD_SIG = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
32
+ const CENTRAL_SIG = 0x02014b50;
33
+ /** Unix 文件类型掩码(external attrs 高 16 位) */
34
+ const S_IFMT = 0xf000;
35
+ /** 符号链接 */
36
+ const S_IFLNK = 0xa000;
37
+
38
+ /** 可执行文件扩展名黑名单(§19.6:只警告,本插件不执行任何脚本) */
39
+ export const EXECUTABLE_EXTENSIONS = new Set([
40
+ '.exe', '.bat', '.cmd', '.sh', '.ps1', '.dll', '.so', '.dylib', '.bin', '.jar',
41
+ ]);
42
+
43
+ /** 条目名是否疑似可执行文件(按扩展名) */
44
+ export function isExecutableName(name: string): boolean {
45
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
46
+ return EXECUTABLE_EXTENSIONS.has(ext);
47
+ }
48
+
49
+ /* ---------------- 强化解析(parseZipOverride 注入点) ---------------- */
50
+
51
+ /**
52
+ * 强化版 ZIP 解析:在 core 全部安全校验(条目名/条目数/压缩体积)之上,
53
+ * 增加 symlink 拒绝与重复条目名拒绝。返回 core `ZipArchive`(解压校验完全复用)。
54
+ */
55
+ export function parseZipHardened(buf: Uint8Array, limits: ZipSafetyLimits = {}): ZipArchive {
56
+ const merged: Required<ZipSafetyLimits> = { ...DEFAULT_ZIP_SAFETY_LIMITS, ...limits };
57
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
58
+ if (b.length < 22) throw new ZipSafetyError('不是合法的 ZIP 文件(体积过小)');
59
+
60
+ const eocdIdx = b.lastIndexOf(EOCD_SIG);
61
+ if (eocdIdx < 0 || eocdIdx + 22 > b.length) {
62
+ throw new ZipSafetyError('不是合法的 ZIP 文件(缺少中央目录结束记录)');
63
+ }
64
+ const totalEntries = b.readUInt16LE(eocdIdx + 10);
65
+ const cdSize = b.readUInt32LE(eocdIdx + 12);
66
+ const cdOffset = b.readUInt32LE(eocdIdx + 16);
67
+ if (cdOffset + cdSize > b.length) throw new ZipSafetyError('中央目录越界(ZIP 损坏)');
68
+ if (totalEntries > merged.maxEntries) {
69
+ throw new ZipSafetyError(`ZIP 条目数 ${totalEntries} 超过上限 ${merged.maxEntries}`);
70
+ }
71
+
72
+ const metas: ZipEntryMeta[] = [];
73
+ const seenNames = new Set<string>();
74
+ let pos = cdOffset;
75
+ let compressedTotal = 0;
76
+ for (let i = 0; i < totalEntries; i++) {
77
+ if (pos + 46 > cdOffset + cdSize) throw new ZipSafetyError('中央目录条目越界(ZIP 损坏)');
78
+ if (b.readUInt32LE(pos) !== CENTRAL_SIG) throw new ZipSafetyError('中央目录签名损坏');
79
+
80
+ const method = b.readUInt16LE(pos + 10);
81
+ const crc = b.readUInt32LE(pos + 16);
82
+ const compSize = b.readUInt32LE(pos + 20);
83
+ const uncompSize = b.readUInt32LE(pos + 24);
84
+ const nameLen = b.readUInt16LE(pos + 28);
85
+ const extraLen = b.readUInt16LE(pos + 30);
86
+ const commentLen = b.readUInt16LE(pos + 32);
87
+ const externalAttrs = b.readUInt32LE(pos + 38);
88
+ const localOffset = b.readUInt32LE(pos + 42);
89
+ const name = b.subarray(pos + 46, pos + 46 + nameLen).toString('utf8');
90
+
91
+ // —— 强化检查 ——
92
+ if (!isPathSafe(name)) throw new ZipSafetyError(`ZIP 条目名不安全: ${name}`);
93
+ if (seenNames.has(name)) throw new ZipSafetyError(`ZIP 条目名重复: ${name}`);
94
+ seenNames.add(name);
95
+ const mode = (externalAttrs >>> 16) & S_IFMT;
96
+ if (mode === S_IFLNK) {
97
+ throw new ZipSafetyError(`ZIP 含符号链接条目,已拒绝: ${name}`);
98
+ }
99
+ if (localOffset + 30 > b.length) {
100
+ throw new ZipSafetyError(`条目 "${name}" 本地文件头越界`);
101
+ }
102
+
103
+ compressedTotal += compSize;
104
+ if (compressedTotal > merged.maxCompressedBytes) {
105
+ throw new ZipSafetyError('ZIP 压缩数据总量超过上限');
106
+ }
107
+ metas.push({
108
+ name,
109
+ method,
110
+ compressedSize: compSize,
111
+ uncompressedSize: uncompSize,
112
+ crc32: crc,
113
+ isDirectory: name.endsWith('/'),
114
+ localOffset,
115
+ });
116
+ pos += 46 + nameLen + extraLen + commentLen;
117
+ }
118
+ return new ZipArchive(b, metas, merged);
119
+ }
120
+
121
+ /** 工厂:带默认限额的强化解析器(对齐 ImporterOptions.parseZipOverride 签名) */
122
+ export function createHardenedZipParser(defaultLimits?: ZipSafetyLimits) {
123
+ return (buf: Uint8Array, limits?: ZipSafetyLimits): ZipArchive =>
124
+ parseZipHardened(buf, { ...defaultLimits, ...limits });
125
+ }
126
+
127
+ /* ---------------- 强化安全解压(m5 导入文件类分区用) ---------------- */
128
+
129
+ export interface SafeExtractResult {
130
+ /** 已解压条目的 ZIP 相对路径(正斜杠) */
131
+ files: string[];
132
+ /** 非阻塞告警(可执行文件条目等) */
133
+ warnings: string[];
134
+ }
135
+
136
+ /**
137
+ * 安全解压到受控目录(对齐 core safeExtract 语义 + 强化):
138
+ * - 条目名/限额/symlink/重复名检查(parseZipHardened);
139
+ * - 逐条 CRC32/尺寸/预算校验(ZipArchive.readEntry);
140
+ * - 解压后 lstat 复查「全部产物必须是普通文件」(防符号链接/非常规写入);
141
+ * - 任何异常 → 中止并**完整清理** destDir 后抛出(不残留部分落盘)。
142
+ */
143
+ export async function safeExtractHardened(
144
+ zipPath: string,
145
+ destDir: string,
146
+ limits: ZipSafetyLimits = {},
147
+ ): Promise<SafeExtractResult> {
148
+ const data = await fs.readFile(zipPath);
149
+ const archive = parseZipHardened(data, limits);
150
+ const extracted: string[] = [];
151
+ const warnings: string[] = [];
152
+
153
+ try {
154
+ for (const meta of archive.entries()) {
155
+ if (meta.isDirectory) continue;
156
+ const target = path.join(destDir, ...meta.name.split('/'));
157
+ if (!isSameOrChild(target, destDir)) {
158
+ throw new ZipSafetyError(`解压目标越界: ${meta.name}`);
159
+ }
160
+ await fs.mkdir(path.dirname(target), { recursive: true });
161
+ await fs.writeFile(target, archive.readEntry(meta.name));
162
+ extracted.push(meta.name);
163
+ if (isExecutableName(meta.name)) {
164
+ warnings.push(`条目 "${meta.name}" 是潜在可执行文件,本插件不会执行它`);
165
+ }
166
+ }
167
+ // 解压后复查:全部产物必须是普通文件(防 symlink 逃逸 / 非常规写入)
168
+ for (const rel of extracted) {
169
+ const p = path.join(destDir, ...rel.split('/'));
170
+ const st = await fs.lstat(p);
171
+ if (!st.isFile()) {
172
+ throw new ZipSafetyError(`解压产物不是普通文件: ${rel}`);
173
+ }
174
+ }
175
+ return { files: extracted, warnings };
176
+ } catch (err) {
177
+ await fs.rm(destDir, { recursive: true, force: true });
178
+ throw err;
179
+ }
180
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * conflict-view 测试(m6-ui,规范 §11):
3
+ * Keep Current / Use Imported / Review 决策收集与 core 决策表转换。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { ConflictCollector } from './conflict-view.ts';
8
+ import { makePlan, makePlanItem } from './test-helpers.ts';
9
+
10
+ function conflictPlan() {
11
+ return makePlan({
12
+ items: [
13
+ makePlanItem({ id: 'settings:a', kind: 'Conflict', description: '设置 a 与目标不同', detail: 'current={x:1} imported={x:2}' }),
14
+ makePlanItem({ id: 'settings:b', kind: 'Conflict', description: '设置 b 与目标不同' }),
15
+ makePlanItem({ id: 'plugin:c', kind: 'Conflict', adapter: 'plugins', description: '插件 c 版本不同' }),
16
+ makePlanItem({ id: 'prompt:d', kind: 'Create', adapter: 'prompts', description: '创建提示 d' }),
17
+ ],
18
+ });
19
+ }
20
+
21
+ test('conflict-view: 只收集 Conflict 项', () => {
22
+ const c = new ConflictCollector(conflictPlan());
23
+ assert.equal(c.conflicts.length, 3);
24
+ assert.equal(c.hasUnresolved, true);
25
+ });
26
+
27
+ test('conflict-view: resolve 设置决策;未知 id 忽略', () => {
28
+ const c = new ConflictCollector(conflictPlan());
29
+ assert.equal(c.resolve('settings:a', 'useImported'), true);
30
+ assert.equal(c.resolve('nope', 'review'), false);
31
+ assert.equal(c.decisionOf('settings:a'), 'useImported');
32
+ });
33
+
34
+ test('conflict-view: unresolved 只含未决/ review 项', () => {
35
+ const c = new ConflictCollector(conflictPlan());
36
+ c.resolve('settings:a', 'keepCurrent');
37
+ c.resolve('plugin:c', 'review');
38
+ const unresolved = c.unresolved();
39
+ assert.deepEqual(unresolved.map((i) => i.id), ['settings:b', 'plugin:c']);
40
+ assert.equal(c.hasUnresolved, true);
41
+ c.resolve('settings:b', 'useImported');
42
+ c.resolve('plugin:c', 'useImported');
43
+ assert.equal(c.hasUnresolved, false);
44
+ });
45
+
46
+ test('conflict-view: toResolutions 输出 core 决策表', () => {
47
+ const c = new ConflictCollector(conflictPlan());
48
+ c.resolve('settings:a', 'keepCurrent');
49
+ c.resolve('settings:b', 'useImported');
50
+ const resolutions = c.toResolutions();
51
+ assert.deepEqual(resolutions, { 'settings:a': 'keepCurrent', 'settings:b': 'useImported' });
52
+ });
53
+
54
+ test('conflict-view: viewItems 提供视图数据(React 绑定)', () => {
55
+ const c = new ConflictCollector(conflictPlan());
56
+ c.resolve('settings:a', 'useImported');
57
+ const views = c.viewItems();
58
+ assert.equal(views.length, 3);
59
+ const first = views.find((v) => v.item.id === 'settings:a')!;
60
+ assert.equal(first.resolution, 'useImported');
61
+ assert.ok(first.currentSummary !== undefined);
62
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 冲突决策收集(规范 §11,m6-ui)。
3
+ *
4
+ * 输入:ImportPlan.items 中的 Conflict 项(目标存在且不同)。
5
+ * 输出:逐项决策 Keep Current / Use Imported / Review,转为 core ImportDecisions.resolutions。
6
+ *
7
+ * 纯逻辑:只收集用户选择,不触碰 core;未决(review/未选)项在预览时如实展示。
8
+ */
9
+ import type { ImportPlan, ItemResolution, PlanItem } from '../core/types.ts';
10
+ import type { ConflictViewItem } from './types.ts';
11
+
12
+ export class ConflictCollector {
13
+ private readonly plan: ImportPlan;
14
+ private readonly decisions = new Map<string, ItemResolution>();
15
+
16
+ constructor(plan: ImportPlan) {
17
+ this.plan = plan;
18
+ }
19
+
20
+ /** 需要用户决策的冲突项(kind === 'Conflict') */
21
+ get conflicts(): PlanItem[] {
22
+ return this.plan.items.filter((i) => i.kind === 'Conflict');
23
+ }
24
+
25
+ /** 设置某项决策;未出现在冲突列表中的 id 忽略并返回 false */
26
+ resolve(itemId: string, resolution: ItemResolution): boolean {
27
+ if (!this.conflicts.some((i) => i.id === itemId)) return false;
28
+ this.decisions.set(itemId, resolution);
29
+ return true;
30
+ }
31
+
32
+ /** 单项当前决策(未决策返回 null) */
33
+ decisionOf(itemId: string): ItemResolution | null {
34
+ return this.decisions.get(itemId) ?? null;
35
+ }
36
+
37
+ /** 仍未解决(review 或未决策)的冲突项 */
38
+ unresolved(): PlanItem[] {
39
+ return this.conflicts.filter((i) => {
40
+ const d = this.decisions.get(i.id);
41
+ return d === undefined || d === 'review';
42
+ });
43
+ }
44
+
45
+ get hasUnresolved(): boolean {
46
+ return this.unresolved().length > 0;
47
+ }
48
+
49
+ /** 转 core 决策表(供 createImportPlan / executeImportPlan 使用) */
50
+ toResolutions(): Record<string, ItemResolution> {
51
+ const out: Record<string, ItemResolution> = {};
52
+ for (const [id, res] of this.decisions) out[id] = res;
53
+ return out;
54
+ }
55
+
56
+ /** 构造视图项列表(React 绑定用):含当前/导入摘要占位 */
57
+ viewItems(): ConflictViewItem[] {
58
+ return this.conflicts.map((item) => ({
59
+ item,
60
+ currentSummary: item.detail?.split('\n')[0],
61
+ importedSummary: undefined,
62
+ resolution: this.decisionOf(item.id),
63
+ }));
64
+ }
65
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * errors 测试(m6-ui,规范 §23):可操作错误 Reason/Suggested action,Secret 脱敏。
3
+ */
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { formatActionableError, toActionableError } from './errors.ts';
7
+
8
+ test('errors: SETTINGS_CONFLICT 映射为可操作建议', () => {
9
+ const e = toActionableError(new Error('SETTINGS_CONFLICT: llm-deepseek revision 12 != 14'));
10
+ assert.equal(e.title, '配置已被并发修改');
11
+ assert.ok(e.suggestedAction !== undefined);
12
+ assert.equal(e.retryable, true);
13
+ });
14
+
15
+ test('errors: 完整性失败不可重试', () => {
16
+ const e = toActionableError(new Error('备份完整性校验失败: "config/settings.json"'));
17
+ assert.equal(e.title, '备份完整性校验失败');
18
+ assert.equal(e.retryable, false);
19
+ });
20
+
21
+ test('errors: 未知错误回退通用文案(仍给 Reason)', () => {
22
+ const e = toActionableError(new Error('boom'));
23
+ assert.equal(e.title, '操作失败');
24
+ assert.equal(e.reason, 'boom');
25
+ });
26
+
27
+ test('errors: Secret 值绝不进入输出(脱敏)', () => {
28
+ const e = toActionableError(new Error('写入失败: apiKey=sk-abcdef1234567890'));
29
+ assert.ok(!e.reason.includes('sk-abcdef1234567890'));
30
+ assert.ok(e.reason.includes('REDACTED'));
31
+ });
32
+
33
+ test('errors: 带 item 的错误也脱敏', () => {
34
+ const e = toActionableError(new Error('fail'), { item: 'apiKey=sk-zzz999' });
35
+ assert.ok(!e.item!.includes('sk-zzz999'));
36
+ });
37
+
38
+ test('errors: formatActionableError 输出 Reason + Suggested action', () => {
39
+ const e = toActionableError(new Error('ENOENT: no such file'), { item: 'x.zip' });
40
+ const text = formatActionableError(e);
41
+ assert.ok(text.includes('Reason:'));
42
+ assert.ok(text.includes('Suggested action:'));
43
+ assert.ok(text.includes('Item:'));
44
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * 可操作错误模型(规范 §23,m6-ui)。
3
+ *
4
+ * 绝不只有 "Something went wrong.":
5
+ * - 解析错误 → Reason + Suggested action(可操作);
6
+ * - 输出前强制脱敏(redact),Secret 永不进入 UI/日志。
7
+ */
8
+ import { redact } from '../security/redaction.ts';
9
+
10
+ /** 可操作错误(UI 渲染为 Reason / Suggested action 两段) */
11
+ export interface ActionableError {
12
+ title: string;
13
+ reason: string;
14
+ suggestedAction?: string;
15
+ /** 相关项(如 MCP 服务器名 / 插件包名);可空 */
16
+ item?: string;
17
+ /** 是否可重试(true 显示重试按钮) */
18
+ retryable: boolean;
19
+ }
20
+
21
+ /** 常见错误 → 建议动作 的规则表(按错误消息子串匹配) */
22
+ interface ErrorRule {
23
+ match: (msg: string) => boolean;
24
+ title: string;
25
+ suggestedAction: string;
26
+ retryable: boolean;
27
+ }
28
+
29
+ const ERROR_RULES: readonly ErrorRule[] = [
30
+ {
31
+ match: (m) => /SETTINGS_CONFLICT|revision.*conflict/i.test(m),
32
+ title: '配置已被并发修改',
33
+ suggestedAction: '目标 DSH 的该配置在导入期间被其他进程修改,为避免覆盖请重新导出或手动核对后再试。',
34
+ retryable: true,
35
+ },
36
+ {
37
+ match: (m) => /ImportNotConfirmed|未确认/i.test(m),
38
+ title: '导入未确认',
39
+ suggestedAction: '请在预览页确认导入内容后重试。',
40
+ retryable: true,
41
+ },
42
+ {
43
+ match: (m) => /backup integrity|完整性校验失败|checksum/i.test(m),
44
+ title: '备份完整性校验失败',
45
+ suggestedAction: '备份文件可能已损坏或被篡改,请重新导出备份后再试。',
46
+ retryable: false,
47
+ },
48
+ {
49
+ match: (m) => /schema.*(不支持|超出)|无法导入|Unsupported/i.test(m),
50
+ title: '备份格式版本不受支持',
51
+ suggestedAction: '请升级 DSH Config Manager 或使用相同版本的备份文件。',
52
+ retryable: false,
53
+ },
54
+ {
55
+ match: (m) => /ENOENT|not found|无法读取|不存在/i.test(m),
56
+ title: '文件或路径不存在',
57
+ suggestedAction: '请检查文件路径是否正确、文件是否已被移动或删除。',
58
+ retryable: true,
59
+ },
60
+ {
61
+ match: (m) => /EACCES|permission|权限/i.test(m),
62
+ title: '权限不足',
63
+ suggestedAction: '请检查目标目录的读写权限后重试。',
64
+ retryable: true,
65
+ },
66
+ {
67
+ match: (m) => /install.*(plugin|插件)|needsRestart|重启/i.test(m),
68
+ title: '插件安装需要重启生效',
69
+ suggestedAction: '导入已完成,插件将在重启 DSH 后生效;请在 DSH Desktop 中重启服务。',
70
+ retryable: false,
71
+ },
72
+ ];
73
+
74
+ /** 将任意错误转换为可操作错误(消息已脱敏,不泄漏 Secret) */
75
+ export function toActionableError(err: unknown, opts?: { item?: string; fallbackTitle?: string }): ActionableError {
76
+ const raw = err instanceof Error ? err.message : String(err ?? '未知错误');
77
+ const message = redact(raw);
78
+ const rule = ERROR_RULES.find((r) => r.match(message));
79
+ return {
80
+ title: rule?.title ?? opts?.fallbackTitle ?? '操作失败',
81
+ reason: message,
82
+ suggestedAction: rule?.suggestedAction,
83
+ item: opts?.item !== undefined ? redact(opts.item) : undefined,
84
+ retryable: rule?.retryable ?? true,
85
+ };
86
+ }
87
+
88
+ /** 格式化为用户可读的多行文本(Reason / Suggested action) */
89
+ export function formatActionableError(e: ActionableError): string {
90
+ const lines = [e.title];
91
+ lines.push(`Reason: ${e.reason}`);
92
+ if (e.suggestedAction) lines.push(`Suggested action: ${e.suggestedAction}`);
93
+ if (e.item) lines.push(`Item: ${e.item}`);
94
+ return lines.join('\n');
95
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * export-flow 测试(m6-ui):Quick 推荐项 / Custom 分组目录 / 校验 / 执行进度与端口调用。
3
+ */
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { ExportFlow } from './export-flow.ts';
7
+ import { MockExportPort, makeExportReport } from './test-helpers.ts';
8
+
9
+ test('export-flow: Quick 推荐项 = defaultIncluded 且非 deviceSpecific', () => {
10
+ const flow = new ExportFlow({ port: new MockExportPort() });
11
+ const sel = flow.quickSelection();
12
+ assert.ok(sel.includes('settings'));
13
+ assert.ok(sel.includes('plugins'));
14
+ assert.ok(sel.includes('mcp'));
15
+ assert.ok(sel.includes('providers'));
16
+ assert.ok(!sel.includes('sessions'), 'sessions 默认关闭');
17
+ assert.ok(!sel.includes('pluginFiles'), 'pluginFiles 默认关闭');
18
+ assert.ok(!sel.includes('credentialsStatus'), 'deviceSpecific 不进 Quick');
19
+ });
20
+
21
+ test('export-flow: Custom 分组目录按 §1 分组且 automation 有说明', () => {
22
+ const flow = new ExportFlow({ port: new MockExportPort() });
23
+ const groups = flow.groupedCatalog();
24
+ const ids = groups.map((g) => g.group);
25
+ assert.ok(ids.includes('general'));
26
+ assert.ok(ids.includes('ai'));
27
+ assert.ok(ids.includes('extensions'));
28
+ assert.ok(ids.includes('optional'));
29
+ const automation = groups.find((g) => g.group === 'automation');
30
+ assert.ok(automation, 'automation 组存在(说明 DSH 无对应配置)');
31
+ assert.ok(automation!.note !== undefined);
32
+ const optional = groups.find((g) => g.group === 'optional')!;
33
+ assert.ok(optional.categories.some((c) => c.id === 'sessions'), '可选分区在 optional 组');
34
+ });
35
+
36
+ test('export-flow: validateSelection 对 deviceSpecific 分区给出警告', () => {
37
+ const flow = new ExportFlow({ port: new MockExportPort() });
38
+ const { valid, warnings } = flow.validateSelection(['settings', 'sessions']);
39
+ assert.equal(valid, true);
40
+ assert.ok(warnings.some((w) => w.includes('Sessions')));
41
+ const bad = flow.validateSelection(['nope'] as never);
42
+ assert.ok(bad.warnings.some((w) => w.includes('未知分区')));
43
+ });
44
+
45
+ test('export-flow: run 调 port 并携带 only 选择与进度事件', async () => {
46
+ const port = new MockExportPort();
47
+ const events: string[] = [];
48
+ const flow = new ExportFlow({ port, onProgress: (e) => events.push(e.stage) });
49
+ const out = await flow.run('custom', ['settings', 'plugins']);
50
+
51
+ assert.equal(port.calls.length, 1);
52
+ assert.deepEqual(port.calls[0]!.only, ['settings', 'plugins']);
53
+ assert.equal(port.calls[0]!.includeSecrets, false);
54
+ assert.equal(out.text.includes('Backup Created'), true);
55
+ assert.equal(out.text.includes('✓ settings'), true);
56
+ assert.equal(out.text.includes('8 plugins'), true);
57
+ assert.equal(events[events.length - 1], 'done');
58
+ // 进度覆盖导出阶段序列
59
+ assert.ok(events.includes('analyzing'));
60
+ assert.ok(events.includes('creating-archive'));
61
+ });
62
+
63
+ test('export-flow: Quick 模式 only 使用推荐项', async () => {
64
+ const port = new MockExportPort();
65
+ const flow = new ExportFlow({ port });
66
+ await flow.run('quick', []);
67
+ assert.deepEqual(port.calls[0]!.only, flow.quickSelection());
68
+ });
69
+
70
+ test('export-flow: 报告包含 Excluded 与 Security 信息', async () => {
71
+ const report = makeExportReport({ excluded: ['sessions'] });
72
+ const out = await new ExportFlow({ port: new MockExportPort(report) }).run('quick', []);
73
+ assert.equal(out.report.excluded.includes('sessions'), true);
74
+ assert.equal(out.report.security.secretsExcluded, true);
75
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Export 控制器(规范 §1 / §21,m6-ui)。
3
+ *
4
+ * 职责:
5
+ * - Quick Export:推荐项 = defaultIncluded 且非 deviceSpecific 的分区(设计 §11.1);
6
+ * - Custom Export:按 §1 分组目录逐项勾选(含默认关闭的可选分区 pluginFiles/sessions);
7
+ * - 进度事件:调用 core 前后按 §29 阶段文案发出(UI 不冻结);
8
+ * - 结果:返回 ExportReport 并渲染 §21 文本报告。
9
+ *
10
+ * 依赖注入:ExportPort 由宿主在挂载时接入真实 Exporter(core/exporter.ts),
11
+ * 测试注入内存 mock —— 本层零依赖 core 实现。
12
+ */
13
+ import type { Manifest, SectionId } from '../schema/types.ts';
14
+ import type { ExportOptions, ExportReport } from '../core/types.ts';
15
+ import {
16
+ EXPORT_GROUPS, type ExportCategory, type ExportGroup,
17
+ type ProgressListener, isQuickRecommended,
18
+ } from './types.ts';
19
+ import { EXPORT_STAGES, ProgressTracker } from './progress.ts';
20
+ import { renderExportReport } from './report.ts';
21
+
22
+ /** UI → core 的导出端口(宿主注入 Exporter.export;测试注入 mock) */
23
+ export interface ExportPort {
24
+ export(options: ExportOptions): Promise<{ zipPath: string; manifest: Manifest; report: ExportReport }>;
25
+ }
26
+
27
+ export interface ExportFlowOptions {
28
+ port: ExportPort;
29
+ /** 分类目录(缺省用内置目录,与 adapters 的 displayName/defaultIncluded/portability 对齐) */
30
+ categories?: ExportCategory[];
31
+ onProgress?: ProgressListener;
32
+ }
33
+
34
+ export interface ExportRunResult {
35
+ zipPath: string;
36
+ manifest: Manifest;
37
+ report: ExportReport;
38
+ /** §21 渲染文本(report.ts) */
39
+ text: string;
40
+ }
41
+
42
+ /** 内置分类目录(与 src/adapters/* 的 displayName/defaultIncluded/portability 对齐,研究报告 §2.2) */
43
+ export const DEFAULT_CATEGORIES: readonly ExportCategory[] = [
44
+ { id: 'settings', label: 'Settings', description: 'DSH 全局设置(namespace 分区,redacted)', defaultIncluded: true, portability: 'portable', group: 'general' },
45
+ { id: 'providers', label: 'Providers & Models', description: 'LLM Provider / Model / 默认模型 / BaseURL', defaultIncluded: true, portability: 'portable', group: 'ai' },
46
+ { id: 'plugins', label: 'Plugins', description: '已安装插件清单与启用状态(不含二进制)', defaultIncluded: true, portability: 'portable', group: 'extensions' },
47
+ { id: 'pluginFiles', label: 'Plugin Files', description: '插件自有配置文件(dsh-ssh.json 等,白名单)', defaultIncluded: false, portability: 'deviceSpecific', group: 'extensions' },
48
+ { id: 'mcp', label: 'MCP Servers', description: 'MCP 服务器组合配置(需重启生效)', defaultIncluded: true, portability: 'platformSpecific', group: 'mcp' },
49
+ { id: 'prompts', label: 'Prompts', description: 'System Prompt / Plan Mode 提示', defaultIncluded: true, portability: 'portable', group: 'customization' },
50
+ { id: 'skills', label: 'Skills', description: '用户技能文件(~/.dsh/skills)', defaultIncluded: true, portability: 'portable', group: 'customization' },
51
+ { id: 'agentPresets', label: 'Agent Presets', description: 'Agent 预设(~/.dsh/.agent-presets)', defaultIncluded: true, portability: 'portable', group: 'customization' },
52
+ { id: 'workspaces', label: 'Workspaces', description: '工作区记录(含绝对路径,需路径映射)', defaultIncluded: true, portability: 'platformSpecific', group: 'workspace' },
53
+ { id: 'ui', label: 'UI Preferences', description: 'UI 类 settings namespace(localStorage 项仅说明)', defaultIncluded: true, portability: 'portable', group: 'ui' },
54
+ { id: 'credentialsStatus', label: 'Credentials Status', description: '凭据状态(configured 标记,永不导出值)', defaultIncluded: true, portability: 'deviceSpecific', group: 'optional', sensitive: true },
55
+ { id: 'sessions', label: 'Sessions', description: '历史会话(默认关闭,含敏感内容)', defaultIncluded: false, portability: 'deviceSpecific', group: 'optional' },
56
+ ] as const;
57
+
58
+ export class ExportFlow {
59
+ readonly categories: readonly ExportCategory[];
60
+ private readonly port: ExportPort;
61
+ private readonly onProgress: ProgressListener | undefined;
62
+
63
+ constructor(opts: ExportFlowOptions) {
64
+ this.port = opts.port;
65
+ this.categories = opts.categories ?? DEFAULT_CATEGORIES;
66
+ this.onProgress = opts.onProgress;
67
+ }
68
+
69
+ /** Quick Export 推荐分区(defaultIncluded 且非 deviceSpecific) */
70
+ quickSelection(): SectionId[] {
71
+ return this.categories.filter(isQuickRecommended).map((c) => c.id);
72
+ }
73
+
74
+ /** 按 §1 分组返回分类目录(Custom Export 树) */
75
+ groupedCatalog(): { group: ExportGroup; label: string; note?: string; categories: ExportCategory[] }[] {
76
+ return EXPORT_GROUPS.map((g) => ({
77
+ group: g.id,
78
+ label: g.label,
79
+ note: g.note,
80
+ categories: this.categories.filter((c) => c.group === g.id),
81
+ }));
82
+ }
83
+
84
+ /** 校验 Custom 勾选:未知分区 = invalid;deviceSpecific 分区给提示警告(仍可继续) */
85
+ validateSelection(selection: readonly SectionId[]): { valid: boolean; warnings: string[] } {
86
+ const warnings: string[] = [];
87
+ let valid = true;
88
+ const known = new Set(this.categories.map((c) => c.id));
89
+ for (const id of selection) {
90
+ if (!known.has(id)) {
91
+ warnings.push(`未知分区:${id}`);
92
+ valid = false;
93
+ }
94
+ }
95
+ for (const c of this.categories) {
96
+ if (selection.includes(c.id) && c.portability === 'deviceSpecific') {
97
+ warnings.push(`${c.label} 为设备相关数据(${c.portability}),跨设备导入时可能不适用`);
98
+ }
99
+ }
100
+ return { valid, warnings };
101
+ }
102
+
103
+ /** 执行导出:发进度事件 → 调 core → 渲染 §21 报告 */
104
+ async run(
105
+ mode: 'quick' | 'custom',
106
+ selection: readonly SectionId[],
107
+ opts: { includeSecrets?: boolean } = {},
108
+ ): Promise<ExportRunResult> {
109
+ const tracker = new ProgressTracker(EXPORT_STAGES, this.onProgress);
110
+ const only = mode === 'quick' ? this.quickSelection() : [...selection];
111
+ if (mode === 'quick' && opts.includeSecrets === undefined) opts = { ...opts, includeSecrets: false };
112
+
113
+ tracker.emit('analyzing');
114
+ tracker.emit('exporting-settings');
115
+ tracker.emit('scanning-secrets');
116
+ tracker.emit('exporting-plugins');
117
+ tracker.emit('creating-archive');
118
+ tracker.emit('calculating-checksums');
119
+
120
+ const result = await this.port.export({
121
+ includeSecrets: opts.includeSecrets ?? false,
122
+ only,
123
+ });
124
+
125
+ tracker.emit('done');
126
+ return { ...result, text: renderExportReport(result.report) };
127
+ }
128
+ }