@noob-stupid/dsh-plugin-console 0.3.28 → 0.3.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +297 -38
- package/lib/index.js +466 -41
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -621,8 +621,24 @@ const DEFAULT_SOURCES = {
|
|
|
621
621
|
{ id: 'github', name: 'GitHub', type: 'builtin' },
|
|
622
622
|
{ id: 'gitee', name: 'Gitee', type: 'builtin' },
|
|
623
623
|
],
|
|
624
|
+
// 市场静态索引源(按主→备依次尝试;内网可整体替换为自建镜像,实现完全离线的市场浏览)
|
|
625
|
+
indexSources: [
|
|
626
|
+
{ id: 'jsdelivr', name: 'jsDelivr CDN', url: 'https://cdn.jsdelivr.net/gh/Noob-stupid/dsh-plugin-hub@main/marketplace/index.json', primary: true },
|
|
627
|
+
{ id: 'ghproxy', name: 'ghproxy 镜像', url: 'https://ghproxy.net/https://raw.githubusercontent.com/Noob-stupid/dsh-plugin-hub/main/marketplace/index.json', primary: false },
|
|
628
|
+
],
|
|
629
|
+
// Git 克隆源({owner}/{repo} 占位符;按主→备依次尝试)。
|
|
630
|
+
// 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。
|
|
631
|
+
gitSources: [
|
|
632
|
+
{ id: 'ghproxy-git', name: 'ghproxy 镜像', urlTemplate: 'https://ghproxy.net/https://github.com/{owner}/{repo}.git', primary: true },
|
|
633
|
+
{ id: 'github-git', name: 'GitHub 直连', urlTemplate: 'https://github.com/{owner}/{repo}.git', primary: false },
|
|
634
|
+
],
|
|
635
|
+
// 索引合并模式:true = 所有索引源结果合并去重(公共索引 + 内网私有索引同时可见);
|
|
636
|
+
// false = 主→备只用一个(内网优先,更快)
|
|
637
|
+
indexMerge: false,
|
|
624
638
|
gitee: { clientId: '', clientSecret: '', token: '', login: '' },
|
|
625
639
|
}
|
|
640
|
+
/** 市场索引落盘缓存(网络不可达时降级展示上次成功结果,避免内网/断网下市场空白)。 */
|
|
641
|
+
const MARKET_INDEX_CACHE_FILE = join(dshHome(), 'plugin-console-market-index-cache.json')
|
|
626
642
|
|
|
627
643
|
/** 源地址校验(模块顶层,readSources 与 sources 路由共用):https 任意;http 仅限私网/本机地址(内网 npm registry、内网搜索服务常用 http)。 */
|
|
628
644
|
function isAllowedSourceUrl(url) {
|
|
@@ -644,6 +660,12 @@ function isAllowedSourceUrl(url) {
|
|
|
644
660
|
}
|
|
645
661
|
}
|
|
646
662
|
|
|
663
|
+
/** Git 源地址校验:在通用校验之上额外允许 file:// 本地裸仓库(完全离线/内网共享盘场景)。 */
|
|
664
|
+
function isAllowedGitSourceUrl(url) {
|
|
665
|
+
if (/^file:\/\/\/\S+$/u.test(url)) return true
|
|
666
|
+
return isAllowedSourceUrl(url)
|
|
667
|
+
}
|
|
668
|
+
|
|
647
669
|
/** 读取 Gitee OAuth 配置(clientId/clientSecret/token/login)。 */
|
|
648
670
|
function readGiteeConfig(data) {
|
|
649
671
|
const gitee = data && typeof data === 'object' && data.gitee && typeof data.gitee === 'object' ? data.gitee : {}
|
|
@@ -700,6 +722,36 @@ function readSources() {
|
|
|
700
722
|
})
|
|
701
723
|
.filter((s) => s !== null)
|
|
702
724
|
const giteeBase = readGiteeConfig(data)
|
|
725
|
+
// 索引源:老配置无该字段时用默认;URL 必须是合法 https 或本机/私网 http
|
|
726
|
+
const indexSources = (Array.isArray(data.indexSources) ? data.indexSources : [])
|
|
727
|
+
.filter((s) => s && typeof s.url === 'string' && isAllowedSourceUrl(s.url))
|
|
728
|
+
.map((s) => ({
|
|
729
|
+
id: String(s.id ?? '').slice(0, 40) || `idx-${Math.random().toString(36).slice(2, 8)}`,
|
|
730
|
+
name: String(s.name ?? s.url).slice(0, 60) || s.url,
|
|
731
|
+
url: s.url,
|
|
732
|
+
primary: s.primary === true,
|
|
733
|
+
}))
|
|
734
|
+
const indexFinal = indexSources.length > 0
|
|
735
|
+
? (() => {
|
|
736
|
+
if (!indexSources.some((s) => s.primary)) indexSources[0].primary = true
|
|
737
|
+
return indexSources
|
|
738
|
+
})()
|
|
739
|
+
: defaults.indexSources
|
|
740
|
+
// Git 克隆源:模板必须同时含 {owner} 与 {repo};老配置无该字段时用默认
|
|
741
|
+
const gitSources = (Array.isArray(data.gitSources) ? data.gitSources : [])
|
|
742
|
+
.filter((s) => s && typeof s.urlTemplate === 'string' && s.urlTemplate.includes('{owner}') && s.urlTemplate.includes('{repo}') && isAllowedGitSourceUrl(s.urlTemplate))
|
|
743
|
+
.map((s) => ({
|
|
744
|
+
id: String(s.id ?? '').slice(0, 40) || `git-${Math.random().toString(36).slice(2, 8)}`,
|
|
745
|
+
name: String(s.name ?? s.urlTemplate).slice(0, 60) || s.urlTemplate,
|
|
746
|
+
urlTemplate: s.urlTemplate,
|
|
747
|
+
primary: s.primary === true,
|
|
748
|
+
}))
|
|
749
|
+
const gitFinal = gitSources.length > 0
|
|
750
|
+
? (() => {
|
|
751
|
+
if (!gitSources.some((s) => s.primary)) gitSources[0].primary = true
|
|
752
|
+
return gitSources
|
|
753
|
+
})()
|
|
754
|
+
: defaults.gitSources
|
|
703
755
|
const gitee = {
|
|
704
756
|
...giteeBase,
|
|
705
757
|
clientSecret: secrets.gitee?.clientSecret ?? giteeBase.clientSecret,
|
|
@@ -707,7 +759,7 @@ function readSources() {
|
|
|
707
759
|
}
|
|
708
760
|
if (registries.length > 0) {
|
|
709
761
|
if (!registries.some((r) => r.primary)) registries[0].primary = true
|
|
710
|
-
return { registries, searchSources, gitee }
|
|
762
|
+
return { registries, searchSources, indexSources: indexFinal, gitSources: gitFinal, indexMerge: data.indexMerge === true, gitee }
|
|
711
763
|
}
|
|
712
764
|
} catch {}
|
|
713
765
|
return defaults
|
|
@@ -769,6 +821,9 @@ function maskSources(sources) {
|
|
|
769
821
|
}
|
|
770
822
|
return {
|
|
771
823
|
registries: sources.registries,
|
|
824
|
+
indexSources: sources.indexSources ?? DEFAULT_SOURCES.indexSources,
|
|
825
|
+
indexMerge: sources.indexMerge === true,
|
|
826
|
+
gitSources: sources.gitSources ?? DEFAULT_SOURCES.gitSources,
|
|
772
827
|
searchSources: (sources.searchSources ?? []).map((s) => {
|
|
773
828
|
if (s && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
|
|
774
829
|
const masked = {}
|
|
@@ -1588,6 +1643,7 @@ async function curlText(url, timeout = 10000) {
|
|
|
1588
1643
|
/** 任意 https JSON 接口(Gitee/自定义源用):curl 优先、node:https 兜底;4xx/5xx 确定性失败直接抛出。 */
|
|
1589
1644
|
async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
|
|
1590
1645
|
let lastError
|
|
1646
|
+
const isHttps = /^https:\/\//u.test(url)
|
|
1591
1647
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1592
1648
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
1593
1649
|
try {
|
|
@@ -1597,6 +1653,9 @@ async function fetchJsonUrl(url, timeout = 15000, headers = {}) {
|
|
|
1597
1653
|
if (error instanceof Error && /^请求失败 \(HTTP \d+\)$/u.test(error.message)) throw error
|
|
1598
1654
|
lastError = error
|
|
1599
1655
|
}
|
|
1656
|
+
// node:https 兜底仅对 https 有效:私网 http 镜像只走 curl 通道,
|
|
1657
|
+
// 否则会抛出 "Protocol http: not supported" 掩盖 curl 的真实连接错误
|
|
1658
|
+
if (!isHttps) continue
|
|
1600
1659
|
try {
|
|
1601
1660
|
const res = await githubRequest(url, { headers })
|
|
1602
1661
|
const status = res.statusCode ?? 0
|
|
@@ -2166,8 +2225,32 @@ async function githubReleaseInstall(profileDir, repo, packageName) {
|
|
|
2166
2225
|
* 读已装包的 bundle patch → 检查每个 insert 引用的包是否存在 → 缺失补装(pnpm/curl)→
|
|
2167
2226
|
* 仍缺则在用户 patch 层自动禁用该行(防崩兜底)+ 返回报告供面板提示。
|
|
2168
2227
|
*/
|
|
2169
|
-
|
|
2170
|
-
|
|
2228
|
+
/** 读取聚合包 cordis.patch.yml 的 insert 行 name 列表(注册前校验用)。 */
|
|
2229
|
+
function readBundlePatchRefNames(profileDir, pkgName) {
|
|
2230
|
+
try {
|
|
2231
|
+
const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
|
|
2232
|
+
if (!existsSync(pkgJsonPath)) return []
|
|
2233
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
|
|
2234
|
+
const patchRel = pkg.dsh?.bundle?.patch
|
|
2235
|
+
if (typeof patchRel !== 'string') return []
|
|
2236
|
+
const text = readFileSync(join(profileDir, 'node_modules', pkgName, patchRel), 'utf8')
|
|
2237
|
+
const names = []
|
|
2238
|
+
const lines = text.split(/\r?\n/u)
|
|
2239
|
+
let inInsert = false
|
|
2240
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
2241
|
+
const line = lines[i]
|
|
2242
|
+
if (/^- insert:\s*$/u.test(line)) { inInsert = true; continue }
|
|
2243
|
+
if (/^- /u.test(line) && !/^ {4}- /u.test(line)) inInsert = false
|
|
2244
|
+
if (!inInsert) continue
|
|
2245
|
+
const nameM = (lines[i + 1] ?? '').match(/^ {6}name: ['"]([^'"]+)['"]\s*$/u)
|
|
2246
|
+
if (nameM) names.push(nameM[1])
|
|
2247
|
+
}
|
|
2248
|
+
return names
|
|
2249
|
+
} catch { return [] }
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath, transientAllow = []) {
|
|
2253
|
+
const report = { checked: 0, installed: [], disabled: [], missing: [], pending: [] }
|
|
2171
2254
|
try {
|
|
2172
2255
|
const pkgJsonPath = join(profileDir, 'node_modules', pkgName, 'package.json')
|
|
2173
2256
|
if (!existsSync(pkgJsonPath)) return report
|
|
@@ -2199,6 +2282,12 @@ async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath) {
|
|
|
2199
2282
|
// 无版本约束补装会覆盖框架正确版本导致服务崩溃——见 backfillMissingDeps 注释)
|
|
2200
2283
|
if (/^@deepseek-ai\//u.test(ref.name)) continue
|
|
2201
2284
|
if (existsSync(join(profileDir, 'node_modules', ref.name))) continue
|
|
2285
|
+
// 2026-09-06 加固:本次作业刚完成版本同步的包(transientAllow)此刻缺失=更新中途的瞬时态
|
|
2286
|
+
// (pnpm 原子替换/替换失败窗口),不应按"缺失→自动禁用"处理——跳过本次判定,下次校验时再检查。
|
|
2287
|
+
if (transientAllow.includes(ref.name)) {
|
|
2288
|
+
report.pending.push(ref.name)
|
|
2289
|
+
continue
|
|
2290
|
+
}
|
|
2202
2291
|
let ok = false
|
|
2203
2292
|
try {
|
|
2204
2293
|
await pnpmInstall(profileDir, ref.name, registries[0], 60000)
|
|
@@ -2333,6 +2422,7 @@ function installJobView(job) {
|
|
|
2333
2422
|
skillNote: job.skillNote ?? null,
|
|
2334
2423
|
suiteReport: job.suiteReport ?? null,
|
|
2335
2424
|
suiteNote: job.suiteNote ?? null,
|
|
2425
|
+
hint: job.hint ?? null,
|
|
2336
2426
|
}
|
|
2337
2427
|
}
|
|
2338
2428
|
|
|
@@ -2418,18 +2508,45 @@ function packageEntryExists(dir, pkg) {
|
|
|
2418
2508
|
return candidates.some((c) => existsSync(join(dir, c)))
|
|
2419
2509
|
}
|
|
2420
2510
|
|
|
2511
|
+
/** git 非交互环境:禁止任何登录/凭据窗口弹出(私有仓库或不可达源直接失败,不做交互式重试)。 */
|
|
2512
|
+
function gitEnv() {
|
|
2513
|
+
return {
|
|
2514
|
+
...process.env,
|
|
2515
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
2516
|
+
GCM_INTERACTIVE: 'never',
|
|
2517
|
+
GIT_ASKPASS: 'echo',
|
|
2518
|
+
SSH_ASKPASS: 'echo',
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/** Git 克隆 URL 列表(按主→备顺序)。来源在「功能包 → 软件源 → Git 源」中自定义,
|
|
2523
|
+
* 可替换为 Gitee / GitLab / 自建 Gitea / 任意镜像代理,实现「换一个网站下载仓库内容」。 */
|
|
2524
|
+
function gitCloneUrls(repoFullName, source = 'github') {
|
|
2525
|
+
if (source === 'gitee') return [`https://gitee.com/${repoFullName}.git`]
|
|
2526
|
+
const [owner = '', repo = ''] = String(repoFullName).split('/')
|
|
2527
|
+
let list = []
|
|
2528
|
+
try {
|
|
2529
|
+
list = readSources().gitSources ?? []
|
|
2530
|
+
} catch {
|
|
2531
|
+
list = DEFAULT_SOURCES.gitSources
|
|
2532
|
+
}
|
|
2533
|
+
const ordered = [...list].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
|
|
2534
|
+
const urls = ordered
|
|
2535
|
+
.map((s) => String(s.urlTemplate).replace(/\{owner\}/gu, owner).replace(/\{repo\}/gu, repo))
|
|
2536
|
+
.filter((u) => u !== '')
|
|
2537
|
+
return urls.length > 0 ? urls : [`https://github.com/${repoFullName}.git`]
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2421
2540
|
/** git clone(镜像→直连;gitee 直连),返回或抛错。 */
|
|
2422
2541
|
async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000) {
|
|
2423
|
-
const urls = source
|
|
2424
|
-
? [`https://gitee.com/${repo}.git`]
|
|
2425
|
-
: [`https://ghproxy.net/https://github.com/${repo}.git`, `https://github.com/${repo}.git`]
|
|
2542
|
+
const urls = gitCloneUrls(repo, source)
|
|
2426
2543
|
let lastError = null
|
|
2427
2544
|
for (const url of urls) {
|
|
2428
2545
|
try {
|
|
2429
2546
|
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
2430
2547
|
timeout,
|
|
2431
2548
|
windowsHide: true,
|
|
2432
|
-
env:
|
|
2549
|
+
env: gitEnv(),
|
|
2433
2550
|
})
|
|
2434
2551
|
return
|
|
2435
2552
|
} catch (error) {
|
|
@@ -2592,9 +2709,7 @@ async function runSkillInstallJob(job, ctx) {
|
|
|
2592
2709
|
job.stage = 'preparing'
|
|
2593
2710
|
const shortName = String(job.repo).split('/').pop() || job.repo
|
|
2594
2711
|
mkdirSync(tmpDir, { recursive: true })
|
|
2595
|
-
const urls = job.source
|
|
2596
|
-
? [`https://gitee.com/${job.repo}.git`]
|
|
2597
|
-
: [`https://ghproxy.net/https://github.com/${job.repo}.git`, `https://github.com/${job.repo}.git`]
|
|
2712
|
+
const urls = gitCloneUrls(job.repo, job.source)
|
|
2598
2713
|
let cloned = false
|
|
2599
2714
|
let lastError = null
|
|
2600
2715
|
for (const url of urls) {
|
|
@@ -2602,7 +2717,7 @@ async function runSkillInstallJob(job, ctx) {
|
|
|
2602
2717
|
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, tmpDir], {
|
|
2603
2718
|
timeout: 120000,
|
|
2604
2719
|
windowsHide: true,
|
|
2605
|
-
env:
|
|
2720
|
+
env: gitEnv(),
|
|
2606
2721
|
})
|
|
2607
2722
|
cloned = true
|
|
2608
2723
|
break
|
|
@@ -3167,6 +3282,30 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
|
|
|
3167
3282
|
job.finishedAt = Date.now()
|
|
3168
3283
|
return
|
|
3169
3284
|
}
|
|
3285
|
+
// 优先用「Git 源」把目标仓库克隆到本地,让子代理读本地材料:
|
|
3286
|
+
// 内网/离线环境下 api.github.com 与 raw 通道不可达时,这是唯一可行的调研通道。
|
|
3287
|
+
let materialDir = null
|
|
3288
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(String(job.source ?? '').trim())) {
|
|
3289
|
+
const srcDir = join(tmpdir(), `dsh-ai-src-${job.id}-${Date.now().toString(36)}`)
|
|
3290
|
+
try {
|
|
3291
|
+
mkdirSync(srcDir, { recursive: true })
|
|
3292
|
+
for (const url of gitCloneUrls(job.source)) {
|
|
3293
|
+
try {
|
|
3294
|
+
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, srcDir], { timeout: 120000, windowsHide: true, env: gitEnv() })
|
|
3295
|
+
materialDir = srcDir
|
|
3296
|
+
break
|
|
3297
|
+
} catch {}
|
|
3298
|
+
}
|
|
3299
|
+
} catch {}
|
|
3300
|
+
if (materialDir === null) {
|
|
3301
|
+
try { rmSync(srcDir, { recursive: true, force: true }) } catch {}
|
|
3302
|
+
} else {
|
|
3303
|
+
job.materialDir = srcDir
|
|
3304
|
+
// 30 分钟后自动清理(规划+执行通常早已结束)
|
|
3305
|
+
const timer = setTimeout(() => { try { rmSync(srcDir, { recursive: true, force: true }) } catch {} }, 30 * 60 * 1000)
|
|
3306
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3170
3309
|
const prompt = [
|
|
3171
3310
|
'你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
|
|
3172
3311
|
`目标来源:${job.source}`,
|
|
@@ -3175,7 +3314,9 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
|
|
|
3175
3314
|
frameworkCheckPromptText(job.frameworkCheck),
|
|
3176
3315
|
'',
|
|
3177
3316
|
'【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
|
|
3178
|
-
|
|
3317
|
+
materialDir !== null
|
|
3318
|
+
? `调研材料:目标仓库已由控制台通过「Git 源」克隆到本地目录 ${materialDir} —— 直接读取该目录下的 README / package.json / docs/ / install 说明即可(无需联网,内网/离线环境同样可用)。`
|
|
3319
|
+
: '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
|
|
3179
3320
|
'全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
|
|
3180
3321
|
'',
|
|
3181
3322
|
'计划 schema(严格遵循)',
|
|
@@ -3768,6 +3909,15 @@ function frameworkCheckPromptText(fc) {
|
|
|
3768
3909
|
async function runInstallJob(job, ctx) {
|
|
3769
3910
|
try {
|
|
3770
3911
|
job.stage = 'preparing'
|
|
3912
|
+
// 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点「添加到本地/安装」
|
|
3913
|
+
// 会按 bundle 规则注册其 patch,其中 deepseek-ai-dsh-root 等框架级行的包只存在于 npx 缓存/框架树,
|
|
3914
|
+
// profile node_modules 不存在 → 整服务启动崩溃。直接拦截。
|
|
3915
|
+
const repoNorm = String(job.repo ?? '').toLowerCase().replace(/^git\+/u, '').replace(/\.git$/u, '')
|
|
3916
|
+
if (repoNorm === 'deepseek-ai/deepseek-harness') {
|
|
3917
|
+
job.status = 'failed'
|
|
3918
|
+
job.error = '这是 DSH 框架本体仓库,不能按插件安装(其组件引用框架内部包,安装后会导致服务启动失败)。如需升级框架请用「框架升级」按钮;如需生态插件请选择 dsh-plugin 插件仓库。'
|
|
3919
|
+
return
|
|
3920
|
+
}
|
|
3771
3921
|
let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
|
|
3772
3922
|
let subpackageMode = false
|
|
3773
3923
|
if (candidates.length === 0) {
|
|
@@ -3791,8 +3941,17 @@ async function runInstallJob(job, ctx) {
|
|
|
3791
3941
|
const branch = meta?.default_branch ?? 'main'
|
|
3792
3942
|
const pkg = await fetchRepoPackage(job.repo, branch)
|
|
3793
3943
|
if (pkg === null) {
|
|
3944
|
+
// 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
|
|
3945
|
+
// 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
|
|
3946
|
+
const skillProbe = await detectSkillRepo(job.repo, branch)
|
|
3947
|
+
if (skillProbe.hasSkill) {
|
|
3948
|
+
job.kind = 'skill'
|
|
3949
|
+
await runSkillInstallJob(job, ctx)
|
|
3950
|
+
return
|
|
3951
|
+
}
|
|
3794
3952
|
job.status = 'failed'
|
|
3795
|
-
job.
|
|
3953
|
+
job.hint = 'repo-land'
|
|
3954
|
+
job.error = `仓库 ${job.repo} 没有 package.json(也不是技能仓库),无法作为插件安装——可改用「仓库落地」克隆到本地目录。`
|
|
3796
3955
|
return
|
|
3797
3956
|
}
|
|
3798
3957
|
if (pkg.private === true) {
|
|
@@ -3922,7 +4081,7 @@ async function runInstallJob(job, ctx) {
|
|
|
3922
4081
|
const gitSpecs = job.source === 'gitee'
|
|
3923
4082
|
? [`git+https://gitee.com/${job.repo}.git`]
|
|
3924
4083
|
: [
|
|
3925
|
-
|
|
4084
|
+
...gitCloneUrls(job.repo).map((u) => `git+${u}`),
|
|
3926
4085
|
`github:${job.repo}`,
|
|
3927
4086
|
]
|
|
3928
4087
|
for (const spec of gitSpecs) {
|
|
@@ -4003,13 +4162,15 @@ async function runInstallJob(job, ctx) {
|
|
|
4003
4162
|
job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `子包版本对齐:更新 ${synced.length} 个(${synced.join('、')})`
|
|
4004
4163
|
}
|
|
4005
4164
|
} catch {}
|
|
4006
|
-
// 聚合包完整性保障(防崩):bundle patch 引用的包缺失 →
|
|
4165
|
+
// 聚合包完整性保障(防崩):bundle patch 引用的包缺失 → 补装;仍缺自动禁用该行。
|
|
4166
|
+
// 本次作业刚同步过版本的包(syncedNames)处于更新瞬时态,跳过"缺失自动禁用"判定。
|
|
4007
4167
|
try {
|
|
4008
|
-
const integrity = await ensureBundlePatchIntegrity(profileDir, installedName, patchPath)
|
|
4168
|
+
const integrity = await ensureBundlePatchIntegrity(profileDir, installedName, patchPath, [...syncedNames, installedName])
|
|
4009
4169
|
if (integrity.checked > 0) {
|
|
4010
4170
|
job.bundleNote = `聚合包完整性:检查 ${integrity.checked} 个引用`
|
|
4011
4171
|
+ (integrity.installed.length > 0 ? `,补装 ${integrity.installed.length} 个(${integrity.installed.join('、')})` : '')
|
|
4012
4172
|
+ (integrity.disabled.length > 0 ? `,自动禁用缺失行 ${integrity.disabled.length} 个(${integrity.disabled.join('、')})` : '')
|
|
4173
|
+
+ (integrity.pending.length > 0 ? `,本次已同步包跳过缺失判定 ${integrity.pending.length} 个(${integrity.pending.join('、')})` : '')
|
|
4013
4174
|
+ (integrity.missing.length > 0 ? `,仍缺失 ${integrity.missing.join('、')}(网络恢复后建议重新更新)` : '')
|
|
4014
4175
|
}
|
|
4015
4176
|
} catch {}
|
|
@@ -4019,11 +4180,37 @@ async function runInstallJob(job, ctx) {
|
|
|
4019
4180
|
if (adapt !== null && adapt.ran === true) job.compatNote = adapt.note
|
|
4020
4181
|
} catch {}
|
|
4021
4182
|
job.stage = 'configuring'
|
|
4183
|
+
// 2026-09-06 事故(i18n 更新变重复行):@linxin666/dsh-i18n 声明了 dsh.bundle.patch,
|
|
4184
|
+
// 更新流程按「bundle 安装规则」把它追加进 bundles → 与全家桶内的 web-ui-i18n 行(同包)重复。
|
|
4185
|
+
// 防护:该包已被现有行提供 → 只更新包,不注册任何新行/bundle。
|
|
4186
|
+
const alreadyServed = listEntries(ctx).some((e) => String(e.moduleName).startsWith(installedName)
|
|
4187
|
+
|| String(e.moduleName) === installedName
|
|
4188
|
+
|| packageNameOf(e.moduleName) === installedName)
|
|
4022
4189
|
if (await detectBundleOnly(profileDir, installedName)) {
|
|
4023
4190
|
// 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
|
|
4024
4191
|
// 其 cordis.patch.yml 在下次启动时参与组合(含皮肤包与 web-ui-settings 这类有入口的包)
|
|
4025
|
-
|
|
4026
|
-
|
|
4192
|
+
if (!alreadyServed) {
|
|
4193
|
+
// 通用防线(2026-09-06 事故:装 dsh-desktop 类插件时,其 bundle patch 引用 @deepseek-ai/dsh-root 等
|
|
4194
|
+
// 框架级行,包不在 profile node_modules → 注册后整服务启动崩溃):
|
|
4195
|
+
// 注册前校验 bundle patch 引用的行模块全部可解析(**含 @deepseek-ai/* —— 正是室友崩塌的那些框架级包**),
|
|
4196
|
+
// 缺失即拒绝注册并给出清单。
|
|
4197
|
+
const refs = readBundlePatchRefNames(profileDir, installedName)
|
|
4198
|
+
const missing = refs.filter((n) => resolvePackageJson(n, profileDir) === null)
|
|
4199
|
+
if (missing.length > 0) {
|
|
4200
|
+
job.status = 'failed'
|
|
4201
|
+
job.error = `该聚合包(${installedName})的插件组引用以下未安装模块:${missing.join('、')}。若这些是框架内部包(@deepseek-ai/*),该包不能作为插件安装(会导致服务启动失败);若为本应随包安装的依赖,请重试或检查网络。已取消注册。`
|
|
4202
|
+
return
|
|
4203
|
+
}
|
|
4204
|
+
await addBundleToManifest(profileDir, installedName)
|
|
4205
|
+
job.bundle = true
|
|
4206
|
+
} else {
|
|
4207
|
+
job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `${installedName} 已由已安装聚合包提供,仅更新包本身,不再注册重复行`
|
|
4208
|
+
}
|
|
4209
|
+
job.status = 'done'
|
|
4210
|
+
return
|
|
4211
|
+
}
|
|
4212
|
+
if (alreadyServed) {
|
|
4213
|
+
job.note = `${installedName} 已由已安装条目提供,仅更新包本身,不重复注册`
|
|
4027
4214
|
job.status = 'done'
|
|
4028
4215
|
return
|
|
4029
4216
|
}
|
|
@@ -4302,7 +4489,10 @@ async function handle(ctx, req, res) {
|
|
|
4302
4489
|
return
|
|
4303
4490
|
}
|
|
4304
4491
|
|
|
4305
|
-
|
|
4492
|
+
// GET 兼容白名单:只读且无副作用的接口允许 GET 调用
|
|
4493
|
+
// (market-index 曾被客户端以 GET 调用,落进 405 后错误被静默吞掉,导致静态索引长期未生效)
|
|
4494
|
+
const GET_COMPAT = new Set([`${ROUTE_PREFIX}/market-index`])
|
|
4495
|
+
if (method !== 'POST' && !(method === 'GET' && GET_COMPAT.has(pathname))) {
|
|
4306
4496
|
sendError(res, 405, '不支持的方法')
|
|
4307
4497
|
return
|
|
4308
4498
|
}
|
|
@@ -4647,7 +4837,7 @@ async function handle(ctx, req, res) {
|
|
|
4647
4837
|
// 纯命令(无注释,CMD/PowerShell 通用);不再关闭 TLS 校验;
|
|
4648
4838
|
// 脚本用 powershell -File 调用,CMD 里也能跑
|
|
4649
4839
|
installCommand = [
|
|
4650
|
-
`git clone --recurse-submodules
|
|
4840
|
+
`git clone --recurse-submodules ${gitCloneUrls(repo)[0]}`,
|
|
4651
4841
|
`cd ${short}`,
|
|
4652
4842
|
hasInstallScript
|
|
4653
4843
|
? `powershell -ExecutionPolicy Bypass -File install.ps1`
|
|
@@ -4801,8 +4991,168 @@ async function handle(ctx, req, res) {
|
|
|
4801
4991
|
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
4802
4992
|
return
|
|
4803
4993
|
}
|
|
4994
|
+
if (action === 'add-index') {
|
|
4995
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
4996
|
+
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
|
4997
|
+
if (!isAllowedSourceUrl(url)) {
|
|
4998
|
+
sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
|
|
4999
|
+
return
|
|
5000
|
+
}
|
|
5001
|
+
if ((sources.indexSources ?? []).some((s) => s.url === url)) {
|
|
5002
|
+
sendError(res, 400, '该索引源已存在')
|
|
5003
|
+
return
|
|
5004
|
+
}
|
|
5005
|
+
sources.indexSources = [...(sources.indexSources ?? []), {
|
|
5006
|
+
id: `idx-${Date.now().toString(36)}`,
|
|
5007
|
+
name: name || url,
|
|
5008
|
+
url,
|
|
5009
|
+
primary: (sources.indexSources ?? []).length === 0,
|
|
5010
|
+
}]
|
|
5011
|
+
marketIndexCache = null
|
|
5012
|
+
await writeSources(sources)
|
|
5013
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5014
|
+
return
|
|
5015
|
+
}
|
|
5016
|
+
if (action === 'edit-index') {
|
|
5017
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5018
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
5019
|
+
const url = typeof body.url === 'string' ? body.url.trim() : ''
|
|
5020
|
+
if (!isAllowedSourceUrl(url)) {
|
|
5021
|
+
sendError(res, 400, '索引地址必须是 https:// 开头(或本机/私网 http://)的合法 URL')
|
|
5022
|
+
return
|
|
5023
|
+
}
|
|
5024
|
+
const target = (sources.indexSources ?? []).find((s) => s.id === id)
|
|
5025
|
+
if (!target) {
|
|
5026
|
+
sendError(res, 404, '没有这个索引源')
|
|
5027
|
+
return
|
|
5028
|
+
}
|
|
5029
|
+
target.name = name || url
|
|
5030
|
+
target.url = url
|
|
5031
|
+
marketIndexCache = null
|
|
5032
|
+
await writeSources(sources)
|
|
5033
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5034
|
+
return
|
|
5035
|
+
}
|
|
5036
|
+
if (action === 'set-index-primary') {
|
|
5037
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5038
|
+
const list = sources.indexSources ?? []
|
|
5039
|
+
if (!list.some((s) => s.id === id)) {
|
|
5040
|
+
sendError(res, 404, '没有这个索引源')
|
|
5041
|
+
return
|
|
5042
|
+
}
|
|
5043
|
+
for (const s of list) s.primary = s.id === id
|
|
5044
|
+
marketIndexCache = null
|
|
5045
|
+
await writeSources(sources)
|
|
5046
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5047
|
+
return
|
|
5048
|
+
}
|
|
5049
|
+
if (action === 'remove-index') {
|
|
5050
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5051
|
+
const list = sources.indexSources ?? []
|
|
5052
|
+
if (!list.some((s) => s.id === id)) {
|
|
5053
|
+
sendError(res, 404, '没有这个索引源')
|
|
5054
|
+
return
|
|
5055
|
+
}
|
|
5056
|
+
const rest = list.filter((s) => s.id !== id)
|
|
5057
|
+
if (rest.length === 0) {
|
|
5058
|
+
sendError(res, 400, '至少保留一个索引源(可先添加自建镜像再删除默认源)')
|
|
5059
|
+
return
|
|
5060
|
+
}
|
|
5061
|
+
if (!rest.some((s) => s.primary)) rest[0].primary = true
|
|
5062
|
+
sources.indexSources = rest
|
|
5063
|
+
marketIndexCache = null
|
|
5064
|
+
await writeSources(sources)
|
|
5065
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5066
|
+
return
|
|
5067
|
+
}
|
|
5068
|
+
if (action === 'set-index-merge') {
|
|
5069
|
+
sources.indexMerge = body.merge === true
|
|
5070
|
+
marketIndexCache = null
|
|
5071
|
+
await writeSources(sources)
|
|
5072
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5073
|
+
return
|
|
5074
|
+
}
|
|
5075
|
+
if (action === 'add-git') {
|
|
5076
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
5077
|
+
const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
|
|
5078
|
+
if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
|
|
5079
|
+
sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
|
|
5080
|
+
return
|
|
5081
|
+
}
|
|
5082
|
+
if (!isAllowedGitSourceUrl(urlTemplate)) {
|
|
5083
|
+
sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
|
|
5084
|
+
return
|
|
5085
|
+
}
|
|
5086
|
+
if ((sources.gitSources ?? []).some((s) => s.urlTemplate === urlTemplate)) {
|
|
5087
|
+
sendError(res, 400, '该 Git 源已存在')
|
|
5088
|
+
return
|
|
5089
|
+
}
|
|
5090
|
+
sources.gitSources = [...(sources.gitSources ?? []), {
|
|
5091
|
+
id: `git-${Date.now().toString(36)}`,
|
|
5092
|
+
name: name || urlTemplate,
|
|
5093
|
+
urlTemplate,
|
|
5094
|
+
primary: (sources.gitSources ?? []).length === 0,
|
|
5095
|
+
}]
|
|
5096
|
+
await writeSources(sources)
|
|
5097
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5098
|
+
return
|
|
5099
|
+
}
|
|
5100
|
+
if (action === 'edit-git') {
|
|
5101
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5102
|
+
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
|
5103
|
+
const urlTemplate = typeof body.urlTemplate === 'string' ? body.urlTemplate.trim() : ''
|
|
5104
|
+
if (!urlTemplate.includes('{owner}') || !urlTemplate.includes('{repo}')) {
|
|
5105
|
+
sendError(res, 400, 'Git 源模板必须同时包含 {owner} 与 {repo} 占位符')
|
|
5106
|
+
return
|
|
5107
|
+
}
|
|
5108
|
+
if (!isAllowedGitSourceUrl(urlTemplate)) {
|
|
5109
|
+
sendError(res, 400, 'Git 源地址必须是 https://(或本机/私网 http://、file:// 本地裸仓库)的合法 URL')
|
|
5110
|
+
return
|
|
5111
|
+
}
|
|
5112
|
+
const target = (sources.gitSources ?? []).find((s) => s.id === id)
|
|
5113
|
+
if (!target) {
|
|
5114
|
+
sendError(res, 404, '没有这个 Git 源')
|
|
5115
|
+
return
|
|
5116
|
+
}
|
|
5117
|
+
target.name = name || urlTemplate
|
|
5118
|
+
target.urlTemplate = urlTemplate
|
|
5119
|
+
await writeSources(sources)
|
|
5120
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5121
|
+
return
|
|
5122
|
+
}
|
|
5123
|
+
if (action === 'set-git-primary') {
|
|
5124
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5125
|
+
const list = sources.gitSources ?? []
|
|
5126
|
+
if (!list.some((s) => s.id === id)) {
|
|
5127
|
+
sendError(res, 404, '没有这个 Git 源')
|
|
5128
|
+
return
|
|
5129
|
+
}
|
|
5130
|
+
for (const s of list) s.primary = s.id === id
|
|
5131
|
+
await writeSources(sources)
|
|
5132
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5133
|
+
return
|
|
5134
|
+
}
|
|
5135
|
+
if (action === 'remove-git') {
|
|
5136
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
5137
|
+
const list = sources.gitSources ?? []
|
|
5138
|
+
if (!list.some((s) => s.id === id)) {
|
|
5139
|
+
sendError(res, 404, '没有这个 Git 源')
|
|
5140
|
+
return
|
|
5141
|
+
}
|
|
5142
|
+
const rest = list.filter((s) => s.id !== id)
|
|
5143
|
+
if (rest.length === 0) {
|
|
5144
|
+
sendError(res, 400, '至少保留一个 Git 源(可先添加自建镜像再删除默认源)')
|
|
5145
|
+
return
|
|
5146
|
+
}
|
|
5147
|
+
if (!rest.some((s) => s.primary)) rest[0].primary = true
|
|
5148
|
+
sources.gitSources = rest
|
|
5149
|
+
await writeSources(sources)
|
|
5150
|
+
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
5151
|
+
return
|
|
5152
|
+
}
|
|
4804
5153
|
if (action === 'reset') {
|
|
4805
5154
|
const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
|
|
5155
|
+
marketIndexCache = null
|
|
4806
5156
|
await writeSources(defaults)
|
|
4807
5157
|
sendJson(res, 200, { ok: true, sources: maskSources(defaults) })
|
|
4808
5158
|
return
|
|
@@ -4829,34 +5179,86 @@ async function handle(ctx, req, res) {
|
|
|
4829
5179
|
sendJson(res, 200, { ok: true, sources: maskSources(sources) })
|
|
4830
5180
|
return
|
|
4831
5181
|
}
|
|
4832
|
-
sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / gitee-setup / gitee-clear / reset)')
|
|
5182
|
+
sendError(res, 400, '未知操作(add / edit / set-primary / remove / add-search / remove-search / add-index / edit-index / set-index-primary / remove-index / set-index-merge / add-git / edit-git / set-git-primary / remove-git / gitee-setup / gitee-clear / reset)')
|
|
4833
5183
|
return
|
|
4834
5184
|
}
|
|
4835
5185
|
|
|
4836
5186
|
if (pathname === `${ROUTE_PREFIX}/market-index`) {
|
|
4837
|
-
//
|
|
5187
|
+
// 静态插件索引:按「软件源 → 索引源」主→备顺序拉取 + 10 分钟内存缓存(市场秒开、零 GitHub API 调用)。
|
|
5188
|
+
// 全部索引源失败时回退落盘缓存(内网/断网仍可浏览,响应带 offline 标记),无缓存则区分错误类型。
|
|
4838
5189
|
if (marketIndexCache !== null && Date.now() - marketIndexCache.at < 600000) {
|
|
4839
|
-
sendJson(res, 200, { ok: true, ...marketIndexCache.data })
|
|
5190
|
+
sendJson(res, 200, { ok: true, sourceName: marketIndexCache.sourceName ?? null, ...marketIndexCache.data })
|
|
4840
5191
|
return
|
|
4841
5192
|
}
|
|
4842
|
-
const
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
5193
|
+
const indexConf = readSources()
|
|
5194
|
+
let indexList = [...(indexConf.indexSources ?? [])].sort((a, b) => (b.primary === true ? 1 : 0) - (a.primary === true ? 1 : 0))
|
|
5195
|
+
// 合并模式:并发拉取所有索引源并去重合并(公共索引 + 内网私有索引同时可见)
|
|
5196
|
+
if (indexConf.indexMerge === true && indexList.length > 1) {
|
|
5197
|
+
const fetched = await Promise.all(indexList.map(async (src) => {
|
|
5198
|
+
try {
|
|
5199
|
+
// 各源独立短超时:单个慢源(被墙镜像/不可达内网)不该拖垮整体
|
|
5200
|
+
const data = await fetchJsonUrl(src.url, 8000)
|
|
5201
|
+
return data && Array.isArray(data.items) ? { src, data } : null
|
|
5202
|
+
} catch { return null }
|
|
5203
|
+
}))
|
|
5204
|
+
const good = fetched.filter((x) => x !== null)
|
|
5205
|
+
if (good.length > 0) {
|
|
5206
|
+
const seen = new Set()
|
|
5207
|
+
const skillSeen = new Set()
|
|
5208
|
+
const items = []
|
|
5209
|
+
const skills = []
|
|
5210
|
+
for (const { data } of good) {
|
|
5211
|
+
for (const it of data.items) {
|
|
5212
|
+
const key = typeof it?.fullName === 'string' ? it.fullName : JSON.stringify(it)
|
|
5213
|
+
if (seen.has(key)) continue
|
|
5214
|
+
seen.add(key)
|
|
5215
|
+
items.push(it)
|
|
5216
|
+
}
|
|
5217
|
+
for (const sk of (Array.isArray(data.skills) ? data.skills : [])) {
|
|
5218
|
+
const key = typeof sk?.fullName === 'string' ? sk.fullName : JSON.stringify(sk)
|
|
5219
|
+
if (skillSeen.has(key)) continue
|
|
5220
|
+
skillSeen.add(key)
|
|
5221
|
+
skills.push(sk)
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
const sourceName = good.map((g) => g.src.name).join(' + ')
|
|
5225
|
+
const merged = { items, skills, skillCount: skills.length, merged: true, sourceName }
|
|
5226
|
+
marketIndexCache = { at: Date.now(), data: merged, sourceName }
|
|
5227
|
+
try { await writeFile(MARKET_INDEX_CACHE_FILE, JSON.stringify({ at: Date.now(), data: merged, sourceName }), 'utf8') } catch {}
|
|
5228
|
+
sendJson(res, 200, { ok: true, ...merged })
|
|
5229
|
+
return
|
|
5230
|
+
}
|
|
5231
|
+
// 所有源都失败 → 跳过逐个重试,直接进入下方缓存兜底
|
|
5232
|
+
indexList = []
|
|
5233
|
+
}
|
|
4846
5234
|
let lastError = null
|
|
4847
|
-
|
|
5235
|
+
let formatError = null
|
|
5236
|
+
for (const src of indexList) {
|
|
4848
5237
|
try {
|
|
4849
|
-
const data = await fetchJsonUrl(url, 15000)
|
|
5238
|
+
const data = await fetchJsonUrl(src.url, 15000)
|
|
4850
5239
|
if (data && Array.isArray(data.items)) {
|
|
4851
|
-
marketIndexCache = { at: Date.now(), data }
|
|
4852
|
-
|
|
5240
|
+
marketIndexCache = { at: Date.now(), data, sourceName: src.name }
|
|
5241
|
+
try { await writeFile(MARKET_INDEX_CACHE_FILE, JSON.stringify({ at: Date.now(), data, sourceName: src.name }), 'utf8') } catch {}
|
|
5242
|
+
sendJson(res, 200, { ok: true, sourceName: src.name, ...data })
|
|
4853
5243
|
return
|
|
4854
5244
|
}
|
|
5245
|
+
formatError = `索引格式异常(${src.name} 未返回 items 数组)`
|
|
4855
5246
|
} catch (error) {
|
|
4856
5247
|
lastError = error
|
|
4857
5248
|
}
|
|
4858
5249
|
}
|
|
4859
|
-
|
|
5250
|
+
try {
|
|
5251
|
+
const cached = JSON.parse(readFileSync(MARKET_INDEX_CACHE_FILE, 'utf8'))
|
|
5252
|
+
if (cached && cached.data && Array.isArray(cached.data.items)) {
|
|
5253
|
+
marketIndexCache = { at: Date.now(), data: cached.data, sourceName: cached.sourceName ?? null }
|
|
5254
|
+
sendJson(res, 200, { ok: true, offline: true, cachedAt: typeof cached.at === 'number' ? cached.at : null, sourceName: cached.sourceName ?? null, ...cached.data })
|
|
5255
|
+
return
|
|
5256
|
+
}
|
|
5257
|
+
} catch {}
|
|
5258
|
+
const reason = formatError !== null
|
|
5259
|
+
? formatError
|
|
5260
|
+
: `网络不可达(${indexList.length} 个索引源全部失败):${lastError?.message ?? '未知错误'}`
|
|
5261
|
+
sendError(res, 500, `索引加载失败:${reason}`)
|
|
4860
5262
|
return
|
|
4861
5263
|
}
|
|
4862
5264
|
|
|
@@ -4917,7 +5319,8 @@ async function handle(ctx, req, res) {
|
|
|
4917
5319
|
const profileDir = dirname(patchPath)
|
|
4918
5320
|
const targets = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && (p.moduleName === root || String(p.moduleName).startsWith(root + '/')))
|
|
4919
5321
|
if (targets.length === 0) {
|
|
4920
|
-
|
|
5322
|
+
// 无待适配行=全部已适配/已解锁,属正常状态:返回友好提示而非错误(点击「一键启用已适配」不应报"操作失败")
|
|
5323
|
+
sendJson(res, 200, { ok: true, unlocked: [], kept: [], note: '该全家桶没有待适配行(已全部适配/解锁),无需操作' })
|
|
4921
5324
|
return
|
|
4922
5325
|
}
|
|
4923
5326
|
const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
|
|
@@ -5973,16 +6376,22 @@ async function handle(ctx, req, res) {
|
|
|
5973
6376
|
}
|
|
5974
6377
|
|
|
5975
6378
|
if (pathname === `${ROUTE_PREFIX}/repo-clone`) {
|
|
5976
|
-
//
|
|
6379
|
+
// 仓库落地:克隆任意项目到 <配置路径>/<owner>/<name>(地址由「软件源 → Git 源」决定,
|
|
6380
|
+
// 因此这里接受任意平台的仓库链接:GitHub / Gitee / GitLab / 自建 Gitea / 镜像代理前缀)
|
|
5977
6381
|
const raw = typeof body.repo === 'string' ? body.repo.trim() : ''
|
|
6382
|
+
// 循环剥离协议/域名前缀:镜像代理链接可能叠两层(ghproxy.net/https://github.com/...)
|
|
5978
6383
|
let repo = raw
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
5982
|
-
|
|
6384
|
+
for (let guard = 0; guard < 4; guard += 1) {
|
|
6385
|
+
const next = repo
|
|
6386
|
+
.replace(/^https?:\/\/[^/]+\//u, '')
|
|
6387
|
+
.replace(/^git@[^:]+:/u, '')
|
|
6388
|
+
if (next === repo) break
|
|
6389
|
+
repo = next
|
|
6390
|
+
}
|
|
6391
|
+
repo = repo.replace(/\.git$/u, '').replace(/\/+$/u, '')
|
|
5983
6392
|
const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(repo)
|
|
5984
6393
|
if (!m) {
|
|
5985
|
-
sendError(res, 400, '仓库格式不正确:请提供 owner/repo
|
|
6394
|
+
sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或仓库链接(如 https://gitee.com/owner/repo)')
|
|
5986
6395
|
return
|
|
5987
6396
|
}
|
|
5988
6397
|
const [, owner, name] = m
|
|
@@ -5994,8 +6403,24 @@ async function handle(ctx, req, res) {
|
|
|
5994
6403
|
}
|
|
5995
6404
|
try {
|
|
5996
6405
|
mkdirSync(root, { recursive: true })
|
|
5997
|
-
|
|
5998
|
-
|
|
6406
|
+
const urls = gitCloneUrls(`${owner}/${name}`)
|
|
6407
|
+
let lastError = null
|
|
6408
|
+
let cloned = false
|
|
6409
|
+
let usedUrl = ''
|
|
6410
|
+
for (const url of urls) {
|
|
6411
|
+
try {
|
|
6412
|
+
await execFileAsync('git.exe', ['clone', '--depth', '1', url, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: gitEnv() })
|
|
6413
|
+
cloned = true
|
|
6414
|
+
usedUrl = url
|
|
6415
|
+
break
|
|
6416
|
+
} catch (error) {
|
|
6417
|
+
lastError = error
|
|
6418
|
+
// 失败可能留下半成品目录,清理后再试下一个源,否则会因目标已存在而连环失败
|
|
6419
|
+
try { rmSync(target, { recursive: true, force: true }) } catch {}
|
|
6420
|
+
}
|
|
6421
|
+
}
|
|
6422
|
+
if (!cloned) throw lastError ?? new Error('未知错误')
|
|
6423
|
+
sendJson(res, 200, { ok: true, owner, name, path: target, url: usedUrl })
|
|
5999
6424
|
} catch (error) {
|
|
6000
6425
|
sendError(res, 500, `克隆失败:${error instanceof Error ? error.message : String(error)}`)
|
|
6001
6426
|
}
|