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.
- package/README.md +216 -0
- package/README.zh-CN.md +218 -0
- package/cordis.patch.yml +45 -0
- package/dsh.bundle.patch +21 -0
- package/dsh.client +39 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/agent-presets.js.map +1 -0
- package/lib/adapters/credentials.d.ts +36 -0
- package/lib/adapters/credentials.js +121 -0
- package/lib/adapters/credentials.js.map +1 -0
- package/lib/adapters/file-collection.d.ts +14 -0
- package/lib/adapters/file-collection.js +102 -0
- package/lib/adapters/file-collection.js.map +1 -0
- package/lib/adapters/index.d.ts +38 -0
- package/lib/adapters/index.js +46 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/mcp.d.ts +27 -0
- package/lib/adapters/mcp.js +175 -0
- package/lib/adapters/mcp.js.map +1 -0
- package/lib/adapters/plugin-files.d.ts +15 -0
- package/lib/adapters/plugin-files.js +91 -0
- package/lib/adapters/plugin-files.js.map +1 -0
- package/lib/adapters/plugins.d.ts +13 -0
- package/lib/adapters/plugins.js +177 -0
- package/lib/adapters/plugins.js.map +1 -0
- package/lib/adapters/prompts.d.ts +29 -0
- package/lib/adapters/prompts.js +228 -0
- package/lib/adapters/prompts.js.map +1 -0
- package/lib/adapters/providers.d.ts +33 -0
- package/lib/adapters/providers.js +143 -0
- package/lib/adapters/providers.js.map +1 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/sessions.js.map +1 -0
- package/lib/adapters/settings.d.ts +23 -0
- package/lib/adapters/settings.js +142 -0
- package/lib/adapters/settings.js.map +1 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/skills.js.map +1 -0
- package/lib/adapters/test-helpers.d.ts +99 -0
- package/lib/adapters/test-helpers.js +192 -0
- package/lib/adapters/test-helpers.js.map +1 -0
- package/lib/adapters/ui.d.ts +26 -0
- package/lib/adapters/ui.js +88 -0
- package/lib/adapters/ui.js.map +1 -0
- package/lib/adapters/workspaces.d.ts +12 -0
- package/lib/adapters/workspaces.js +92 -0
- package/lib/adapters/workspaces.js.map +1 -0
- package/lib/client.d.ts +377 -0
- package/lib/client.js +2598 -0
- package/lib/client.js.map +1 -0
- package/lib/core/analyzer.d.ts +35 -0
- package/lib/core/analyzer.js +563 -0
- package/lib/core/analyzer.js.map +1 -0
- package/lib/core/backup.d.ts +29 -0
- package/lib/core/backup.js +184 -0
- package/lib/core/backup.js.map +1 -0
- package/lib/core/exporter.d.ts +37 -0
- package/lib/core/exporter.js +222 -0
- package/lib/core/exporter.js.map +1 -0
- package/lib/core/importer.d.ts +42 -0
- package/lib/core/importer.js +25 -0
- package/lib/core/importer.js.map +1 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +11 -0
- package/lib/core/index.js.map +1 -0
- package/lib/core/rollback.d.ts +9 -0
- package/lib/core/rollback.js +136 -0
- package/lib/core/rollback.js.map +1 -0
- package/lib/core/types.d.ts +331 -0
- package/lib/core/types.js +18 -0
- package/lib/core/types.js.map +1 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/validator.js.map +1 -0
- package/lib/index.d.ts +51 -0
- package/lib/index.js +875 -0
- package/lib/index.js.map +1 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/index.js.map +1 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/migrations/v1-to-v2.js.map +1 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/index.js.map +1 -0
- package/lib/profiles/profile-manager.d.ts +102 -0
- package/lib/profiles/profile-manager.js +522 -0
- package/lib/profiles/profile-manager.js.map +1 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +136 -0
- package/lib/schema/config.js.map +1 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/manifest.js.map +1 -0
- package/lib/schema/types.d.ts +169 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/types.js.map +1 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/schema/versions.js.map +1 -0
- package/lib/security/encryption.d.ts +41 -0
- package/lib/security/encryption.js +152 -0
- package/lib/security/encryption.js.map +1 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/index.js.map +1 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/integrity.js.map +1 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/redaction.js.map +1 -0
- package/lib/security/secret-scanner.d.ts +48 -0
- package/lib/security/secret-scanner.js +253 -0
- package/lib/security/secret-scanner.js.map +1 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/security/zip-security.js.map +1 -0
- package/lib/ui/conflict-view.d.ts +28 -0
- package/lib/ui/conflict-view.js +49 -0
- package/lib/ui/conflict-view.js.map +1 -0
- package/lib/ui/errors.d.ts +17 -0
- package/lib/ui/errors.js +76 -0
- package/lib/ui/errors.js.map +1 -0
- package/lib/ui/export-flow.d.ts +62 -0
- package/lib/ui/export-flow.js +79 -0
- package/lib/ui/export-flow.js.map +1 -0
- package/lib/ui/import-wizard.d.ts +67 -0
- package/lib/ui/import-wizard.js +169 -0
- package/lib/ui/import-wizard.js.map +1 -0
- package/lib/ui/path-mapping.d.ts +38 -0
- package/lib/ui/path-mapping.js +75 -0
- package/lib/ui/path-mapping.js.map +1 -0
- package/lib/ui/progress.d.ts +27 -0
- package/lib/ui/progress.js +76 -0
- package/lib/ui/progress.js.map +1 -0
- package/lib/ui/report.d.ts +21 -0
- package/lib/ui/report.js +146 -0
- package/lib/ui/report.js.map +1 -0
- package/lib/ui/test-helpers.d.ts +62 -0
- package/lib/ui/test-helpers.js +147 -0
- package/lib/ui/test-helpers.js.map +1 -0
- package/lib/ui/types.d.ts +129 -0
- package/lib/ui/types.js +21 -0
- package/lib/ui/types.js.map +1 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/hashing.js.map +1 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/json.js.map +1 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/logger.js.map +1 -0
- package/lib/utils/paths.d.ts +31 -0
- package/lib/utils/paths.js +132 -0
- package/lib/utils/paths.js.map +1 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/lib/utils/zip.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +141 -0
- package/src/adapters/file-collection.ts +110 -0
- package/src/adapters/files.test.ts +113 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +181 -0
- package/src/adapters/plugin-files.ts +96 -0
- package/src/adapters/plugins.test.ts +72 -0
- package/src/adapters/plugins.ts +176 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +226 -0
- package/src/adapters/providers.test.ts +125 -0
- package/src/adapters/providers.ts +161 -0
- package/src/adapters/roundtrip.test.ts +188 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +93 -0
- package/src/adapters/settings.ts +165 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +207 -0
- package/src/adapters/ui.test.ts +56 -0
- package/src/adapters/ui.ts +106 -0
- package/src/adapters/workspaces.test.ts +79 -0
- package/src/adapters/workspaces.ts +95 -0
- package/src/client/CLIENT_DEPENDENCIES.md +101 -0
- package/src/client/ConfigManagerSection.tsx +60 -0
- package/src/client/api.ts +270 -0
- package/src/client/client-types.ts +27 -0
- package/src/client/common/ErrorBanner.tsx +68 -0
- package/src/client/common/ProgressBar.tsx +49 -0
- package/src/client/common/ReportView.tsx +117 -0
- package/src/client/common/ui.tsx +165 -0
- package/src/client/config-manager.module.css +759 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/export/ExportView.tsx +222 -0
- package/src/client/import/ConflictList.tsx +72 -0
- package/src/client/import/ImportWizardView.tsx +406 -0
- package/src/client/import/PathMappingForm.tsx +77 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +182 -0
- package/src/core/analyzer.ts +631 -0
- package/src/core/backup.ts +212 -0
- package/src/core/exporter.ts +246 -0
- package/src/core/importer.ts +66 -0
- package/src/core/index.ts +39 -0
- package/src/core/rollback.ts +140 -0
- package/src/core/smoke.test.ts +717 -0
- package/src/core/types.ts +343 -0
- package/src/core/validator.ts +84 -0
- package/src/index.ts +942 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +280 -0
- package/src/profiles/profile-manager.ts +606 -0
- package/src/schema/config.ts +137 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/types.ts +179 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +180 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +275 -0
- package/src/security/security.test.ts +796 -0
- package/src/security/zip-security.ts +180 -0
- package/src/ui/conflict-view.test.ts +62 -0
- package/src/ui/conflict-view.ts +65 -0
- package/src/ui/errors.test.ts +44 -0
- package/src/ui/errors.ts +95 -0
- package/src/ui/export-flow.test.ts +75 -0
- package/src/ui/export-flow.ts +128 -0
- package/src/ui/import-wizard.test.ts +128 -0
- package/src/ui/import-wizard.ts +208 -0
- package/src/ui/path-mapping.test.ts +67 -0
- package/src/ui/path-mapping.ts +103 -0
- package/src/ui/progress.test.ts +42 -0
- package/src/ui/progress.ts +90 -0
- package/src/ui/report.test.ts +93 -0
- package/src/ui/report.ts +140 -0
- package/src/ui/test-helpers.ts +177 -0
- package/src/ui/types.ts +193 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +122 -0
- 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
|
+
}
|