@noob-stupid/dsh-plugin-console 0.3.8

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/lib/index.js ADDED
@@ -0,0 +1,3703 @@
1
+ /**
2
+ * @deepseek-ai/dsh-plugin-console — 插件控制台宿主端。
3
+ *
4
+ * 提供环回 HTTP 路由(前缀 /plugin-console):
5
+ * GET /plugin-console/state 当前插件清单 + 用户补丁层状态
6
+ * POST /plugin-console/toggle 一键启用/停用插件(写 cordis.patch.yml,HMR 生效)
7
+ * POST /plugin-console/search GitHub 仓库搜索(dsh-plugin 相关)
8
+ * POST /plugin-console/repo 读取仓库的 package.json,判断是否可安装
9
+ * POST /plugin-console/install 安装 npm 包(或 git 仓库)并追加启用条目
10
+ *
11
+ * 插件开关的机制:用户补丁层 cordis.patch.yml 是逐键覆盖(id-targeted patch),
12
+ * 追加 `- id: X` + `disabled: true` 即可停用任意行(含 bundle 行与用户 insert 行),
13
+ * 移除该块即恢复;HMR 监视器会自动重组合,无需重启。
14
+ */
15
+ import { readFile, writeFile } from 'node:fs/promises'
16
+ import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs'
17
+ import { execFile } from 'node:child_process'
18
+ import { promisify } from 'node:util'
19
+ import { dirname, join, basename } from 'node:path'
20
+ import { tmpdir, homedir } from 'node:os'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { createRequire } from 'node:module'
23
+ import { request as httpsRequest } from 'node:https'
24
+
25
+ const execFileAsync = promisify(execFile)
26
+
27
+ /** Cordis 插件元信息。 */
28
+ export const name = '@deepseek-ai/dsh-plugin-console'
29
+ export const inject = ['webServer', 'loader']
30
+
31
+ const ROUTE_PREFIX = '/plugin-console'
32
+ const GITHUB_API = 'https://api.github.com'
33
+ const GITHUB_RAW = 'https://raw.githubusercontent.com'
34
+ const GITHUB_UA = 'dsh-plugin-console/0.1 (local dsh web instance)'
35
+ /** Gitee OAuth 端点(第三方应用需在 gitee.com → 数据管理 → 第三方应用 创建)。 */
36
+ const GITEE_AUTH_URL = 'https://gitee.com/oauth/authorize'
37
+ const GITEE_TOKEN_URL = 'https://gitee.com/oauth/token'
38
+ const DEFAULT_SEARCH = 'dsh-plugin'
39
+ /** 技能市场收录的 topic(/search skills 分支三 topic 并行合并)。 */
40
+ const SKILL_TOPICS = ['agent-skills', 'claude-skills', 'dsh-skill']
41
+
42
+ /**
43
+ * 宿主基础设施行:停用会连带破坏 HMR/传输/存储/设置链(例如停用 timer
44
+ * 会让补丁热加载整体失效,停用 webserver 会让页面失联)。这些行禁止开关。
45
+ */
46
+ const PROTECTED_MODULE_PATTERNS = [
47
+ /^cordis:/u,
48
+ /^@deepseek-ai\/cordis-plugin-/u,
49
+ /^@deepseek-ai\/dsh-host-/u,
50
+ /^@deepseek-ai\/dsh-client-modules$/u,
51
+ /^@deepseek-ai\/dsh-client-connection$/u,
52
+ /^@deepseek-ai\/dsh-client-hmr$/u,
53
+ /^@deepseek-ai\/dsh-client-runtime$/u,
54
+ /^@deepseek-ai\/dsh-client-locale$/u,
55
+ /^@deepseek-ai\/dsh-client-web/u,
56
+ /^@deepseek-ai\/dsh-web-frontend$/u,
57
+ /^@deepseek-ai\/dsh-web-app$/u,
58
+ /^@deepseek-ai\/dsh-settings/u,
59
+ /^@deepseek-ai\/dsh-credentials/u,
60
+ /^@deepseek-ai\/dsh-session/u,
61
+ /^@deepseek-ai\/dsh-storage/u,
62
+ /^@deepseek-ai\/dsh-typert/u,
63
+ /^@deepseek-ai\/dsh-api-remotes$/u,
64
+ /^@deepseek-ai\/dsh-tools$/u,
65
+ /^@deepseek-ai\/dsh-system-prompt$/u,
66
+ /^@deepseek-ai\/dsh-agent/u,
67
+ /^@deepseek-ai\/dsh-llm/u,
68
+ /^@deepseek-ai\/dsh-persona$/u,
69
+ /^@deepseek-ai\/dsh-scope$/u,
70
+ /^@deepseek-ai\/dsh-launch-environment$/u,
71
+ /^@deepseek-ai\/dsh-shell$/u,
72
+ /^@deepseek-ai\/dsh-subprocess/u,
73
+ /^@deepseek-ai\/dsh-fs/u,
74
+ /^@deepseek-ai\/dsh-sandbox/u,
75
+ /^@deepseek-ai\/dsh-jobs/u,
76
+ /^@deepseek-ai\/dsh-skill/u,
77
+ /^@deepseek-ai\/dsh-goal/u,
78
+ /^@deepseek-ai\/dsh-workflow/u,
79
+ /^@deepseek-ai\/dsh-subagent/u,
80
+ /^@deepseek-ai\/dsh-web$/u,
81
+ /^@deepseek-ai\/dsh-workspace/u,
82
+ /^@deepseek-ai\/dsh-user-approval$/u,
83
+ /^@deepseek-ai\/dsh-user-questions$/u,
84
+ /^@deepseek-ai\/dsh-commands$/u,
85
+ /^@deepseek-ai\/dsh-hook/u,
86
+ /^@deepseek-ai\/dsh-spill/u,
87
+ /^@deepseek-ai\/dsh-guard/u,
88
+ /^@deepseek-ai\/dsh-tool-call-timeout-policy$/u,
89
+ /^@deepseek-ai\/dsh-repeat-tool-reminder$/u,
90
+ ]
91
+
92
+ function isProtectedModule(moduleName) {
93
+ return typeof moduleName === 'string' && PROTECTED_MODULE_PATTERNS.some((pattern) => pattern.test(moduleName))
94
+ }
95
+
96
+ /** Cordis Fiber 状态映射(与 dsh-host-plugin-inventory 一致)。 */
97
+ const FIBER_STATE = { PENDING: 0, LOADING: 1, ACTIVE: 2, FAILED: 3, DISPOSED: 4, UNLOADING: 5 }
98
+ const FIBER_PHASE = {
99
+ [FIBER_STATE.PENDING]: 'pending',
100
+ [FIBER_STATE.LOADING]: 'loading',
101
+ [FIBER_STATE.ACTIVE]: 'active',
102
+ [FIBER_STATE.FAILED]: 'failed',
103
+ [FIBER_STATE.DISPOSED]: null,
104
+ [FIBER_STATE.UNLOADING]: 'unloading',
105
+ }
106
+
107
+ /** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
108
+ * profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
109
+ * 无论有没有 JS 入口都走 bundle 层——皮肤包(无入口)与 web-ui-settings
110
+ * (有入口)都是这样安装的,当作插件条目 insert 会漏掉它们的 bundle 补丁。 */
111
+ async function detectBundleOnly(profileDir, packageName) {
112
+ try {
113
+ const require = createRequire(join(profileDir, 'package.json'))
114
+ const pkgPath = require.resolve(`${packageName}/package.json`)
115
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
116
+ return typeof pkg.dsh?.bundle?.patch === 'string'
117
+ } catch {
118
+ return false
119
+ }
120
+ }
121
+
122
+ /** 把包追加进 profile 的 dsh.profile.bundles 层(与官方 dsh plugin add 的 reconcile 一致)。 */
123
+ async function addBundleToManifest(profileDir, packageName) {
124
+ return queuedWrite(async () => {
125
+ const manifestPath = join(profileDir, 'package.json')
126
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
127
+ const bundles = manifest.dsh?.profile?.bundles ?? []
128
+ if (!bundles.includes(packageName)) {
129
+ bundles.push(packageName)
130
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles } }
131
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
132
+ }
133
+ })
134
+ }
135
+
136
+ /** 官方 profile 模板自带的 bundle(其余 bundle 视为用户额外添加)。 */
137
+ const DEFAULT_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
138
+
139
+ /** 已加载插件的包元信息缓存(安装日期/版本/仓库),60 秒 TTL。 */
140
+ const pkgMetaCache = new Map()
141
+ const PKG_META_TTL = 60000
142
+ function entryPkgMeta(moduleName, baseUrl) {
143
+ if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return null
144
+ const hit = pkgMetaCache.get(moduleName)
145
+ if (hit !== undefined && Date.now() - hit.at < PKG_META_TTL) return hit
146
+ const meta = { at: Date.now(), installDate: null, version: null, repository: null }
147
+ try {
148
+ const require = createRequire(baseUrl)
149
+ const pkgPath = require.resolve(`${moduleName}/package.json`)
150
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
151
+ // 安装日期:优先 curl 通道写入的 .dsh-installed-at 标记(真实安装时刻);
152
+ // 否则用 package.json 的 mtime,但 npm tarball 会把文件时间固定为 1985-10-26
153
+ // (可复现构建),此时回退到目录创建时间(birthtime,解压时刻,Windows 上可靠)。
154
+ try {
155
+ const marker = join(dirname(pkgPath), '.dsh-installed-at')
156
+ if (existsSync(marker)) {
157
+ const ts = Number(readFileSync(marker, 'utf8').trim())
158
+ if (Number.isFinite(ts) && ts > 0) {
159
+ const d = new Date(ts)
160
+ meta.installDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
161
+ }
162
+ }
163
+ if (meta.installDate === null) {
164
+ const st = statSync(pkgPath)
165
+ const yearOk = (t) => t >= 946684800000 // 2000-01-01:早于它都是打包器固定时间戳等伪日期
166
+ const candidates = [st.mtimeMs, st.birthtimeMs ?? NaN].filter((t) => Number.isFinite(t) && yearOk(t))
167
+ if (candidates.length > 0) {
168
+ const d = new Date(Math.min(...candidates))
169
+ meta.installDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
170
+ }
171
+ }
172
+ } catch {}
173
+ meta.version = typeof pkg.version === 'string' ? pkg.version : null
174
+ const rawRepo = typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url ?? null)
175
+ if (typeof rawRepo === 'string') meta.repository = rawRepo.replace(/^git\+/u, '').replace(/\.git$/u, '').toLowerCase()
176
+ } catch {}
177
+ pkgMetaCache.set(moduleName, meta)
178
+ return meta
179
+ }
180
+
181
+ /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
182
+ async function readExtraBundleRows(profileDir) {
183
+ const rows = new Set()
184
+ try {
185
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
186
+ const bundles = manifest.dsh?.profile?.bundles ?? []
187
+ for (const pkg of bundles) {
188
+ if (DEFAULT_BUNDLES.includes(pkg)) continue
189
+ try {
190
+ const require = createRequire(join(profileDir, 'package.json'))
191
+ const dir = dirname(require.resolve(`${pkg}/package.json`))
192
+ const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
193
+ const lines = text.split(/\r?\n/u)
194
+ let inInsert = false
195
+ for (let index = 0; index < lines.length; index += 1) {
196
+ const line = lines[index]
197
+ if (/^- insert:\s*$/u.test(line)) {
198
+ inInsert = true
199
+ continue
200
+ }
201
+ if (/^- /u.test(line)) inInsert = false
202
+ if (!inInsert) continue
203
+ const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
204
+ if (!idMatch) continue
205
+ rows.add(idMatch[1])
206
+ const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
207
+ if (nameMatch) rows.add(nameMatch[1])
208
+ }
209
+ } catch {}
210
+ }
211
+ } catch {}
212
+ return rows
213
+ }
214
+
215
+ /** 从加载器读取 webserver 监听端口(默认 3080)。 */
216
+ function webPort(ctx) {
217
+ for (const entry of ctx.loader.entries()) {
218
+ if (entry.options?.name === '@deepseek-ai/dsh-host-webserver') {
219
+ const port = entry.options?.config?.port
220
+ if (typeof port === 'number' && port > 0) return port
221
+ }
222
+ }
223
+ return 3080
224
+ }
225
+
226
+ /** 简单 https POST 表单(Gitee token 交换用),返回 {status, body}。 */
227
+ function postJsonUrl(url, formBody) {
228
+ return new Promise((resolve, reject) => {
229
+ const body = new URLSearchParams(formBody).toString()
230
+ const req = httpsRequest(url, {
231
+ method: 'POST',
232
+ headers: {
233
+ 'content-type': 'application/x-www-form-urlencoded',
234
+ 'user-agent': GITHUB_UA,
235
+ accept: 'application/json',
236
+ },
237
+ rejectUnauthorized: false,
238
+ timeout: 20000,
239
+ }, (res) => {
240
+ const chunks = []
241
+ res.on('data', (chunk) => chunks.push(chunk))
242
+ res.on('end', () => {
243
+ try {
244
+ resolve({ status: res.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
245
+ } catch {
246
+ resolve({ status: res.statusCode ?? 0, body: null })
247
+ }
248
+ })
249
+ })
250
+ req.on('error', reject)
251
+ req.write(body)
252
+ req.end()
253
+ })
254
+ }
255
+
256
+ /** 解析 dsh CLI 的 bin.js 绝对路径(守护拉起用)。 */
257
+ function resolveDshBin() {
258
+ try {
259
+ const requireLocal = createRequire(join(dirname(fileURLToPath(import.meta.url)), 'package.json'))
260
+ return join(dirname(requireLocal.resolve('@deepseek-ai/dsh/package.json')), 'lib', 'bin.js')
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ /** 读取用户额外 bundle 的插入行归属表:行 id / 包名 → 所属 bundle 包名。 */
267
+ async function readExtraBundleOwners(profileDir) {
268
+ const owners = new Map()
269
+ try {
270
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
271
+ const bundles = manifest.dsh?.profile?.bundles ?? []
272
+ for (const pkg of bundles) {
273
+ if (DEFAULT_BUNDLES.includes(pkg)) continue
274
+ try {
275
+ const require = createRequire(join(profileDir, 'package.json'))
276
+ const dir = dirname(require.resolve(`${pkg}/package.json`))
277
+ const text = await readFile(join(dir, 'cordis.patch.yml'), 'utf8')
278
+ const lines = text.split(/\r?\n/u)
279
+ let inInsert = false
280
+ for (let index = 0; index < lines.length; index += 1) {
281
+ const line = lines[index]
282
+ if (/^- insert:\s*$/u.test(line)) {
283
+ inInsert = true
284
+ continue
285
+ }
286
+ if (/^- /u.test(line)) inInsert = false
287
+ if (!inInsert) continue
288
+ const idMatch = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
289
+ if (!idMatch) continue
290
+ owners.set(idMatch[1], pkg)
291
+ const nameMatch = (lines[index + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
292
+ if (nameMatch) owners.set(nameMatch[1], pkg)
293
+ }
294
+ } catch {}
295
+ }
296
+ } catch {}
297
+ return owners
298
+ }
299
+
300
+ /** 从补丁文件移除某行的 insert 块与 disabled/forced 覆盖块。 */
301
+ async function removeInsertRow(patchPath, rowId) {
302
+ return queuedWrite(async () => {
303
+ const { text } = await readPatchState(patchPath)
304
+ const blockRe = new RegExp(`^- insert:\\s*\\r?\\n {4}- id: ${escapeRegExp(rowId)}\\s*\\r?\\n( {6}name: [^\\r\\n]*\\r?\\n)?`, 'mu')
305
+ let next = text.replace(blockRe, '')
306
+ const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
307
+ next = next.replace(overrideRe, '')
308
+ if (next !== text) await writeFile(patchPath, next, 'utf8')
309
+ })
310
+ }
311
+
312
+ /** 从 profile manifest 移除一个 bundle。 */
313
+ async function removeBundleFromManifest(profileDir, bundlePkg) {
314
+ return queuedWrite(async () => {
315
+ const manifestPath = join(profileDir, 'package.json')
316
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
317
+ const bundles = manifest.dsh?.profile?.bundles ?? []
318
+ const next = bundles.filter((name) => name !== bundlePkg)
319
+ if (next.length !== bundles.length) {
320
+ manifest.dsh = { ...(manifest.dsh ?? {}), profile: { ...(manifest.dsh?.profile ?? {}), bundles: next } }
321
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8')
322
+ }
323
+ })
324
+ }
325
+
326
+ /** 用 corepack pnpm 移除包(与安装同一管理器与凭据抑制环境)。
327
+ * 注意:pnpm remove 不支持 --registry 选项,去掉避免静默失败。 */
328
+ async function pnpmRemove(profileDir, packageName) {
329
+ const args = ['pnpm', 'remove', packageName]
330
+ const opts = {
331
+ cwd: profileDir,
332
+ timeout: 120000,
333
+ windowsHide: true,
334
+ maxBuffer: 4 * 1024 * 1024,
335
+ env: { ...process.env, COREPACK_NPM_REGISTRY: 'https://registry.npmmirror.com', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' },
336
+ }
337
+ if (process.platform === 'win32') {
338
+ const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
339
+ if (existsSync(corepackJs)) {
340
+ await execFileAsync(process.execPath, [corepackJs, ...args], opts)
341
+ return
342
+ }
343
+ const cmd = `${JSON.stringify('corepack')} ${args.map((arg) => JSON.stringify(arg)).join(' ')}`
344
+ await execFileAsync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', cmd], opts)
345
+ return
346
+ }
347
+ await execFileAsync('corepack', args, opts)
348
+ }
349
+
350
+ /** 清理陈旧包目录与 pnpm _tmp_ 残留(Windows 原子替换 EPERM 的根因),返回清理数量。 */
351
+ function cleanupStalePackageDir(profileDir, packageName) {
352
+ const segments = packageName.startsWith('@') ? packageName.split('/') : [packageName]
353
+ const dir = join(profileDir, 'node_modules', ...segments)
354
+ const base = basename(dir)
355
+ const parent = dirname(dir)
356
+ let removed = 0
357
+ try {
358
+ if (existsSync(dir)) {
359
+ rmSync(dir, { recursive: true, force: true })
360
+ removed += 1
361
+ }
362
+ } catch {}
363
+ try {
364
+ for (const entry of readdirSync(parent)) {
365
+ if (entry.startsWith(`${base}_tmp_`)) {
366
+ try {
367
+ rmSync(join(parent, entry), { recursive: true, force: true })
368
+ removed += 1
369
+ } catch {}
370
+ }
371
+ }
372
+ } catch {}
373
+ return removed
374
+ }
375
+
376
+ /** 软件源配置文件(registry 列表,安装链按主→备依次尝试)。 */
377
+ const SOURCES_FILE = join(dshHome(), 'plugin-console-sources.json')
378
+ const DEFAULT_SOURCES = {
379
+ registries: [
380
+ { id: 'npmmirror', name: 'npmmirror(国内镜像)', url: 'https://registry.npmmirror.com', primary: true },
381
+ { id: 'npmjs', name: 'npmjs(官方源)', url: 'https://registry.npmjs.org', primary: false },
382
+ ],
383
+ searchSources: [
384
+ { id: 'github', name: 'GitHub', type: 'builtin' },
385
+ { id: 'gitee', name: 'Gitee', type: 'builtin' },
386
+ ],
387
+ gitee: { clientId: '', clientSecret: '', token: '', login: '' },
388
+ }
389
+
390
+ /** 源地址校验(模块顶层,readSources 与 sources 路由共用):https 任意;http 仅限私网/本机地址(内网 npm registry、内网搜索服务常用 http)。 */
391
+ function isAllowedSourceUrl(url) {
392
+ if (/^https:\/\/\S+$/u.test(url)) return true
393
+ if (!/^http:\/\/\S+$/u.test(url)) return false
394
+ try {
395
+ const host = new URL(url).hostname.toLowerCase()
396
+ if (host === 'localhost' || host === '::1' || host === '[::1]') return true
397
+ if (/^127\.\d+\.\d+\.\d+$/u.test(host)) return true
398
+ if (/^10\.\d+\.\d+\.\d+$/u.test(host)) return true
399
+ if (/^192\.168\.\d+\.\d+$/u.test(host)) return true
400
+ if (/^169\.254\.\d+\.\d+$/u.test(host)) return true
401
+ const m = host.match(/^172\.(\d+)\.\d+\.\d+$/u)
402
+ if (m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return true
403
+ if (/^[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){2,7}$/iu.test(host)) return true
404
+ return false
405
+ } catch {
406
+ return false
407
+ }
408
+ }
409
+
410
+ /** 读取 Gitee OAuth 配置(clientId/clientSecret/token/login)。 */
411
+ function readGiteeConfig(data) {
412
+ const gitee = data && typeof data === 'object' && data.gitee && typeof data.gitee === 'object' ? data.gitee : {}
413
+ return {
414
+ clientId: typeof gitee.clientId === 'string' ? gitee.clientId : '',
415
+ clientSecret: typeof gitee.clientSecret === 'string' ? gitee.clientSecret : '',
416
+ token: typeof gitee.token === 'string' ? gitee.token : '',
417
+ login: typeof gitee.login === 'string' ? gitee.login : '',
418
+ }
419
+ }
420
+
421
+ /** 读取软件源配置(损坏/缺失时回退默认)。 */
422
+ function readSources() {
423
+ const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
424
+ try {
425
+ const data = JSON.parse(readFileSync(SOURCES_FILE, 'utf8'))
426
+ const registries = (Array.isArray(data.registries) ? data.registries : [])
427
+ .filter((r) => r && typeof r.url === 'string' && isAllowedSourceUrl(r.url))
428
+ .map((r) => ({
429
+ id: String(r.id ?? '').slice(0, 40) || `src-${Math.random().toString(36).slice(2, 8)}`,
430
+ name: String(r.name ?? r.url).slice(0, 60) || r.url,
431
+ url: r.url,
432
+ primary: r.primary === true,
433
+ }))
434
+ const searchSources = (Array.isArray(data.searchSources) ? data.searchSources : [])
435
+ .map((s) => {
436
+ if (s && (s.id === 'github' || s.id === 'gitee')) {
437
+ return { id: String(s.id), name: String(s.name ?? s.id), type: 'builtin' }
438
+ }
439
+ if (s && typeof s.url === 'string' && s.url.includes('{q}')) {
440
+ const headers = {}
441
+ if (Array.isArray(s.headers)) {
442
+ for (const h of s.headers) {
443
+ if (h && typeof h.name === 'string' && h.name !== '' && typeof h.value === 'string') {
444
+ headers[h.name] = h.value
445
+ }
446
+ }
447
+ }
448
+ return {
449
+ id: String(s.id ?? `search-${Math.random().toString(36).slice(2, 8)}`).slice(0, 40),
450
+ name: String(s.name ?? s.url).slice(0, 60),
451
+ type: 'custom',
452
+ url: s.url,
453
+ headers,
454
+ }
455
+ }
456
+ return null
457
+ })
458
+ .filter((s) => s !== null)
459
+ if (registries.length > 0) {
460
+ if (!registries.some((r) => r.primary)) registries[0].primary = true
461
+ return { registries, searchSources, gitee: readGiteeConfig(data) }
462
+ }
463
+ } catch {}
464
+ return defaults
465
+ }
466
+
467
+ async function writeSources(sources) {
468
+ await writeFile(SOURCES_FILE, JSON.stringify(sources, null, 2) + '\n', 'utf8')
469
+ }
470
+
471
+ /** 按主→备顺序返回 registry URL 列表。 */
472
+ function orderedRegistries(sources) {
473
+ const list = [...sources.registries]
474
+ return [...list.filter((r) => r.primary), ...list.filter((r) => !r.primary)].map((r) => r.url)
475
+ }
476
+
477
+ /** 串行化补丁文件写入,避免并发 toggle 的读改写竞争。 */
478
+ let writeQueue = Promise.resolve()
479
+ function queuedWrite(fn) {
480
+ const run = writeQueue.then(fn, fn)
481
+ writeQueue = run.then(() => undefined, () => undefined)
482
+ return run
483
+ }
484
+
485
+ /** 默认 profile 用户补丁层路径(无 include 条目可推导时的兜底)。 */
486
+ function defaultPatchPath() {
487
+ const home = dshHome()
488
+ return join(home, 'profiles', 'web', 'cordis.patch.yml')
489
+ }
490
+
491
+ /** DSH 数据根目录(与 dsh-github-login 工具共享令牌文件位置)。 */
492
+ function dshHome() {
493
+ return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
494
+ }
495
+
496
+ /**
497
+ * 读取 dsh-github-login(独立登录工具)写入的 GitHub 令牌文件。
498
+ * 只对外暴露登录状态(login),绝不下发令牌本身。
499
+ */
500
+ function readGithubAuth() {
501
+ try {
502
+ const data = JSON.parse(readFileSync(join(dshHome(), 'github-auth.json'), 'utf8'))
503
+ if (data && typeof data.token === 'string' && data.token) {
504
+ return { loggedIn: true, login: typeof data.login === 'string' && data.login && data.login !== 'unknown' ? data.login : null, token: data.token }
505
+ }
506
+ } catch {}
507
+ return { loggedIn: false, login: null, token: null }
508
+ }
509
+
510
+ /**
511
+ * 兼容性探测:读取 profile 中 web-app / cli 的版本。
512
+ * 官方破坏性升级(0.2、1.0 等)可能改动本插件依赖的补丁/加载器/插槽接口,
513
+ * 因此面板披露版本并给出提示,而不是默默失效。
514
+ */
515
+ const CONSOLE_VERSION = '0.1.0'
516
+ const SUPPORTED_WEB_APP_PATTERN = /^0\.1\.0-/u
517
+
518
+ async function detectCompat(baseUrl) {
519
+ const result = { consoleVersion: CONSOLE_VERSION, webAppVersion: null, dshVersion: null, supported: true, notice: null }
520
+ try {
521
+ const require = createRequire(baseUrl)
522
+ try {
523
+ const webAppPkg = JSON.parse(await readFile(require.resolve('@deepseek-ai/dsh-web-app/package.json'), 'utf8'))
524
+ result.webAppVersion = webAppPkg.version ?? null
525
+ } catch {}
526
+ try {
527
+ const dshPkg = JSON.parse(await readFile(require.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
528
+ result.dshVersion = dshPkg.version ?? null
529
+ } catch {}
530
+ } catch {}
531
+ // 兜底:ctx.baseUrl 不可用导致 require.resolve 失败时,从插件自身目录解析——
532
+ // 曾导致 dshVersion 为空 → 客户端 current="" → 误判「升级到 latest(rc.7)」(方向相反)
533
+ if (result.dshVersion === null) {
534
+ try {
535
+ const requireLocal = createRequire(join(dirname(fileURLToPath(import.meta.url)), 'package.json'))
536
+ const dshPkg = JSON.parse(readFileSync(requireLocal.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
537
+ result.dshVersion = dshPkg.version ?? null
538
+ } catch {}
539
+ }
540
+ if (result.webAppVersion !== null && !SUPPORTED_WEB_APP_PATTERN.test(result.webAppVersion)) {
541
+ result.supported = false
542
+ result.notice = `当前 DSH web 包版本 ${result.webAppVersion} 不在受支持的 0.1.0 系列内,插件控制台的部分功能可能因官方破坏性更新而失效;请到 https://github.com/Noob-stupid/dsh-plugin-hub 获取匹配的更新`
543
+ }
544
+ return result
545
+ }
546
+
547
+ const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-state.json')
548
+ const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
549
+
550
+ /** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
551
+ function locateAppBootFile(baseUrl) {
552
+ try {
553
+ const require = createRequire(baseUrl)
554
+ const dshPkg = require.resolve('@deepseek-ai/dsh/package.json')
555
+ const candidate = join(dirname(dshPkg), 'dsh-app-boot', 'lib', 'index.js')
556
+ if (existsSync(candidate)) return candidate
557
+ } catch {}
558
+ const cacheRoots = [
559
+ process.env.NODE_CACHE || '',
560
+ 'D:\\node_cache\\_npx',
561
+ join(homedir(), '.npm', '_npx'),
562
+ join(process.env.LOCALAPPDATA || '', 'node_cache', '_npx'),
563
+ ].filter(Boolean)
564
+ for (const root of cacheRoots) {
565
+ if (!existsSync(root)) continue
566
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
567
+ if (!entry.isDirectory()) continue
568
+ const candidate = join(root, entry.name, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
569
+ if (existsSync(candidate)) return candidate
570
+ }
571
+ }
572
+ return null
573
+ }
574
+
575
+ /** 内联框架补丁(dsh-app-boot parsePatchList 容错,issue #5)——DSH 升级后框架文件被覆盖,需重打。 */
576
+ function applyFrameworkTolerancePatchOnce(baseUrl) {
577
+ const target = locateAppBootFile(baseUrl)
578
+ if (!target) return { applied: false, reason: 'dsh-app-boot 未找到' }
579
+ let source = ''
580
+ try { source = readFileSync(target, 'utf8') } catch (error) { return { applied: false, reason: `读取失败:${error.message}` } }
581
+ if (source.includes('tryDropEmptyArrayPlaceholder')) return { applied: false, reason: '已打过补丁' }
582
+ if (!source.includes('function parsePatchList')) return { applied: false, reason: 'parsePatchList 不存在(框架版本可能已变更)' }
583
+ const OLD = 'function parsePatchList(binName, file, content, label) {\n\tlet parsed;\n\ttry {\n\t\tparsed = yaml.load(content, { schema: userPatchesSchema });\n\t} catch (error) {\n\t\tthrow new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`);\n\t}'
584
+ const NEW = 'function parsePatchList(binName, file, content, label) {\n\tlet parsed;\n\ttry {\n\t\tparsed = yaml.load(content, { schema: userPatchesSchema });\n\t} catch (error) {\n\t\tconst retried = tryDropEmptyArrayPlaceholder(content);\n\t\tif (retried !== null) {\n\t\t\ttry {\n\t\t\t\tparsed = yaml.load(retried, { schema: userPatchesSchema });\n\t\t\t} catch {\n\t\t\t\tthrow new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`);\n\t\t\t}\n\t\t} else {\n\t\t\tthrow new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`);\n\t\t}\n\t}'
585
+ const HELPER = '\n/**\n * 容错辅助(issue #5):若文件含顶格空数组占位行(`[]` / `[ ]`,可带行尾注释),视为 no-op 移除。\n */\nfunction tryDropEmptyArrayPlaceholder(content) {\n\tconst lines = String(content).split("\\n");\n\tconst kept = [];\n\tlet dropped = false;\n\tfor (const line of lines) {\n\t\tif (/^\\[\\s*\\]\\s*(?:#.*)?$/u.test(line)) {\n\t\t\tdropped = true;\n\t\t\tcontinue;\n\t\t}\n\t\tkept.push(line);\n\t}\n\tif (!dropped) return null;\n\treturn kept.join("\\n");\n}\n'
586
+ try {
587
+ copyFileSync(target, `${target}.bak-issue5`)
588
+ const next = source.replace(OLD, NEW) + HELPER
589
+ writeFileSync(target, next, 'utf8')
590
+ return { applied: true, target }
591
+ } catch (error) {
592
+ return { applied: false, reason: `应用失败:${error.message}` }
593
+ }
594
+ }
595
+
596
+ /** 备份当前 profile 配置快照(按版本目录;框架升级后旧版本目录即升级前配置)。 */
597
+ function backupProfileSnapshot(profileDir, version, ctx) {
598
+ const dir = join(FRAMEWORK_BACKUP_ROOT(), version)
599
+ mkdirSync(dir, { recursive: true })
600
+ try { copyFileSync(join(profileDir, 'cordis.patch.yml'), join(dir, 'cordis.patch.yml')) } catch {}
601
+ try { copyFileSync(join(profileDir, 'package.json'), join(dir, 'profile-package.json')) } catch {}
602
+ try {
603
+ const plugins = listEntries(ctx).map((e) => {
604
+ const meta = entryPkgMeta(e.moduleName, ctx.baseUrl ?? 'file:///')
605
+ return { rowId: e.rowId, moduleName: e.moduleName, enabled: e.enabled, version: meta?.version ?? null, installDate: meta?.installDate ?? null }
606
+ })
607
+ writeFileSync(join(dir, 'plugins.json'), JSON.stringify(plugins, null, 2), 'utf8')
608
+ } catch {}
609
+ return dir
610
+ }
611
+
612
+ /** 框架升级检测与适配:记录版本 → 每次启动备份配置快照 → 升级/首次时重打框架补丁。 */
613
+ function detectFrameworkUpgrade(ctx) {
614
+ let current = null
615
+ try {
616
+ const require = createRequire(ctx.baseUrl ?? 'file:///')
617
+ const dshPkg = JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
618
+ current = dshPkg.version ?? null
619
+ } catch {}
620
+ const statePath = FRAMEWORK_STATE_FILE()
621
+ let prev = null
622
+ try { prev = JSON.parse(readFileSync(statePath, 'utf8')) } catch {}
623
+ const upgraded = prev !== null && prev.lastVersion !== null && current !== null && prev.lastVersion !== current
624
+ const result = { version: current, upgraded, from: prev?.lastVersion ?? null, backupDir: null, patchApplied: false, patchNote: null }
625
+ try {
626
+ mkdirSync(dirname(statePath), { recursive: true })
627
+ const profileDir = dirname(findPatchPath(ctx))
628
+ if (current !== null) result.backupDir = backupProfileSnapshot(profileDir, current, ctx)
629
+ const patch = applyFrameworkTolerancePatchOnce(ctx.baseUrl ?? 'file:///')
630
+ result.patchApplied = patch.applied
631
+ result.patchNote = patch.reason ?? null
632
+ writeFileSync(statePath, JSON.stringify({ lastVersion: current, backupAt: Date.now() }), 'utf8')
633
+ } catch {}
634
+ return result
635
+ }
636
+
637
+ /** 从 loader 树推导 profile 的 cordis.patch.yml 绝对路径。 */
638
+ function findPatchPath(ctx) {
639
+ for (const entry of ctx.loader.entries()) {
640
+ const cfg = entry.options?.config
641
+ if (entry.options?.name !== 'cordis:include' || cfg == null || typeof cfg.path !== 'string') continue
642
+ if (!cfg.path.includes('cordis.yml')) continue
643
+ const configPath = fileURLToPath(new URL(cfg.path))
644
+ return configPath.replace(/cordis\.yml$/u, 'cordis.patch.yml')
645
+ }
646
+ return defaultPatchPath()
647
+ }
648
+
649
+ /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
650
+ async function readPatchState(patchPath) {
651
+ let text = ''
652
+ try {
653
+ text = await readFile(patchPath, 'utf8')
654
+ } catch (error) {
655
+ if (error.code !== 'ENOENT') throw error
656
+ }
657
+ const disables = []
658
+ const forced = []
659
+ const inserts = []
660
+ const lines = text.split(/\r?\n/u)
661
+ let inInsert = false
662
+ for (let index = 0; index < lines.length; index += 1) {
663
+ const line = lines[index]
664
+ if (/^- insert:\s*$/u.test(line)) {
665
+ inInsert = true
666
+ continue
667
+ }
668
+ if (/^- /u.test(line)) inInsert = false
669
+ if (inInsert) {
670
+ const insertRow = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)/u)
671
+ if (insertRow) inserts.push(insertRow[1])
672
+ continue
673
+ }
674
+ const disableRow = line.match(/^- id: ([A-Za-z0-9_.-]+)\s*$/u)
675
+ if (!disableRow) continue
676
+ const next = lines[index + 1] ?? ''
677
+ if (/^ {2}disabled: true\s*$/u.test(next)) disables.push(disableRow[1])
678
+ else if (/^ {2}disabled: false\s*$/u.test(next)) forced.push(disableRow[1])
679
+ }
680
+ return { disables, forced, inserts, text }
681
+ }
682
+
683
+ /** include 前缀(加载器条目 id 形如 include:schedule,补丁行 id 为 schedule)。 */
684
+ function includePrefix(ctx) {
685
+ for (const entry of ctx.loader.entries()) {
686
+ if (entry.options?.name === 'cordis:include') return `${entry.id}:`
687
+ }
688
+ return ''
689
+ }
690
+
691
+ /** 接受加载器条目 id 或行 id,返回补丁行 id。 */
692
+ function rowIdOf(ctx, entryId) {
693
+ const prefix = includePrefix(ctx)
694
+ if (prefix.length > 0 && entryId.startsWith(prefix)) return entryId.slice(prefix.length)
695
+ return entryId
696
+ }
697
+
698
+ function escapeRegExp(value) {
699
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
700
+ }
701
+
702
+ /** 提取 README 的标题与开篇段落摘要(首个二级标题之前的正文)。 */
703
+ function summarizeReadme(text) {
704
+ const lines = text.split(/\r?\n/u)
705
+ let title = ''
706
+ const intro = []
707
+ for (const line of lines) {
708
+ const heading = line.match(/^(#{1,3})\s+(.+)$/u)
709
+ if (heading) {
710
+ if (title === '') {
711
+ title = heading[2].trim()
712
+ continue
713
+ }
714
+ break
715
+ }
716
+ if (title === '' && /^[-=]{3,}$/u.test(line.trim()) && line.trim() !== '') continue
717
+ if (title === '') continue
718
+ const cleaned = line
719
+ .replace(/!\[[^\]]*\]\([^)]*\)/gu, '')
720
+ .replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
721
+ .replace(/[`*_~]/gu, '')
722
+ .trim()
723
+ if (cleaned) intro.push(cleaned)
724
+ if (intro.join(' ').length > 700) break
725
+ }
726
+ return { title, summary: intro.join(' ').trim().slice(0, 900) }
727
+ }
728
+
729
+ /** 读取一个已加载插件的 package.json 元信息与 README 摘要。 */
730
+ async function readPluginDetails(moduleName, baseUrl) {
731
+ if (typeof moduleName !== 'string' || moduleName.startsWith('cordis:')) return { meta: null, readme: null }
732
+ try {
733
+ const require = createRequire(baseUrl)
734
+ const pkgPath = require.resolve(`${moduleName}/package.json`)
735
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
736
+ const meta = {
737
+ name: pkg.name ?? moduleName,
738
+ version: pkg.version ?? null,
739
+ description: pkg.description ?? null,
740
+ homepage: pkg.homepage ?? null,
741
+ repository: typeof pkg.repository === 'string' ? pkg.repository : (pkg.repository?.url ?? null),
742
+ }
743
+ let readme = null
744
+ for (const candidate of ['README.zh.md', 'README.md']) {
745
+ try {
746
+ const text = await readFile(join(dirname(pkgPath), candidate), 'utf8')
747
+ readme = summarizeReadme(text)
748
+ break
749
+ } catch {}
750
+ }
751
+ return { meta, readme }
752
+ } catch {
753
+ return { meta: null, readme: null }
754
+ }
755
+ }
756
+
757
+ function disableBlock(id) {
758
+ return `- id: ${id}\n disabled: true\n`
759
+ }
760
+
761
+ /** 停用:追加 disabled:true 块(已存在则不动)。 */
762
+ async function disableEntry(patchPath, id) {
763
+ return queuedWrite(async () => {
764
+ const { disables, text } = await readPatchState(patchPath)
765
+ if (disables.includes(id)) return { changed: false }
766
+ const next = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`
767
+ await writeFile(patchPath, `${next}${disableBlock(id)}`, 'utf8')
768
+ return { changed: true }
769
+ })
770
+ }
771
+
772
+ /** 启用:移除 disabled:true 块;若仍被 bundle 停用则追加 disabled:false 覆盖。 */
773
+ async function enableEntry(patchPath, id) {
774
+ return queuedWrite(async () => {
775
+ const { disables, forced, text } = await readPatchState(patchPath)
776
+ const blockRe = new RegExp(`^- id: ${escapeRegExp(id)}\\r?\\n disabled: true\\r?\\n`, 'mu')
777
+ if (blockRe.test(text)) {
778
+ await writeFile(patchPath, text.replace(blockRe, ''), 'utf8')
779
+ return { changed: true }
780
+ }
781
+ if (forced.includes(id)) return { changed: false }
782
+ const next = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`
783
+ await writeFile(patchPath, `${next}- id: ${id}\n disabled: false\n`, 'utf8')
784
+ return { changed: true }
785
+ })
786
+ }
787
+
788
+ /** 追加一条 insert 启用行(插件包需已安装到 profile)。 */
789
+ async function appendInsert(patchPath, entryId, packageName) {
790
+ return queuedWrite(async () => {
791
+ const { inserts, text } = await readPatchState(patchPath)
792
+ if (inserts.includes(entryId)) return { changed: false }
793
+ const next = text.length === 0 || text.endsWith('\n') ? text : `${text}\n`
794
+ const block = `- insert:\n - id: ${entryId}\n name: '${packageName}'\n`
795
+ await writeFile(patchPath, `${next}${block}`, 'utf8')
796
+ return { changed: true }
797
+ })
798
+ }
799
+
800
+ /** 包名 → 稳定的 entryId(去 scope、非字母数字转 -、查重加后缀)。 */
801
+ function deriveEntryId(packageName, taken) {
802
+ const base = packageName
803
+ .replace(/^@/u, '')
804
+ .replace(/\//gu, '-')
805
+ .replace(/[^A-Za-z0-9_-]/gu, '-')
806
+ .replace(/-+/gu, '-')
807
+ .replace(/^-|-$/gu, '')
808
+ .slice(0, 40) || 'plugin'
809
+ if (!taken.has(base)) return base
810
+ for (let index = 2; index < 1000; index += 1) {
811
+ const candidate = `${base}-${index}`
812
+ if (!taken.has(candidate)) return candidate
813
+ }
814
+ throw new Error('无法为插件生成唯一的条目 id')
815
+ }
816
+
817
+ function isLoopback(address) {
818
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
819
+ }
820
+
821
+ function sendJson(res, status, body) {
822
+ const payload = JSON.stringify(body)
823
+ res.writeHead(status, {
824
+ 'content-type': 'application/json; charset=utf-8',
825
+ 'cache-control': 'no-store',
826
+ })
827
+ res.end(payload)
828
+ }
829
+
830
+ function sendError(res, status, message, details) {
831
+ sendJson(res, status, { ok: false, error: message, ...(details === undefined ? {} : { details }) })
832
+ }
833
+
834
+ async function readBody(req, maxBytes = 64 * 1024) {
835
+ const chunks = []
836
+ let total = 0
837
+ for await (const chunk of req) {
838
+ total += chunk.length
839
+ if (total > maxBytes) throw new Error('请求体过大')
840
+ chunks.push(chunk)
841
+ }
842
+ if (chunks.length === 0) return {}
843
+ try {
844
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
845
+ } catch {
846
+ throw new Error('请求体不是合法 JSON')
847
+ }
848
+ }
849
+
850
+ /** 单次 https 请求;卡死的连接会在超时后被销毁。 */
851
+ function githubRequestOnce(url, { signal, accept, token, timeout = 20000 } = {}) {
852
+ return new Promise((resolve, reject) => {
853
+ const req = httpsRequest(url, {
854
+ method: 'GET',
855
+ headers: {
856
+ 'user-agent': GITHUB_UA,
857
+ accept: accept ?? 'application/vnd.github+json',
858
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
859
+ },
860
+ rejectUnauthorized: false,
861
+ }, resolve)
862
+ req.on('error', reject)
863
+ req.setTimeout(timeout, () => req.destroy(new Error('GitHub 请求超时')))
864
+ if (signal !== undefined) {
865
+ if (signal.aborted) {
866
+ req.destroy(new Error('请求已取消'))
867
+ return
868
+ }
869
+ signal.addEventListener('abort', () => req.destroy(new Error('请求已取消')), { once: true })
870
+ }
871
+ })
872
+ }
873
+
874
+ /** api.github.com 的镜像竞速前缀(主通道黑洞期可用的兜底)。 */
875
+ const API_MIRROR_PREFIXES = [
876
+ 'https://ghproxy.net/https://api.github.com',
877
+ 'https://ghfast.top/https://api.github.com',
878
+ ]
879
+
880
+ /** raw.githubusercontent.com 的镜像竞速前缀(与浏览器端四通道一致)。 */
881
+ const RAW_MIRROR_PREFIXES = [
882
+ 'https://ghproxy.net/https://raw.githubusercontent.com',
883
+ 'https://ghfast.top/https://raw.githubusercontent.com',
884
+ 'https://mirror.ghproxy.com/https://raw.githubusercontent.com',
885
+ ]
886
+
887
+ /** 并行竞速:任一分支拿到 2xx 即胜出;非 2xx 与错误都视为失败,全部失败则拒绝。 */
888
+ function raceFirst2xx(promises) {
889
+ return new Promise((resolve, reject) => {
890
+ let pending = promises.length
891
+ let done = false
892
+ for (const promise of promises) {
893
+ promise.then(
894
+ (res) => {
895
+ if (done) return
896
+ const ok = res.statusCode === undefined || (res.statusCode >= 200 && res.statusCode < 300)
897
+ if (ok) {
898
+ done = true
899
+ resolve(res)
900
+ return
901
+ }
902
+ if (--pending === 0) reject(new Error(`HTTP ${res.statusCode}`))
903
+ },
904
+ () => {
905
+ if (done) return
906
+ if (--pending === 0) reject(new Error('GitHub 请求失败'))
907
+ },
908
+ )
909
+ }
910
+ })
911
+ }
912
+
913
+ /**
914
+ * GitHub 公开元数据请求。用 node:https 而非全局 fetch,并跳过证书校验:
915
+ * 国内网络环境的中间设备会注入不可信证书,全局 fetch 因此直接失败;
916
+ * 本插件只经此通道拉取公开的仓库/包元数据,npm 安装本身仍走 registry 的
917
+ * 完整 TLS 校验,所以这里放宽校验不会让安装环节失去 TLS 保护。
918
+ *
919
+ * 原逻辑保持不动:官方通道两次尝试(每次 20 秒,间隔 1.5 秒)。
920
+ * 在此基础上新增并行分支:镜像(api/raw 各自前缀)同时竞速,15 秒封顶,
921
+ * 任一分支先拿到 2xx 即胜出;全部失败时回退官方通道的最终错误。
922
+ */
923
+ async function githubRequest(url, options) {
924
+ const isApi = url.startsWith(GITHUB_API)
925
+ const isRaw = url.startsWith(GITHUB_RAW)
926
+ // ── 原逻辑:官方通道两次尝试 ──
927
+ const official = (async () => {
928
+ let lastError
929
+ for (let attempt = 0; attempt < 2; attempt += 1) {
930
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1500))
931
+ try {
932
+ return await githubRequestOnce(url, options)
933
+ } catch (error) {
934
+ lastError = error
935
+ }
936
+ }
937
+ throw lastError ?? new Error('GitHub 请求失败')
938
+ })()
939
+ // ── 新增并行分支:镜像竞速(15 秒封顶;非 2xx 视为失败)──
940
+ const prefixes = isApi ? API_MIRROR_PREFIXES : isRaw ? RAW_MIRROR_PREFIXES : []
941
+ const mirrors = prefixes.map((prefix) => (async () => {
942
+ const res = await githubRequestOnce(`${prefix}${url.slice(isApi ? GITHUB_API.length : GITHUB_RAW.length)}`, { ...options, timeout: 15000 })
943
+ if (res.statusCode !== undefined && (res.statusCode < 200 || res.statusCode >= 300)) throw new Error(`HTTP ${res.statusCode}`)
944
+ return res
945
+ })())
946
+ try {
947
+ return await raceFirst2xx([official, ...mirrors])
948
+ } catch {
949
+ // 全灭:回退官方通道的最终错误(保留原始报错语义)
950
+ try {
951
+ return await official
952
+ } catch (error) {
953
+ throw error
954
+ }
955
+ }
956
+ }
957
+
958
+ function collectBody(res) {
959
+ return new Promise((resolve, reject) => {
960
+ const chunks = []
961
+ res.on('data', (chunk) => chunks.push(chunk))
962
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
963
+ res.on('error', reject)
964
+ })
965
+ }
966
+
967
+ /** gh CLI 通道:api.github.com 黑洞期(node:https 全部超时)时的最后兜底。
968
+ * 服务进程 PATH 可能不含 gh(桌面壳环境):依次尝试 gh、常见安装路径。 */
969
+ const GH_BIN_CANDIDATES = [
970
+ 'gh',
971
+ 'C:\\Program Files\\GitHub CLI\\gh.exe',
972
+ join(homedir(), 'AppData', 'Local', 'Microsoft', 'WinGet', 'Links', 'gh.exe'),
973
+ join(homedir(), 'scoop', 'shims', 'gh.exe'),
974
+ ]
975
+
976
+ function githubViaGh(apiPath) {
977
+ return new Promise((resolve, reject) => {
978
+ const attempt = (index) => {
979
+ if (index >= GH_BIN_CANDIDATES.length) {
980
+ reject(new Error('gh api 兜底失败:未找到 gh CLI'))
981
+ return
982
+ }
983
+ execFile(GH_BIN_CANDIDATES[index], ['api', apiPath, '--jq', '.'], { windowsHide: true, timeout: 45000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => {
984
+ if (error) {
985
+ // ENOENT = 该路径不存在,继续下一个候选;其他错误(网络/认证)直接失败
986
+ if (error.code === 'ENOENT') {
987
+ attempt(index + 1)
988
+ return
989
+ }
990
+ reject(new Error(`gh api 兜底失败:${error.message}`))
991
+ return
992
+ }
993
+ try {
994
+ resolve(JSON.parse(stdout))
995
+ } catch {
996
+ reject(new Error('gh api 兜底返回的不是合法 JSON'))
997
+ }
998
+ })
999
+ }
1000
+ attempt(0)
1001
+ })
1002
+ }
1003
+
1004
+ async function githubJson(url, signal, token = null) {
1005
+ const apiPath = url.startsWith(GITHUB_API) ? url.slice(GITHUB_API.length) : null
1006
+ // https 主通道(官方重试+镜像并行)与 gh CLI **并行竞速**:黑洞期 gh 秒回,不再等 https 超时
1007
+ if (apiPath !== null) {
1008
+ const https = (async () => {
1009
+ const res = await githubRequest(url, { signal, token })
1010
+ const status = res.statusCode ?? 0
1011
+ if (status === 403 && Number(res.headers['x-ratelimit-remaining'] ?? '1') === 0) {
1012
+ throw new Error('GitHub 接口限流已用尽,请稍后再试')
1013
+ }
1014
+ const body = await collectBody(res)
1015
+ if (status < 200 || status >= 300) throw new Error(`GitHub 请求失败 (HTTP ${status})`)
1016
+ return JSON.parse(body)
1017
+ })()
1018
+ try {
1019
+ return await raceFirst2xx([https, githubViaGh(apiPath)])
1020
+ } catch (error) {
1021
+ try { return await https } catch (httpsError) { throw httpsError }
1022
+ }
1023
+ }
1024
+ const res = await githubRequest(url, { signal, token })
1025
+ const status = res.statusCode ?? 0
1026
+ if (status === 403 && Number(res.headers['x-ratelimit-remaining'] ?? '1') === 0) {
1027
+ throw new Error('GitHub 接口限流已用尽,请稍后再试')
1028
+ }
1029
+ const body = await collectBody(res)
1030
+ if (status < 200 || status >= 300) throw new Error(`GitHub 请求失败 (HTTP ${status})`)
1031
+ return JSON.parse(body)
1032
+ }
1033
+
1034
+ async function githubText(url, signal, token = null) {
1035
+ const res = await githubRequest(url, { signal, accept: 'application/json', token })
1036
+ const status = res.statusCode ?? 0
1037
+ const body = await collectBody(res)
1038
+ if (status < 200 || status >= 300) return null
1039
+ return body
1040
+ }
1041
+
1042
+ /** 用 curl 子进程请求 JSON(绕过本机中间设备对 node:https TLS 指纹的拦截;curl 走系统网络栈)。 */
1043
+ async function curlJson(url, timeout = 15000, headers = {}) {
1044
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
1045
+ const args = ['-s', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '--insecure', '-H', 'accept: application/json', '-w', '\n__HTTP__%{http_code}']
1046
+ for (const [key, value] of Object.entries(headers)) args.push('-H', `${key}: ${value}`)
1047
+ args.push(url)
1048
+ const { stdout } = await execFileAsync(bin, args, { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
1049
+ const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
1050
+ const code = m ? Number(m[1]) : 0
1051
+ const body = m ? String(stdout).slice(0, m.index) : String(stdout)
1052
+ if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`请求失败 (HTTP ${code})`)
1053
+ return JSON.parse(body)
1054
+ }
1055
+
1056
+ /** 用 curl 拉取文本(raw 文件用:本机 curl 直连 raw.githubusercontent.com 秒回,绕开 node:https 黑洞)。 */
1057
+ async function curlText(url, timeout = 10000) {
1058
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
1059
+ const { stdout } = await execFileAsync(
1060
+ bin,
1061
+ ['-s', '-m', String(Math.max(3, Math.ceil(timeout / 1000))), '--insecure', '-H', 'accept: text/plain', '-w', '\n__HTTP__%{http_code}', url],
1062
+ { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
1063
+ )
1064
+ const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
1065
+ const code = m ? Number(m[1]) : 0
1066
+ const body = m ? String(stdout).slice(0, m.index) : String(stdout)
1067
+ if (code !== 0 && (code < 200 || code >= 300)) throw new Error(`HTTP ${code}`)
1068
+ return body
1069
+ }
1070
+
1071
+ /** 任意 https JSON 接口(Gitee/自定义源用):curl 优先、node:https 兜底;4xx/5xx 确定性失败直接抛出。 */
1072
+ async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
1073
+ let lastError
1074
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1075
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1000))
1076
+ try {
1077
+ return await curlJson(url, timeout, headers)
1078
+ } catch (error) {
1079
+ // HTTP 状态错误(4xx/5xx)是确定性失败:直接抛出,不重试、不走 node 兜底
1080
+ if (error instanceof Error && /^请求失败 \(HTTP \d+\)$/u.test(error.message)) throw error
1081
+ lastError = error
1082
+ }
1083
+ try {
1084
+ const res = await githubRequest(url, { headers })
1085
+ const status = res.statusCode ?? 0
1086
+ const body = await collectBody(res)
1087
+ if (status < 200 || status >= 300) throw new Error(`请求失败 (HTTP ${status})`)
1088
+ return JSON.parse(body)
1089
+ } catch (error) {
1090
+ lastError = error
1091
+ }
1092
+ }
1093
+ throw lastError ?? new Error('请求失败')
1094
+ }
1095
+
1096
+ /** 平台搜索返回归一化(数组或 {items} 均可;兼容 GitHub/Gitee/自定义源字段)。 */
1097
+ function normalizePlatformItems(data, fallbackBranch = 'main') {
1098
+ const list = Array.isArray(data) ? data : (data && Array.isArray(data.items) ? data.items : [])
1099
+ return list
1100
+ .filter((item) => item && typeof item === 'object')
1101
+ .map((item) => ({
1102
+ fullName: String(item.full_name ?? item.path_with_namespace ?? item.name ?? '').trim(),
1103
+ description: item.description ?? '',
1104
+ htmlUrl: item.html_url ?? item.web_url ?? '',
1105
+ stars: item.stargazers_count ?? item.star_count ?? 0,
1106
+ updatedAt: item.updated_at ?? item.last_activity_at ?? '',
1107
+ defaultBranch: item.default_branch ?? fallbackBranch,
1108
+ topics: Array.isArray(item.topics) ? item.topics : [],
1109
+ }))
1110
+ .filter((item) => item.fullName !== '')
1111
+ }
1112
+
1113
+ /** 探测仓库是否为技能仓库:根目录或第一层子目录存在 SKILL.md。
1114
+ * 返回 { hasSkill, skillDir }(skillDir 为相对仓库根的目录,'' 表示根)。
1115
+ * raw 双通道竞速,3 秒封顶,失败静默。 */
1116
+ async function detectSkillRepo(repo, branch = 'main') {
1117
+ const branchEnc = encodeURIComponent(branch)
1118
+ try {
1119
+ const root = await Promise.any([
1120
+ curlText(`${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
1121
+ curlText(`https://ghproxy.net/${GITHUB_RAW}/${repo}/${branchEnc}/SKILL.md`, 3000),
1122
+ curlText(`https://cdn.jsdelivr.net/gh/${repo}@${branchEnc}/SKILL.md`, 3000),
1123
+ ]).catch(() => null)
1124
+ if (root !== null) return { hasSkill: true, skillDir: '' }
1125
+ // 根没有时再查一层子目录(常用布局:skills/<name>/SKILL.md、<name>/SKILL.md)
1126
+ const tree = await curlJson(`https://api.github.com/repos/${repo}/git/trees/${branchEnc}?recursive=1`, 5000).catch(() => null)
1127
+ const skillPaths = (tree?.tree ?? [])
1128
+ .filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path))
1129
+ .map((n) => n.path)
1130
+ if (skillPaths.length > 0) {
1131
+ const dir = skillPaths[0].slice(0, -'SKILL.md'.length).replace(/\/$/u, '')
1132
+ return { hasSkill: true, skillDir: dir }
1133
+ }
1134
+ } catch {}
1135
+ return { hasSkill: false, skillDir: null }
1136
+ }
1137
+
1138
+ /** 提取 SKILL.md frontmatter 摘要(name / description / whenToUse,与客户端 summarizeSkillFrontmatter 同规则)。 */
1139
+ function summarizeSkillFrontmatter(text) {
1140
+ if (!text.startsWith('---')) return null
1141
+ const fmEnd = text.indexOf('\n---', 3)
1142
+ if (fmEnd === -1) return null
1143
+ const fm = text.slice(3, fmEnd)
1144
+ const pick = (key) => {
1145
+ const re = new RegExp(`^${key}:\\s*(.*)$`, 'mu')
1146
+ const m = fm.match(re)
1147
+ if (!m) return ''
1148
+ const first = m[1].trim()
1149
+ if (first.startsWith('|')) {
1150
+ const rest = fm.slice(m.index + m[0].length)
1151
+ const lines = []
1152
+ for (const line of rest.split('\n')) {
1153
+ if (/^[a-zA-Z][\w-]*\s*:/u.test(line)) break
1154
+ const v = line.trim()
1155
+ if (v) lines.push(v)
1156
+ if (lines.join(' ').length > 240) break
1157
+ }
1158
+ return lines.join(' ').slice(0, 500)
1159
+ }
1160
+ return first.slice(0, 200)
1161
+ }
1162
+ const name = pick('name')
1163
+ const description = pick('description')
1164
+ const whenToUse = pick('whenToUse')
1165
+ if (!name && !description && !whenToUse) return null
1166
+ return { name, description, whenToUse }
1167
+ }
1168
+
1169
+ /** 读取仓库 SKILL.md 的 frontmatter 摘要(raw 双通道,失败静默返回 null)。 */
1170
+ async function fetchSkillMeta(repo, branch, skillDir) {
1171
+ try {
1172
+ const path = skillDir ? `${skillDir}/SKILL.md` : 'SKILL.md'
1173
+ const body = await rawTextWithFallback(repo, branch, path)
1174
+ if (body === null) return null
1175
+ return summarizeSkillFrontmatter(body)
1176
+ } catch {
1177
+ return null
1178
+ }
1179
+ }
1180
+
1181
+ /** 批量识别搜索结果类型(官方 bundle / 聚合仓库 / 普通项目 / 技能仓库)。
1182
+ * 全部条目并发 + raw 主站与镜像双通道竞速,单条 4 秒封顶。
1183
+ * 聚合仓库(根包 private+workspaces)额外检查子包是否有 dsh.bundle 清单:
1184
+ * 任一子包可 `dsh plugin add` 直装 → aggregateInstallable = true(★ 筛选会包含它)。 */
1185
+ async function enrichItems(items) {
1186
+ return Promise.all(items.map(async (item) => {
1187
+ let official = null
1188
+ let aggregate = false
1189
+ let aggregateInstallable = false
1190
+ let hasSkill = false
1191
+ let hasSuite = false
1192
+ try {
1193
+ const branch = item.defaultBranch ?? 'main'
1194
+ const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
1195
+ const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
1196
+ Promise.any([
1197
+ curlText(base, 4000),
1198
+ curlText(`https://ghproxy.net/${base}`, 4000),
1199
+ ]),
1200
+ detectSkillRepo(item.fullName, branch),
1201
+ rawTextWithFallback(item.fullName, branch, '.gitmodules'),
1202
+ ])
1203
+ if (suiteResult.status === 'fulfilled' && suiteResult.value !== null) {
1204
+ hasSuite = true
1205
+ }
1206
+ if (pkgResult.status === 'fulfilled') {
1207
+ const pkg = JSON.parse(pkgResult.value)
1208
+ if (typeof pkg.dsh?.bundle?.patch === 'string') {
1209
+ official = true
1210
+ } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1211
+ aggregate = true
1212
+ try {
1213
+ const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1214
+ const pkgPaths = (tree.tree ?? [])
1215
+ .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1216
+ .map((n) => n.path)
1217
+ .slice(0, 12)
1218
+ if (pkgPaths.length > 0) {
1219
+ const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1220
+ .then((t) => { try { return JSON.parse(t) } catch { return null } })
1221
+ .catch(() => null)))
1222
+ if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1223
+ aggregateInstallable = true
1224
+ }
1225
+ }
1226
+ } catch {}
1227
+ }
1228
+ }
1229
+ if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
1230
+ hasSkill = true
1231
+ }
1232
+ } catch {}
1233
+ return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
1234
+ }))
1235
+ }
1236
+
1237
+ function githubRepoInfo(repo) {
1238
+ const match = String(repo).trim().match(/^(?:https:\/\/github\.com\/|https:\/\/gitee\.com\/|git@github\.com:|git@gitee\.com:)?([^\s\/?#]+)\/([^\s\/?#]+?)(?:\.git)?$/u)
1239
+ if (!match) throw new Error('仓库名格式应为 owner/name(支持完整仓库 URL 与中文路径)')
1240
+ return `${match[1]}/${match[2]}`
1241
+ }
1242
+
1243
+ /** raw 文件读取:https 主通道(含镜像)、gh CLI、curl 直连、curl jsDelivr CDN **四通道并行竞速**。
1244
+ * 黑洞期 https/ghproxy 全挂、gh 可能不在服务进程 PATH——jsDelivr CDN 走系统网络且国内可达,是最后兜底。 */
1245
+ async function rawTextWithFallback(repo, branch, path) {
1246
+ const url = `${GITHUB_RAW}/${repo}/${encodeURIComponent(branch)}/${path}`
1247
+ const https = githubText(url)
1248
+ .then((body) => {
1249
+ if (body === null) throw new Error('HTTP 非 2xx')
1250
+ return body
1251
+ })
1252
+ const gh = githubViaGh(`repos/${repo}/contents/${path}?ref=${encodeURIComponent(branch)}`)
1253
+ .then((data) => {
1254
+ if (data && typeof data.content === 'string') return Buffer.from(data.content, 'base64').toString('utf8')
1255
+ throw new Error('contents 无内容')
1256
+ })
1257
+ const curl = curlText(url, 10000)
1258
+ .then((body) => {
1259
+ if (body === null) throw new Error('HTTP 非 2xx')
1260
+ return body
1261
+ })
1262
+ const cdn = curlText(`https://cdn.jsdelivr.net/gh/${repo}@${encodeURIComponent(branch)}/${path}`, 10000)
1263
+ .then((body) => {
1264
+ if (body === null) throw new Error('HTTP 非 2xx')
1265
+ return body
1266
+ })
1267
+ try {
1268
+ return await Promise.any([https, gh, curl, cdn])
1269
+ } catch {
1270
+ return null
1271
+ }
1272
+ }
1273
+
1274
+ async function fetchRepoPackage(repo, branch) {
1275
+ try {
1276
+ const body = await rawTextWithFallback(repo, branch, 'package.json')
1277
+ if (body === null) return null
1278
+ const pkg = JSON.parse(body)
1279
+ if (pkg == null || typeof pkg !== 'object' || typeof pkg.name !== 'string') return null
1280
+ return pkg
1281
+ } catch {
1282
+ return null
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * 插件安装:与官方 `dsh plugin add` 使用同一管理器——corepack → pnpm add。
1288
+ * profile 目录由 pnpm 管理;若用 npm 写入会与 pnpm 的目录重建互相破坏
1289
+ * (曾导致入口链接丢失、DSH 启动崩溃)。registry 走国内镜像。
1290
+ */
1291
+ async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000) {
1292
+ const args = ['pnpm', 'add', spec, '--registry', registry]
1293
+ const opts = {
1294
+ cwd: profileDir,
1295
+ timeout,
1296
+ windowsHide: true,
1297
+ maxBuffer: 4 * 1024 * 1024,
1298
+ env: {
1299
+ ...process.env,
1300
+ COREPACK_NPM_REGISTRY: registry,
1301
+ // git 通道禁止交互式凭据:避免 Git Credential Manager 弹登录窗(匿名失败即静默失败)
1302
+ GIT_TERMINAL_PROMPT: '0',
1303
+ GCM_INTERACTIVE: 'never',
1304
+ },
1305
+ }
1306
+ if (process.platform === 'win32') {
1307
+ // .cmd 批处理不能直接 execFile(EINVAL):优先 node 直跑 corepack.js
1308
+ const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
1309
+ if (existsSync(corepackJs)) {
1310
+ await execFileAsync(process.execPath, [corepackJs, ...args], opts)
1311
+ return
1312
+ }
1313
+ // 兜底:经 cmd.exe 运行 corepack
1314
+ const cmd = `${JSON.stringify('corepack')} ${args.map((arg) => JSON.stringify(arg)).join(' ')}`
1315
+ await execFileAsync(process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', cmd], opts)
1316
+ return
1317
+ }
1318
+ await execFileAsync('corepack', args, opts)
1319
+ }
1320
+
1321
+ /**
1322
+ * curl 手动安装通道:node 网络黑洞(pnpm 下载卡死:socket hang up / TIMEOUT / downloaded 0)时,
1323
+ * curl 与系统 tar 仍可用——用 curl 下载 registry tarball、解压到 profile 的 node_modules。
1324
+ * 零依赖包可完整安装;带依赖包记录未补齐列表(不阻塞,供面板提示)。
1325
+ * 返回 { version, missingDeps };失败抛错由调用方落入 next 通道。
1326
+ */
1327
+ async function curlManualInstall(profileDir, packageName, registries) {
1328
+ let meta = null
1329
+ let metaError = null
1330
+ for (const reg of registries) {
1331
+ try {
1332
+ const encoded = packageName.startsWith('@')
1333
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
1334
+ : encodeURIComponent(packageName)
1335
+ meta = await fetchJsonUrl(`${reg}/${encoded}`)
1336
+ if (meta) break
1337
+ } catch (error) {
1338
+ metaError = error
1339
+ }
1340
+ }
1341
+ if (!meta || typeof meta !== 'object') throw new Error(`curl 通道:无法获取 registry 元数据(${metaError?.message ?? '未知'})`)
1342
+ const version = meta['dist-tags']?.latest ?? null
1343
+ const tarball = version ? meta.versions?.[version]?.dist?.tarball ?? null : null
1344
+ if (!version || !tarball) throw new Error('curl 通道:registry 无 dist-tags.latest / tarball')
1345
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
1346
+ const tmp = join(tmpdir(), `pc-curl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
1347
+ mkdirSync(tmp, { recursive: true })
1348
+ try {
1349
+ const tgz = join(tmp, 'pkg.tgz')
1350
+ await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, tarball], { timeout: 70000, windowsHide: true })
1351
+ await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 30000, windowsHide: true })
1352
+ const pkgPath = join(tmp, 'package')
1353
+ // 盒子实验:解压后先验证,通过才覆盖正式位置(失败保留旧版本,服务不中断)
1354
+ const box = verifyPackageBox(pkgPath, profileDir, packageName)
1355
+ const pkg = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
1356
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
1357
+ const missingDeps = Object.keys(deps).filter((d) => !existsSync(join(profileDir, 'node_modules', d)))
1358
+ const target = join(profileDir, 'node_modules', packageName)
1359
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
1360
+ mkdirSync(dirname(target), { recursive: true })
1361
+ copyTree(pkgPath, target)
1362
+ // 落真实安装时间标记:npm tarball 内文件 mtime 是固定时间戳(1985-10-26,可复现构建),
1363
+ // 解压后 package.json 的 mtime 不可靠,面板安装日期优先读此标记
1364
+ try {
1365
+ writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
1366
+ } catch {}
1367
+ return { version, missingDeps, boxNote: box.note }
1368
+ } finally {
1369
+ rmSync(tmp, { recursive: true, force: true })
1370
+ }
1371
+ }
1372
+
1373
+ /**
1374
+ * 盒子实验验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
1375
+ * - package.json 必须可解析且 name 与安装目标一致
1376
+ * - main / exports 入口文件必须真实存在(防"装上了但加载即崩")
1377
+ * - bundle patch(cordis.patch.yml)引用的包必须已就位(防聚合包引用缺失崩溃)
1378
+ * 失败抛错 → 调用方保留旧版本(安装不中断服务)。
1379
+ */
1380
+ function verifyPackageBox(pkgPath, profileDir, packageName, refCheckRoot = null) {
1381
+ const note = []
1382
+ const pkgRaw = readFileSync(join(pkgPath, 'package.json'), 'utf8')
1383
+ const pkg = JSON.parse(pkgRaw)
1384
+ if (pkg.name !== packageName) {
1385
+ throw new Error(`盒子验证失败:包名不符(tarball 内为 ${pkg.name},期望 ${packageName}),已保留旧版本`)
1386
+ }
1387
+ // 入口存在性:main 字段 / exports.'.'(字符串或对象 default)指向的文件必须存在
1388
+ let entry = typeof pkg.main === 'string' ? pkg.main : null
1389
+ if (entry === null && pkg.exports && typeof pkg.exports === 'object') {
1390
+ const dot = pkg.exports['.'] ?? pkg.exports['./package.json'] === undefined ? pkg.exports['.'] : null
1391
+ if (typeof dot === 'string') entry = dot
1392
+ else if (dot && typeof dot === 'object') entry = typeof dot.default === 'string' ? dot.default : null
1393
+ }
1394
+ if (entry !== null) {
1395
+ const entryPath = join(pkgPath, ...entry.split('/'))
1396
+ if (!existsSync(entryPath)) {
1397
+ throw new Error(`盒子验证失败:入口文件缺失(${entry}),已保留旧版本`)
1398
+ }
1399
+ }
1400
+ // bundle patch 引用预检:引用的包必须已存在于目标 node_modules(防聚合包半更新崩溃)。
1401
+ // refCheckRoot 指定检查根(宿主插件在根层 node_modules,与 profile 层不同)。
1402
+ const checkRoot = refCheckRoot ?? join(profileDir, 'node_modules')
1403
+ try {
1404
+ const patchRel = pkg.dsh?.bundle?.patch
1405
+ if (typeof patchRel === 'string') {
1406
+ const bundlePatch = join(pkgPath, patchRel)
1407
+ if (existsSync(bundlePatch)) {
1408
+ const refs = parseBundlePatchRefs(readFileSync(bundlePatch, 'utf8'))
1409
+ const missingRefs = refs.filter((r) => !existsSync(join(checkRoot, r.name)))
1410
+ if (missingRefs.length > 0) {
1411
+ throw new Error(`盒子验证失败:bundle patch 引用的包缺失(${missingRefs.map((r) => r.name).join('、')}),已保留旧版本,请先补装后再更新`)
1412
+ }
1413
+ if (refs.length > 0) note.push(`bundle 引用 ${refs.length} 个已就位`)
1414
+ }
1415
+ }
1416
+ } catch (error) {
1417
+ if (error instanceof Error && error.message.startsWith('盒子验证失败')) throw error
1418
+ }
1419
+ return { note: note.length > 0 ? note.join(';') : null }
1420
+ }
1421
+
1422
+ /** 解析 bundle patch(cordis.patch.yml)的 insert 引用列表(id + 包名)。 */
1423
+ function parseBundlePatchRefs(text) {
1424
+ const refs = []
1425
+ const lines = text.split(/\r?\n/u)
1426
+ let inInsert = false
1427
+ for (let i = 0; i < lines.length; i += 1) {
1428
+ const line = lines[i]
1429
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
1430
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
1431
+ if (!inInsert) continue
1432
+ const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
1433
+ if (!idM) continue
1434
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
1435
+ if (nameM) refs.push({ id: idM[1], name: nameM[1] })
1436
+ }
1437
+ return refs
1438
+ }
1439
+
1440
+ /**
1441
+ * 捆绑依赖补装(事故教训:dsh-web-ui-all 更新后 17 个捆绑依赖缺失 → 服务加载崩溃):
1442
+ * curl/GitHub 通道只解压主包,这里逐个补装直接依赖(pnpm → curl 依次尝试)。
1443
+ * 返回仍缺失的依赖列表。
1444
+ */
1445
+ async function backfillMissingDeps(profileDir, deps, registries) {
1446
+ const stillMissing = []
1447
+ for (const dep of deps) {
1448
+ if (existsSync(join(profileDir, 'node_modules', dep))) continue
1449
+ let ok = false
1450
+ try {
1451
+ await pnpmInstall(profileDir, dep, registries[0], 60000)
1452
+ ok = existsSync(join(profileDir, 'node_modules', dep))
1453
+ } catch {}
1454
+ if (!ok) {
1455
+ try {
1456
+ const depInfo = await curlManualInstall(profileDir, dep, registries)
1457
+ ok = depInfo.missingDeps.length === 0
1458
+ } catch {}
1459
+ }
1460
+ if (!ok) stillMissing.push(dep)
1461
+ }
1462
+ return stillMissing
1463
+ }
1464
+
1465
+ /**
1466
+ * GitHub release 下载安装通道(npm 上不存在的包,例如面板自身 @deepseek-ai/dsh-plugin-console):
1467
+ * 拉取仓库 latest release 源码 tarball → 盒子验证 → 覆盖 node_modules 正式位置。
1468
+ * 宿主插件特判:本面板部署在宿主根层 node_modules(import.meta.url 解析),更新时覆盖根层而非 web profile。
1469
+ */
1470
+ async function githubReleaseInstall(profileDir, repo, packageName) {
1471
+ const auth = readGithubAuth()
1472
+ const headers = { 'User-Agent': 'dsh-plugin-console' }
1473
+ if (auth.token) headers.Authorization = `token ${auth.token}`
1474
+ const release = await fetchJsonUrl(`https://api.github.com/repos/${repo}/releases/latest`, 15000, headers)
1475
+ const tag = typeof release?.tag_name === 'string' ? release.tag_name : null
1476
+ if (tag === null) throw new Error('GitHub 通道:仓库没有 latest release(版本发布后才可更新)')
1477
+ const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
1478
+ const tmp = join(tmpdir(), `pc-gh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
1479
+ mkdirSync(tmp, { recursive: true })
1480
+ try {
1481
+ const tgz = join(tmp, 'pkg.tgz')
1482
+ // codeload 官方源码 tarball(已验证本机可用 200)。GitHub 黑洞期由上层通道兜底
1483
+ // (pnpm/git 通道),此处失败即报错保留旧版本。
1484
+ await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, `https://codeload.github.com/${repo}/tar.gz/refs/tags/${encodeURIComponent(tag)}`], { timeout: 70000, windowsHide: true })
1485
+ if (!existsSync(tgz) || statSync(tgz).size < 100) throw new Error(`GitHub 通道:下载源码 tarball 失败(${repo}@${tag})`)
1486
+ await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 60000, windowsHide: true })
1487
+ // 源码 tarball 顶层目录名是 {repo}-{sha}:定位含 package.json 的顶层目录
1488
+ const subdirs = readdirSync(tmp, { withFileTypes: true })
1489
+ .filter((d) => d.isDirectory())
1490
+ .map((d) => join(tmp, d.name))
1491
+ const pkgPath = subdirs.find((d) => existsSync(join(d, 'package.json')))
1492
+ if (!pkgPath) throw new Error('GitHub 通道:tarball 内未找到 package.json')
1493
+ // 目标位置:宿主插件(面板自身,部署在宿主根层 node_modules)→ 覆盖自身所在目录;
1494
+ // 普通插件 → profile node_modules
1495
+ let targetRoot = join(profileDir, 'node_modules')
1496
+ try {
1497
+ const selfDir = dirname(dirname(fileURLToPath(import.meta.url))) // 自身包目录(含 package.json)
1498
+ const selfRoot = dirname(selfDir) // 自身包所在 node_modules
1499
+ if (selfRoot !== targetRoot && existsSync(join(selfDir, 'package.json'))) {
1500
+ targetRoot = selfRoot
1501
+ }
1502
+ } catch {}
1503
+ // 盒子实验:静态验证通过才覆盖(失败保留旧版本)。引用检查根用目标目录(宿主插件在根层)
1504
+ const box = verifyPackageBox(pkgPath, profileDir, packageName, targetRoot)
1505
+ const pkg = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
1506
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
1507
+ const missingDeps = Object.keys(deps).filter((d) => !existsSync(join(profileDir, 'node_modules', d)))
1508
+ const target = join(targetRoot, packageName)
1509
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
1510
+ mkdirSync(dirname(target), { recursive: true })
1511
+ copyTree(pkgPath, target)
1512
+ // 版本号对齐(防死循环):release tarball 内 package.json version 可能滞后于 tag
1513
+ // (历史发布只打 tag 不改 version),改写为 tag 版本 → 下次检测 latest===current → "已是最新"
1514
+ try {
1515
+ const targetPkgPath = join(target, 'package.json')
1516
+ const targetPkg = JSON.parse(readFileSync(targetPkgPath, 'utf8'))
1517
+ const tagVersion = tag.replace(/^v/iu, '')
1518
+ if (targetPkg.version !== tagVersion) {
1519
+ targetPkg.version = tagVersion
1520
+ writeFileSync(targetPkgPath, JSON.stringify(targetPkg, null, 4), 'utf8')
1521
+ }
1522
+ } catch {}
1523
+ try {
1524
+ writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
1525
+ } catch {}
1526
+ return { version: tag.replace(/^v/iu, ''), missingDeps, boxNote: box.note, source: 'github' }
1527
+ } finally {
1528
+ rmSync(tmp, { recursive: true, force: true })
1529
+ }
1530
+ }
1531
+
1532
+ /**
1533
+ * 聚合包完整性保障(事故教训):dsh-web-ui-all 更新后捆绑依赖缺失,其 bundle patch
1534
+ * (cordis.patch.yml)引用的包未安装 → 服务加载崩溃。
1535
+ * 读已装包的 bundle patch → 检查每个 insert 引用的包是否存在 → 缺失补装(pnpm/curl)→
1536
+ * 仍缺则在用户 patch 层自动禁用该行(防崩兜底)+ 返回报告供面板提示。
1537
+ */
1538
+ async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath) {
1539
+ const report = { checked: 0, installed: [], disabled: [], missing: [] }
1540
+ try {
1541
+ const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
1542
+ if (!existsSync(pkgJsonPath)) return report
1543
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
1544
+ const patchRel = pkg.dsh?.bundle?.patch
1545
+ if (typeof patchRel !== 'string') return report
1546
+ const bundlePatch = join(profileDir, 'node_modules', pkgName, patchRel)
1547
+ if (!existsSync(bundlePatch)) return report
1548
+ const text = readFileSync(bundlePatch, 'utf8')
1549
+ // 解析 insert 引用(与 readExtraBundleOwners 同构):- insert: 块下 - id: xxx + name: '包名'
1550
+ const refs = []
1551
+ const lines = text.split(/\r?\n/u)
1552
+ let inInsert = false
1553
+ for (let i = 0; i < lines.length; i += 1) {
1554
+ const line = lines[i]
1555
+ if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
1556
+ if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
1557
+ if (!inInsert) continue
1558
+ const idM = line.match(/^ {4}- id: ([A-Za-z0-9_.-]+)\s*$/u)
1559
+ if (!idM) continue
1560
+ const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
1561
+ if (nameM) refs.push({ id: idM[1], name: nameM[1] })
1562
+ }
1563
+ report.checked = refs.length
1564
+ if (refs.length === 0) return report
1565
+ const registries = orderedRegistries(readSources())
1566
+ for (const ref of refs) {
1567
+ if (existsSync(join(profileDir, 'node_modules', ref.name))) continue
1568
+ let ok = false
1569
+ try {
1570
+ await pnpmInstall(profileDir, ref.name, registries[0], 60000)
1571
+ ok = existsSync(join(profileDir, 'node_modules', ref.name))
1572
+ } catch {}
1573
+ if (!ok) {
1574
+ try { await curlManualInstall(profileDir, ref.name, registries); ok = true } catch {}
1575
+ }
1576
+ if (ok) {
1577
+ report.installed.push(ref.name)
1578
+ } else {
1579
+ report.missing.push(ref.name)
1580
+ // 自动禁用该行(用户 patch 层,防服务加载崩溃)
1581
+ try {
1582
+ const userPatch = readFileSync(userPatchPath, 'utf8')
1583
+ if (!userPatch.includes(`id: ${ref.id}`)) {
1584
+ appendFileSync(userPatchPath, `- id: ${ref.id}\n disabled: true\n`, 'utf8')
1585
+ report.disabled.push(ref.id)
1586
+ }
1587
+ } catch {}
1588
+ }
1589
+ }
1590
+ } catch {}
1591
+ return report
1592
+ }
1593
+
1594
+ /**
1595
+ * 后台安装任务注册表:请求立即返回,安装继续在服务端执行;
1596
+ * 面板通过 /install-status 轮询进度(stage + status),离开面板不中断。
1597
+ */
1598
+ const installJobs = new Map()
1599
+ let installJobSeq = 0
1600
+ /** 静态插件索引内存缓存(/market-index 用)。 */
1601
+ let marketIndexCache = null
1602
+
1603
+ function installJobView(job) {
1604
+ return {
1605
+ jobId: job.id,
1606
+ repo: job.repo,
1607
+ packageName: job.packageName,
1608
+ status: job.status,
1609
+ stage: job.stage,
1610
+ error: job.error,
1611
+ startedAt: job.startedAt,
1612
+ finishedAt: job.finishedAt,
1613
+ entryId: job.entryId ?? null,
1614
+ bundle: job.bundle ?? false,
1615
+ ai: job.ai ?? false,
1616
+ aiNote: job.aiNote ?? null,
1617
+ subpackages: job.subpackages ?? null,
1618
+ source: job.source ?? 'github',
1619
+ curlNote: job.curlNote ?? null,
1620
+ bundleNote: job.bundleNote ?? null,
1621
+ kind: job.kind ?? 'plugin',
1622
+ skillName: job.skillName ?? null,
1623
+ skillDir: job.skillDir ?? null,
1624
+ skillNote: job.skillNote ?? null,
1625
+ suiteReport: job.suiteReport ?? null,
1626
+ suiteNote: job.suiteNote ?? null,
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * 递归复制目录树(绕开 fs.cpSync 在本环境的目录复制 EIO bug:
1632
+ * cpSync 复制含子目录的树必报 `EIO, Access is denied`,而逐文件 copyFileSync 正常)。
1633
+ * 跳过 .git(技能/包副本不需要版本库元数据)。
1634
+ */
1635
+ function copyTree(src, dest) {
1636
+ mkdirSync(dest, { recursive: true })
1637
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
1638
+ if (entry.name === '.git') continue
1639
+ const from = join(src, entry.name)
1640
+ const to = join(dest, entry.name)
1641
+ if (entry.isDirectory()) {
1642
+ copyTree(from, to)
1643
+ } else if (entry.isFile()) {
1644
+ copyFileSync(from, to)
1645
+ }
1646
+ }
1647
+ }
1648
+
1649
+ /** 解析 .gitmodules:返回 [{name, path, url}](submodule 套装识别用)。 */
1650
+ function readGitmodules(dir) {
1651
+ const file = join(dir, '.gitmodules')
1652
+ if (!existsSync(file)) return []
1653
+ const text = readFileSync(file, 'utf8')
1654
+ const subs = []
1655
+ let cur = null
1656
+ for (const line of text.split(/\r?\n/u)) {
1657
+ const m = line.match(/^\[submodule\s+"([^"]+)"\]/u)
1658
+ if (m) {
1659
+ cur = { name: m[1], path: '', url: '' }
1660
+ subs.push(cur)
1661
+ continue
1662
+ }
1663
+ if (!cur) continue
1664
+ const pm = line.match(/^\s*path\s*=\s*(.+)$/u)
1665
+ if (pm) { cur.path = pm[1].trim(); continue }
1666
+ const um = line.match(/^\s*url\s*=\s*(.+)$/u)
1667
+ if (um) cur.url = um[1].trim()
1668
+ }
1669
+ return subs.filter((s) => s.path !== '' && s.url !== '')
1670
+ }
1671
+
1672
+ /** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。 */
1673
+ function findPresetDirs(root, maxDepth = 2) {
1674
+ const out = []
1675
+ const walk = (dir, depth) => {
1676
+ if (depth > maxDepth) return
1677
+ let entries = []
1678
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
1679
+ if (existsSync(join(dir, 'preset.yml')) && existsSync(join(dir, 'agent.cordis.yml'))) {
1680
+ out.push(dir)
1681
+ return
1682
+ }
1683
+ for (const e of entries) {
1684
+ if (!e.isDirectory() || e.name.startsWith('.')) continue
1685
+ walk(join(dir, e.name), depth + 1)
1686
+ }
1687
+ }
1688
+ walk(root, 0)
1689
+ return out
1690
+ }
1691
+
1692
+ /** 包是否有构建产物(main/module/exports/bin 或 lib/index.js 任一存在)。
1693
+ * 排除类型声明(.d.ts)与 package.json 自身——exports 的 "./package.json" 是合法导出但不是运行时入口。 */
1694
+ function packageEntryExists(dir, pkg) {
1695
+ const isEntry = (p) => typeof p === 'string' && p !== '' && !p.endsWith('.d.ts') && p !== './package.json' && p !== 'package.json'
1696
+ const candidates = []
1697
+ if (isEntry(pkg.main)) candidates.push(pkg.main)
1698
+ if (isEntry(pkg.module)) candidates.push(pkg.module)
1699
+ if (isEntry(pkg.bin)) candidates.push(pkg.bin)
1700
+ if (pkg.bin && typeof pkg.bin === 'object') Object.values(pkg.bin).forEach((v) => { if (isEntry(v)) candidates.push(v) })
1701
+ if (pkg.exports && typeof pkg.exports === 'object') {
1702
+ const collect = (v) => {
1703
+ if (typeof v === 'string') { if (isEntry(v)) candidates.push(v) }
1704
+ else if (v && typeof v === 'object') Object.values(v).forEach(collect)
1705
+ }
1706
+ collect(pkg.exports)
1707
+ }
1708
+ candidates.push('lib/index.js', 'dist/index.js')
1709
+ return candidates.some((c) => existsSync(join(dir, c)))
1710
+ }
1711
+
1712
+ /** git clone(镜像→直连;gitee 直连),返回或抛错。 */
1713
+ async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
1714
+ const urls = source === 'gitee'
1715
+ ? [`https://gitee.com/${repo}.git`]
1716
+ : [`https://ghproxy.net/https://github.com/${repo}.git`, `https://github.com/${repo}.git`]
1717
+ let lastError = null
1718
+ for (const url of urls) {
1719
+ try {
1720
+ await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
1721
+ timeout,
1722
+ windowsHide: true,
1723
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1724
+ })
1725
+ return
1726
+ } catch (error) {
1727
+ lastError = error
1728
+ }
1729
+ }
1730
+ throw new Error(`git clone 失败:${lastError?.message ?? '未知'}`)
1731
+ }
1732
+
1733
+ /** 从 GitHub Release 下载预构建 tgz 装配到 node_modules/<pkgName>(gh CLI 通道,含绝对路径候选)。 */
1734
+ async function installBundleFromRelease(subRepo, pkgName, target) {
1735
+ const dlDir = join(tmpdir(), `dsh-rel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`)
1736
+ mkdirSync(dlDir, { recursive: true })
1737
+ try {
1738
+ const args = ['release', 'download', '-R', subRepo, '-p', '*.tgz', '-D', dlDir]
1739
+ let downloaded = false
1740
+ let lastError = null
1741
+ for (const bin of GH_BIN_CANDIDATES) {
1742
+ try {
1743
+ await execFileAsync(bin, args, { timeout: 180000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
1744
+ downloaded = true
1745
+ break
1746
+ } catch (error) {
1747
+ lastError = error
1748
+ if (error.code !== 'ENOENT') break
1749
+ }
1750
+ }
1751
+ if (!downloaded) throw new Error(lastError?.message ?? 'gh release download 失败')
1752
+ const tgz = readdirSync(dlDir).find((f) => f.endsWith('.tgz'))
1753
+ if (!tgz) throw new Error('Release 无 tgz 资产')
1754
+ const extractDir = join(dlDir, 'x')
1755
+ mkdirSync(extractDir, { recursive: true })
1756
+ await execFileAsync('tar', ['-xzf', join(dlDir, tgz), '-C', extractDir], { timeout: 60000, windowsHide: true })
1757
+ const pkgDir = join(extractDir, 'package')
1758
+ if (!existsSync(join(pkgDir, 'package.json'))) throw new Error('tgz 内无 package/package.json')
1759
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
1760
+ mkdirSync(dirname(target), { recursive: true })
1761
+ copyTree(pkgDir, target)
1762
+ } finally {
1763
+ rmSync(dlDir, { recursive: true, force: true })
1764
+ }
1765
+ }
1766
+
1767
+ /** 套装安装(submodule 聚合仓库):照仓库 install.ps1/README 语义——
1768
+ * clone 套装 → 手动镜像拉取子模块 → 按类型装配(bundle 插件含 Release tgz 兜底 / 普通插件 / 技能 / agent 预设)。
1769
+ * 不执行第三方脚本本体(安全护栏:脚本型只读语义不运行)。 */
1770
+ async function runSuiteInstallJob(job, ctx) {
1771
+ const tmpDir = join(tmpdir(), `dsh-suite-${job.id}-${Date.now()}`)
1772
+ const report = []
1773
+ try {
1774
+ job.stage = 'preparing'
1775
+ mkdirSync(tmpDir, { recursive: true })
1776
+ await gitCloneRepo(job.repo, tmpDir, job.source)
1777
+ const subs = readGitmodules(tmpDir)
1778
+ if (subs.length === 0) throw new Error('未找到 .gitmodules(不是 submodule 套装仓库)')
1779
+ job.stage = 'detecting'
1780
+ const patchPath = findPatchPath(ctx)
1781
+ const profileDir = dirname(patchPath)
1782
+ // 手动拉取子模块(git 的 insteadOf 重写对 submodule 不生效,按镜像 URL 逐个 clone)
1783
+ for (const sub of subs) {
1784
+ const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
1785
+ const dest = join(tmpDir, sub.path)
1786
+ try {
1787
+ mkdirSync(dirname(dest), { recursive: true })
1788
+ await gitCloneRepo(subRepo, dest, sub.url.includes('gitee.com') ? 'gitee' : 'github', 120000)
1789
+ } catch (error) {
1790
+ report.push({ component: sub.name, type: 'clone', ok: false, note: `子模块拉取失败:${error.message}` })
1791
+ }
1792
+ }
1793
+ for (const sub of subs) {
1794
+ const subDir = join(tmpDir, sub.path)
1795
+ if (!existsSync(subDir)) continue
1796
+ const subRepo = sub.url.replace(/^https?:\/\/[^/]+\//u, '').replace(/\.git$/u, '')
1797
+ let pkg = null
1798
+ try {
1799
+ const pkgPath = join(subDir, 'package.json')
1800
+ if (existsSync(pkgPath)) pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
1801
+ } catch {}
1802
+ let handled = false
1803
+ // a. bundle 型插件(如 injector):自动装配默认**跳过**并给出官方装配指引——
1804
+ // 第三方 bundle 需与当前 DSH 版本严格兼容(peer 依赖、client inject 模块、patch 语义),
1805
+ // 自动写入 bundles 曾导致启动崩溃;预设/技能/普通插件不受影响。
1806
+ if (pkg && typeof pkg.name === 'string' && pkg.dsh?.bundle) {
1807
+ report.push({ component: sub.name, type: 'bundle', ok: false, note: `${pkg.name} 是 bundle 型插件,自动装配已跳过(避免 bundle 不兼容导致启动失败);请按详情面板「官方安装方式」命令手动装配(clone 套装后运行 install.ps1,或构建后加入 profile 的 dsh.profile.bundles)` })
1808
+ handled = true
1809
+ }
1810
+ // b. 技能(根或第一层子目录 SKILL.md)
1811
+ if (!handled) {
1812
+ let skillDir0 = ''
1813
+ if (existsSync(join(subDir, 'SKILL.md'))) {
1814
+ skillDir0 = ''
1815
+ } else {
1816
+ let found = null
1817
+ try {
1818
+ found = readdirSync(subDir, { withFileTypes: true })
1819
+ .find((d) => d.isDirectory() && existsSync(join(subDir, d.name, 'SKILL.md')))
1820
+ } catch {}
1821
+ skillDir0 = found ? found.name : null
1822
+ }
1823
+ if (skillDir0 !== null) {
1824
+ const skillsRoot = join(dshHome(), 'skills')
1825
+ mkdirSync(skillsRoot, { recursive: true })
1826
+ const dest = join(skillsRoot, sub.name)
1827
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
1828
+ copyTree(join(subDir, skillDir0), dest)
1829
+ report.push({ component: sub.name, type: 'skill', ok: true, note: `已安装技能 ~/.dsh/skills/${sub.name}` })
1830
+ handled = true
1831
+ }
1832
+ }
1833
+ // c. agent 预设(含 preset.yml + agent.cordis.yml 的目录;预设优先于普通 npm 包——
1834
+ // 如 dsh-router-standard 既是 npm 包又带预设目录,install.ps1 意图是复制预设)
1835
+ const presets = findPresetDirs(subDir, 2)
1836
+ for (const p of presets) {
1837
+ const pname = basename(p)
1838
+ const presetsRoot = join(dshHome(), '.agent-presets')
1839
+ mkdirSync(presetsRoot, { recursive: true })
1840
+ const dest = join(presetsRoot, pname)
1841
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
1842
+ copyTree(p, dest)
1843
+ report.push({ component: sub.name, type: 'preset', ok: true, note: `已安装预设 ${pname}(新建会话可选)` })
1844
+ handled = true
1845
+ }
1846
+ // d. 普通 npm 插件(无 bundle 且无预设/技能)
1847
+ if (!handled && pkg && typeof pkg.name === 'string') {
1848
+ const target = join(profileDir, 'node_modules', pkg.name)
1849
+ if (existsSync(target)) rmSync(target, { recursive: true, force: true })
1850
+ mkdirSync(dirname(target), { recursive: true })
1851
+ copyTree(subDir, target)
1852
+ const taken = new Set(listEntries(ctx).map((e) => e.rowId))
1853
+ const entryId = deriveEntryId(pkg.name, taken)
1854
+ await appendInsert(patchPath, entryId, pkg.name)
1855
+ report.push({ component: sub.name, type: 'plugin', ok: true, note: `已安装 ${pkg.name}(HMR 生效)` })
1856
+ handled = true
1857
+ }
1858
+ if (!handled) {
1859
+ report.push({ component: sub.name, type: 'unknown', ok: false, note: '未识别组件类型(无 package.json / SKILL.md / 预设)' })
1860
+ }
1861
+ }
1862
+ job.status = 'done'
1863
+ job.stage = 'done'
1864
+ job.suiteReport = report
1865
+ const okCount = report.filter((r) => r.ok).length
1866
+ const failCount = report.filter((r) => !r.ok).length
1867
+ const bundleCount = report.filter((r) => r.type === 'bundle' && r.ok).length
1868
+ job.suiteNote = `套装安装完成:${okCount} 个组件成功${failCount > 0 ? `,${failCount} 个失败(详见报告)` : ''}${bundleCount > 0 ? '。bundle 组件需重启服务生效' : ''};预设需新建会话时选择。`
1869
+ } catch (error) {
1870
+ job.status = 'failed'
1871
+ job.error = error instanceof Error ? error.message : String(error)
1872
+ } finally {
1873
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
1874
+ job.finishedAt = Date.now()
1875
+ }
1876
+ }
1877
+
1878
+ /** 技能安装:git clone 仓库 → 定位 SKILL.md(根或第一层子目录)→ 复制到 ~/.dsh/skills/<name>/。
1879
+ * 技能由 dsh-skill-filesystem 插件扫描(发现根:<dshHome>/skills),与桌面版共享。 */
1880
+ async function runSkillInstallJob(job, ctx) {
1881
+ const tmpDir = join(tmpdir(), `dsh-skill-${job.id}-${Date.now()}`)
1882
+ try {
1883
+ job.stage = 'preparing'
1884
+ const shortName = String(job.repo).split('/').pop() || job.repo
1885
+ mkdirSync(tmpDir, { recursive: true })
1886
+ const urls = job.source === 'gitee'
1887
+ ? [`https://gitee.com/${job.repo}.git`]
1888
+ : [`https://ghproxy.net/https://github.com/${job.repo}.git`, `https://github.com/${job.repo}.git`]
1889
+ let cloned = false
1890
+ let lastError = null
1891
+ for (const url of urls) {
1892
+ try {
1893
+ await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, tmpDir], {
1894
+ timeout: 120000,
1895
+ windowsHide: true,
1896
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
1897
+ })
1898
+ cloned = true
1899
+ break
1900
+ } catch (error) {
1901
+ lastError = error
1902
+ }
1903
+ }
1904
+ if (!cloned) throw new Error(`git clone 失败:${lastError?.message ?? '未知'}`)
1905
+ job.stage = 'detecting'
1906
+ // 定位 SKILL.md:根目录优先,其次第一层子目录(常用布局 skills/<name>/SKILL.md)
1907
+ let skillDir = ''
1908
+ if (!existsSync(join(tmpDir, 'SKILL.md'))) {
1909
+ const sub = readdirSync(tmpDir, { withFileTypes: true })
1910
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
1911
+ .find((d) => existsSync(join(tmpDir, d.name, 'SKILL.md')))
1912
+ if (sub) skillDir = sub.name
1913
+ }
1914
+ if (!existsSync(join(tmpDir, skillDir, 'SKILL.md'))) {
1915
+ // 根与第一层子目录都没有:区分「技能集合仓库」与「非技能仓库」,给出可操作提示
1916
+ let collection = false
1917
+ try {
1918
+ const tree = await curlJson(`https://api.github.com/repos/${job.repo}/git/trees/${encodeURIComponent(job.source === 'gitee' ? 'master' : 'main')}?recursive=1`, 6000).catch(() => null)
1919
+ collection = (tree?.tree ?? []).filter((n) => n.type === 'blob' && /(?:^|\/)SKILL\.md$/u.test(n.path)).length > 1
1920
+ } catch {}
1921
+ if (collection) {
1922
+ throw new Error('这是技能集合仓库(含多个 SKILL.md),请安装其中单个技能仓库(根或第一层子目录含 SKILL.md 的仓库)')
1923
+ }
1924
+ throw new Error('仓库内未找到 SKILL.md(检查根目录或第一层子目录)')
1925
+ }
1926
+ // 技能名:SKILL.md frontmatter 的 name(kebab-case)优先,否则用仓库短名
1927
+ let skillName = shortName
1928
+ try {
1929
+ const text = readFileSync(join(tmpDir, skillDir, 'SKILL.md'), 'utf8')
1930
+ const m = text.match(/^name:\s*([a-z0-9][a-z0-9-]{0,63})/mu)
1931
+ if (m) skillName = m[1]
1932
+ } catch {}
1933
+ const skillsRoot = join(dshHome(), 'skills')
1934
+ const dest = join(skillsRoot, skillName)
1935
+ mkdirSync(skillsRoot, { recursive: true })
1936
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true })
1937
+ copyTree(join(tmpDir, skillDir), dest)
1938
+ job.kind = 'skill'
1939
+ job.skillName = skillName
1940
+ job.skillDir = dest
1941
+ job.status = 'done'
1942
+ job.stage = 'done'
1943
+ job.skillNote = `已安装技能「${skillName}」到 ${dest}。技能由 dsh-skill-filesystem 插件扫描发现(用户根 ~/.dsh/skills);若当前 profile 未启用该插件,请在 profile 的 cordis.yml 启用 @deepseek-ai/dsh-skill-filesystem 后重启即可生效。`
1944
+ } catch (error) {
1945
+ job.status = 'failed'
1946
+ job.error = error instanceof Error ? error.message : String(error)
1947
+ } finally {
1948
+ // 无论成败都清理克隆临时目录(cpSync EIO 时代曾泄漏在 TEMP)
1949
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
1950
+ job.finishedAt = Date.now()
1951
+ }
1952
+ }
1953
+
1954
+ /** 技能停用注入的 frontmatter 行(官方调用策略:disable-model-invocation 从模型目录/loader 排除,
1955
+ * user-invocable 从用户命令排除;两者同设 = 完整停用)。 */
1956
+ const SKILL_DISABLE_LINES = ['disable-model-invocation: true', 'user-invocable: false']
1957
+
1958
+ /** 返回 SKILL.md frontmatter 内容区间(不含首尾 --- 行);无 frontmatter 时 { has: false }。 */
1959
+ function skillFrontmatterBounds(text) {
1960
+ if (!text.startsWith('---')) return { has: false }
1961
+ const nl = text.indexOf('\n')
1962
+ if (nl === -1) return { has: false }
1963
+ const end = text.indexOf('\n---', nl + 1)
1964
+ if (end === -1) return { has: false }
1965
+ return { has: true, start: nl + 1, end }
1966
+ }
1967
+
1968
+ /** 技能是否已停用(frontmatter 含 disable-model-invocation: true)。 */
1969
+ function isSkillDisabled(skillFile) {
1970
+ try {
1971
+ const text = readFileSync(skillFile, 'utf8')
1972
+ const fm = skillFrontmatterBounds(text)
1973
+ if (!fm.has) return false
1974
+ return /^\s*disable-model-invocation:\s*(true|yes|on|1)\s*$/mu.test(text.slice(fm.start, fm.end))
1975
+ } catch {
1976
+ return false
1977
+ }
1978
+ }
1979
+
1980
+ /** 停用/启用技能(可逆):停用 = 备份原始 SKILL.md 到同目录 .dsh-skill-fm.bak 后在
1981
+ * frontmatter 注入调用策略行;启用 = 恢复备份(无备份则移除注入行)。 */
1982
+ function setSkillEnabled(skillFile, enabled) {
1983
+ const backup = join(dirname(skillFile), '.dsh-skill-fm.bak')
1984
+ const text = readFileSync(skillFile, 'utf8')
1985
+ const fm = skillFrontmatterBounds(text)
1986
+ if (!enabled) {
1987
+ if (!existsSync(backup)) writeFileSync(backup, text, 'utf8')
1988
+ const inject = `${SKILL_DISABLE_LINES.join('\n')}\n`
1989
+ if (!fm.has) {
1990
+ writeFileSync(skillFile, `---\n${inject}---\n\n${text}`, 'utf8')
1991
+ } else {
1992
+ writeFileSync(skillFile, `${text.slice(0, fm.start)}${inject}${text.slice(fm.start)}`, 'utf8')
1993
+ }
1994
+ } else if (existsSync(backup)) {
1995
+ writeFileSync(skillFile, readFileSync(backup, 'utf8'), 'utf8')
1996
+ rmSync(backup, { force: true })
1997
+ } else if (fm.has) {
1998
+ const content = text.slice(fm.start, fm.end)
1999
+ const cleaned = content.split('\n')
2000
+ .filter((l) => !/^\s*(disable-model-invocation|user-invocable)\s*:/u.test(l))
2001
+ .join('\n')
2002
+ writeFileSync(skillFile, `${text.slice(0, fm.start)}${cleaned}${text.slice(fm.end)}`, 'utf8')
2003
+ }
2004
+ }
2005
+
2006
+ /** 列出已安装技能(~/.dsh/skills 下一层含 SKILL.md 的目录 + 平铺 .md 文件)。
2007
+ * 点号开头(如 .system)是系统技能根(dsh-skill-filesystem 保留目录),标记 system: true,
2008
+ * 前端展示「系统」标签且不提供删除。disabled = 已按官方调用策略停用。 */
2009
+ function listInstalledSkills() {
2010
+ const root = join(dshHome(), 'skills')
2011
+ const skills = []
2012
+ try {
2013
+ if (!existsSync(root)) return skills
2014
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
2015
+ const system = entry.name.startsWith('.')
2016
+ if (entry.isDirectory()) {
2017
+ const skillFile = join(root, entry.name, 'SKILL.md')
2018
+ if (existsSync(skillFile)) {
2019
+ skills.push({ name: entry.name, path: skillFile, system, disabled: isSkillDisabled(skillFile) })
2020
+ }
2021
+ } else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md') {
2022
+ const skillFile = join(root, entry.name)
2023
+ skills.push({ name: entry.name.slice(0, -3), path: skillFile, system, disabled: isSkillDisabled(skillFile) })
2024
+ }
2025
+ }
2026
+ } catch {}
2027
+ return skills
2028
+ }
2029
+
2030
+ /** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。 */
2031
+ async function fetchSubpackageNames(repo, branch, auth) {
2032
+ try {
2033
+ const data = await githubJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
2034
+ const paths = (data.tree ?? [])
2035
+ .filter((node) => node.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(node.path))
2036
+ .map((node) => node.path)
2037
+ // 并行读取:黑洞期单条最坏 40s,24 条串行会拖到十几分钟
2038
+ const results = await Promise.all(paths.slice(0, 24).map(async (path) => {
2039
+ const bodyText = await rawTextWithFallback(repo, branch, path)
2040
+ if (bodyText === null) return null
2041
+ try {
2042
+ const pkg = JSON.parse(bodyText)
2043
+ if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], name: pkg.name }
2044
+ } catch {}
2045
+ return null
2046
+ }))
2047
+ return results.filter((item) => item !== null)
2048
+ } catch {
2049
+ return []
2050
+ }
2051
+ }
2052
+
2053
+ /** 子包候选:聚合包(名字带 all)优先,上限 8 个。 */
2054
+ async function subpackageCandidates(repo, branch, auth) {
2055
+ const isAll = (name) => /(^|-)all$/u.test(name) || /-all-/u.test(name)
2056
+ const subs = await fetchSubpackageNames(repo, branch, auth)
2057
+ return subs
2058
+ .slice()
2059
+ .sort((a, b) => Number(isAll(b.name)) - Number(isAll(a.name)))
2060
+ .map((sub) => sub.name)
2061
+ .slice(0, 8)
2062
+ }
2063
+
2064
+ /**
2065
+ * 本地 AI 修复:拉起无父上下文的 in-process 子代理接管安装。
2066
+ * 子代理与本会话使用同一套工具(终端/文件),能真实修复安装。
2067
+ */
2068
+ async function aiRepair(job, ctx, profileDir, candidates, lastError) {
2069
+ job.stage = 'repairing'
2070
+ job.ai = true
2071
+ let subagents = null
2072
+ try { subagents = ctx.get('subagents') } catch {}
2073
+ const startFn = subagents?.start
2074
+ if (typeof startFn !== 'function') {
2075
+ job.status = 'failed'
2076
+ job.error = `确定性安装通道全部失败,且本地 AI 修复通道(subagents 服务)不可用。原始错误:${lastError ?? '未知'};请手动执行:dsh plugin --profile web add <包名>`
2077
+ return
2078
+ }
2079
+ let provider = 'spawn'
2080
+ try {
2081
+ const list = subagents.list?.() ?? []
2082
+ if (!list.includes(provider)) provider = list[0]
2083
+ if (provider === undefined) throw new Error('no provider')
2084
+ } catch (error) {
2085
+ job.status = 'failed'
2086
+ job.error = `本地 AI 修复通道没有可用的子代理提供方:${String(error)};请手动执行:dsh plugin --profile web add <包名>`
2087
+ return
2088
+ }
2089
+ const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
2090
+ const prompt = [
2091
+ '你是 DeepSeek Harness 的插件安装专家。用户通过插件管理面板安装插件失败,现在由你主动接管安装(不是被动"修复",而是像人工一样把它装好)。',
2092
+ `目标仓库:${job.repo}`,
2093
+ `候选包名:${candidates.join('、')}`,
2094
+ `profile 目录:${profileDir}(pnpm 管理,绝不能用 npm 写入,会破坏链接)`,
2095
+ `此前确定性通道的错误:${lastError ?? '未知'}`,
2096
+ '接管步骤:',
2097
+ `1) 先弄清楚装什么:若根包是 private 的聚合仓库(monorepo),用 gh api repos/${job.repo}/git/trees/main?recursive=1 或读 packages 目录下的 package.json 找出子包名;优先选聚合包(名字含 all,如 dsh-web-ui-all,一个包装全部);`,
2098
+ `2) 用 corepack pnpm 安装(Windows 执行 node ${JSON.stringify(corepackJs)} pnpm add <包名> --registry https://registry.npmmirror.com,工作目录 ${profileDir});镜像 404 时改 --registry https://registry.npmjs.org;都不行再用 git 通道;`,
2099
+ '3) **node 网络黑洞判定**:pnpm 长时间无下载进度(Progress 停在 downloaded 0)、或报 socket hang up / EPERM / TIMEOUT、或 node 脚本连 127.0.0.1 都超时,说明本机拦截 node 进程网络——此时 curl 与系统 git 通常仍可用,立即改用 curl 手动安装:先 `curl -s https://registry.npmmirror.com/<包名>` 取最新版本号,再 `curl -sL -o <临时目录>/pkg.tgz https://registry.npmmirror.com/<包名>/-/<包名>-<版本>.tgz`,解压 tar -xzf 后把 package 内容放入 profile/node_modules/<包名>(先备份旧目录);包有 dependencies 时用同样方式逐个 curl 拉取补齐到 node_modules;零依赖包(如 dsh-skin)一条龙即可完成;',
2100
+ `4) 安装成功后按官方 dsh plugin add 规则落配置:包声明 dsh.bundle 时把包名追加进 profile 目录 package.json 的 dsh.profile.bundles 数组;普通插件在 cordis.patch.yml 追加 insert 行(id 由包名去 scope、非字母数字转连字符生成,name 填包名);`,
2101
+ '5) 装完自查:确认包已出现在 node_modules、配置已写入;必要时用 gh api 核对仓库信息;',
2102
+ '6) 执行 git/pnpm 前先设置环境变量 GIT_TERMINAL_PROMPT=0 和 GCM_INTERACTIVE=never,禁止弹出任何登录/凭据窗口;',
2103
+ '7) 不要杀进程、不要重启服务、不要改动与本次安装无关的文件。',
2104
+ '完成后用一两句话报告结果;确实无法安装也请说明原因。',
2105
+ ].join('\n')
2106
+ try {
2107
+ // in-process 驱动要求 parent 是真实的活动 Agent(parent.ctx.agents.create)。
2108
+ // 控制台在根上下文运行,从 Agent 注册表借一个顶级会话作为结构性父代理。
2109
+ let parent = null
2110
+ try {
2111
+ const agents = ctx.get('agents')
2112
+ const candidates = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
2113
+ parent = candidates[0] ?? null
2114
+ } catch {}
2115
+ if (parent === null) {
2116
+ job.status = 'failed'
2117
+ job.error = `本地 AI 修复无法借用活动会话(agents 注册表为空)。原始错误:${lastError ?? '未知'};请手动执行:dsh plugin --profile web add <包名>`
2118
+ return
2119
+ }
2120
+ const controller = new AbortController()
2121
+ const run = await startFn.call(subagents, provider, {
2122
+ label: `install-repair-${String(job.repo).split('/')[1] ?? 'plugin'}`,
2123
+ prompt: [{ type: 'text', text: prompt }],
2124
+ // 父代理深度 0,修复子代理至少深度 1(0 会触发 depth 校验失败)
2125
+ maxDepth: 1,
2126
+ signal: controller.signal,
2127
+ parent,
2128
+ })
2129
+ let settleFn = null
2130
+ try {
2131
+ const requireLocal = createRequire(join(profileDir, 'package.json'))
2132
+ ;({ settleRun: settleFn } = requireLocal('@deepseek-ai/dsh-subagent'))
2133
+ } catch {}
2134
+ const settle = settleFn ?? (async (runHandle) => {
2135
+ try {
2136
+ const result = await runHandle.result
2137
+ return { status: result?.stopReason === 'completed' ? 'completed' : 'failed', detail: String(result?.stopReason ?? 'unknown') }
2138
+ } catch (error) {
2139
+ return { status: 'failed', detail: String(error) }
2140
+ }
2141
+ })
2142
+ const outcome = await Promise.race([
2143
+ settle(run),
2144
+ new Promise((resolve) => setTimeout(() => {
2145
+ controller.abort()
2146
+ resolve({ status: 'failed', detail: '本地 AI 修复超时(10 分钟)' })
2147
+ }, 600000)),
2148
+ ])
2149
+ if (outcome.status === 'completed') {
2150
+ // 校验修复结果:任一候选包现在可解析(或已进 bundle 层)才算成功,
2151
+ // 避免子代理"跑完流程但没装上"被误报为成功
2152
+ let verified = false
2153
+ try {
2154
+ const requireLocal = createRequire(join(profileDir, 'package.json'))
2155
+ for (const name of candidates) {
2156
+ try {
2157
+ requireLocal.resolve(`${name}/package.json`)
2158
+ verified = true
2159
+ break
2160
+ } catch {}
2161
+ }
2162
+ if (!verified) {
2163
+ const manifest = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'))
2164
+ const bundles = manifest.dsh?.profile?.bundles ?? []
2165
+ if (candidates.some((name) => bundles.includes(name))) verified = true
2166
+ }
2167
+ } catch {}
2168
+ if (verified) {
2169
+ job.status = 'done'
2170
+ job.aiNote = '本地 AI 已接管并完成修复,请刷新页面查看'
2171
+ } else {
2172
+ job.status = 'failed'
2173
+ job.error = `本地 AI 完成了修复流程,但未能确认安装成功(候选包 ${candidates.join('、')} 均不可解析);请手动执行:dsh plugin --profile web add <包名>`
2174
+ }
2175
+ } else {
2176
+ job.status = 'failed'
2177
+ job.error = `本地 AI 修复未成功(${outcome.detail ?? outcome.status})。请手动执行:dsh plugin --profile web add <包名>`
2178
+ }
2179
+ } catch (error) {
2180
+ job.status = 'failed'
2181
+ job.error = `本地 AI 修复通道异常:${error instanceof Error ? error.message : String(error)};请手动执行:dsh plugin --profile web add <包名>`
2182
+ }
2183
+ }
2184
+
2185
+ async function runInstallJob(job, ctx) {
2186
+ try {
2187
+ job.stage = 'preparing'
2188
+ let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
2189
+ let subpackageMode = false
2190
+ if (candidates.length === 0) {
2191
+ // 套装兜底:submodule 聚合仓库(根 .gitmodules 存在)→ 自动转套装安装,
2192
+ // 不依赖前端标记(搜索结果 enrich 是异步的、索引浏览条目无 enrich)
2193
+ let suiteProbe = await rawTextWithFallback(job.repo, 'main', '.gitmodules')
2194
+ if (suiteProbe === null) suiteProbe = await rawTextWithFallback(job.repo, 'master', '.gitmodules')
2195
+ if (suiteProbe !== null) {
2196
+ job.kind = 'suite'
2197
+ await runSuiteInstallJob(job, ctx)
2198
+ return
2199
+ }
2200
+ // 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 3s 超时降级,黑洞期不卡 40s)
2201
+ const meta = await Promise.race([
2202
+ Promise.any([
2203
+ githubJson(`${GITHUB_API}/repos/${job.repo}`),
2204
+ curlJson(`${GITHUB_API}/repos/${job.repo}`, 12000),
2205
+ ]),
2206
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
2207
+ ]).catch(() => null)
2208
+ const branch = meta?.default_branch ?? 'main'
2209
+ const pkg = await fetchRepoPackage(job.repo, branch)
2210
+ if (pkg === null) {
2211
+ job.status = 'failed'
2212
+ job.error = `仓库 ${job.repo} 没有可用的 package.json,无法作为 npm 包安装`
2213
+ return
2214
+ }
2215
+ if (pkg.private === true) {
2216
+ // 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
2217
+ subpackageMode = true
2218
+ candidates = await subpackageCandidates(job.repo, branch)
2219
+ if (candidates.length === 0) {
2220
+ job.status = 'failed'
2221
+ job.error = `仓库 ${job.repo} 的根包未发布到 npm(private: true)且未发现子包;请到"查看"详情确认`
2222
+ return
2223
+ }
2224
+ job.subpackages = candidates
2225
+ } else {
2226
+ candidates = [pkg.name]
2227
+ }
2228
+ job.packageName = pkg.name
2229
+ }
2230
+ // 给了根包名但根包实际是 private 聚合仓库(如直接填 dsh-web-ui):
2231
+ // 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试
2232
+ if (!subpackageMode && job.repo && job.packageName !== null) {
2233
+ const rootPkg = await fetchRepoPackage(job.repo, 'main')
2234
+ if (rootPkg !== null && rootPkg.private === true) {
2235
+ subpackageMode = true
2236
+ const subs = await subpackageCandidates(job.repo, 'main', readGithubAuth().token)
2237
+ if (subs.length > 0) {
2238
+ candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
2239
+ job.subpackages = subs
2240
+ }
2241
+ }
2242
+ }
2243
+ job.stage = 'installing'
2244
+ const patchPath = findPatchPath(ctx)
2245
+ const profileDir = dirname(patchPath)
2246
+ const taken = new Set(listEntries(ctx).map((entry) => entry.rowId))
2247
+ const patch = await readPatchState(patchPath)
2248
+ for (const id of [...patch.inserts, ...patch.disables, ...patch.forced]) taken.add(id)
2249
+ let installedName = null
2250
+ let lastError = null
2251
+ let expanded = false
2252
+ // 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
2253
+ const registries = orderedRegistries(readSources())
2254
+ const deadline = Date.now() + 8 * 60 * 1000
2255
+ for (let index = 0; index < candidates.length && installedName === null; index += 1) {
2256
+ if (Date.now() > deadline) break
2257
+ const name = candidates[index]
2258
+ // 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
2259
+ for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
2260
+ try {
2261
+ await pnpmInstall(profileDir, name, registries[ri])
2262
+ installedName = name
2263
+ break
2264
+ } catch (error) {
2265
+ lastError = error
2266
+ }
2267
+ }
2268
+ if (installedName === null) {
2269
+ // 通道 n+1:curl 手动安装(node 网络黑洞时 pnpm 下载卡死、curl 可用)——
2270
+ // 下载 registry tarball 解压到 node_modules,零依赖包可完整安装
2271
+ if (!subpackageMode && !expanded) {
2272
+ try {
2273
+ const info = await curlManualInstall(profileDir, name, registries)
2274
+ installedName = name
2275
+ const stillMissing = await backfillMissingDeps(profileDir, info.missingDeps, registries)
2276
+ job.curlNote = `已通过 curl 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
2277
+ if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
2278
+ } catch (curlError) {
2279
+ lastError = curlError
2280
+ }
2281
+ }
2282
+ }
2283
+ if (installedName === null) {
2284
+ // 通道 n+1b:GitHub release 下载安装(npm 上不存在的包,例如面板自身
2285
+ // @deepseek-ai/dsh-plugin-console)——拉 latest release 源码 tarball → 盒子验证 → 覆盖。
2286
+ // 触发条件:curl 通道失败(registry 404/网络),且能从 job.repo 或已装包 repository 反查到 GitHub。
2287
+ if (!subpackageMode && !expanded) {
2288
+ let ghRepo = typeof job.repo === 'string' && job.repo !== '' && job.repo.includes('/') ? job.repo : null
2289
+ if (ghRepo === null) {
2290
+ try {
2291
+ const meta = entryPkgMeta(name, ctx.baseUrl ?? 'file:///')
2292
+ const repoUrl = typeof meta?.repository === 'string' ? meta.repository : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
2293
+ const m = typeof repoUrl === 'string' ? repoUrl.replace(/^git\+/u, '').match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
2294
+ if (m) ghRepo = m[1]
2295
+ } catch {}
2296
+ }
2297
+ if (ghRepo !== null) {
2298
+ try {
2299
+ const info = await githubReleaseInstall(profileDir, ghRepo, name)
2300
+ installedName = name
2301
+ const stillMissing = await backfillMissingDeps(profileDir, info.missingDeps, registries)
2302
+ job.curlNote = `已通过 GitHub release 通道安装 v${info.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
2303
+ if (info.boxNote) job.curlNote += `(盒子验证:${info.boxNote})`
2304
+ } catch (ghError) {
2305
+ lastError = ghError
2306
+ }
2307
+ }
2308
+ }
2309
+ }
2310
+ if (installedName === null) {
2311
+ // 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台;各 60 秒封顶)
2312
+ if (!subpackageMode && !expanded) {
2313
+ const gitSpecs = job.source === 'gitee'
2314
+ ? [`git+https://gitee.com/${job.repo}.git`]
2315
+ : [
2316
+ `git+https://ghproxy.net/https://github.com/${job.repo}.git`,
2317
+ `github:${job.repo}`,
2318
+ ]
2319
+ for (const spec of gitSpecs) {
2320
+ try {
2321
+ await pnpmInstall(profileDir, spec, undefined, 60000)
2322
+ installedName = name
2323
+ break
2324
+ } catch (gitError) {
2325
+ lastError = gitError
2326
+ }
2327
+ }
2328
+ }
2329
+ }
2330
+ if (installedName !== null) break
2331
+ // Windows 原子替换失败(陈旧目录 / _tmp_ 残留)是 EPERM 类错误的根因:
2332
+ // 清理后用主源重试一次(把 AI 人工修复经验自动化,减少 AI 兜底触发)
2333
+ if (installedName === null && /EPERM|EACCES|rename/i.test(String(lastError?.message ?? ''))) {
2334
+ const cleaned = cleanupStalePackageDir(profileDir, name)
2335
+ if (cleaned > 0) {
2336
+ try {
2337
+ await pnpmInstall(profileDir, name, registries[0])
2338
+ installedName = name
2339
+ } catch (error) {
2340
+ lastError = error
2341
+ }
2342
+ }
2343
+ }
2344
+ if (installedName !== null) break
2345
+ // 懒惰展开:registry 与 git 通道都失败时,自动发现仓库子包继续尝试(聚合包优先),
2346
+ // 覆盖"给了根包名但根包未发布"的场景——AI 兜底只处理真正无解的案例
2347
+ if (!expanded && job.repo) {
2348
+ expanded = true
2349
+ let subs = await fetchSubpackageNames(job.repo, 'main', readGithubAuth().token)
2350
+ if (subs.length === 0) subs = await fetchSubpackageNames(job.repo, 'master', readGithubAuth().token)
2351
+ if (subs.length > 0) {
2352
+ const extra = subs
2353
+ .slice()
2354
+ .sort((a, b) => Number(/(^|-)all$/u.test(b.name) || /-all-/u.test(b.name)) - Number(/(^|-)all$/u.test(a.name) || /-all-/u.test(a.name)))
2355
+ .map((sub) => sub.name)
2356
+ .filter((n) => !candidates.includes(n))
2357
+ .slice(0, 8)
2358
+ if (extra.length > 0) {
2359
+ candidates = [...candidates, ...extra]
2360
+ if (!Array.isArray(job.subpackages)) job.subpackages = []
2361
+ for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
2362
+ }
2363
+ }
2364
+ }
2365
+ }
2366
+ if (installedName === null) {
2367
+ // 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
2368
+ job.stage = 'ai-consent'
2369
+ job.aiPending = { lastError: lastError?.message ?? null }
2370
+ job.aiWait = new Promise((resolve) => { job.aiPending.resolver = resolve })
2371
+ const decision = await Promise.race([
2372
+ job.aiWait,
2373
+ new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), 600000)),
2374
+ ])
2375
+ job.aiPending = null
2376
+ job.aiWait = null
2377
+ if (decision.approved === true) {
2378
+ await aiRepair(job, ctx, profileDir, candidates, lastError?.message ?? null)
2379
+ } else {
2380
+ job.status = 'failed'
2381
+ job.error = decision.timeout === true
2382
+ ? '等待授权超时(10 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
2383
+ : '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
2384
+ }
2385
+ return
2386
+ }
2387
+ job.packageName = installedName
2388
+ // 聚合包完整性保障(防崩):bundle patch 引用的包缺失 → 补装;仍缺自动禁用该行
2389
+ try {
2390
+ const integrity = await ensureBundlePatchIntegrity(profileDir, installedName, patchPath)
2391
+ if (integrity.checked > 0) {
2392
+ job.bundleNote = `聚合包完整性:检查 ${integrity.checked} 个引用`
2393
+ + (integrity.installed.length > 0 ? `,补装 ${integrity.installed.length} 个(${integrity.installed.join('、')})` : '')
2394
+ + (integrity.disabled.length > 0 ? `,自动禁用缺失行 ${integrity.disabled.length} 个(${integrity.disabled.join('、')})` : '')
2395
+ + (integrity.missing.length > 0 ? `,仍缺失 ${integrity.missing.join('、')}(网络恢复后建议重新更新)` : '')
2396
+ }
2397
+ } catch {}
2398
+ job.stage = 'configuring'
2399
+ if (await detectBundleOnly(profileDir, installedName)) {
2400
+ // 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
2401
+ // 其 cordis.patch.yml 在下次启动时参与组合(含皮肤包与 web-ui-settings 这类有入口的包)
2402
+ await addBundleToManifest(profileDir, installedName)
2403
+ job.bundle = true
2404
+ job.status = 'done'
2405
+ return
2406
+ }
2407
+ const entryId = deriveEntryId(installedName, taken)
2408
+ await appendInsert(patchPath, entryId, installedName)
2409
+ job.entryId = entryId
2410
+ job.status = 'done'
2411
+ } catch (error) {
2412
+ job.status = 'failed'
2413
+ job.error = error instanceof Error ? error.message : String(error)
2414
+ } finally {
2415
+ job.finishedAt = Date.now()
2416
+ }
2417
+ }
2418
+
2419
+ /** 组装当前插件清单(镜像 dsh-host-plugin-inventory 的读取逻辑)。 */
2420
+ function listEntries(ctx) { const entries = []
2421
+ for (const entry of ctx.loader.entries()) {
2422
+ if (entry.options.group) continue
2423
+ const moduleName = entry.options.name
2424
+ const rowId = rowIdOf(ctx, entry.id)
2425
+ const protectedRow = isProtectedModule(moduleName)
2426
+ entries.push({
2427
+ entryId: entry.id,
2428
+ rowId,
2429
+ moduleName,
2430
+ enabled: !entry.disabled,
2431
+ fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
2432
+ protected: protectedRow,
2433
+ toggleable: rowId !== 'plugin-console'
2434
+ && !protectedRow
2435
+ && typeof moduleName === 'string'
2436
+ && !moduleName.startsWith('cordis:'),
2437
+ })
2438
+ }
2439
+ return entries
2440
+ }
2441
+
2442
+ /** 应用插件:注册 /plugin-console 路由。 */
2443
+ export function apply(ctx) {
2444
+ ctx.effect(() => {
2445
+ const route = {
2446
+ kind: 'prefix',
2447
+ path: ROUTE_PREFIX,
2448
+ handler: async (req, res) => {
2449
+ if (!isLoopback(req.socket?.remoteAddress ?? '')) {
2450
+ sendError(res, 403, '仅允许本机访问')
2451
+ return
2452
+ }
2453
+ try {
2454
+ await handle(ctx, req, res)
2455
+ } catch (error) {
2456
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
2457
+ }
2458
+ },
2459
+ }
2460
+ return ctx.webServer.register(route)
2461
+ }, 'plugin-console: routes')
2462
+ }
2463
+
2464
+ async function handle(ctx, req, res) {
2465
+ const url = new URL(req.url ?? '/', 'http://x')
2466
+ const pathname = url.pathname
2467
+ const method = req.method ?? 'GET'
2468
+
2469
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/state`) {
2470
+ const patchPath = findPatchPath(ctx)
2471
+ const patch = await readPatchState(patchPath)
2472
+ const extraRows = await readExtraBundleRows(dirname(patchPath))
2473
+ const entries = listEntries(ctx).map((entry) => {
2474
+ const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///')
2475
+ return {
2476
+ ...entry,
2477
+ userDisabled: patch.disables.includes(entry.rowId),
2478
+ userForced: patch.forced.includes(entry.rowId),
2479
+ extra: patch.inserts.includes(entry.rowId) || extraRows.has(entry.moduleName) || extraRows.has(entry.rowId),
2480
+ installDate: meta?.installDate ?? null,
2481
+ version: meta?.version ?? null,
2482
+ repository: meta?.repository ?? null,
2483
+ }
2484
+ })
2485
+ const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
2486
+ const auth = readGithubAuth()
2487
+ const jobs = [...installJobs.values()].filter((job) => job.status === 'installing').map(installJobView)
2488
+ const recentFailures = [...installJobs.values()].filter((job) => job.status === 'failed').slice(-3).map(installJobView)
2489
+ // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
2490
+ let framework = null
2491
+ try { framework = detectFrameworkUpgrade(ctx) } catch {}
2492
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework })
2493
+ return
2494
+ }
2495
+
2496
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/sources`) {
2497
+ const sources = readSources()
2498
+ const gitee = readGiteeConfig(sources)
2499
+ sendJson(res, 200, { ok: true, sources, giteeStatus: { clientConfigured: gitee.clientId !== '', hasToken: gitee.token !== '', login: gitee.login } })
2500
+ return
2501
+ }
2502
+
2503
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/gitee-oauth-url`) {
2504
+ const gitee = readGiteeConfig(readSources())
2505
+ if (!gitee.clientId) {
2506
+ sendError(res, 400, '请先在软件源管理中配置 Gitee 应用的 client_id / client_secret')
2507
+ return
2508
+ }
2509
+ const port = webPort(ctx)
2510
+ const redirect = `http://127.0.0.1:${port}/plugin-console/gitee-oauth-callback`
2511
+ // scope 请求 user_info + projects:Gitee 会校验请求的 scope 必须在应用已勾选的权限范围内,
2512
+ // 应用权限必须同步勾选 user_info、projects,否则报「请求范围无效、未知或格式不正确」
2513
+ const url = `${GITEE_AUTH_URL}?client_id=${encodeURIComponent(gitee.clientId)}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=${encodeURIComponent('user_info projects')}`
2514
+ sendJson(res, 200, { ok: true, url, redirect })
2515
+ return
2516
+ }
2517
+
2518
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/gitee-oauth-callback`) {
2519
+ const code = new URL(req.url ?? '/', 'http://x').searchParams.get('code')
2520
+ const sources = readSources()
2521
+ const gitee = readGiteeConfig(sources)
2522
+ const failPage = (text) => {
2523
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
2524
+ res.end(`<h3>${text}</h3>`)
2525
+ }
2526
+ if (!code) {
2527
+ failPage('这是 Gitee 授权回调地址,不能直接访问。<br>正确流程:插件面板 → 软件源管理 → 填入 client_id / client_secret → 保存配置 → 点击「授权登录 Gitee」,授权完成后会自动跳回这里。')
2528
+ return
2529
+ }
2530
+ if (!gitee.clientId || !gitee.clientSecret) {
2531
+ failPage('未配置 Gitee 应用:请先在 gitee.com 创建第三方应用(回调地址填本页完整地址),再在插件面板 → 软件源管理 中填入 client_id / client_secret 并保存。')
2532
+ return
2533
+ }
2534
+ const port = webPort(ctx)
2535
+ const redirect = `http://127.0.0.1:${port}/plugin-console/gitee-oauth-callback`
2536
+ const result = await postJsonUrl(GITEE_TOKEN_URL, {
2537
+ grant_type: 'authorization_code',
2538
+ code,
2539
+ client_id: gitee.clientId,
2540
+ client_secret: gitee.clientSecret,
2541
+ redirect_uri: redirect,
2542
+ })
2543
+ if (result.status < 200 || result.status >= 300 || !result.body || !result.body.access_token) {
2544
+ failPage('Gitee 授权失败:token 交换错误,请检查 client_id / client_secret')
2545
+ return
2546
+ }
2547
+ gitee.token = result.body.access_token
2548
+ try {
2549
+ const user = await fetchJsonUrl(`https://gitee.com/api/v5/user?access_token=${encodeURIComponent(gitee.token)}`)
2550
+ gitee.login = user && typeof user.login === 'string' ? user.login : ''
2551
+ } catch {}
2552
+ sources.gitee = gitee
2553
+ await writeSources(sources)
2554
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
2555
+ res.end('<h3>Gitee 授权成功,可以关闭此页面并回到插件面板</h3>')
2556
+ return
2557
+ }
2558
+
2559
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/framework-upgrade-status`) {
2560
+ // 框架升级进度(页面断连后重连恢复进度条用):读状态文件 {status|message}
2561
+ let status = { status: 'idle', message: null }
2562
+ try {
2563
+ const f = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
2564
+ if (existsSync(f)) {
2565
+ // PS5.1 Set-Content -Encoding UTF8 会写 BOM——strip 掉,否则 status 变成 '\uFEFFdone',
2566
+ // 客户端 status === 'done' 永不匹配(进度条/悬浮按钮不消失)
2567
+ let raw = readFileSync(f, 'utf8')
2568
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
2569
+ const [st, ...rest] = raw.split('|')
2570
+ const at = statSync(f).mtimeMs
2571
+ status = { status: st ?? 'idle', message: rest.join('|') || null, at }
2572
+ // 残留清理:非终止状态(starting/stopped/installing/rollback/pkg/relaunching)超过 15 分钟
2573
+ // 视为上次升级的残留——升级脚本要么成功(done)要么失败(failed),服务重启后不可能还在
2574
+ // 中途;任务调度失败/脚本空跑时状态会永远停在 starting,重启后不应再自动恢复进度条。
2575
+ // (升级进行中页面断连后用户手动重启服务属边缘情况:<15 分钟不受影响,进度仍可恢复)
2576
+ if (status.status !== 'idle' && status.status !== 'done' && status.status !== 'failed'
2577
+ && Date.now() - at > 15 * 60 * 1000) {
2578
+ try { writeFileSync(f, 'idle|', 'utf8') } catch {}
2579
+ status = { status: 'idle', message: null, at: Date.now() }
2580
+ }
2581
+ }
2582
+ } catch {}
2583
+ sendJson(res, 200, { ok: true, ...status })
2584
+ return
2585
+ }
2586
+
2587
+ if (method === 'POST' && pathname === `${ROUTE_PREFIX}/framework-relaunch`) {
2588
+ // 手动拉起服务(升级期间左侧悬浮按钮调用):Start-Process node bin.js web。
2589
+ // 端口已有监听则不重复拉起;bin.js 缺失时明确报错。
2590
+ const port = webPort(ctx)
2591
+ const binPath = resolveDshBin()
2592
+ const nodePath = process.execPath
2593
+ if (binPath === null || !existsSync(binPath)) {
2594
+ sendError(res, 500, '无法定位 DSH 启动入口(bin.js 缺失)')
2595
+ return
2596
+ }
2597
+ try {
2598
+ const ps1 = join(tmpdir(), `console-relaunch-${Date.now()}.ps1`)
2599
+ const lines = [
2600
+ '$ok = $false',
2601
+ `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true } } catch {}`,
2602
+ `if (-not $ok) { Start-Process -FilePath ${JSON.stringify(nodePath)} -ArgumentList ${JSON.stringify(binPath)},'web' -WindowStyle Hidden }`,
2603
+ ]
2604
+ writeFile(ps1, lines.join('\r\n'), 'utf8').then(
2605
+ () => execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true }, () => {}),
2606
+ () => {},
2607
+ )
2608
+ sendJson(res, 200, { ok: true, message: '已发起手动拉起(端口无监听时自动启动服务)' })
2609
+ } catch (error) {
2610
+ sendError(res, 500, `拉起失败:${error instanceof Error ? error.message : String(error)}`)
2611
+ }
2612
+ return
2613
+ }
2614
+
2615
+ if (method === 'GET' && pathname === `${ROUTE_PREFIX}/skills-installed`) {
2616
+ // 已安装技能清单(~/.dsh/skills 用户根),前端用于标记「已安装」与展示
2617
+ sendJson(res, 200, { ok: true, skills: listInstalledSkills() })
2618
+ return
2619
+ }
2620
+
2621
+ if (method !== 'POST') {
2622
+ sendError(res, 405, '不支持的方法')
2623
+ return
2624
+ }
2625
+
2626
+ const body = await readBody(req)
2627
+
2628
+ if (pathname === `${ROUTE_PREFIX}/details`) {
2629
+ const { entryId } = body
2630
+ if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
2631
+ sendError(res, 400, 'entryId 无效')
2632
+ return
2633
+ }
2634
+ const entry = ctx.loader.entries().find((candidate) => candidate.id === entryId)
2635
+ if (!entry) {
2636
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
2637
+ return
2638
+ }
2639
+ const moduleName = entry.options.name
2640
+ const details = await readPluginDetails(moduleName, ctx.baseUrl ?? 'file:///')
2641
+ sendJson(res, 200, { ok: true, entryId, rowId: rowIdOf(ctx, entryId), moduleName, ...details })
2642
+ return
2643
+ }
2644
+
2645
+ if (pathname === `${ROUTE_PREFIX}/toggle`) {
2646
+ const { entryId, enabled } = body
2647
+ if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
2648
+ sendError(res, 400, 'entryId 无效')
2649
+ return
2650
+ }
2651
+ if (typeof enabled !== 'boolean') {
2652
+ sendError(res, 400, 'enabled 必须是布尔值')
2653
+ return
2654
+ }
2655
+ const exists = ctx.loader.entries().some((entry) => entry.id === entryId)
2656
+ if (!exists) {
2657
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
2658
+ return
2659
+ }
2660
+ const target = ctx.loader.entries().find((entry) => entry.id === entryId)
2661
+ if (isProtectedModule(target?.options?.name)) {
2662
+ sendError(res, 403, `${target.options.name} 属于宿主基础设施,禁止开关(停用会破坏热加载/传输/存储链)`)
2663
+ return
2664
+ }
2665
+ const rowId = rowIdOf(ctx, entryId)
2666
+ if (rowId === 'plugin-console') {
2667
+ sendError(res, 400, '不能停用插件控制台自身')
2668
+ return
2669
+ }
2670
+ const patchPath = findPatchPath(ctx)
2671
+ const result = enabled
2672
+ ? await enableEntry(patchPath, rowId)
2673
+ : await disableEntry(patchPath, rowId)
2674
+ sendJson(res, 200, { ok: true, entryId, rowId, enabled, changed: result.changed, patchPath })
2675
+ return
2676
+ }
2677
+
2678
+ if (pathname === `${ROUTE_PREFIX}/uninstall`) {
2679
+ const { entryId } = body
2680
+ if (typeof entryId !== 'string' || !/^[A-Za-z0-9_:.-]{1,80}$/u.test(entryId)) {
2681
+ sendError(res, 400, 'entryId 无效')
2682
+ return
2683
+ }
2684
+ const entry = ctx.loader.entries().find((candidate) => candidate.id === entryId)
2685
+ if (!entry) {
2686
+ sendError(res, 404, `没有名为 ${entryId} 的插件条目`)
2687
+ return
2688
+ }
2689
+ const moduleName = entry.options.name
2690
+ const rowId = rowIdOf(ctx, entryId)
2691
+ if (rowId === 'plugin-console') {
2692
+ sendError(res, 400, '不能删除插件控制台自身')
2693
+ return
2694
+ }
2695
+ if (isProtectedModule(moduleName)) {
2696
+ sendError(res, 403, `${moduleName} 属于宿主基础设施,禁止删除`)
2697
+ return
2698
+ }
2699
+ const patchPath = findPatchPath(ctx)
2700
+ const profileDir = dirname(patchPath)
2701
+ const patch = await readPatchState(patchPath)
2702
+ // bundle 来源的额外插件(如皮肤中心):删除其所属 bundle
2703
+ const owners = await readExtraBundleOwners(profileDir)
2704
+ const ownerBundle = owners.get(rowId) ?? owners.get(moduleName)
2705
+ if (ownerBundle !== undefined) {
2706
+ await removeBundleFromManifest(profileDir, ownerBundle)
2707
+ let uninstallError = null
2708
+ try {
2709
+ await pnpmRemove(profileDir, ownerBundle)
2710
+ } catch (error) {
2711
+ uninstallError = error instanceof Error ? error.message : String(error)
2712
+ }
2713
+ sendJson(res, 200, { ok: true, removed: 'bundle', packageName: ownerBundle, restart: true, uninstallError })
2714
+ return
2715
+ }
2716
+ if (!patch.inserts.includes(rowId)) {
2717
+ sendError(res, 400, '该插件不是用户安装的额外插件(不可删除)')
2718
+ return
2719
+ }
2720
+ await removeInsertRow(patchPath, rowId)
2721
+ let uninstallError = null
2722
+ try {
2723
+ await pnpmRemove(profileDir, moduleName)
2724
+ } catch (error) {
2725
+ uninstallError = error instanceof Error ? error.message : String(error)
2726
+ }
2727
+ sendJson(res, 200, { ok: true, removed: 'entry', packageName: moduleName, restart: false, uninstallError })
2728
+ return
2729
+ }
2730
+
2731
+ if (pathname === `${ROUTE_PREFIX}/search`) {
2732
+ const raw = typeof body.q === 'string' ? body.q.trim() : ''
2733
+ const query = raw === '' ? DEFAULT_SEARCH : raw
2734
+ const page = Math.max(Math.min(Number.parseInt(String(body.page), 10) || 1, 5), 1)
2735
+ const source = typeof body.source === 'string' && body.source !== '' ? body.source : 'github'
2736
+ const auth = readGithubAuth()
2737
+ let items = []
2738
+ if (body.multi === true) {
2739
+ // 多源汇总:GitHub + 全部自定义搜索源并行检索,结果合并(每项带 source 标记)。
2740
+ // Gitee 为直装模式(关键词搜索无意义),不参与多源汇总。
2741
+ const sources = readSources()
2742
+ const tasks = [
2743
+ (async () => {
2744
+ try {
2745
+ const data = await githubJson(
2746
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
2747
+ req.signal,
2748
+ auth.token,
2749
+ )
2750
+ return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', sourceName: 'GitHub' }))
2751
+ } catch {
2752
+ return []
2753
+ }
2754
+ })(),
2755
+ ...sources.searchSources.filter((s) => s.type === 'custom').map((s) => (async () => {
2756
+ try {
2757
+ const url = s.url.replace('{q}', encodeURIComponent(query)).replace('{page}', String(page))
2758
+ const data = await fetchJsonUrl(url, 15000, s.headers ?? {})
2759
+ return normalizePlatformItems(data, 'main').map((item) => ({ ...item, source: s.id, sourceName: s.name }))
2760
+ } catch {
2761
+ return []
2762
+ }
2763
+ })()),
2764
+ ]
2765
+ const results = await Promise.all(tasks)
2766
+ items = results.flat()
2767
+ items = await enrichItems(items)
2768
+ sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'all', multi: true })
2769
+ return
2770
+ }
2771
+ if (body.skills === true) {
2772
+ // 技能模式搜索:agent-skills / claude-skills / dsh-skill 三 topic 并行检索后合并去重
2773
+ // (GitHub search 的 OR 语法优先级不可靠,分开查最稳),按 star 排序取前 20。
2774
+ if (source !== 'github') {
2775
+ sendError(res, 400, '技能搜索仅支持 GitHub 源')
2776
+ return
2777
+ }
2778
+ const keyword = raw === '' ? '' : `${raw} in:name,description,topics `
2779
+ const tasks = SKILL_TOPICS.map((topic) => (async () => {
2780
+ try {
2781
+ const data = await githubJson(
2782
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`${keyword}topic:${topic}`)}&sort=stars&order=desc&per_page=20&page=${page}`,
2783
+ req.signal,
2784
+ auth.token,
2785
+ )
2786
+ return normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github', skillTopics: [topic] }))
2787
+ } catch {
2788
+ return []
2789
+ }
2790
+ })())
2791
+ const merged = (await Promise.all(tasks)).flat()
2792
+ const seen = new Set()
2793
+ items = []
2794
+ for (const item of merged.sort((a, b) => b.stars - a.stars)) {
2795
+ if (seen.has(item.fullName)) continue
2796
+ seen.add(item.fullName)
2797
+ items.push(item)
2798
+ if (items.length >= 20) break
2799
+ }
2800
+ items = await enrichItems(items)
2801
+ sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source: 'github', skills: true })
2802
+ return
2803
+ }
2804
+ if (source === 'gitee') {
2805
+ // Gitee 官方 v5 搜索接口(search/repositories)已废弃(恒返回空);
2806
+ // so.gitee.com/v1(Indexea 后端)有百度云 WAF 反爬且需映答账号 token。
2807
+ // 因此 Gitee 源采用仓库直装模式:输入 owner/repo 直接取仓库信息(公开接口,无需登录)。
2808
+ const gitee = readGiteeConfig(readSources())
2809
+ let repo = ''
2810
+ let giteeError = ''
2811
+ try {
2812
+ repo = githubRepoInfo(query)
2813
+ } catch (error) {
2814
+ giteeError = error instanceof Error ? error.message : String(error)
2815
+ }
2816
+ if (repo) {
2817
+ try {
2818
+ const tokenQ = gitee.token ? `?access_token=${encodeURIComponent(gitee.token)}` : ''
2819
+ // repo 已由 githubRepoInfo 校验;分段编码(只编码中文等非 ASCII,斜杠保留原样——
2820
+ // Gitee 服务器不认 %2F 编码的路径分隔,返回 404)
2821
+ const [owner, name] = repo.split('/')
2822
+ const data = await fetchJsonUrl(`https://gitee.com/api/v5/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}${tokenQ}`)
2823
+ items = normalizePlatformItems([data], 'master').map((item) => ({ ...item, source: 'gitee' }))
2824
+ } catch (error) {
2825
+ giteeError = error instanceof Error ? error.message : String(error)
2826
+ }
2827
+ }
2828
+ sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source, giteeNeedsLogin: false, directOnly: true, giteeError })
2829
+ return
2830
+ } else if (source !== 'github') {
2831
+ // 自定义搜索源:URL 模板({q}/{page} 占位符),返回数组或 {items} 结构;支持配置的请求头
2832
+ const sources = readSources()
2833
+ const custom = sources.searchSources.find((s) => s.id === source && s.type === 'custom')
2834
+ if (!custom) {
2835
+ sendError(res, 404, `没有这个搜索源:${source}`)
2836
+ return
2837
+ }
2838
+ const url = custom.url
2839
+ .replace('{q}', encodeURIComponent(query))
2840
+ .replace('{page}', String(page))
2841
+ const data = await fetchJsonUrl(url, 15000, custom.headers ?? {})
2842
+ items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
2843
+ } else {
2844
+ const data = await githubJson(
2845
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
2846
+ req.signal,
2847
+ auth.token,
2848
+ )
2849
+ items = normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github' }))
2850
+ items = await enrichItems(items)
2851
+ }
2852
+ items = items.filter((item) => item.fullName !== '')
2853
+ sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source })
2854
+ return
2855
+ }
2856
+
2857
+ if (pathname === `${ROUTE_PREFIX}/enrich`) {
2858
+ // 为浏览器直连的搜索结果补官方/聚合标记(服务端通道可靠;客户端直连无标记能力)
2859
+ const raw = Array.isArray(body.items) ? body.items.slice(0, 30) : []
2860
+ const items = await enrichItems(raw)
2861
+ sendJson(res, 200, { ok: true, items })
2862
+ return
2863
+ }
2864
+
2865
+ if (pathname === `${ROUTE_PREFIX}/repo`) {
2866
+ const repo = githubRepoInfo(typeof body.repo === 'string' ? body.repo : '')
2867
+ const auth = readGithubAuth()
2868
+ // meta 降级策略:githubJson(https+镜像+gh)与 curl 竞速,3 秒超时即降级——
2869
+ // Promise.any 全失败时要等最慢分支(黑洞期 https 41.5s),加 race 超时避免拖累整体。
2870
+ let meta = null
2871
+ try {
2872
+ meta = await Promise.race([
2873
+ Promise.any([
2874
+ githubJson(`${GITHUB_API}/repos/${repo}`, req.signal, auth.token),
2875
+ curlJson(`${GITHUB_API}/repos/${repo}`, 12000),
2876
+ ]),
2877
+ new Promise((resolve) => setTimeout(() => resolve(null), 3000)),
2878
+ ])
2879
+ } catch {}
2880
+ const branch = meta?.default_branch ?? 'main'
2881
+ const pkg = await fetchRepoPackage(repo, branch)
2882
+ const skill = await detectSkillRepo(repo, branch)
2883
+ const skillMeta = skill.hasSkill ? await fetchSkillMeta(repo, branch, skill.skillDir) : null
2884
+ // 套装识别:根 .gitmodules 存在(submodule 聚合仓库)
2885
+ const hasSuite = (await rawTextWithFallback(repo, branch, '.gitmodules')) !== null
2886
+ // 官方安装方式(详情面板展示 + 一键复制,供用户手动安装):
2887
+ // 套装 → 仓库 install.ps1/README 的官方步骤;普通/聚合 → dsh plugin add 官方命令
2888
+ let installCommand = null
2889
+ if (hasSuite) {
2890
+ const short = repo.split('/')[1] ?? repo
2891
+ const hasInstallScript = (await rawTextWithFallback(repo, branch, 'install.ps1')) !== null
2892
+ || (await rawTextWithFallback(repo, branch, 'install.sh')) !== null
2893
+ // 纯命令(无注释,CMD/PowerShell 通用);sslVerify=false 兼容本机证书链问题;
2894
+ // 脚本用 powershell -File 调用,CMD 里也能跑
2895
+ installCommand = [
2896
+ `git -c http.sslVerify=false clone --recurse-submodules https://github.com/${repo}.git`,
2897
+ `cd ${short}`,
2898
+ hasInstallScript
2899
+ ? `powershell -ExecutionPolicy Bypass -File install.ps1`
2900
+ : `git submodule update --init --recursive`,
2901
+ ].join('\n')
2902
+ } else {
2903
+ installCommand = `dsh plugin --profile web add github:${repo}`
2904
+ }
2905
+ sendJson(res, 200, {
2906
+ ok: true,
2907
+ repo,
2908
+ defaultBranch: branch,
2909
+ description: meta?.description ?? '',
2910
+ stars: meta?.stargazers_count ?? 0,
2911
+ packageName: pkg?.name ?? null,
2912
+ packageDescription: pkg?.description ?? null,
2913
+ hasPackageJson: pkg !== null,
2914
+ privateRoot: pkg !== null && pkg.private === true,
2915
+ hasSkill: skill.hasSkill,
2916
+ skillDir: skill.skillDir,
2917
+ skill: skillMeta,
2918
+ hasSuite,
2919
+ installCommand,
2920
+ dshHint: pkg !== null && (
2921
+ typeof pkg.name === 'string' && /(^|-)dsh[-/]/u.test(pkg.name)
2922
+ || pkg.peerDependencies?.['@deepseek-ai/cordis'] !== undefined
2923
+ || Array.isArray(pkg.keywords) && pkg.keywords.includes('dsh-plugin')
2924
+ ),
2925
+ })
2926
+ return
2927
+ }
2928
+
2929
+ if (pathname === `${ROUTE_PREFIX}/subpackages`) {
2930
+ const repo = githubRepoInfo(typeof body.repo === 'string' ? body.repo : '')
2931
+ const branch = typeof body.branch === 'string' && body.branch ? body.branch : 'main'
2932
+ const auth = readGithubAuth()
2933
+ // 复用安装链的防护实现:任一 raw 拉取失败只跳过该子包,不整体 500
2934
+ const subpackages = await fetchSubpackageNames(repo, branch, auth.token)
2935
+ sendJson(res, 200, { ok: true, repo, branch, subpackages })
2936
+ return
2937
+ }
2938
+
2939
+ if (pathname === `${ROUTE_PREFIX}/sources`) {
2940
+ const { action } = body
2941
+ const sources = readSources()
2942
+ if (action === 'add') {
2943
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
2944
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
2945
+ if (!isAllowedSourceUrl(url)) {
2946
+ sendError(res, 400, '软件源地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
2947
+ return
2948
+ }
2949
+ if (sources.registries.some((r) => r.url === url)) {
2950
+ sendError(res, 400, '该软件源已存在')
2951
+ return
2952
+ }
2953
+ const entry = {
2954
+ id: `src-${Date.now().toString(36)}`,
2955
+ name: name || url,
2956
+ url,
2957
+ primary: sources.registries.length === 0,
2958
+ }
2959
+ sources.registries.push(entry)
2960
+ await writeSources(sources)
2961
+ sendJson(res, 200, { ok: true, sources })
2962
+ return
2963
+ }
2964
+ if (action === 'remove') {
2965
+ sendError(res, 400, '软件源不可删除(插件安装依赖的 npm 源,请使用编辑/设为主源)')
2966
+ return
2967
+ }
2968
+ if (action === 'edit') {
2969
+ const id = typeof body.id === 'string' ? body.id : ''
2970
+ const target = sources.registries.find((r) => r.id === id)
2971
+ if (!target) {
2972
+ sendError(res, 404, '没有这个软件源')
2973
+ return
2974
+ }
2975
+ const url = typeof body.url === 'string' ? body.url.trim() : target.url
2976
+ const name = typeof body.name === 'string' ? body.name.trim() : target.name
2977
+ if (!isAllowedSourceUrl(url)) {
2978
+ sendError(res, 400, '软件源地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
2979
+ return
2980
+ }
2981
+ if (sources.registries.some((r) => r.url === url && r.id !== id)) {
2982
+ sendError(res, 400, '该软件源地址已存在')
2983
+ return
2984
+ }
2985
+ target.url = url
2986
+ target.name = name || url
2987
+ await writeSources(sources)
2988
+ sendJson(res, 200, { ok: true, sources })
2989
+ return
2990
+ }
2991
+ if (action === 'set-primary') {
2992
+ const id = typeof body.id === 'string' ? body.id : ''
2993
+ if (!sources.registries.some((r) => r.id === id)) {
2994
+ sendError(res, 404, '没有这个软件源')
2995
+ return
2996
+ }
2997
+ for (const r of sources.registries) r.primary = r.id === id
2998
+ await writeSources(sources)
2999
+ sendJson(res, 200, { ok: true, sources })
3000
+ return
3001
+ }
3002
+ if (action === 'add-search') {
3003
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
3004
+ const url = typeof body.url === 'string' ? body.url.trim() : ''
3005
+ if (!isAllowedSourceUrl(url)) {
3006
+ sendError(res, 400, '搜索地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
3007
+ return
3008
+ }
3009
+ if (!url.includes('{q}')) {
3010
+ sendError(res, 400, '搜索 URL 模板必须包含 {q} 占位符')
3011
+ return
3012
+ }
3013
+ if (sources.searchSources.some((s) => s.url === url)) {
3014
+ sendError(res, 400, '该搜索源已存在')
3015
+ return
3016
+ }
3017
+ // 可选请求头(认证等):[{name, value}] 结构,仅服务端使用,不下发浏览器
3018
+ const headers = Array.isArray(body.headers)
3019
+ ? body.headers
3020
+ .filter((h) => h && typeof h.name === 'string' && h.name.trim() !== '' && typeof h.value === 'string')
3021
+ .map((h) => ({ name: h.name.trim().slice(0, 100), value: h.value.slice(0, 500) }))
3022
+ : []
3023
+ sources.searchSources.push({
3024
+ id: `search-${Date.now().toString(36)}`,
3025
+ name: name || url,
3026
+ type: 'custom',
3027
+ url,
3028
+ ...(headers.length > 0 ? { headers } : {}),
3029
+ })
3030
+ await writeSources(sources)
3031
+ sendJson(res, 200, { ok: true, sources })
3032
+ return
3033
+ }
3034
+ if (action === 'remove-search') {
3035
+ const id = typeof body.id === 'string' ? body.id : ''
3036
+ const target = sources.searchSources.find((s) => s.id === id)
3037
+ if (!target) {
3038
+ sendError(res, 404, '没有这个搜索源')
3039
+ return
3040
+ }
3041
+ if (target.type === 'builtin') {
3042
+ sendError(res, 400, '内置搜索源不可删除')
3043
+ return
3044
+ }
3045
+ sources.searchSources = sources.searchSources.filter((s) => s.id !== id)
3046
+ await writeSources(sources)
3047
+ sendJson(res, 200, { ok: true, sources })
3048
+ return
3049
+ }
3050
+ if (action === 'reset') {
3051
+ const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
3052
+ await writeSources(defaults)
3053
+ sendJson(res, 200, { ok: true, sources: defaults })
3054
+ return
3055
+ }
3056
+ if (action === 'gitee-setup') {
3057
+ const clientId = typeof body.clientId === 'string' ? body.clientId.trim() : ''
3058
+ const clientSecret = typeof body.clientSecret === 'string' ? body.clientSecret.trim() : ''
3059
+ if (!clientId || !clientSecret) {
3060
+ sendError(res, 400, 'client_id 与 client_secret 不能为空')
3061
+ return
3062
+ }
3063
+ const current = readGiteeConfig(sources)
3064
+ sources.gitee = { ...current, clientId, clientSecret }
3065
+ await writeSources(sources)
3066
+ sendJson(res, 200, { ok: true, sources })
3067
+ return
3068
+ }
3069
+ if (action === 'gitee-clear') {
3070
+ const current = readGiteeConfig(sources)
3071
+ sources.gitee = { clientId: current.clientId, clientSecret: current.clientSecret, token: '', login: '' }
3072
+ await writeSources(sources)
3073
+ sendJson(res, 200, { ok: true, sources })
3074
+ return
3075
+ }
3076
+ sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / gitee-setup / gitee-clear / reset)')
3077
+ return
3078
+ }
3079
+
3080
+ if (pathname === `${ROUTE_PREFIX}/market-index`) {
3081
+ // 静态插件索引(jsDelivr CDN):curl 拉取 + 10 分钟内存缓存,市场秒开、零 GitHub API 调用
3082
+ if (marketIndexCache !== null && Date.now() - marketIndexCache.at < 600000) {
3083
+ sendJson(res, 200, { ok: true, ...marketIndexCache.data })
3084
+ return
3085
+ }
3086
+ const urls = [
3087
+ 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json',
3088
+ 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json',
3089
+ ]
3090
+ let lastError = null
3091
+ for (const url of urls) {
3092
+ try {
3093
+ const data = await fetchJsonUrl(url, 15000)
3094
+ if (data && Array.isArray(data.items)) {
3095
+ marketIndexCache = { at: Date.now(), data }
3096
+ sendJson(res, 200, { ok: true, ...data })
3097
+ return
3098
+ }
3099
+ } catch (error) {
3100
+ lastError = error
3101
+ }
3102
+ }
3103
+ sendError(res, 500, `索引加载失败:${lastError?.message ?? '未知'}`)
3104
+ return
3105
+ }
3106
+
3107
+ if (pathname === `${ROUTE_PREFIX}/check-update`) {
3108
+ // 检测已安装插件是否有新版本:curl registry 元数据取 dist-tags.latest(node 网络黑洞时 curl 可用)。
3109
+ // 聚合包(有 dependencies)额外对比子包版本:声明版本 vs 本地 node_modules 实际版本,
3110
+ // 返回 depsOutdated 提示"更新本包需同步子包",避免半更新混搭导致启动冲突。
3111
+ const packageName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
3112
+ if (!packageName) {
3113
+ sendError(res, 400, 'packageName 不能为空')
3114
+ return
3115
+ }
3116
+ let latest = null
3117
+ let next = null
3118
+ let depsOutdated = []
3119
+ let error = null
3120
+ let source = 'npm'
3121
+ try {
3122
+ const encoded = packageName.startsWith('@')
3123
+ ? `@${encodeURIComponent(packageName.slice(1).split('/')[0])}%2f${encodeURIComponent(packageName.split('/').slice(1).join('/'))}`
3124
+ : encodeURIComponent(packageName)
3125
+ const data = await fetchJsonUrl(`https://registry.npmmirror.com/${encoded}`)
3126
+ latest = data?.['dist-tags']?.latest ?? null
3127
+ next = data?.['dist-tags']?.next ?? null
3128
+ if (latest === null) throw new Error(`registry 无 dist-tags.latest(${packageName})`)
3129
+ // 子包配套检查:最新版声明依赖 vs 本地实际版本
3130
+ const patchPath = findPatchPath(ctx)
3131
+ const profileDir = dirname(patchPath)
3132
+ const declared = latest ? data?.versions?.[latest]?.dependencies ?? {} : {}
3133
+ const keys = typeof declared === 'object' ? Object.keys(declared) : []
3134
+ for (const dep of keys) {
3135
+ const required = String(declared[dep] ?? '').replace(/^[\^~>=< ]+/u, '')
3136
+ if (!required) continue
3137
+ let current = null
3138
+ try {
3139
+ const pkgPath = join(profileDir, 'node_modules', dep, 'package.json')
3140
+ if (existsSync(pkgPath)) {
3141
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3142
+ current = typeof pkg.version === 'string' ? pkg.version : null
3143
+ }
3144
+ } catch {}
3145
+ if (current !== null && current !== required) {
3146
+ depsOutdated.push({ name: dep, current, required })
3147
+ }
3148
+ }
3149
+ } catch (err) {
3150
+ // GitHub 发布回退(自身/未发布到 npm 的插件):npm registry 404/无 latest 时,
3151
+ // 从已装包 package.json 的 repository 字段反查 GitHub 最新版本。
3152
+ // 通道顺序:GitHub API(带 token)→ jsDelivr 版本 API(GitHub 黑洞期可用,已验证 200)。
3153
+ // 覆盖 dsh-plugin-console(本面板)这类"源码在 GitHub、npm 上不存在"的宿主插件。
3154
+ let fallbackError = err instanceof Error ? err.message : String(err)
3155
+ try {
3156
+ const meta = entryPkgMeta(packageName, ctx.baseUrl ?? 'file:///')
3157
+ const repo = typeof meta?.repository === 'string'
3158
+ ? meta.repository.replace(/^git\+/u, '').replace(/\.git$/u, '')
3159
+ : (meta?.repository && typeof meta.repository === 'object' ? meta.repository.url : null)
3160
+ const m = typeof repo === 'string' ? repo.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/u) : null
3161
+ if (m) {
3162
+ let tag = null
3163
+ // 通道 1:GitHub API(匿名可读,限流 60/h;token 时 5000/h)
3164
+ try {
3165
+ const auth = readGithubAuth()
3166
+ const headers = { 'User-Agent': 'dsh-plugin-console' }
3167
+ if (auth.token) headers.Authorization = `token ${auth.token}`
3168
+ const release = await fetchJsonUrl(`https://api.github.com/repos/${m[1]}/releases/latest`, 12000, headers)
3169
+ if (typeof release?.tag_name === 'string') tag = release.tag_name
3170
+ } catch {}
3171
+ // 通道 2:jsDelivr 版本列表(GitHub 直连黑洞时可用;取最高版本号)
3172
+ if (tag === null) {
3173
+ try {
3174
+ const data = await fetchJsonUrl(`https://data.jsdelivr.com/v1/packages/gh/${m[1]}`, 12000)
3175
+ const versions = Array.isArray(data?.versions) ? data.versions.map((v) => String(v.version ?? '')) : []
3176
+ // 按语义版本号排序取最高(v 前缀剥离后比较)
3177
+ const parsed = versions
3178
+ .map((v) => ({ raw: v, ver: v.replace(/^v/iu, '') }))
3179
+ .filter((x) => /^\d+\.\d+\.\d+/u.test(x.ver))
3180
+ .sort((a, b) => {
3181
+ const pa = a.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
3182
+ const pb = b.ver.split(/[.-]/u).map((n) => (Number.isFinite(Number(n)) ? Number(n) : n))
3183
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
3184
+ const x = pa[i] ?? -1; const y = pb[i] ?? -1
3185
+ if (x !== y) return typeof x === 'number' && typeof y === 'number' ? x - y : String(x) < String(y) ? -1 : 1
3186
+ }
3187
+ return 0
3188
+ })
3189
+ if (parsed.length > 0) tag = parsed[parsed.length - 1].raw
3190
+ } catch {}
3191
+ }
3192
+ if (tag !== null) {
3193
+ latest = tag.replace(/^v/iu, '')
3194
+ next = null
3195
+ source = 'github'
3196
+ error = null
3197
+ fallbackError = null
3198
+ }
3199
+ }
3200
+ if (fallbackError !== null && latest === null) error = `npm 与 GitHub 均未检测到版本(${fallbackError})`
3201
+ } catch (fbErr) {
3202
+ error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
3203
+ }
3204
+ }
3205
+ sendJson(res, 200, { ok: true, packageName, latest, next, depsOutdated, error, source })
3206
+ return
3207
+ }
3208
+
3209
+ if (pathname === `${ROUTE_PREFIX}/framework-upgrade`) {
3210
+ // 框架升级入口:一键流程 = 备份配置快照 + 备份框架本体(回滚点)+ 自动升级
3211
+ // (npx 缓存 dsh 本体 + profile 官方配套包)+ 失败自动回滚 + 重启提示。
3212
+ // 升级完成重启后,框架适配逻辑自动:备份新版本快照 + 重打框架补丁 + 版本提示。
3213
+ let current = null
3214
+ let dshDir = null
3215
+ try {
3216
+ const require = createRequire(ctx.baseUrl ?? 'file:///')
3217
+ const dshPkgPath = require.resolve('@deepseek-ai/dsh/package.json')
3218
+ const dshPkg = JSON.parse(readFileSync(dshPkgPath, 'utf8'))
3219
+ current = dshPkg.version ?? null
3220
+ dshDir = dirname(dshPkgPath) // .../node_modules/@deepseek-ai/dsh
3221
+ } catch {
3222
+ // 兜底:ctx.baseUrl 不可用时从插件自身目录解析(与 resolveDshBin 同源)
3223
+ try {
3224
+ const requireLocal = createRequire(join(dirname(fileURLToPath(import.meta.url)), 'package.json'))
3225
+ const dshPkgPath = requireLocal.resolve('@deepseek-ai/dsh/package.json')
3226
+ const dshPkg = JSON.parse(readFileSync(dshPkgPath, 'utf8'))
3227
+ current = dshPkg.version ?? null
3228
+ dshDir = dirname(dshPkgPath)
3229
+ } catch {}
3230
+ }
3231
+ let latest = null
3232
+ let next = null
3233
+ let registryError = null
3234
+ try {
3235
+ const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
3236
+ latest = data?.['dist-tags']?.latest ?? null
3237
+ next = data?.['dist-tags']?.next ?? null
3238
+ } catch (error) {
3239
+ // 网络黑洞/超时:区分「检测失败」与「无更新」,避免误导用户以为已是最新
3240
+ registryError = error instanceof Error ? error.message : String(error)
3241
+ }
3242
+ // 升级目标:稳定版 latest 优先;latest 与当前相同而 next(预发布渠道)更新时,目标取 next
3243
+ const target = latest !== null && current !== null && latest !== current
3244
+ ? latest
3245
+ : (next !== null && current !== null && next !== current ? next : null)
3246
+ const profileDir = dirname(findPatchPath(ctx))
3247
+ // 1) 升级前打包备份现有配置(patch / profile package.json / 插件清单)
3248
+ let backupDir = null
3249
+ try {
3250
+ if (current !== null) backupDir = backupProfileSnapshot(profileDir, current, ctx)
3251
+ } catch {}
3252
+ const hasUpdate = target !== null
3253
+ const steps = []
3254
+ let upgraded = false
3255
+ // 2) 自动升级框架:运行中的服务锁着 dsh/lib 目录(EBUSY 无法原地替换),
3256
+ // 因此生成独立升级脚本(kill 服务 → npm 升级 → 失败回滚 → 拉起服务),后台执行。
3257
+ // 与 /restart 同款自守护模式:端口无监听会自动拉起,不会留下"服务起不来"。
3258
+ if (hasUpdate && dshDir !== null) {
3259
+ const port = webPort(ctx)
3260
+ const nodePath = process.execPath
3261
+ const binPath = resolveDshBin()
3262
+ const npmCli = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
3263
+ // 事故教训:npm 的 lib/cli.js 是「函数模块」(module.exports = (process) => validateEngines(...)),
3264
+ // 直跑 `node lib/cli.js` 只加载函数定义、不执行命令——立即退出 exit 0(假成功,曾导致
3265
+ // "npm 退出码 0 但版本未更新")。唯一正确入口是 bin/npm-cli.js(它调用 cli.js 函数)。
3266
+ const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
3267
+ // 预检(事故教训):corepack(pnpm 通道)/ dsh bin.js 缺失时**拒绝生成升级脚本**(不 kill 服务)——
3268
+ // 避免"kill 后升级/拉起双双失败,服务永久 down"
3269
+ if (!existsSync(corepackJs) || binPath === null || !existsSync(binPath)) {
3270
+ steps.push(`升级已取消:无法定位 ${!existsSync(corepackJs) ? 'corepack(pnpm 通道)' : 'dsh bin.js'}(框架安装可能不完整),服务保持运行;请先修复框架再升级`)
3271
+ sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, steps, hints: steps })
3272
+ return
3273
+ }
3274
+ const profileDir2 = profileDir
3275
+ // 官方配套包列表(profile package.json 里的 @deepseek-ai/* 依赖)
3276
+ let officialList = ''
3277
+ try {
3278
+ const profilePkg = JSON.parse(readFileSync(join(profileDir2, 'package.json'), 'utf8'))
3279
+ const deps = { ...(profilePkg.dependencies ?? {}), ...(profilePkg.devDependencies ?? {}) }
3280
+ officialList = Object.keys(deps).filter((d) => d.startsWith('@deepseek-ai/')).join(' ')
3281
+ } catch {}
3282
+ const rollbackDir = backupDir !== null ? join(backupDir, 'dsh-package-backup') : null
3283
+ // 官方配套包参数(Start-Process ArgumentList 需要逐包独立元素;包名无空格,单引号安全)
3284
+ const pkgArgs = officialList !== '' ? officialList.split(' ').map((p) => `'${p}'`) : []
3285
+ const taskName = `DSH-FW-Upgrade-${process.pid}`
3286
+ // 备份 dsh 目录(回滚点)
3287
+ try {
3288
+ if (rollbackDir !== null) {
3289
+ mkdirSync(rollbackDir, { recursive: true })
3290
+ copyTree(dshDir, rollbackDir)
3291
+ }
3292
+ } catch {}
3293
+ // 安全护栏(事故教训):回滚点必须真实有效(含 lib/bin.js),否则升级失败时无副本可恢复
3294
+ // ——曾因备份缺失导致回滚后框架目录为空、服务无法重启。备份无效直接取消升级。
3295
+ if (rollbackDir === null || !existsSync(join(rollbackDir, 'lib', 'bin.js'))) {
3296
+ sendError(res, 500, `框架备份失败(回滚点${rollbackDir !== null ? `:${rollbackDir}` : '(无)'}缺失或无效),已取消升级——请检查磁盘空间/权限后重试`)
3297
+ return
3298
+ }
3299
+ const ps1 = join(tmpdir(), `fw-upgrade-${process.pid}.ps1`)
3300
+ const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
3301
+ const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
3302
+ try { mkdirSync(dirname(logFile), { recursive: true }) } catch {}
3303
+ // PowerShell 字符串里反斜杠是字面量(无 \\ 转义):JSON.stringify 产生的 \\ 必须还原为 \,
3304
+ // 否则所有路径无效(npm 调不起来、回滚无效、拉起失败——曾导致升级脚本空跑)
3305
+ const ps = (s) => JSON.stringify(s).replace(/\\\\/gu, '\\')
3306
+ const lines = [
3307
+ `$state = ${ps(stateFile)}`,
3308
+ `$log = ${ps(logFile)}`,
3309
+ "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
3310
+ "function SetState($s, $m) { try { Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8 } catch {} }",
3311
+ // 全局异常兜底(事故教训):脚本任何未捕获异常都会无声死亡、状态永远卡住、服务没人拉起。
3312
+ // trap 捕获后:写 failed 状态 + 日志 + 尝试拉起服务 + 自删任务 + 退出
3313
+ "trap {",
3314
+ " try { SetState 'failed' ('升级脚本异常终止:' + $_.Exception.Message) } catch {}",
3315
+ " try { Log ('升级脚本异常终止:' + $_.Exception.Message) } catch {}",
3316
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c) { $bt = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $bt = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($bt -ne '') { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $bt,'web' -WindowStyle Hidden } } } catch {}`,
3317
+ ` schtasks /delete /f /tn ${taskName} 2>$null`,
3318
+ " exit 1",
3319
+ "}",
3320
+ "SetState 'starting' '备份配置…'",
3321
+ "Log '框架升级脚本启动'",
3322
+ // 流程优化(用户建议):先在线下载安装(服务保持运行、页面不断、进度可见),
3323
+ // 成功后最后重启服务生效;在线替换失败(服务占用框架目录 EBUSY)才停止服务重试
3324
+ `SetState 'installing' '升级框架 ${current} -> ${target}(npm 下载中,服务保持在线)'`,
3325
+ `Log '升级框架本体 ${current} -> ${target}…'`,
3326
+ // 关键:npm 的工作目录必须是缓存 node_modules(schtasks 默认 cwd 是 system32/用户目录,
3327
+ // 不指定会装错位置——缓存永远不更新的根因)
3328
+ `Set-Location -Path ${ps(dirname(dshDir))}`,
3329
+ // npm 安装函数:Start-Process 跑 npm-cli.js(唯一正确入口——lib/cli.js 是函数模块,
3330
+ // 直跑只加载不执行,假成功 exit 0)+ --force(显式指定版本但已在 package.json 的
3331
+ // semver 范围内时 npm 会跳过安装,假成功)+ 10 分钟总时长硬超时。注意:**不能加
3332
+ // -RedirectStandardOutput/-RedirectStandardError**——schtasks 任务环境下带重定向的
3333
+ // Start-Process 子进程会启动即异常退出(ExitCode 读不到、输出 0 字节,实测);
3334
+ // 无重定向时 ExitCode 正常(实测 exit=0)。npm 自身 debug 日志(cache\_logs)留档。
3335
+ // 超时策略:总时长 10 分钟硬上限 + npm debug 日志 5 分钟无更新判定卡死(网络黑洞/挂起时
3336
+ // 快速失败换 registry,不用干等 10 分钟);等待期间每 15 秒更新进度状态(客户端可见)。
3337
+ // 成功后校验 package.json 版本 === target(防 npm 假成功)。
3338
+ // 另注意:npm install 在 schtasks 环境启动慢(约 1-2 分钟 0 日志,初始化/编译缓存),
3339
+ // 属正常,5 分钟卡死阈值不会误杀。
3340
+ `function Install-Framework($reg) {`,
3341
+ ` $startAt = Get-Date`,
3342
+ ` $deadline = $startAt.AddMinutes(10)`,
3343
+ ` $stallDeadline = $startAt.AddMinutes(5)`,
3344
+ ` $lastLogM = (Get-Date)`,
3345
+ ` $logSeen = $false`,
3346
+ ` $cacheDir = ''`,
3347
+ ` try {`,
3348
+ ` $npmrcFile = Join-Path $env:USERPROFILE '.npmrc'`,
3349
+ ` if (Test-Path $npmrcFile) { $m = Select-String -Path $npmrcFile -Pattern '^cache\s*=\s*(.+)$' | Select-Object -Last 1; if ($m) { $cacheDir = $m.Matches[0].Groups[1].Value.Trim().Trim('"') } }`,
3350
+ ` } catch {}`,
3351
+ ` if ($cacheDir -eq '') { $fb = Join-Path $env:APPDATA 'npm-cache'; if (Test-Path (Join-Path $fb '_logs')) { $cacheDir = $fb } }`,
3352
+ ` $npmOut = $log + '.npm.out.txt'`,
3353
+ ` $marker = $log + '.pnpm.marker'`,
3354
+ ` try { Remove-Item $marker -Force -ErrorAction SilentlyContinue } catch {}`,
3355
+ ` $proc = $null`,
3356
+ ` try {`,
3357
+ ` # pnpm 通道 + 黑框实时进度:start 独立窗口(有真实 stdout,黑框显示 pnpm 进度条,`,
3358
+ ` # 标题 DSH-Upgrade);pnpm 结束后 cmd 写 marker(含退出码),脚本轮询 marker 判断完成`,
3359
+ ` $cmdLine = 'start "DSH-Upgrade" cmd /c "title DSH-Upgrade && ${nodePath} ${corepackJs} pnpm install @deepseek-ai/dsh@${target} --force --config.dangerouslyAllowAllBuilds=true --registry ' + $reg + ' & echo DSH-DONE:%ERRORLEVEL% > ' + $marker + '"'`,
3360
+ ` $proc = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdLine) -PassThru -WindowStyle Hidden`,
3361
+ ` } catch {`,
3362
+ ` Log ('启动 pnpm 失败:' + $_.Exception.Message)`,
3363
+ ` return 1`,
3364
+ ` }`,
3365
+ ` $done = $false`,
3366
+ ` $code = 1`,
3367
+ ` while (-not $done -and (Get-Date) -lt $deadline) {`,
3368
+ ` Start-Sleep -Seconds 5`,
3369
+ ` if (Test-Path $marker) {`,
3370
+ ` $mc = Get-Content $marker -Raw -ErrorAction SilentlyContinue`,
3371
+ ` $mm = [regex]::Match($mc, 'DSH-DONE:(\\d+)')`,
3372
+ ` if ($mm.Success) { $code = [int]$mm.Groups[1].Value; $done = $true }`,
3373
+ ` }`,
3374
+ ` if (-not $done) {`,
3375
+ ` SetState 'installing' ('升级框架 ${current} -> ${target}(已等待 ' + [int]((Get-Date) - $startAt).TotalSeconds + ' 秒,进度见 DSH-Upgrade 窗口)')`,
3376
+ ` }`,
3377
+ ` }`,
3378
+ ` if (-not $done) {`,
3379
+ ` Log 'pnpm 超过 10 分钟未完成,判定超时'`,
3380
+ ` try { taskkill /F /FI "WINDOWTITLE eq DSH-Upgrade" 2>$null | Out-Null } catch {}`,
3381
+ ` return 1`,
3382
+ ` }`,
3383
+ ` if ($code -eq 0) {`,
3384
+ ` try {`,
3385
+ ` $v = (Get-Content ${ps(join(dshDir, 'package.json'))} -Raw | ConvertFrom-Json).version`,
3386
+ ` if ($v -ne '${target}') { Log ('pnpm 退出码 0 但版本未更新(当前 ' + $v + '),按失败处理'); $code = 1 }`,
3387
+ ` } catch { Log 'pnpm 退出码 0 但无法读取新版本,按失败处理'; $code = 1 }`,
3388
+ ` }`,
3389
+ ` return $code`,
3390
+ `}`,
3391
+ // 回滚函数:robocopy 支持长路径(Copy-Item 对 >260 字符路径静默失败,曾导致回滚后
3392
+ // 框架目录缺失、服务无法重启);回滚前确保服务已停(进程 cwd 会锁住框架目录)
3393
+ `function Invoke-Rollback {`,
3394
+ ` SetState 'rollback' '升级失败,回滚框架本体…'`,
3395
+ ` Log '升级失败,回滚框架本体…'`,
3396
+ ` # 备份缺失时绝不删除现有框架目录(曾因先删后拷失败导致框架目录为空、服务无法重启)`,
3397
+ ` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:备份缺失(' + ${ps(rollbackDir)} + '),跳过删除框架目录'); return }`,
3398
+ ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } } catch {}`,
3399
+ ` Remove-Item ${ps(dshDir)} -Recurse -Force -ErrorAction SilentlyContinue`,
3400
+ ` robocopy ${ps(rollbackDir)} ${ps(dshDir)} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null`,
3401
+ ` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(框架目录已恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
3402
+ `}`,
3403
+ `$script:rolledBack = $false`,
3404
+ `$code = Install-Framework 'https://registry.npmmirror.com'`,
3405
+ `if ($code -ne 0) { Log ('npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
3406
+ `if ($code -ne 0) {`,
3407
+ ` # 在线安装失败(服务可能占用框架目录导致替换失败):停止服务后重试一次`,
3408
+ ` SetState 'stopped' '在线安装失败(框架目录可能被服务占用),停止服务后重试…'`,
3409
+ ` Log '在线安装失败,停止服务后重试'`,
3410
+ ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
3411
+ ` $code = Install-Framework 'https://registry.npmmirror.com'`,
3412
+ ` if ($code -ne 0) { Log ('(停服后)npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
3413
+ `}`,
3414
+ `if ($code -ne 0) { Invoke-Rollback } else { Log '框架本体升级完成' }`,
3415
+ pkgArgs.length > 0 ? `SetState 'pkg' '更新官方配套包…'\n Log '更新官方配套包…'\n Set-Location -Path ${ps(profileDir2)}\n if (Test-Path ${ps(corepackJs)}) {\n $pp = Start-Process -FilePath ${ps(nodePath)} -ArgumentList @(${ps(corepackJs)}, 'pnpm', 'update', ${pkgArgs.join(', ')}, '--no-optional', '--registry', 'https://registry.npmmirror.com') -PassThru -WindowStyle Hidden\n if (-not $pp.WaitForExit(300000)) { Stop-Process -Id $pp.Id -Force -ErrorAction SilentlyContinue; Log '官方配套包更新超时(5 分钟),已跳过' } else { try { Log ('官方配套包更新结束(exit=' + $pp.ExitCode + ')') } catch { Log '官方配套包更新结束(退出码读取失败)' } }\n }` : '',
3416
+ "SetState 'relaunching' '升级完成,重启 DSH 服务生效…'",
3417
+ "Log '重启 DSH 服务生效…'",
3418
+ // 升级全程服务保持在线(先下载后重启):这里停掉旧服务(内存仍是旧代码),拉起新版本生效
3419
+ `try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3; Log '旧服务已停止(重启生效)' } } catch {}`,
3420
+ '$ok = $false',
3421
+ '$started = $false',
3422
+ `for ($i = 0; $i -lt 20; $i++) {`,
3423
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
3424
+ ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务' } else { Log 'bin.js 缺失,跳过拉起' } }`,
3425
+ ' Start-Sleep -Seconds 5',
3426
+ '}',
3427
+ `if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已回滚到原版本(服务已恢复)' ; Log '升级失败已回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } else { SetState 'failed' '拉起失败:请手动运行 node ${binPath} web' ; Log '拉起失败:请手动运行 node ${binPath} web(参考本日志排查)' }`,
3428
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
3429
+ ].filter((l) => l !== '').join('\r\n')
3430
+ // 初始状态(备份完成、脚本即将执行)
3431
+ try { writeFileSync(stateFile, 'starting|备份配置完成,启动升级脚本…', 'utf8') } catch {}
3432
+ // UTF-8 BOM:powershell 5.1 按 ANSI 读无 BOM 文件,中文会乱码——加 BOM 保证解析正确。
3433
+ // 执行方式:schtasks 一次性计划任务(Task Scheduler 启动,独立于服务进程树/Job Object)——
3434
+ // 服务进程被杀时,其子进程(execFile/detached 的 powershell)会被 Job/进程树连带杀死,
3435
+ // 曾多次卡死在「停止服务」之后;计划任务彻底脱离,杀服务绝对影响不到升级脚本。
3436
+ writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
3437
+ () => {
3438
+ const ps1Posix = ps1.replace(/\\/gu, '/')
3439
+ // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
3440
+ // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
3441
+ // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
3442
+ // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
3443
+ // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
3444
+ const tr = / /.test(ps1Posix)
3445
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
3446
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
3447
+ execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
3448
+ if (error) {
3449
+ // 兜底:schtasks 不可用时退回 detached(可能仍受 Job 影响,但尽力)
3450
+ try { writeFileSync(stateFile, 'starting|升级脚本将通过 detached 启动(schtasks 不可用)|detached', 'utf8') } catch {}
3451
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
3452
+ return
3453
+ }
3454
+ try { writeFileSync(stateFile, 'starting|升级脚本已通过计划任务启动|schtasks', 'utf8') } catch {}
3455
+ // create 回调里立即 /run 会因任务注册未完成而静默失败(曾导致任务从未执行):
3456
+ // 延迟 800ms 再 run;run 仍失败则退回 detached 兜底
3457
+ setTimeout(() => {
3458
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, (runError) => {
3459
+ if (runError) {
3460
+ try { writeFileSync(stateFile, 'starting|计划任务运行失败,改用 detached 兜底|detached-fallback', 'utf8') } catch {}
3461
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
3462
+ }
3463
+ })
3464
+ }, 800)
3465
+ })
3466
+ },
3467
+ () => {},
3468
+ )
3469
+ upgraded = true
3470
+ steps.push(`框架升级脚本已启动:${current} → ${target}(在线下载安装,服务保持运行,成功后自动重启生效,约 1-3 分钟)`)
3471
+ steps.push(`升级前配置已打包:${backupDir ?? '(跳过)'};框架本体回滚点:${rollbackDir ?? '(跳过)'}`)
3472
+ } else if (hasUpdate) {
3473
+ steps.push('检测到新版本,但无法定位框架安装目录(@deepseek-ai/dsh 解析失败),已取消升级——请修复框架安装后重试')
3474
+ } else if (!hasUpdate) {
3475
+ steps.push(registryError !== null ? `版本检测失败(${registryError}),无法确认是否有新版本——请检查网络后重试` : '当前已是最新版本,无需升级')
3476
+ }
3477
+ // 4) 配置移植说明:cordis.patch.yml / bundles 位于 profile(升级不触碰),天然保留;
3478
+ // 重启后框架适配逻辑自动备份新版本快照 + 重打补丁
3479
+ const hints = [
3480
+ `现有配置已打包备份:${backupDir ?? '(跳过)'}。升级不触碰 profile 配置(cordis.patch.yml / bundles 天然保留)。`,
3481
+ '重要:升级全程约 1-3 分钟,服务保持在线(进度实时可见),仅最后重启生效时页面短暂断开——期间请勿手动拉起服务或重启桌面端,否则会中断升级。',
3482
+ ...steps,
3483
+ '升级完成后请重启 DSH 服务生效——重启后 Hub 自动:备份新版本配置快照、重打框架补丁(issue #5 容错)、给出适配提示。',
3484
+ '官方配套组件(@deepseek-ai/*)随框架一起升级,请勿在市场单独更新。',
3485
+ ]
3486
+ sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, steps, hints })
3487
+ return
3488
+ }
3489
+
3490
+ if (pathname === `${ROUTE_PREFIX}/install`) {
3491
+ // repo 允许为空:纯 registry 更新(已安装插件的"检测更新"走此路径,无 git 兜底)
3492
+ const rawRepo = typeof body.repo === 'string' ? body.repo.trim() : ''
3493
+ let repo = ''
3494
+ if (rawRepo !== '') {
3495
+ try { repo = githubRepoInfo(rawRepo) } catch {}
3496
+ }
3497
+ const givenName = typeof body.packageName === 'string' ? body.packageName.trim() : ''
3498
+ // 框架本体拦截:deepseek-harness 仓库与其根包 @deepseek-ai/dsh-root 是 DSH 框架自身,
3499
+ // 作为插件安装会试图构建整个框架源码——直接拒绝并提示
3500
+ if (repo === 'deepseek-ai/deepseek-harness'
3501
+ || givenName === '@deepseek-ai/dsh-root'
3502
+ || givenName === '@deepseek-ai/dsh') {
3503
+ sendError(res, 400, 'deepseek-harness 是 DSH 框架本体,不是插件——无需安装(升级请用官方 dsh 升级方式)')
3504
+ return
3505
+ }
3506
+ const kind = body.kind === 'skill' ? 'skill' : body.kind === 'suite' ? 'suite' : 'plugin'
3507
+ if ((kind === 'skill' || kind === 'suite') && repo === '') {
3508
+ sendError(res, 400, `${kind === 'skill' ? '技能' : '套装'}安装必须提供仓库(owner/name)`)
3509
+ return
3510
+ }
3511
+ const source = body.source === 'gitee' ? body.source : 'github'
3512
+ const npmNamePattern = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u
3513
+ if (givenName !== '' && (!npmNamePattern.test(givenName) || givenName.length > 214)) {
3514
+ sendError(res, 400, 'packageName 不是合法的 npm 包名')
3515
+ return
3516
+ }
3517
+ // 防重(事故教训):同一插件已在安装/更新中时拒绝新任务——否则反复点更新/安装会产生
3518
+ // 几十个并发下载任务(同一插件重复安装、消息刷屏、浪费流量)
3519
+ const dupJob = [...installJobs.values()].some((j) => j.status === 'installing'
3520
+ && ((repo !== '' && j.repo === repo) || (givenName !== '' && j.packageName === givenName)))
3521
+ if (dupJob) {
3522
+ sendError(res, 409, '该插件正在安装/更新中,请等待当前任务完成后再试')
3523
+ return
3524
+ }
3525
+ const job = {
3526
+ id: `job-${installJobSeq += 1}`,
3527
+ repo,
3528
+ source,
3529
+ packageName: givenName || null,
3530
+ status: 'installing',
3531
+ stage: 'preparing',
3532
+ error: null,
3533
+ startedAt: Date.now(),
3534
+ finishedAt: null,
3535
+ entryId: null,
3536
+ bundle: false,
3537
+ ai: false,
3538
+ aiNote: null,
3539
+ subpackages: null,
3540
+ lastError: null,
3541
+ kind,
3542
+ }
3543
+ installJobs.set(job.id, job)
3544
+ // 后台执行:请求立即返回,安装不受客户端断开/离开面板影响
3545
+ void (kind === 'skill' ? runSkillInstallJob(job, ctx) : kind === 'suite' ? runSuiteInstallJob(job, ctx) : runInstallJob(job, ctx))
3546
+ sendJson(res, 200, { ok: true, jobId: job.id, status: 'installing', kind })
3547
+ return
3548
+ }
3549
+
3550
+ if (pathname === `${ROUTE_PREFIX}/skill-remove`) {
3551
+ // 删除已安装技能:仅接受 kebab-case 名称(防目录穿越),删除 ~/.dsh/skills/<name>
3552
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
3553
+ if (name.startsWith('.')) {
3554
+ // 点号开头是系统/隐藏技能根(如 .system,dsh-skill-filesystem 保留目录):禁止删除
3555
+ sendError(res, 403, `技能 ${name} 属于系统/隐藏技能,禁止删除(保留 DSH 自带技能)`)
3556
+ return
3557
+ }
3558
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(name)) {
3559
+ sendError(res, 400, '技能名称无效(仅允许 kebab-case)')
3560
+ return
3561
+ }
3562
+ const dest = join(dshHome(), 'skills', name)
3563
+ if (!existsSync(dest)) {
3564
+ sendError(res, 404, `技能 ${name} 不存在`)
3565
+ return
3566
+ }
3567
+ try {
3568
+ rmSync(dest, { recursive: true, force: true })
3569
+ sendJson(res, 200, { ok: true, name })
3570
+ } catch (error) {
3571
+ sendError(res, 500, `删除技能失败:${error instanceof Error ? error.message : String(error)}`)
3572
+ }
3573
+ return
3574
+ }
3575
+
3576
+ if (pathname === `${ROUTE_PREFIX}/skill-toggle`) {
3577
+ // 停用/启用技能:写入官方调用策略 frontmatter(disable-model-invocation / user-invocable),
3578
+ // 可逆(原始内容备份于技能目录 .dsh-skill-fm.bak);系统/隐藏技能禁止停用(保护机制)。
3579
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
3580
+ if (name.startsWith('.')) {
3581
+ // 点号开头是系统/隐藏技能根(如 .system,dsh-skill-filesystem 保留目录):禁止停用
3582
+ sendError(res, 403, `技能 ${name} 属于系统/隐藏技能,禁止停用(保留 DSH 自带技能)`)
3583
+ return
3584
+ }
3585
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(name)) {
3586
+ sendError(res, 400, '技能名称无效(仅允许 kebab-case)')
3587
+ return
3588
+ }
3589
+ const dest = join(dshHome(), 'skills', name)
3590
+ let skillFile = join(dest, 'SKILL.md')
3591
+ if (!existsSync(skillFile)) {
3592
+ // 平铺技能(<name>.md)
3593
+ const flat = `${dest}.md`
3594
+ if (existsSync(flat)) skillFile = flat
3595
+ else {
3596
+ sendError(res, 404, `技能 ${name} 不存在`)
3597
+ return
3598
+ }
3599
+ }
3600
+ const enabled = body.enabled === true
3601
+ try {
3602
+ setSkillEnabled(skillFile, enabled)
3603
+ sendJson(res, 200, { ok: true, name, enabled, skillFile })
3604
+ } catch (error) {
3605
+ sendError(res, 500, `${enabled ? '启用' : '停用'}技能失败:${error instanceof Error ? error.message : String(error)}`)
3606
+ }
3607
+ return
3608
+ }
3609
+
3610
+ if (pathname === `${ROUTE_PREFIX}/install-status`) {
3611
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
3612
+ const job = installJobs.get(jobId)
3613
+ if (!job) {
3614
+ sendError(res, 404, '没有这个安装任务')
3615
+ return
3616
+ }
3617
+ sendJson(res, 200, { ok: true, ...installJobView(job) })
3618
+ return
3619
+ }
3620
+
3621
+ if (pathname === `${ROUTE_PREFIX}/ai-consent`) {
3622
+ const jobId = typeof body.jobId === 'string' ? body.jobId : ''
3623
+ const approved = body.approved === true
3624
+ const job = installJobs.get(jobId)
3625
+ if (!job) {
3626
+ sendError(res, 404, '没有这个安装任务')
3627
+ return
3628
+ }
3629
+ if (job.stage !== 'ai-consent' || typeof job.aiWait?.then !== 'function') {
3630
+ sendError(res, 400, '该任务不在等待 AI 授权状态')
3631
+ return
3632
+ }
3633
+ const resolver = job.aiPending?.resolver
3634
+ job.aiWait = null
3635
+ job.aiPending = null
3636
+ if (typeof resolver === 'function') resolver({ approved })
3637
+ sendJson(res, 200, { ok: true, jobId, approved })
3638
+ return
3639
+ }
3640
+
3641
+ if (pathname === `${ROUTE_PREFIX}/restart`) {
3642
+ // 自带守护的自杀式重启:分离脚本杀掉本进程后,若端口无人监听则自动拉起服务。
3643
+ // 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
3644
+ // 安全护栏(事故教训):无法定位 dsh bin 或 bin.js 不存在时**拒绝重启**——
3645
+ // 避免"kill 后拉不起"(框架缓存损坏时常见),提示先修复框架安装。
3646
+ const port = webPort(ctx)
3647
+ const binPath = resolveDshBin()
3648
+ const nodePath = process.execPath
3649
+ if (binPath === null || !existsSync(binPath)) {
3650
+ sendError(res, 500, `无法定位 DSH 启动入口(bin.js${binPath !== null ? `:${binPath}` : ''}),已取消重启——框架安装可能已损坏,请先修复 @deepseek-ai/dsh 后再重启`)
3651
+ return
3652
+ }
3653
+ if (process.platform === 'win32') {
3654
+ const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
3655
+ const taskName = `DSH-Restart-${process.pid}`
3656
+ const lines = [
3657
+ `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`,
3658
+ 'Start-Sleep -Seconds 3',
3659
+ '$ok = $false',
3660
+ `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; $ok = $c.Count -gt 0 } catch {}`,
3661
+ `if (-not $ok) { Start-Process -FilePath ${JSON.stringify(nodePath)} -ArgumentList ${JSON.stringify(binPath)},'web' -WindowStyle Hidden }`,
3662
+ // 任务自删:否则 DSH-Restart-* 残留(服务重启后任务列表里一堆 Ready 僵尸任务)
3663
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
3664
+ ]
3665
+ // schtasks 计划任务执行(独立于服务进程树/Job Object):服务被杀时脚本不被连带杀死,
3666
+ // 否则「重启」会在 kill 后无人拉起(历史重启偶发崩溃的根因之一)
3667
+ writeFile(ps1, lines.join('\r\n'), 'utf8').then(
3668
+ () => {
3669
+ const ps1Posix = ps1.replace(/\\/gu, '/')
3670
+ // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
3671
+ // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
3672
+ // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
3673
+ // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
3674
+ // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
3675
+ const tr = / /.test(ps1Posix)
3676
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
3677
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
3678
+ execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
3679
+ if (error) {
3680
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
3681
+ return
3682
+ }
3683
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
3684
+ })
3685
+ },
3686
+ () => {},
3687
+ )
3688
+ } else {
3689
+ const script = process.platform === 'win32'
3690
+ ? `Start-Sleep -Seconds 2; Stop-Process -Id ${process.pid} -Force`
3691
+ : `sleep 2; kill -9 ${process.pid}`
3692
+ const cmd = process.platform === 'win32' ? 'powershell.exe' : 'sh'
3693
+ const args = process.platform === 'win32'
3694
+ ? ['-NoProfile', '-WindowStyle', 'Hidden', '-Command', script]
3695
+ : ['-c', script]
3696
+ execFile(cmd, args, { windowsHide: true }, () => {})
3697
+ }
3698
+ sendJson(res, 200, { ok: true, message: `正在重启 DSH 服务(自带守护,端口 ${port} 无监听会自动拉起),页面稍后自动恢复` })
3699
+ return
3700
+ }
3701
+
3702
+ sendError(res, 404, `未知接口 ${pathname}`)
3703
+ }