@xxxyz/dsh-mcp-manager 2.0.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/install.mjs ADDED
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env node
2
+ // dsh-mcp-manager install.mjs — the single cross-platform installer core.
3
+ // Runs on Windows / macOS / Linux with any Node.js >= 18 (no dependencies).
4
+ //
5
+ // install.ps1 (Windows) and install.sh (macOS / Linux) are thin wrappers that
6
+ // just forward their arguments to this file.
7
+ //
8
+ // What it does (idempotent — safe to run repeatedly):
9
+ // 1. copy this package to <dshHome>/local-packages/dsh-mcp-manager
10
+ // (source of record, kept outside node_modules so DSH upgrades never touch it)
11
+ // 2. copy it to <dshHome>/profiles/node_modules/dsh-mcp-manager
12
+ // (a PLAIN copy on purpose — a symlink would make Node ESM resolve the
13
+ // plugin's realpath where @deepseek-ai/dsh-tools cannot be found)
14
+ // 3. append the loader row (`id: mcp-manager`) to
15
+ // <dshHome>/profiles/<profile>/cordis.patch.yml
16
+ // (idempotent; keeps the patch a valid top-level YAML array)
17
+ //
18
+ // --repair additionally bumps the loader row's config.version to force an
19
+ // HMR re-apply, then polls POST /dsh-mcp-manager/api until it
20
+ // answers {ok:true} (default 30 s).
21
+ import fs from 'node:fs'
22
+ import path from 'node:path'
23
+ import os from 'node:os'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
27
+ const PKG_NAME = 'dsh-mcp-manager'
28
+ const LOADER_ID = 'mcp-manager'
29
+ const DEFAULT_PROFILE = 'web'
30
+ const DEFAULT_PORT = 3080
31
+ const REPAIR_TIMEOUT_MS = 30000
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // small helpers
35
+ // ---------------------------------------------------------------------------
36
+ const log = (msg) => console.log(msg)
37
+ const warn = (msg) => console.warn('[警告] ' + msg)
38
+ const fail = (msg) => { console.error('[错误] ' + msg); process.exit(1) }
39
+
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
41
+
42
+ function timeoutSignal(ms) {
43
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
44
+ return AbortSignal.timeout(ms)
45
+ const ac = new AbortController()
46
+ setTimeout(() => ac.abort(), ms)
47
+ return ac.signal
48
+ }
49
+
50
+ // Recursive copy that skips heavyweight / irrelevant dirs (node_modules, .git).
51
+ // The destination is wiped first so stale files never survive an upgrade.
52
+ function copyDir(src, dest) {
53
+ fs.rmSync(dest, { recursive: true, force: true })
54
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
55
+ fs.cpSync(src, dest, {
56
+ recursive: true,
57
+ force: true,
58
+ filter: (p) => {
59
+ const base = path.basename(p)
60
+ return base !== 'node_modules' && base !== '.git'
61
+ },
62
+ })
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // argument parsing
67
+ // ---------------------------------------------------------------------------
68
+ function parseArgs(argv) {
69
+ const opts = {
70
+ dshHome: process.env.DSH_HOME || null,
71
+ profile: DEFAULT_PROFILE,
72
+ port: DEFAULT_PORT,
73
+ repair: false,
74
+ skipPatch: false,
75
+ help: false,
76
+ }
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const a = argv[i]
79
+ const next = () => {
80
+ i++
81
+ if (i >= argv.length) fail('缺少参数值: ' + a + '(用 --help 查看用法)')
82
+ return argv[i]
83
+ }
84
+ switch (a) {
85
+ case '--dsh-home': opts.dshHome = next(); break
86
+ case '--profile': opts.profile = next(); break
87
+ case '--port':
88
+ opts.port = parseInt(next(), 10)
89
+ if (Number.isNaN(opts.port)) fail('--port 需为数字')
90
+ break
91
+ case '--repair': opts.repair = true; break
92
+ case '--skip-patch': opts.skipPatch = true; break
93
+ case '-h':
94
+ case '--help': opts.help = true; break
95
+ default: fail('未知参数: ' + a + '(用 --help 查看用法)')
96
+ }
97
+ }
98
+ if (!opts.dshHome) opts.dshHome = path.join(os.homedir(), '.dsh')
99
+ return opts
100
+ }
101
+
102
+ function printUsage() {
103
+ log(`dsh-mcp-manager 安装脚本(跨平台:Windows / macOS / Linux,需要 Node.js >= 18)
104
+
105
+ 用法:
106
+ node install.mjs [选项]
107
+
108
+ 选项:
109
+ --dsh-home <path> DSH 主目录(含 profiles/、settings.yaml 的目录)。
110
+ 默认取 $DSH_HOME 环境变量,否则 ~/.dsh
111
+ --profile <name> 要安装到的 profile 名(默认 ${DEFAULT_PROFILE})
112
+ --port <n> 修复模式下探测 API 的端口,即 DSH Web 端口(默认 ${DEFAULT_PORT})
113
+ --repair 修复模式:重新部署 + 递增 loader 行 config.version 触发
114
+ HMR 重应用 + 轮询 API 直到返回 {ok:true}
115
+ --skip-patch 只复制包文件,不修改 cordis.patch.yml
116
+ -h, --help 显示本帮助
117
+
118
+ 示例:
119
+ node install.mjs
120
+ node install.mjs --dsh-home D:\\path\\.dsh --profile web
121
+ node install.mjs --repair --port 3080`)
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // loader-row patch editing (line based, keeps the patch a valid YAML array)
126
+ // ---------------------------------------------------------------------------
127
+ // IMPORTANT: in the DSH loader patch dialect (applyEntryPatches), a plain
128
+ // `- id: x` row is an OVERRIDE of an already-existing entry and is SKIPPED with
129
+ // a warning when no such entry exists — it can never ADD a new plugin. Adding
130
+ // entries requires the `insert` form (same shape the plugin itself uses for
131
+ // MCP server rows). This is the whole plugin's loader row:
132
+ //
133
+ // - insert:
134
+ // - id: mcp-manager
135
+ // name: dsh-mcp-manager
136
+ // config:
137
+ // version: 1
138
+ function toLoaderBlock(version) {
139
+ return [
140
+ '- insert:',
141
+ ' - id: ' + LOADER_ID,
142
+ ' name: ' + PKG_NAME,
143
+ ' config:',
144
+ ' version: ' + version,
145
+ ].join('\n')
146
+ }
147
+
148
+ const LOADER_BLOCK = toLoaderBlock(1)
149
+
150
+ const reLoaderChild = () => new RegExp("^(\\s+)- id:\\s*'?" + LOADER_ID + "'?\\s*$")
151
+ const reLoaderPlain = () => new RegExp("^- id:\\s*'?" + LOADER_ID + "'?\\s*$")
152
+
153
+ // Find the loader entry: either inside a top-level `- insert:` block (current
154
+ // form) or as a legacy plain top-level row (old form, converted on write).
155
+ // Returns the whole owning block's line range plus its form.
156
+ function findLoaderEntry(content) {
157
+ const lines = content.split(/\r?\n/)
158
+ const n = lines.length
159
+ for (let i = 0; i < n; i++) {
160
+ if (/^- insert:/.test(lines[i])) {
161
+ let end = i + 1
162
+ while (end < n && !/^- /.test(lines[end])) end++
163
+ if (lines.slice(i, end).some((l) => reLoaderChild().test(l)))
164
+ return { start: i, end, lines: lines.slice(i, end), form: 'insert' }
165
+ continue
166
+ }
167
+ if (reLoaderPlain().test(lines[i])) {
168
+ let end = i + 1
169
+ while (end < n && !/^- /.test(lines[end])) end++
170
+ return { start: i, end, lines: lines.slice(i, end), form: 'plain' }
171
+ }
172
+ }
173
+ return null
174
+ }
175
+
176
+ function versionOf(lines) {
177
+ for (const line of lines) {
178
+ const m = line.match(/^(\s*)version:\s*(\d+)\s*$/)
179
+ if (m) return parseInt(m[2], 10)
180
+ }
181
+ return 1
182
+ }
183
+
184
+ // Bump config.version in place; inserts `version: 1` when missing. Returns the
185
+ // new version number.
186
+ function bumpVersion(lines) {
187
+ let configIdx = -1
188
+ let configIndent = 0
189
+ for (let i = 0; i < lines.length; i++) {
190
+ const m = lines[i].match(/^(\s*)config:\s*$/)
191
+ if (m) { configIdx = i; configIndent = m[1].length; break }
192
+ }
193
+ if (configIdx < 0) {
194
+ lines.push(' config:', ' version: 2')
195
+ return 2
196
+ }
197
+ for (let i = configIdx + 1; i < lines.length; i++) {
198
+ const line = lines[i]
199
+ if (/^-\s/.test(line)) break // next top-level entry
200
+ if (!line.trim() || /^\s*#/.test(line)) continue
201
+ const indent = line.match(/^\s*/)[0].length
202
+ if (indent <= configIndent) break // config block ended
203
+ const m = line.match(/^(\s*)version:\s*(\d+)\s*$/)
204
+ if (m) {
205
+ const v = parseInt(m[2], 10) + 1
206
+ lines[i] = m[1] + 'version: ' + v
207
+ return v
208
+ }
209
+ }
210
+ lines.splice(configIdx + 1, 0, ' '.repeat(configIndent + 2) + 'version: 1')
211
+ return 1
212
+ }
213
+
214
+ function appendLoaderBlock(content, block) {
215
+ if (/^\[\]\s*$/m.test(content)) return content.replace(/^\[\]\s*$/m, block + '\n')
216
+ let c = content
217
+ if (!c.trim()) return block + '\n'
218
+ if (!/\n$/.test(c)) c += '\n'
219
+ return c + block + '\n'
220
+ }
221
+
222
+ function replaceLines(content, start, end, newLines) {
223
+ const all = content.split(/\r?\n/)
224
+ all.splice(start, end - start, ...newLines)
225
+ return all.join('\n')
226
+ }
227
+
228
+ // Ensure the loader entry exists (append the `insert` block if missing) and
229
+ // optionally bump its config.version. A legacy plain `- id: mcp-manager` row is
230
+ // converted to the working insert form on the fly. Returns the resulting
231
+ // version for reporting.
232
+ function patchLoader(patchPath, bump) {
233
+ let content = ''
234
+ if (fs.existsSync(patchPath)) content = fs.readFileSync(patchPath, 'utf8')
235
+
236
+ const entry = findLoaderEntry(content)
237
+ if (entry) {
238
+ if (entry.form === 'plain') {
239
+ // Legacy row that the DSH loader silently skips — convert it to an
240
+ // insert block (bumping when asked) so the plugin actually mounts.
241
+ const v = bump ? versionOf(entry.lines) + 1 : versionOf(entry.lines)
242
+ content = replaceLines(content, entry.start, entry.end, toLoaderBlock(v).split('\n'))
243
+ fs.mkdirSync(path.dirname(patchPath), { recursive: true })
244
+ fs.writeFileSync(patchPath, content, 'utf8')
245
+ return v
246
+ }
247
+ if (bump) {
248
+ const lines = entry.lines.slice()
249
+ const v = bumpVersion(lines)
250
+ content = replaceLines(content, entry.start, entry.end, lines)
251
+ fs.mkdirSync(path.dirname(patchPath), { recursive: true })
252
+ fs.writeFileSync(patchPath, content, 'utf8')
253
+ return v
254
+ }
255
+ return versionOf(entry.lines)
256
+ }
257
+
258
+ const updated = appendLoaderBlock(content, LOADER_BLOCK)
259
+ fs.mkdirSync(path.dirname(patchPath), { recursive: true })
260
+ fs.writeFileSync(patchPath, updated, 'utf8')
261
+ return 1
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // --repair: poll the plugin API until it answers {ok:true}
266
+ // ---------------------------------------------------------------------------
267
+ async function pollApi(port, timeoutMs) {
268
+ const url = `http://127.0.0.1:${port}/dsh-mcp-manager/api`
269
+ const deadline = Date.now() + timeoutMs
270
+ let attempt = 0
271
+ while (Date.now() < deadline) {
272
+ attempt++
273
+ try {
274
+ const res = await fetch(url, {
275
+ method: 'POST',
276
+ headers: { 'content-type': 'application/json' },
277
+ body: JSON.stringify({ op: 'mcpm-list', args: {} }),
278
+ signal: timeoutSignal(2000),
279
+ })
280
+ if (res.ok) {
281
+ const data = await res.json().catch(() => null)
282
+ if (data && data.ok) return true
283
+ }
284
+ } catch (e) { /* DSH not answering yet */ }
285
+ if (attempt === 1 || attempt % 5 === 0)
286
+ log(` 已等待 ${Math.round((Date.now() - (deadline - timeoutMs)) / 1000)} 秒…(${attempt} 次探测)`)
287
+ await sleep(1000)
288
+ }
289
+ return false
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // main
294
+ // ---------------------------------------------------------------------------
295
+ async function main() {
296
+ const opts = parseArgs(process.argv.slice(2))
297
+ if (opts.help) { printUsage(); return }
298
+
299
+ // Sanity-check the shipped artifacts we are about to deploy.
300
+ for (const f of ['package.json', 'lib/index.js', 'lib/client.js']) {
301
+ if (!fs.existsSync(path.join(SCRIPT_DIR, f)))
302
+ fail(`未找到 ${f}:请确认从 dsh-mcp-manager 包目录运行,或先执行 npm run build`)
303
+ }
304
+
305
+ const dshHome = opts.dshHome
306
+ const localPkg = path.join(dshHome, 'local-packages', PKG_NAME)
307
+ const deployDir = path.join(dshHome, 'profiles', 'node_modules', PKG_NAME)
308
+ const profileDir = path.join(dshHome, 'profiles', opts.profile)
309
+ const projectPatch = path.join(profileDir, 'cordis.patch.yml')
310
+
311
+ // When run through npx (npm cache or a `node_modules` install), the package
312
+ // lives in a temp directory — everything still works (the source is copied
313
+ // from SCRIPT_DIR), we just say so for clarity.
314
+ const runViaNpx = /node_modules[\\/]/.test(SCRIPT_DIR)
315
+
316
+ log(`操作系统: ${process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux'}`)
317
+ if (runViaNpx) log('运行来源: npx(npm 缓存 / GitHub 直拉的临时安装)')
318
+ log(`DSH 主目录: ${dshHome}`)
319
+ log(`profile: ${opts.profile}`)
320
+ if (!fs.existsSync(path.join(dshHome, 'profiles')))
321
+ warn(`未在 ${dshHome} 下找到 profiles 目录,可能不是正确的 DSH 主目录;如需指定请用 --dsh-home`)
322
+ log('')
323
+
324
+ log('1/3 复制包到 local-packages(真源,DSH 升级不会动它)…')
325
+ try {
326
+ copyDir(SCRIPT_DIR, localPkg)
327
+ } catch (e) {
328
+ fail(`复制到 local-packages 失败:${e.message}(DSH 正在运行?请先退出 DSH 再重试)`)
329
+ }
330
+ log(' → ' + localPkg)
331
+
332
+ log('2/3 复制包到 profiles/node_modules(普通复制,不用软链接)…')
333
+ try {
334
+ copyDir(SCRIPT_DIR, deployDir)
335
+ } catch (e) {
336
+ fail(`复制到 profiles/node_modules 失败:${e.message}(DSH 正在运行?请先退出 DSH 再重试)`)
337
+ }
338
+ log(' → ' + deployDir)
339
+
340
+ let version = null
341
+ if (opts.skipPatch) {
342
+ log('3/3 已跳过补丁修改(--skip-patch)')
343
+ } else {
344
+ log('3/3 更新 loader 行 → ' + projectPatch)
345
+ version = patchLoader(projectPatch, opts.repair)
346
+ log(` loader 行已就绪:id=${LOADER_ID}, config.version=${version}` +
347
+ (opts.repair ? '(已递增,触发 HMR 重应用)' : '(幂等,重复运行不会重复添加)'))
348
+ }
349
+
350
+ if (opts.repair) {
351
+ log('修复模式:轮询 API 等待插件重新加载(默认 30 秒)…')
352
+ const ok = await pollApi(opts.port, REPAIR_TIMEOUT_MS)
353
+ if (!ok) {
354
+ warn(`30 秒内 POST http://127.0.0.1:${opts.port}/dsh-mcp-manager/api 未返回 {ok:true}`)
355
+ warn('请重启一次 DSH(loader 会在启动时重新导入),然后打开 设置 → MCP 管理 验证。')
356
+ process.exit(1)
357
+ }
358
+ log('API 已恢复:{ok:true},插件已重新加载。')
359
+ }
360
+
361
+ log('')
362
+ log('✔ 安装完成。')
363
+ if (!opts.repair)
364
+ log(' 请重启 DSH,然后打开 设置 → MCP 管理(4 个 mcp_manager_* 工具将在重启后注册)。')
365
+ else
366
+ log(' 打开 设置 → MCP 管理 即可使用。')
367
+ }
368
+
369
+ main().catch((e) => { console.error(e); process.exit(1) })
package/install.ps1 ADDED
@@ -0,0 +1,43 @@
1
+ # dsh-mcp-manager install.ps1 — Windows installer (thin wrapper around install.mjs)
2
+ #
3
+ # .\dsh-mcp-manager\install.ps1 # default: ~/.dsh, web profile
4
+ # .\install.ps1 -DshHome D:\path\.dsh -Profile web
5
+ # .\install.ps1 -Repair -Port 3080
6
+ #
7
+ # See install.mjs for the full cross-platform logic and options.
8
+ [CmdletBinding()]
9
+ param(
10
+ [string]$DshHome,
11
+ [string]$Profile = '',
12
+ [int]$Port = 0,
13
+ [switch]$Repair,
14
+ [switch]$SkipPatch,
15
+ [switch]$Help
16
+ )
17
+
18
+ $ErrorActionPreference = 'Stop'
19
+
20
+ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
21
+ $mjs = Join-Path $scriptDir 'install.mjs'
22
+
23
+ $node = Get-Command node -ErrorAction SilentlyContinue
24
+ if (-not $node) {
25
+ Write-Error '未找到 node 命令,请先安装 Node.js 18+(https://nodejs.org)'
26
+ exit 1
27
+ }
28
+
29
+ if ($Help) {
30
+ & $node $mjs '--help'
31
+ exit $LASTEXITCODE
32
+ }
33
+
34
+ # Build the argument list as separate elements so paths with spaces survive.
35
+ $argsList = [System.Collections.Generic.List[string]]::new()
36
+ if ($DshHome) { $argsList.Add('--dsh-home'); $argsList.Add($DshHome) }
37
+ if ($Profile) { $argsList.Add('--profile'); $argsList.Add($Profile) }
38
+ if ($Port -gt 0) { $argsList.Add('--port'); $argsList.Add([string]$Port) }
39
+ if ($Repair) { $argsList.Add('--repair') }
40
+ if ($SkipPatch){ $argsList.Add('--skip-patch') }
41
+
42
+ & $node $mjs @argsList
43
+ exit $LASTEXITCODE
package/install.sh ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # dsh-mcp-manager install.sh — macOS / Linux installer (thin wrapper around install.mjs)
3
+ #
4
+ # ./dsh-mcp-manager/install.sh # default: ~/.dsh, web profile
5
+ # ./install.sh --dsh-home /path/.dsh --profile web
6
+ # ./install.sh --repair --port 3080
7
+ #
8
+ # If "permission denied", run: chmod +x dsh-mcp-manager/install.sh
9
+ # See install.mjs for the full cross-platform logic and options.
10
+ set -euo pipefail
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ MJS="$SCRIPT_DIR/install.mjs"
14
+
15
+ if ! command -v node >/dev/null 2>&1; then
16
+ echo "未找到 node 命令,请先安装 Node.js 18+(https://nodejs.org)" >&2
17
+ exit 1
18
+ fi
19
+
20
+ declare -a ARGS=()
21
+ while [[ $# -gt 0 ]]; do
22
+ case "$1" in
23
+ --dsh-home) ARGS+=("--dsh-home" "${2:?--dsh-home 需要路径参数}"); shift 2 ;;
24
+ --profile) ARGS+=("--profile" "${2:?--profile 需要名称参数}"); shift 2 ;;
25
+ --port) ARGS+=("--port" "${2:?--port 需要数字参数}"); shift 2 ;;
26
+ --repair) ARGS+=("--repair"); shift ;;
27
+ --skip-patch) ARGS+=("--skip-patch"); shift ;;
28
+ -h|--help) ARGS+=("--help"); shift ;;
29
+ *) echo "未知参数: $1(用 --help 查看用法)" >&2; exit 2 ;;
30
+ esac
31
+ done
32
+
33
+ exec node "$MJS" "${ARGS[@]}"