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
package/src/index.ts ADDED
@@ -0,0 +1,942 @@
1
+ /**
2
+ * dsh-config-manager — host half.
3
+ *
4
+ * Mounts the backup / export / import engine (src/core Exporter + Importer
5
+ * three-stage flow) behind the `/api/dsh-config-manager/*` route family that
6
+ * the browser half (`./client`) calls, and wraps the real DSH host services
7
+ * into the engine's `HostContext` facade (src/core/types.ts):
8
+ *
9
+ * ctx.settings -> SettingsFacade (@deepseek-ai/dsh-settings)
10
+ * ctx.credentials -> CredentialsFacade (@deepseek-ai/dsh-credentials)
11
+ * ctx.pluginInventory + -> PluginsFacade (@deepseek-ai/dsh-host-plugin-inventory
12
+ * ctx.pluginMarketplace + @deepseek-ai/dsh-plugin-marketplace, web)
13
+ * ctx.workspaceRegistry -> WorkspaceFacade (@deepseek-ai/dsh-workspace)
14
+ * ~/.dsh/cordis.patch.yml-> PatchFileFacade (js-yaml)
15
+ * $DSH_HOME files -> FileSystemFacade (node:fs, home-relative)
16
+ * resolveDshHome() -> homeDir (@deepseek-ai/dsh-home-paths)
17
+ *
18
+ * Security posture (mirrors the verified @linxin666/dsh-ssh@0.1.12 routes):
19
+ * - every route carries the loopback-only + same-origin trust fence
20
+ * (isLoopbackRequest); LAN-exposed deployments never serve these endpoints;
21
+ * - uploads/exported ZIPs are staged under $DSH_HOME/dsh-config-manager/{tmp,exports}
22
+ * and every `path`/`zipPath` reference is confined to those roots;
23
+ * - the encryption password is in-memory only: used to derive the AES-256-GCM
24
+ * key for secrets.enc, never written to any file, manifest, or log;
25
+ * - the import execute endpoint refuses to run without `confirm: true`
26
+ * (core ImportNotConfirmedError safety valve).
27
+ *
28
+ * Optional services are read with ctx.get() at call time (never injected), so
29
+ * the engine keeps working in profiles without the web-only marketplace or
30
+ * workspace services; hard dependencies are the core `settings`/`credentials`
31
+ * services present in every profile.
32
+ */
33
+
34
+ import { randomBytes } from 'node:crypto'
35
+ import { execFile } from 'node:child_process'
36
+ import { createReadStream, createWriteStream, mkdirSync, readFileSync } from 'node:fs'
37
+ import fs from 'node:fs/promises'
38
+ import type { IncomingMessage, ServerResponse } from 'node:http'
39
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
40
+ import { promisify } from 'node:util'
41
+
42
+ import type { Context } from '@deepseek-ai/cordis'
43
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
44
+ import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
45
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
46
+ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
47
+ // Type-only: pull the Cordis Context augmentations (webServer / pluginInventory /
48
+ // workspaceRegistry) and the WebRoute contract without any runtime import.
49
+ import type {} from '@deepseek-ai/dsh-host-webserver'
50
+ import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
51
+ import type {} from '@deepseek-ai/dsh-host-plugin-inventory'
52
+ import type {} from '@deepseek-ai/dsh-workspace'
53
+ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace'
54
+ import * as yaml from 'js-yaml'
55
+
56
+ import { Exporter, FileSnapshotStore, Importer } from './core/index.ts'
57
+ import type {
58
+ ConfigAdapter, CredentialsFacade, FileSystemFacade, HostContext, ImportDecisions,
59
+ ImportPlan, NamespaceInfo, PatchFileFacade, PluginInfo, PluginsFacade,
60
+ SettingsFacade, WorkspaceFacade,
61
+ } from './core/types.ts'
62
+ import { createAdapters, USER_PATCH_FILE } from './adapters/index.ts'
63
+ import { createEncryptionProvider, decryptCredentials } from './security/index.ts'
64
+ import { createHardenedZipParser } from './security/zip-security.ts'
65
+ import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
66
+ import { SECTION_IDS } from './schema/config.ts'
67
+ import type { Manifest, SectionId, WorkspaceRecord } from './schema/types.ts'
68
+ import { parseZip } from './utils/zip.ts'
69
+ import { isSameOrChild, normalizePath } from './utils/paths.ts'
70
+ import { createLogger, type Logger } from './utils/logger.ts'
71
+
72
+ /* ---------------------------------------------------------------- identity */
73
+
74
+ /** Stable cordis plugin name — must match the cordis.patch.yml row id. */
75
+ export const name = 'config-manager'
76
+
77
+ /** Services required before the engine can mount (present in every profile). */
78
+ export const inject = ['settings', 'credentials']
79
+
80
+ /** Plugin version, kept in sync with package.json ("version"). */
81
+ const PLUGIN_VERSION = '0.1.0'
82
+
83
+ /** Plugin config (composition entry); the loader applies it as-is. */
84
+ export interface Config {
85
+ /** Master switch; defaults to true. */
86
+ enabled?: boolean
87
+ /** Data root override; defaults to $DSH_HOME/dsh-config-manager. */
88
+ dataDir?: string
89
+ }
90
+
91
+ /* ---------------------------------------------------------------- constants */
92
+
93
+ /** Route family — must match the browser half's CONFIG_MANAGER_API exactly. */
94
+ const API = {
95
+ status: '/api/dsh-config-manager/status',
96
+ export: '/api/dsh-config-manager/export',
97
+ download: '/api/dsh-config-manager/download',
98
+ upload: '/api/dsh-config-manager/upload',
99
+ analyze: '/api/dsh-config-manager/analyze',
100
+ plan: '/api/dsh-config-manager/plan',
101
+ execute: '/api/dsh-config-manager/execute',
102
+ } as const
103
+
104
+ /** Cap on JSON request bodies (import plans can be large: 4 MB). */
105
+ const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024
106
+
107
+ /** Cap on raw upload bodies (staged to the controlled tmp dir). */
108
+ const MAX_UPLOAD_BYTES = 256 * 1024 * 1024
109
+
110
+ /* ---------------------------------------------------------- loopback fence */
111
+
112
+ /** Loopback literal check plus browser same-origin markers (dsh-ssh's fence). */
113
+ function isLoopbackRequest(request: IncomingMessage): boolean {
114
+ const address = request.socket.remoteAddress
115
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
116
+ const host = request.headers.host
117
+ if (typeof host !== 'string') return false
118
+ let hostUrl: URL
119
+ try {
120
+ hostUrl = new URL(`http://${host}`)
121
+ } catch {
122
+ return false
123
+ }
124
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
125
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
126
+ const origin = request.headers.origin
127
+ if (origin === undefined) return true
128
+ try {
129
+ return new URL(origin).host === hostUrl.host
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
135
+ /* ---------------------------------------------------------------- responses */
136
+
137
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
138
+ const payload = JSON.stringify(body)
139
+ res.writeHead(status, {
140
+ 'content-type': 'application/json; charset=utf-8',
141
+ 'referrer-policy': 'no-referrer',
142
+ })
143
+ res.end(payload)
144
+ }
145
+
146
+ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
147
+ const chunks: Buffer[] = []
148
+ let size = 0
149
+ for await (const chunk of req) {
150
+ const buffer = chunk as Buffer
151
+ size += buffer.length
152
+ if (size > MAX_JSON_BODY_BYTES) return undefined
153
+ chunks.push(buffer)
154
+ }
155
+ try {
156
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
157
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
158
+ } catch {
159
+ return undefined
160
+ }
161
+ }
162
+
163
+ /** URL query helper (first value, decoded). */
164
+ function queryParam(url: URL, name: string): string | undefined {
165
+ const value = url.searchParams.get(name)
166
+ return value === null ? undefined : value
167
+ }
168
+
169
+ /** Stream a raw request body to a file, enforcing a byte cap. */
170
+ async function writeRequestBodyToFile(req: IncomingMessage, dest: string, maxBytes: number): Promise<number> {
171
+ const sink = createWriteStream(dest)
172
+ let size = 0
173
+ await new Promise<void>((resolvePromise, reject) => {
174
+ req.on('error', reject)
175
+ sink.on('error', reject)
176
+ req.on('data', (chunk: Buffer) => {
177
+ size += chunk.length
178
+ if (size > maxBytes) {
179
+ sink.destroy()
180
+ req.destroy()
181
+ reject(new Error(`upload body exceeds ${maxBytes} bytes`))
182
+ }
183
+ })
184
+ req.pipe(sink)
185
+ sink.on('finish', () => resolvePromise())
186
+ })
187
+ return size
188
+ }
189
+
190
+ /* -------------------------------------------------------------- dsh version */
191
+
192
+ /** Resolve the real DSH version from the profile dependency tree (read-only). */
193
+ function resolveDshVersion(home: string): string {
194
+ const candidates = [
195
+ join(home, 'profiles', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
196
+ join(home, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'),
197
+ ]
198
+ for (const p of candidates) {
199
+ try {
200
+ const parsed = JSON.parse(readFileSync(p, 'utf8')) as { version?: unknown }
201
+ if (typeof parsed.version === 'string' && parsed.version !== '') return parsed.version
202
+ } catch {
203
+ // try the next candidate
204
+ }
205
+ }
206
+ return 'unknown'
207
+ }
208
+
209
+ /* ------------------------------------------------------- HostContext facades */
210
+
211
+ /** Optional Cordis service reader (never injected → never blocks the fiber). */
212
+ function readService<T>(ctx: Context, serviceName: string): T | undefined {
213
+ const candidate = ctx.get(serviceName)
214
+ return candidate === null || typeof candidate !== 'object' ? undefined : candidate as T
215
+ }
216
+
217
+ /** Real-plugin marketplace service shape (@deepseek-ai/dsh-plugin-marketplace
218
+ * ships no types; shape verified from its lib/index.js). */
219
+ interface MarketplacePlugin { name: string; version: string; isBundle: boolean; isClient: boolean; inBundles: string[] }
220
+ interface MarketplaceSnapshot { profileDir: string; bundles: string[]; plugins: MarketplacePlugin[] }
221
+ interface MarketplaceService {
222
+ installed(): MarketplaceSnapshot
223
+ installPlugin(packageName: string): Promise<{
224
+ ok: boolean; name: string; version?: string; needsRestart?: boolean; error?: string
225
+ }>
226
+ }
227
+
228
+ /** Settings facade over the real ctx.settings (describe() is namespace-less). */
229
+ class DshSettingsFacade implements SettingsFacade {
230
+ private readonly ctx: Context
231
+
232
+ constructor(ctx: Context) {
233
+ this.ctx = ctx
234
+ }
235
+
236
+ private provider(): SettingsProvider {
237
+ return this.ctx.settings
238
+ }
239
+
240
+ async describe(namespace: string, opts?: { redactSecrets?: boolean }): Promise<NamespaceInfo> {
241
+ const all = this.provider().describe({ redactSecrets: opts?.redactSecrets ?? true })
242
+ const descriptor = all.find((d) => String(d.ns) === namespace)
243
+ if (!descriptor) throw new Error(`namespace not found: ${namespace}`)
244
+ return {
245
+ value: descriptor.value,
246
+ base: descriptor.base,
247
+ revision: descriptor.revision,
248
+ // Real service reports a single applies value; the core contract is an array.
249
+ applies: descriptor.applies === undefined ? undefined : [descriptor.applies],
250
+ secrets: descriptor.secrets ?? [],
251
+ }
252
+ }
253
+
254
+ async replace(namespace: string, value: unknown, expectedRevision?: number): Promise<void> {
255
+ await this.provider().replace(settingsNamespace(namespace), value as object, expectedRevision)
256
+ }
257
+
258
+ async update(namespace: string, patch: unknown, expectedRevision?: number): Promise<void> {
259
+ await this.provider().update(settingsNamespace(namespace), patch as object, expectedRevision)
260
+ }
261
+ }
262
+
263
+ /** Credentials facade over the real ctx.credentials (values never round-trip). */
264
+ class DshCredentialsFacade implements CredentialsFacade {
265
+ private readonly ctx: Context
266
+
267
+ constructor(ctx: Context) {
268
+ this.ctx = ctx
269
+ }
270
+
271
+ async describe(ref: string): Promise<{ configured: boolean; source?: string; writable?: boolean }> {
272
+ const info = await this.ctx.credentials.describe(credentialRef(ref))
273
+ return { configured: info.configured, source: info.source, writable: info.writable }
274
+ }
275
+
276
+ async set(ref: string, value: string): Promise<void> {
277
+ await this.ctx.credentials.set(credentialRef(ref), value)
278
+ }
279
+
280
+ async unset(ref: string): Promise<void> {
281
+ await this.ctx.credentials.unset(credentialRef(ref))
282
+ }
283
+ }
284
+
285
+ /** Plugins facade: inventory (tree) + marketplace (user installs) merged. */
286
+ class DshPluginsFacade implements PluginsFacade {
287
+ private readonly ctx: Context
288
+
289
+ constructor(ctx: Context) {
290
+ this.ctx = ctx
291
+ }
292
+
293
+ async listInstalled(): Promise<PluginInfo[]> {
294
+ const out: PluginInfo[] = []
295
+ const inventory = readService<{ list(): { entries: readonly { moduleName: string; enabled: boolean }[] } }>(
296
+ this.ctx, 'pluginInventory',
297
+ )
298
+ const marketplace = readService<MarketplaceService>(this.ctx, 'pluginMarketplace')
299
+ if (inventory) {
300
+ for (const entry of inventory.list().entries) {
301
+ out.push({ name: entry.moduleName, version: '', enabled: entry.enabled, isBundle: false, inBundles: [] })
302
+ }
303
+ }
304
+ if (marketplace) {
305
+ try {
306
+ for (const p of marketplace.installed().plugins) {
307
+ const existing = out.find((o) => o.name === p.name)
308
+ if (existing) {
309
+ existing.version = p.version
310
+ existing.isBundle = p.isBundle
311
+ existing.inBundles = p.inBundles
312
+ } else {
313
+ out.push({ name: p.name, version: p.version, enabled: true, isBundle: p.isBundle, inBundles: p.inBundles })
314
+ }
315
+ }
316
+ } catch {
317
+ // marketplace hiccup → still return the inventory view
318
+ }
319
+ }
320
+ return out
321
+ }
322
+
323
+ async install(pkg: string): Promise<{ needsRestart: boolean }> {
324
+ const marketplace = readService<MarketplaceService>(this.ctx, 'pluginMarketplace')
325
+ if (!marketplace) {
326
+ throw new Error('插件市场服务不可用(仅 web profile 提供),无法安装 ' + pkg)
327
+ }
328
+ const result = await marketplace.installPlugin(pkg)
329
+ if (result.ok !== true) throw new Error(result.error ?? `安装失败: ${pkg}`)
330
+ return { needsRestart: result.needsRestart ?? true }
331
+ }
332
+ }
333
+
334
+ /** Workspace facade over the real ctx.workspaceRegistry. */
335
+ class DshWorkspaceFacade implements WorkspaceFacade {
336
+ private readonly ctx: Context
337
+
338
+ constructor(ctx: Context) {
339
+ this.ctx = ctx
340
+ }
341
+
342
+ private registry(): { list(): { id: unknown; path: string; title: string; sessionIds: readonly unknown[]; createdAt: string; updatedAt: string }[]; get(id: unknown): { title: string; setTitle(title: string): Promise<void> } | undefined; create(path: string, title?: string): Promise<unknown>; delete(id: unknown): Promise<boolean> } | undefined {
343
+ return readService(this.ctx, 'workspaceRegistry')
344
+ }
345
+
346
+ async listRecords(): Promise<WorkspaceRecord[]> {
347
+ const registry = this.registry()
348
+ if (!registry) return []
349
+ return registry.list().map((w) => ({
350
+ id: String(w.id),
351
+ path: w.path,
352
+ title: w.title,
353
+ sessionIds: [...w.sessionIds].map(String),
354
+ createdAt: w.createdAt,
355
+ updatedAt: w.updatedAt,
356
+ }))
357
+ }
358
+
359
+ async writeRecord(record: WorkspaceRecord): Promise<void> {
360
+ const registry = this.registry()
361
+ if (!registry) throw new Error('workspaceRegistry 服务不可用,无法写入工作区')
362
+ const existing = registry.get(record.id as unknown as WorkspaceId)
363
+ if (existing) {
364
+ // DSH API 没有「整体覆盖」写通道:标题可更新;path/会话由 registry 依真实目录维护
365
+ if (record.title !== undefined && existing.title !== record.title) await existing.setTitle(record.title)
366
+ return
367
+ }
368
+ await registry.create(record.path, record.title)
369
+ }
370
+
371
+ async removeRecord(id: string): Promise<void> {
372
+ const registry = this.registry()
373
+ if (!registry) return
374
+ await registry.delete(id as unknown as WorkspaceId)
375
+ }
376
+ }
377
+
378
+ /** Patch-file facade over the user patch layer ($DSH_HOME/cordis.patch.yml). */
379
+ class DshPatchFileFacade implements PatchFileFacade {
380
+ private readonly homeDir: string
381
+
382
+ constructor(homeDir: string) {
383
+ this.homeDir = homeDir
384
+ }
385
+
386
+ private patchPath(file: string): string {
387
+ if (file !== USER_PATCH_FILE) throw new Error(`dsh-config-manager 仅支持管理 ${USER_PATCH_FILE}(收到 ${file})`)
388
+ return join(this.homeDir, USER_PATCH_FILE)
389
+ }
390
+
391
+ async readPatchLines(file: string): Promise<{ lineId: string; raw: unknown }[]> {
392
+ const p = this.patchPath(file)
393
+ let text: string
394
+ try {
395
+ text = await fs.readFile(p, 'utf8')
396
+ } catch {
397
+ return []
398
+ }
399
+ let doc: unknown
400
+ try {
401
+ doc = yaml.load(text)
402
+ } catch {
403
+ return []
404
+ }
405
+ if (!Array.isArray(doc)) return []
406
+ const lines: { lineId: string; raw: unknown }[] = []
407
+ for (const item of doc) {
408
+ if (item === null || typeof item !== 'object') continue
409
+ const obj = item as Record<string, unknown>
410
+ const insert = obj['insert']
411
+ if (Array.isArray(insert)) {
412
+ for (const entry of insert) {
413
+ if (entry === null || typeof entry !== 'object') continue
414
+ const id = (entry as Record<string, unknown>)['id']
415
+ if (typeof id === 'string' && id !== '') lines.push({ lineId: id, raw: entry })
416
+ }
417
+ continue
418
+ }
419
+ const id = obj['id']
420
+ if (typeof id === 'string' && id !== '') lines.push({ lineId: id, raw: obj })
421
+ }
422
+ return lines
423
+ }
424
+
425
+ async applyPatchChanges(
426
+ file: string,
427
+ changes: { lineId: string; raw: unknown; action: 'insert' | 'update' | 'remove' }[],
428
+ ): Promise<void> {
429
+ const p = this.patchPath(file)
430
+
431
+ // 1. Load the current document into an ordered lineId → raw table.
432
+ const rows = new Map<string, unknown>()
433
+ const order: string[] = []
434
+ let doc: unknown
435
+ try {
436
+ doc = yaml.load(await fs.readFile(p, 'utf8'))
437
+ } catch {
438
+ doc = undefined
439
+ }
440
+ if (Array.isArray(doc)) {
441
+ for (const item of doc) {
442
+ if (item === null || typeof item !== 'object') continue
443
+ const obj = item as Record<string, unknown>
444
+ const insert = obj['insert']
445
+ if (Array.isArray(insert)) {
446
+ for (const entry of insert) {
447
+ if (entry === null || typeof entry !== 'object') continue
448
+ const id = (entry as Record<string, unknown>)['id']
449
+ if (typeof id === 'string' && id !== '' && !rows.has(id)) {
450
+ rows.set(id, entry)
451
+ order.push(id)
452
+ }
453
+ }
454
+ continue
455
+ }
456
+ const id = obj['id']
457
+ if (typeof id === 'string' && id !== '' && !rows.has(id)) {
458
+ rows.set(id, obj)
459
+ order.push(id)
460
+ }
461
+ }
462
+ }
463
+
464
+ // 2. Apply the changes.
465
+ for (const change of changes) {
466
+ if (change.action === 'remove') {
467
+ if (rows.delete(change.lineId)) {
468
+ const at = order.indexOf(change.lineId)
469
+ if (at >= 0) order.splice(at, 1)
470
+ }
471
+ } else if (change.action === 'insert' || change.action === 'update') {
472
+ if (!rows.has(change.lineId)) order.push(change.lineId)
473
+ rows.set(change.lineId, change.raw)
474
+ }
475
+ }
476
+
477
+ // 3. Rebuild: every id row is emitted as a top-level row. The loader treats
478
+ // a top-level { id, name } row exactly like an `- insert:` block member
479
+ // (dsh-base patch precedent), so the document stays semantically equal.
480
+ const out: unknown[] = []
481
+ for (const id of order) {
482
+ const raw = rows.get(id)
483
+ if (raw !== undefined) out.push(raw)
484
+ }
485
+ const text = '# rewritten by dsh-config-manager import (original comments not preserved)\n'
486
+ + yaml.dump(out)
487
+ await fs.mkdir(dirname(p), { recursive: true })
488
+ await fs.writeFile(p, text, 'utf8')
489
+ }
490
+ }
491
+
492
+ /** File facade over $DSH_HOME, confined to the home root. */
493
+ class DshFileSystemFacade implements FileSystemFacade {
494
+ private readonly homeDir: string
495
+
496
+ constructor(homeDir: string) {
497
+ this.homeDir = homeDir
498
+ }
499
+
500
+ private abs(relPath: string): string {
501
+ const target = resolve(isAbsolute(relPath) ? relPath : join(this.homeDir, relPath))
502
+ if (!isSameOrChild(target, this.homeDir)) throw new Error(`路径越界: ${relPath}`)
503
+ return target
504
+ }
505
+
506
+ async readFile(relPath: string): Promise<Uint8Array> {
507
+ return fs.readFile(this.abs(relPath))
508
+ }
509
+
510
+ async writeFile(relPath: string, data: Uint8Array): Promise<void> {
511
+ const target = this.abs(relPath)
512
+ await fs.mkdir(dirname(target), { recursive: true })
513
+ await fs.writeFile(target, data)
514
+ }
515
+
516
+ async exists(relPath: string): Promise<boolean> {
517
+ try {
518
+ await fs.access(this.abs(relPath))
519
+ return true
520
+ } catch {
521
+ return false
522
+ }
523
+ }
524
+
525
+ async copy(from: string, to: string): Promise<void> {
526
+ const target = this.abs(to)
527
+ await fs.mkdir(dirname(target), { recursive: true })
528
+ await fs.copyFile(this.abs(from), target)
529
+ }
530
+
531
+ async remove(relPath: string): Promise<void> {
532
+ await fs.rm(this.abs(relPath), { recursive: true, force: true })
533
+ }
534
+
535
+ async listRecursive(dir: string): Promise<string[]> {
536
+ const base = this.abs(dir)
537
+ const out: string[] = []
538
+ const walk = async (current: string): Promise<void> => {
539
+ if (!isSameOrChild(current, this.homeDir)) return
540
+ let entries
541
+ try {
542
+ entries = await fs.readdir(current, { withFileTypes: true })
543
+ } catch {
544
+ return
545
+ }
546
+ for (const entry of entries) {
547
+ const p = join(current, entry.name)
548
+ if (entry.isDirectory()) {
549
+ await walk(p)
550
+ } else if (entry.isFile()) {
551
+ out.push(normalizePath(relative(this.homeDir, p)))
552
+ }
553
+ }
554
+ }
555
+ await walk(base)
556
+ return out.sort()
557
+ }
558
+
559
+ async mkdir(dir: string): Promise<void> {
560
+ await fs.mkdir(this.abs(dir), { recursive: true })
561
+ }
562
+ }
563
+
564
+ /** The engine's HostContext over real DSH services. */
565
+ class ConfigManagerHostContext implements HostContext {
566
+ readonly platform: string = process.platform
567
+ readonly arch: string = process.arch
568
+ readonly homeDir: string
569
+ readonly dshVersion: string
570
+ readonly log: Logger
571
+ readonly settings: SettingsFacade
572
+ readonly credentials: CredentialsFacade
573
+ readonly plugins: PluginsFacade
574
+ readonly workspace: WorkspaceFacade
575
+ readonly patchFile: PatchFileFacade
576
+ readonly fs: FileSystemFacade
577
+
578
+ constructor(ctx: Context, homeDir: string) {
579
+ this.homeDir = homeDir
580
+ this.dshVersion = resolveDshVersion(homeDir)
581
+ const level = process.env.DSH_CONFIG_MANAGER_LOG_LEVEL
582
+ this.log = createLogger({
583
+ level: level === 'debug' || level === 'info' || level === 'warn' || level === 'error' ? level : 'info',
584
+ })
585
+ this.settings = new DshSettingsFacade(ctx)
586
+ this.credentials = new DshCredentialsFacade(ctx)
587
+ this.plugins = new DshPluginsFacade(ctx)
588
+ this.workspace = new DshWorkspaceFacade(ctx)
589
+ this.patchFile = new DshPatchFileFacade(homeDir)
590
+ this.fs = new DshFileSystemFacade(homeDir)
591
+ }
592
+ }
593
+
594
+ /* ---------------------------------------------------------------- routes */
595
+
596
+ /** Controlled staging roots guard. */
597
+ function isControlledPath(target: string, roots: string[]): boolean {
598
+ const t = resolve(target)
599
+ return roots.some((root) => isSameOrChild(t, resolve(root)))
600
+ }
601
+
602
+ function dateStamp(d: Date = new Date()): string {
603
+ const y = d.getFullYear()
604
+ const m = String(d.getMonth() + 1).padStart(2, '0')
605
+ const day = String(d.getDate()).padStart(2, '0')
606
+ return `${y}-${m}-${day}`
607
+ }
608
+
609
+ /** Minimal real dependency check (MCP §15): `which`/`where` probe. */
610
+ async function dependencyAvailable(command: string): Promise<boolean> {
611
+ const probe = process.platform === 'win32' ? 'where' : 'which'
612
+ try {
613
+ await promisify(execFile)(probe, [command], { windowsHide: true })
614
+ return true
615
+ } catch {
616
+ return false
617
+ }
618
+ }
619
+
620
+ /** Decrypt an encrypted backup's credentials (in-memory only; undefined when not applicable). */
621
+ async function tryDecryptCredentials(
622
+ zipPath: string,
623
+ password: string | undefined,
624
+ ): Promise<Map<string, string> | undefined> {
625
+ if (password === undefined || password === '') return undefined
626
+ const raw = await fs.readFile(zipPath)
627
+ const archive = parseZip(raw)
628
+ if (!archive.has(MANIFEST_FILE)) return undefined
629
+ let manifest: Manifest
630
+ try {
631
+ manifest = parseManifest(archive.readEntryText(MANIFEST_FILE))
632
+ } catch {
633
+ return undefined
634
+ }
635
+ if (!manifest.security.encrypted || manifest.security.encryption === null) return undefined
636
+ if (!archive.has('security/secrets.enc')) return undefined
637
+ const blob = archive.readEntry('security/secrets.enc')
638
+ const plaintext = await decryptCredentials(blob, manifest.security.encryption, password)
639
+ let parsed: unknown
640
+ try {
641
+ parsed = yaml.load(plaintext)
642
+ } catch {
643
+ return undefined
644
+ }
645
+ const map = new Map<string, string>()
646
+ if (parsed !== null && typeof parsed === 'object') {
647
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
648
+ if (typeof v === 'string' && v !== '') map.set(k, v)
649
+ }
650
+ }
651
+ return map
652
+ }
653
+
654
+ interface RoutesDeps {
655
+ host: ConfigManagerHostContext
656
+ adapters: ConfigAdapter[]
657
+ exportsDir: string
658
+ tmpDir: string
659
+ snapshotsDir: string
660
+ }
661
+
662
+ /** Build the /api/dsh-config-manager route family. */
663
+ function makeRoutes(deps: RoutesDeps): WebRoute[] {
664
+ const { host, adapters, exportsDir, tmpDir, snapshotsDir } = deps
665
+ const roots = [exportsDir, tmpDir]
666
+
667
+ const makeImporter = (): Importer => new Importer({
668
+ ctx: host,
669
+ adapters,
670
+ snapshotStore: new FileSnapshotStore({ dir: snapshotsDir }),
671
+ parseZipOverride: createHardenedZipParser(),
672
+ dependencyChecker: dependencyAvailable,
673
+ })
674
+
675
+ /** Fence + method guard (mirrors dsh-ssh). */
676
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
677
+ if (!isLoopbackRequest(req)) {
678
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
679
+ return false
680
+ }
681
+ if (req.method !== method) {
682
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
683
+ return false
684
+ }
685
+ return true
686
+ }
687
+
688
+ return [
689
+ // ------------------------------------------------------------- status
690
+ {
691
+ kind: 'exact',
692
+ path: API.status,
693
+ handler: async (req, res) => {
694
+ if (!guard(req, res, 'GET')) return
695
+ writeJson(res, 200, {
696
+ ready: true,
697
+ pluginVersion: PLUGIN_VERSION,
698
+ dshVersion: host.dshVersion,
699
+ platform: host.platform,
700
+ arch: host.arch,
701
+ })
702
+ },
703
+ },
704
+ // ------------------------------------------------------------- export
705
+ {
706
+ kind: 'exact',
707
+ path: API.export,
708
+ handler: async (req, res) => {
709
+ if (!guard(req, res, 'POST')) return
710
+ const body = await readJsonBody(req)
711
+ if (body === undefined) {
712
+ writeJson(res, 400, { error: 'invalid JSON body' })
713
+ return
714
+ }
715
+ const includeSecrets = body['includeSecrets'] === true
716
+ const only = Array.isArray(body['only'])
717
+ ? body['only'].filter((x): x is SectionId => typeof x === 'string' && (SECTION_IDS as readonly string[]).includes(x))
718
+ : undefined
719
+ // Encryption password is in-memory only (never persisted / logged).
720
+ const password = typeof body['password'] === 'string' && body['password'] !== '' ? body['password'] : undefined
721
+ const outPath = join(exportsDir, `dsh-config-${dateStamp()}-${randomBytes(3).toString('hex')}.zip`)
722
+ try {
723
+ const exporter = new Exporter({
724
+ ctx: host,
725
+ adapters,
726
+ encryption: includeSecrets && password !== undefined ? createEncryptionProvider(password) : null,
727
+ exporterVersion: PLUGIN_VERSION,
728
+ })
729
+ const result = await exporter.export({ includeSecrets, only, outPath })
730
+ writeJson(res, 200, { zipPath: result.zipPath, manifest: result.manifest, report: result.report })
731
+ } catch (error) {
732
+ host.log.error('导出失败', { error: error instanceof Error ? error.message : String(error) })
733
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
734
+ }
735
+ },
736
+ },
737
+ // ------------------------------------------------------------ download
738
+ {
739
+ kind: 'exact',
740
+ path: API.download,
741
+ handler: async (req, res) => {
742
+ if (!guard(req, res, 'GET')) return
743
+ const url = new URL(req.url ?? '/', 'http://localhost')
744
+ const p = queryParam(url, 'path')
745
+ if (p === undefined || p === '') {
746
+ writeJson(res, 400, { error: 'path query parameter is required' })
747
+ return
748
+ }
749
+ const target = resolve(p)
750
+ if (!isControlledPath(target, roots)) {
751
+ writeJson(res, 403, { error: 'path outside controlled staging area' })
752
+ return
753
+ }
754
+ let stat
755
+ try {
756
+ stat = await fs.stat(target)
757
+ } catch {
758
+ writeJson(res, 404, { error: 'file not found' })
759
+ return
760
+ }
761
+ if (!stat.isFile()) {
762
+ writeJson(res, 400, { error: 'not a file' })
763
+ return
764
+ }
765
+ res.writeHead(200, {
766
+ 'content-type': 'application/octet-stream',
767
+ 'content-length': String(stat.size),
768
+ 'content-disposition': `attachment; filename="${basename(target).replace(/"/g, '')}"`,
769
+ 'referrer-policy': 'no-referrer',
770
+ })
771
+ await new Promise<void>((resolvePromise, reject) => {
772
+ const source = createReadStream(target)
773
+ source.on('error', reject)
774
+ res.on('error', reject)
775
+ source.pipe(res)
776
+ source.on('end', resolvePromise)
777
+ })
778
+ },
779
+ },
780
+ // -------------------------------------------------------------- upload
781
+ {
782
+ kind: 'exact',
783
+ path: API.upload,
784
+ handler: async (req, res) => {
785
+ if (!guard(req, res, 'POST')) return
786
+ const url = new URL(req.url ?? '/', 'http://localhost')
787
+ const name = queryParam(url, 'name') ?? 'backup.zip'
788
+ const declared = Number(req.headers['content-length'])
789
+ if (Number.isFinite(declared) && declared > MAX_UPLOAD_BYTES) {
790
+ writeJson(res, 413, { error: 'upload body too large' })
791
+ return
792
+ }
793
+ const tmp = join(tmpDir, `upload-${randomBytes(6).toString('hex')}.zip`)
794
+ try {
795
+ const sizeBytes = await writeRequestBodyToFile(req, tmp, MAX_UPLOAD_BYTES)
796
+ writeJson(res, 200, { zipPath: tmp, name, sizeBytes })
797
+ } catch (error) {
798
+ await fs.rm(tmp, { force: true }).catch(() => undefined)
799
+ if (!res.headersSent) {
800
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
801
+ } else {
802
+ res.destroy()
803
+ }
804
+ }
805
+ },
806
+ },
807
+ // ------------------------------------------------------------- analyze
808
+ {
809
+ kind: 'exact',
810
+ path: API.analyze,
811
+ handler: async (req, res) => {
812
+ if (!guard(req, res, 'POST')) return
813
+ const body = await readJsonBody(req)
814
+ const zipPath = typeof body?.['zipPath'] === 'string' ? body['zipPath'] : ''
815
+ if (zipPath === '' || !isControlledPath(zipPath, roots)) {
816
+ writeJson(res, 400, { error: 'zipPath is required and must reference a staged backup' })
817
+ return
818
+ }
819
+ try {
820
+ writeJson(res, 200, await makeImporter().analyzeImport(zipPath))
821
+ } catch (error) {
822
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
823
+ }
824
+ },
825
+ },
826
+ // ---------------------------------------------------------------- plan
827
+ {
828
+ kind: 'exact',
829
+ path: API.plan,
830
+ handler: async (req, res) => {
831
+ if (!guard(req, res, 'POST')) return
832
+ const body = await readJsonBody(req)
833
+ const zipPath = typeof body?.['zipPath'] === 'string' ? body['zipPath'] : ''
834
+ if (zipPath === '' || !isControlledPath(zipPath, roots)) {
835
+ writeJson(res, 400, { error: 'zipPath is required and must reference a staged backup' })
836
+ return
837
+ }
838
+ const decisions = body?.['decisions'] as ImportDecisions | undefined
839
+ if (decisions === undefined || typeof decisions !== 'object') {
840
+ writeJson(res, 400, { error: 'decisions is required' })
841
+ return
842
+ }
843
+ try {
844
+ writeJson(res, 200, await makeImporter().createImportPlan(zipPath, decisions))
845
+ } catch (error) {
846
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
847
+ }
848
+ },
849
+ },
850
+ // ------------------------------------------------------------- execute
851
+ {
852
+ kind: 'exact',
853
+ path: API.execute,
854
+ handler: async (req, res) => {
855
+ if (!guard(req, res, 'POST')) return
856
+ const body = await readJsonBody(req)
857
+ const zipPath = typeof body?.['zipPath'] === 'string' ? body['zipPath'] : ''
858
+ if (zipPath === '' || !isControlledPath(zipPath, roots)) {
859
+ writeJson(res, 400, { error: 'zipPath is required and must reference a staged backup' })
860
+ return
861
+ }
862
+ const plan = body?.['plan'] as ImportPlan | undefined
863
+ if (plan === undefined || typeof plan !== 'object' || !Array.isArray(plan['items'])) {
864
+ writeJson(res, 400, { error: 'plan is required and must be an ImportPlan' })
865
+ return
866
+ }
867
+ const opts = (body?.['opts'] ?? {}) as Record<string, unknown>
868
+ // decryptPassword is an optional Host extension (the browser contract
869
+ // only carries secretInputs); used to open an encrypted backup.
870
+ const decryptPassword =
871
+ typeof opts['decryptPassword'] === 'string' && opts['decryptPassword'] !== ''
872
+ ? opts['decryptPassword']
873
+ : undefined
874
+ try {
875
+ const decryptedCredentials = await tryDecryptCredentials(zipPath, decryptPassword)
876
+ const result = await makeImporter().executeImportPlan(zipPath, plan, {
877
+ confirm: opts['confirm'] === true,
878
+ secretInputs:
879
+ opts['secretInputs'] !== null && typeof opts['secretInputs'] === 'object'
880
+ ? opts['secretInputs'] as Record<string, string>
881
+ : {},
882
+ rollbackOnError: opts['rollbackOnError'] === true,
883
+ decryptedCredentials,
884
+ })
885
+ writeJson(res, 200, result)
886
+ } catch (error) {
887
+ host.log.error('导入执行失败', { error: error instanceof Error ? error.message : String(error) })
888
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
889
+ }
890
+ },
891
+ },
892
+ ]
893
+ }
894
+
895
+ /* ------------------------------------------------------------------ apply */
896
+
897
+ /**
898
+ * Mount the config-manager engine: host context, adapters, and the
899
+ * /api/dsh-config-manager routes (when a webServer is present).
900
+ * @param ctx - host plugin context carrying settings/credentials/webServer.
901
+ * @param config - resolved plugin config.
902
+ */
903
+ export function apply(ctx: Context, config?: Config): void {
904
+ if (config?.enabled === false) return
905
+
906
+ const homeDir = resolveDshHome()
907
+ const dataDir = config?.dataDir !== undefined && config.dataDir !== ''
908
+ ? resolve(config.dataDir)
909
+ : dshHomePath('dsh-config-manager')
910
+ const exportsDir = join(dataDir, 'exports')
911
+ const tmpDir = join(dataDir, 'tmp')
912
+ const snapshotsDir = join(dataDir, 'snapshots')
913
+ mkdirSync(exportsDir, { recursive: true })
914
+ mkdirSync(tmpDir, { recursive: true })
915
+ mkdirSync(snapshotsDir, { recursive: true })
916
+
917
+ const host = new ConfigManagerHostContext(ctx, homeDir)
918
+ const adapters = createAdapters({
919
+ // Namespace list = everything the settings service has registered.
920
+ namespaces: async () => (await ctx.settings.describe({ redactSecrets: true })).map((d) => String(d.ns)),
921
+ })
922
+
923
+ host.log.info('config-manager 已挂载', {
924
+ homeDir,
925
+ dataDir,
926
+ dshVersion: host.dshVersion,
927
+ adapters: adapters.map((a) => a.id),
928
+ })
929
+
930
+ const routes = makeRoutes({ host, adapters, exportsDir, tmpDir, snapshotsDir })
931
+ const webServer = readService<WebServer>(ctx, 'webServer')
932
+ if (webServer === undefined) {
933
+ host.log.warn('webServer 服务不可用:跳过 /api/dsh-config-manager 路由注册(引擎能力仍可用)')
934
+ return
935
+ }
936
+ ctx.effect(() => {
937
+ const disposers = routes.map((route) => webServer.register(route))
938
+ return () => {
939
+ for (const dispose of disposers) dispose()
940
+ }
941
+ }, 'config-manager: routes')
942
+ }