@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/LICENSE +21 -0
- package/README.md +446 -0
- package/README.zh.md +401 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2570 -0
- package/lib/index.js +3703 -0
- package/package.json +39 -0
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
|
+
}
|