@noob-stupid/dsh-plugin-console 0.5.17 → 0.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/README.zh.md +12 -0
- package/lib/client.js +49 -1
- package/lib/server/domain/archive-source.js +209 -0
- package/lib/server/domain/git-channel.js +91 -0
- package/lib/server/domain/install-cleanup.js +62 -0
- package/lib/server/domain/install-job.js +133 -133
- package/lib/server/domain/install.js +2 -0
- package/lib/server/domain/market.js +72 -6
- package/lib/server/domain/repoland.js +270 -32
- package/lib/server/domain/sources.js +26 -2
- package/lib/server/infra/exec.js +13 -1
- package/lib/server/routes/sources.js +80 -1
- package/package.json +1 -1
|
@@ -2,80 +2,31 @@
|
|
|
2
2
|
// 分层 Step 8c-2 从 lib/index.js 搬出;形参由 ctx 收窄为 ports(调用方注入的窄接口),只搬移未改逻辑。
|
|
3
3
|
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
|
|
4
4
|
|
|
5
|
-
import { readFileSync, existsSync
|
|
5
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
6
6
|
import { readFile } from 'node:fs/promises'
|
|
7
|
-
import {
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
8
|
import { aiRepair } from './ai-run.js'
|
|
9
9
|
import { maybeAutoAdaptCompat } from './compat.js'
|
|
10
10
|
import { getReposDir } from './repoland.js'
|
|
11
|
+
import { aiConsentFailureText, cleanupAttemptedCandidates } from './install-cleanup.js'
|
|
12
|
+
import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel } from './git-channel.js'
|
|
11
13
|
import { DEFAULT_BUNDLES, AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, addBundleToManifest, backfillMissingDeps, curlManualInstall, detectBundleOnly, ensureBundlePatchIntegrity, githubReleaseInstall, pnpmInstall, raceInstallChannels, readBundlePatchRefNames, readGithubAuth, syncAggregateSubpackageVersions } from './install.js'
|
|
12
14
|
import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
|
|
13
15
|
import { jobLog } from './jobs.js'
|
|
14
16
|
import { verifyInstalledEntry } from './install-verify.js'
|
|
15
|
-
import { fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
17
|
+
import { expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
16
18
|
import { declareProfileDependency } from './manifest.js'
|
|
17
19
|
import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
|
|
18
20
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
19
21
|
import { detectSkillRepo, runSkillInstallJob } from './skills.js'
|
|
20
|
-
import {
|
|
22
|
+
import { orderedRegistries, readSources } from './sources.js'
|
|
21
23
|
import { reconcileLockfile } from './selfupdate.js'
|
|
22
24
|
import { probeGitmodules, resolveInstallKind, runSuiteInstallJob } from './suite.js'
|
|
23
25
|
import { buildPnpmEnv, execFileAsync, runPnpmWithFallback } from '../infra/exec.js'
|
|
24
|
-
import { cleanupStalePackageDir,
|
|
26
|
+
import { cleanupStalePackageDir, startTrashCleanup, trashScanRoots } from '../infra/fsx.js'
|
|
25
27
|
import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
|
|
26
28
|
import { findPatchPath, packageNameOf, resolvePackageJson } from '../infra/paths.js'
|
|
27
29
|
|
|
28
|
-
/** 安装失败清场:把本次尝试过的候选包目录与 pnpm `_tmp_` 半成品一起清掉,并**如实汇报**清了什么、什么没清掉
|
|
29
|
-
* (2026-09-20 真装演练:聚合仓库跑 19 分钟后失败,node_modules 留着 `…_tmp_56272_2` 半成品,面板只说"安装失败")。
|
|
30
|
-
* 2026-09-26(本次改错):删除一律走 disposeDir(本机 C 盘/`%TEMP%` 下 rmSync 会静默落空,不核实就会谎报干净);
|
|
31
|
-
* 删不掉时它**改名降级成同父目录的 `.trash-<ts>`**(做法借自 2BingLing/dsh-market 的 `.bak-<ts>` + renameSync)。 */
|
|
32
|
-
function cleanupAttemptedCandidates(profileDir, candidates) {
|
|
33
|
-
const cleaned = []
|
|
34
|
-
const failed = []
|
|
35
|
-
const trashed = []
|
|
36
|
-
const dispose = (label, dir) => {
|
|
37
|
-
const result = disposeDir(dir)
|
|
38
|
-
if (result.trashed === true) trashed.push({ name: label, path: dir, trashPath: result.trashPath, note: disposeNote(result) })
|
|
39
|
-
if (result.ok === true) return 1
|
|
40
|
-
failed.push({ name: label, path: dir, error: result.reason, note: disposeNote(result) })
|
|
41
|
-
return 0
|
|
42
|
-
}
|
|
43
|
-
for (const name of candidates) {
|
|
44
|
-
const dir = join(profileDir, 'node_modules', ...String(name).split('/'))
|
|
45
|
-
const parent = dirname(dir)
|
|
46
|
-
const base = basename(dir)
|
|
47
|
-
let touched = 0
|
|
48
|
-
if (existsSync(dir)) touched += dispose(name, dir)
|
|
49
|
-
try {
|
|
50
|
-
for (const entry of readdirSync(parent)) {
|
|
51
|
-
if (!entry.startsWith(`${base}_tmp_`)) continue
|
|
52
|
-
touched += dispose(`${name}(临时目录)`, join(parent, entry))
|
|
53
|
-
}
|
|
54
|
-
} catch {}
|
|
55
|
-
if (touched > 0) cleaned.push(name)
|
|
56
|
-
}
|
|
57
|
-
return { cleaned, failed, trashed }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 授权被拒/超时后的失败文案(纯函数,单测覆盖):说清为什么失败、清理了什么、什么没清掉。
|
|
61
|
-
* 时长取自 AI_CONSENT_TIMEOUT_MS —— 文案里的"10 分钟"不能与实际等待时间脱节。
|
|
62
|
-
* 2026-09-26(本次改错):**不再出现「请手动删除」**——降级成 `.trash-*` 的项由后台清理接手。 */
|
|
63
|
-
function aiConsentFailureText(decision, leftovers) {
|
|
64
|
-
const base = decision?.timeout === true
|
|
65
|
-
? `等待授权超时(${Math.round(AI_CONSENT_TIMEOUT_MS / 60000)} 分钟),已取消本地 AI 兜底(该操作会调用模型 API 产生费用)`
|
|
66
|
-
: '用户取消本地 AI 兜底(该操作会调用模型 API 产生费用)'
|
|
67
|
-
const cleaned = leftovers?.cleaned ?? []
|
|
68
|
-
const failed = leftovers?.failed ?? []
|
|
69
|
-
const trashed = leftovers?.trashed ?? []
|
|
70
|
-
const noteOf = (item) => (typeof item?.note === 'string' && item.note !== '' ? `(${item.note})` : (item?.error != null ? `(${item.error})` : ''))
|
|
71
|
-
const cleanedNote = cleaned.length > 0 ? `;已清理本次落盘残留:${cleaned.join('、')}` : ''
|
|
72
|
-
const trashedNote = trashed.length > 0
|
|
73
|
-
? `;有 ${trashed.length} 项正被占用、已改名降级为 .trash-*(${trashed.map((t) => (typeof t?.note === 'string' && t.note !== '' ? t.note : basename(String(t?.trashPath ?? '')))).join('、')})`
|
|
74
|
-
: ''
|
|
75
|
-
const failedNote = failed.length > 0 ? `;**有 ${failed.length} 项没能清理**:${failed.map((f) => `${f.path}${noteOf(f)}`).join('、')}` : ''
|
|
76
|
-
return `${base}${cleanedNote}${trashedNote}${failedNote}`
|
|
77
|
-
}
|
|
78
|
-
|
|
79
30
|
/** release 通道的候选预算(issue #3):release 通道现在按包名反查发布仓库,**每个候选**至少一次
|
|
80
31
|
* `releases?per_page=10` 调用(第一梯队仓库没命中还要多试几个候选仓库),而候选可能有十几个
|
|
81
32
|
* (聚合仓库懒惰展开后最多 19 个)——不封顶会把 8 分钟的作业时间预算吃光。
|
|
@@ -89,7 +40,6 @@ const RELEASE_CHANNEL_BUDGET = 3
|
|
|
89
40
|
* 否则替身与 cordis 语义不一致,这类缺陷还会再溜过去一次。 */
|
|
90
41
|
function channelImpls(ports) {
|
|
91
42
|
const real = { pnpmInstall, curlManualInstall, raceInstallChannels, githubReleaseInstall, backfillMissingDeps }
|
|
92
|
-
// 测试注入缝(2026-09-22 事故的修法,**别改回属性访问**):
|
|
93
43
|
// 生产路径上 ports 就是 cordis 的 ctx 代理,属性式读取未 inject 的名字会**同步抛**
|
|
94
44
|
// `cannot get property "installChannels" without inject` —— 0.3.59 因此每一次安装都在这里失败。
|
|
95
45
|
// 正规写法是 ctx.get('installChannels')(Cordis 的可选读取,未声明也不抛,与本文件其它
|
|
@@ -118,7 +68,7 @@ function channelImpls(ports) {
|
|
|
118
68
|
* ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
|
|
119
69
|
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
120
70
|
* 级联顺序也不该被破坏)。 */
|
|
121
|
-
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false }) {
|
|
71
|
+
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
|
|
122
72
|
let installedName = null
|
|
123
73
|
let lastError = null
|
|
124
74
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
@@ -134,16 +84,27 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
134
84
|
}
|
|
135
85
|
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
136
86
|
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
87
|
+
try {
|
|
88
|
+
const raced = await ch.raceInstallChannels(profileDir, name, registries)
|
|
89
|
+
if (raced) {
|
|
90
|
+
installedName = name
|
|
91
|
+
if (raced.channel === 'curl') {
|
|
92
|
+
const racedInfo = raced.info
|
|
93
|
+
try {
|
|
94
|
+
const stillMissing = await ch.backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
|
|
95
|
+
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
96
|
+
} catch (backfillError) {
|
|
97
|
+
// 包已装好:补齐依赖失败只影响提示,绝不能因此把这次安装判成失败
|
|
98
|
+
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}(捆绑依赖补齐失败:${backfillError instanceof Error ? backfillError.message : String(backfillError)};网络恢复后建议重新安装)`
|
|
99
|
+
noteChannel(job, `并行 curl 通道的依赖补齐失败(安装本身已成功):${backfillError instanceof Error ? backfillError.message : String(backfillError)}`)
|
|
100
|
+
}
|
|
101
|
+
if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
|
|
102
|
+
job.integrity = racedInfo.integrity ?? null // ④ 下载物摘要校验结果(加法;见 install-verify.js)
|
|
103
|
+
}
|
|
146
104
|
}
|
|
105
|
+
} catch (raceError) {
|
|
106
|
+
lastError = raceError
|
|
107
|
+
noteChannel(job, `并行竞速通道异常(已继续尝试后续串行通道):${raceError instanceof Error ? raceError.message : String(raceError)}`)
|
|
147
108
|
}
|
|
148
109
|
}
|
|
149
110
|
// 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
|
|
@@ -186,31 +147,33 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
186
147
|
} catch (ghError) {
|
|
187
148
|
lastError = ghError
|
|
188
149
|
}
|
|
189
|
-
} else
|
|
190
|
-
//
|
|
191
|
-
|
|
150
|
+
} else {
|
|
151
|
+
// 预算用尽(批次 A-⑤):原因**总是**记在 job 上供面板显示(旧代码只在 lastError === null 时写,用户看不出)
|
|
152
|
+
noteChannel(job, `release 通道因预算跳过(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
153
|
+
if (lastError === null) lastError = new Error(`release 通道候选预算已用尽(本作业只对前 ${RELEASE_CHANNEL_BUDGET} 个候选做按包名反查+release 扫描)`)
|
|
192
154
|
}
|
|
193
155
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
await ch.pnpmInstall(profileDir, spec, undefined, 60000)
|
|
206
|
-
installedName = name
|
|
207
|
-
break
|
|
208
|
-
} catch (gitError) {
|
|
209
|
-
lastError = gitError
|
|
210
|
-
}
|
|
211
|
-
}
|
|
156
|
+
// 懒惰展开(批次 A-③,2026-09-27 搬家):**registry 类通道全部失败之后、git 通道之前**。
|
|
157
|
+
// 旧顺序是"所有通道(含 git)都失败 → 才展开子包":对"根包 private、未发布到 npm"的仓库
|
|
158
|
+
// (真机 zhu1090093659/dsh-web)就是 registry 404 → 直接去 clone 那个 429 MB 的巨仓
|
|
159
|
+
// (ghproxy 上 git 协议 0 B/s,白等且注定失败),而真正能装的聚合子包 @linxin666/dsh-web-all
|
|
160
|
+
// (5.97 MB)连一次机会都没有。expand() 返回新增候选数;展开失败只记备注,绝不短路。
|
|
161
|
+
if (installedName === null && expanded === false && typeof expand === 'function') {
|
|
162
|
+
try {
|
|
163
|
+
const added = await expand()
|
|
164
|
+
if (added > 0) noteChannel(job, `registry 通道未命中,已自动展开仓库子包:新增 ${added} 个候选`)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
|
|
212
167
|
}
|
|
213
168
|
}
|
|
169
|
+
if (installedName === null) {
|
|
170
|
+
// 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
|
|
171
|
+
// 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
|
|
172
|
+
// install-job.js 有 600 行硬顶,见 test-architecture-guard.mjs),此处只保留调用与错误归并。
|
|
173
|
+
const git = await tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded, budget, deadline })
|
|
174
|
+
installedName = git.installedName
|
|
175
|
+
if (git.lastError !== null) lastError = git.lastError
|
|
176
|
+
}
|
|
214
177
|
// ③ 失败分类 → 只对网络类超时用更长超时定向重试**一次**;其余分类只写 job.diagnosis 提示(见 install-diagnose.js)
|
|
215
178
|
if (installedName === null && pnpmError !== null) {
|
|
216
179
|
const retry = await retryPnpmOnce({ ch, profileDir, name, registries, pnpmError, job, baseTimeoutMs: PNPM_INSTALL_TIMEOUT_MS })
|
|
@@ -234,12 +197,28 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
234
197
|
return { installedName, lastError }
|
|
235
198
|
}
|
|
236
199
|
|
|
237
|
-
async function runInstallJob(job, ports) {
|
|
200
|
+
async function runInstallJob(job, ports, deps = {}) {
|
|
201
|
+
// 测试注入缝(2026-09-27,沿用 channelImpls 风格):市场探测与两个时间预算可替换,只为离线断言
|
|
202
|
+
// 「候选顺序、展开时机、预算边界」;生产调用方不传第三个参数,默认值与旧行为一致。
|
|
203
|
+
const probes = {
|
|
204
|
+
fetchRepoPackage,
|
|
205
|
+
fetchRepoPackageEx,
|
|
206
|
+
fetchSubpackageNames,
|
|
207
|
+
subpackageCandidates,
|
|
208
|
+
npmNameHint: npmNameHintForRepo,
|
|
209
|
+
...(deps.marketProbes ?? {}),
|
|
210
|
+
}
|
|
211
|
+
// 展开实现必须跟随**注入的** fetchSubpackageNames(否则注入的探测桩会被绕过、悄悄去打真网络 ——
|
|
212
|
+
// 2026-09-27 全量测试实测到的一次 30 秒空等就是这么来的)
|
|
213
|
+
if (deps.marketProbes?.expandSubpackages === undefined) {
|
|
214
|
+
probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
|
|
215
|
+
}
|
|
216
|
+
const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
|
|
217
|
+
const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
|
|
238
218
|
try {
|
|
239
219
|
job.stage = 'preparing'
|
|
240
|
-
// 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness
|
|
241
|
-
//
|
|
242
|
-
// profile node_modules 不存在 → 整服务启动崩溃。直接拦截。
|
|
220
|
+
// 2026-09-06 事故(室友机器):对 deepseek-ai/deepseek-harness(框架本体仓库)点安装会按 bundle 规则
|
|
221
|
+
// 注册其 patch,其中框架级行的包只存在于 npx 缓存/框架树 → profile 里不存在 → 整服务启动崩溃。直接拦截。
|
|
243
222
|
const repoNorm = String(job.repo ?? '').toLowerCase().replace(/^git\+/u, '').replace(/\.git$/u, '')
|
|
244
223
|
if (repoNorm === 'deepseek-ai/deepseek-harness') {
|
|
245
224
|
job.status = 'failed'
|
|
@@ -248,9 +227,22 @@ async function runInstallJob(job, ports) {
|
|
|
248
227
|
}
|
|
249
228
|
let candidates = [job.packageName].filter((name) => typeof name === 'string' && name !== '')
|
|
250
229
|
let subpackageMode = false
|
|
230
|
+
// ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
|
|
231
|
+
// 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
|
|
232
|
+
if (candidates.length === 0 && job.repo) {
|
|
233
|
+
const npmHint = probes.npmNameHint(job.repo)
|
|
234
|
+
if (npmHint !== null) {
|
|
235
|
+
candidates = [npmHint]
|
|
236
|
+
job.npmNameHint = npmHint
|
|
237
|
+
job.packageName = npmHint
|
|
238
|
+
// git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
|
|
239
|
+
job.gitChannelBlocked = true
|
|
240
|
+
noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
|
|
241
|
+
noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
251
244
|
if (candidates.length === 0) {
|
|
252
|
-
// 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→
|
|
253
|
-
// 不依赖前端标记(搜索结果 enrich 是异步的、索引浏览条目无 enrich)。
|
|
245
|
+
// 套装兜底:submodule 聚合仓库(根 .gitmodules 内容校验通过)→ 自动转套装安装,不依赖前端标记。
|
|
254
246
|
// 判据是**内容**(resolveInstallKind),不是"探测非 null":后者会把代理/CDN 对不存在文件回的
|
|
255
247
|
// 2xx 空 body、垃圾页当成套装(2026-09-19 用户反馈的「未找到 .gitmodules」事故根因)。
|
|
256
248
|
if (resolveInstallKind(job.kind, await probeGitmodules(job.repo)) === 'suite') {
|
|
@@ -273,9 +265,8 @@ async function runInstallJob(job, ports) {
|
|
|
273
265
|
job.kind = 'plugin'
|
|
274
266
|
job.suiteNote = '探测到的 .gitmodules 与仓库实际内容不符(不是 submodule 套装仓库),已自动回落普通插件安装'
|
|
275
267
|
}
|
|
276
|
-
// 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s
|
|
277
|
-
//
|
|
278
|
-
// 默认分支为 dev 的仓库会取错分支(2026-09-20 另一位用户实测)。
|
|
268
|
+
// 兜底:宿主端自行拉取仓库元数据(githubJson 与 curl 竞速 + 8s 超时降级)。8s 而非旧值 3s:
|
|
269
|
+
// IPv6 无路由时单条通道就要 5.4s,3s 预算必输 → branch 恒为 main(2026-09-20 另一位用户实测)。
|
|
279
270
|
const meta = await Promise.race([
|
|
280
271
|
Promise.any([
|
|
281
272
|
githubJson(`${GITHUB_API}/repos/${job.repo}`),
|
|
@@ -284,7 +275,8 @@ async function runInstallJob(job, ports) {
|
|
|
284
275
|
new Promise((resolve) => setTimeout(() => resolve(null), META_BUDGET_MS)),
|
|
285
276
|
]).catch(() => null)
|
|
286
277
|
const branch = meta?.default_branch ?? 'main'
|
|
287
|
-
|
|
278
|
+
job.defaultBranch = branch // 批次 B-⑥:后续读子包一律以它打头(默认分支是 dev 的仓库不再读不到子包)
|
|
279
|
+
const { pkg, reason } = await probes.fetchRepoPackageEx(job.repo, branch)
|
|
288
280
|
if (pkg === null) {
|
|
289
281
|
// 无 package.json:先探测是否技能仓库(含 SKILL.md)→ 自动转技能安装;
|
|
290
282
|
// 否则标记 hint=repo-land,前端给出「仓库落地」一键入口(克隆到本地目录)。
|
|
@@ -304,17 +296,23 @@ async function runInstallJob(job, ports) {
|
|
|
304
296
|
if (pkg.private === true) {
|
|
305
297
|
// 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
|
|
306
298
|
subpackageMode = true
|
|
299
|
+
// 批次 A-③(2026-09-27):根包 private = **没发布到 npm** → registry 通道必然 404,
|
|
300
|
+
// 而 git 通道会去 clone 整个仓库(真机:dsh-web 429 MB、ghproxy 下 0 B/s)。
|
|
301
|
+
// 对该候选禁用 git,把时间让给"展开子包 → 按包名直装"这条真正能成功的路。
|
|
302
|
+
job.privateRoot = true
|
|
303
|
+
job.gitChannelBlocked = true
|
|
304
|
+
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
307
305
|
// 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
|
|
308
306
|
// private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
|
|
309
307
|
// subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
|
|
310
308
|
// 报成了**不存在**,直接把用户带偏。教训:探测失败必须与确定性结论分开表达。
|
|
311
|
-
let subs = await subpackageCandidates(job.repo, branch)
|
|
309
|
+
let subs = await probes.subpackageCandidates(job.repo, branch)
|
|
312
310
|
if (subs.length === 0) {
|
|
313
311
|
// 读不到时先换一条分支重试:meta 探测失败时 branch 恒为 main,而默认分支为 dev 的仓库
|
|
314
312
|
// (本例 dsh-web 就是 dev 为默认分支)main 上的子包布局可能不同/为空;
|
|
315
313
|
// 换分支几乎零成本,却能把"分支取错"这一类假失败挡在报错之前。
|
|
316
314
|
const altBranch = branch === 'main' ? 'dev' : 'main'
|
|
317
|
-
subs = await subpackageCandidates(job.repo, altBranch)
|
|
315
|
+
subs = await probes.subpackageCandidates(job.repo, altBranch)
|
|
318
316
|
if (subs.length > 0) job.subpackageNote = `子包列表取自 ${altBranch} 分支(默认分支探测可能失败)`
|
|
319
317
|
}
|
|
320
318
|
if (subs.length === 0) {
|
|
@@ -336,12 +334,17 @@ async function runInstallJob(job, ports) {
|
|
|
336
334
|
job.packageName = pkg.name
|
|
337
335
|
}
|
|
338
336
|
// 给了根包名但根包实际是 private 聚合仓库(如直接填 dsh-web-ui):
|
|
339
|
-
// 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git
|
|
340
|
-
|
|
341
|
-
|
|
337
|
+
// 与仓库模式同路径——直接展开子包(聚合包优先),跳过 git 装根包的无意义尝试。
|
|
338
|
+
// 批次 C-⑪:候选来自索引 npmName 时跳过这一步(该装哪个包索引已给答案,再问 GitHub 就把"省掉探测"白省了)
|
|
339
|
+
if (!subpackageMode && job.repo && job.packageName !== null && job.npmNameHint === undefined) {
|
|
340
|
+
const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
|
|
342
341
|
if (rootPkg !== null && rootPkg.private === true) {
|
|
343
342
|
subpackageMode = true
|
|
344
|
-
|
|
343
|
+
// 同上游分支:根包未发布 → 禁用 git 通道(批次 A-③)
|
|
344
|
+
job.privateRoot = true
|
|
345
|
+
job.gitChannelBlocked = true
|
|
346
|
+
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
347
|
+
const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
|
|
345
348
|
if (subs.length > 0) {
|
|
346
349
|
candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
|
|
347
350
|
job.subpackages = subs
|
|
@@ -361,7 +364,7 @@ async function runInstallJob(job, ports) {
|
|
|
361
364
|
let expanded = false
|
|
362
365
|
// 可配置软件源:主→备依次尝试(默认 npmmirror → npmjs,可增删自定义/内网源)
|
|
363
366
|
const registries = orderedRegistries(readSources())
|
|
364
|
-
const deadline = Date.now() +
|
|
367
|
+
const deadline = Date.now() + jobBudgetMs
|
|
365
368
|
// 子包级进度(2026-09-20 真装实测:11 个子包的聚合仓库跑了 19 分钟,job.stage 一直停在
|
|
366
369
|
// installing,面板只有一个不动的进度条)。candidateTotal/Index/Name 每轮开始时更新,
|
|
367
370
|
// 由 installJobView 折算成 progress{index,total,name} 下发给面板。
|
|
@@ -380,33 +383,30 @@ async function runInstallJob(job, ports) {
|
|
|
380
383
|
// ⚠️ 本行必须在 `const name` **之后**求值:旧代码把它写在 name 声明之前,`name === job.packageName`
|
|
381
384
|
// 一被求值就命中 TDZ(ReferenceError: Cannot access 'name' before initialization)——
|
|
382
385
|
// 私有聚合根(subpackageMode=true 且 packageName 非空)安装必失败,且报错文案完全指不到真正原因。
|
|
383
|
-
|
|
384
|
-
const
|
|
386
|
+
// 批次 A-③:再叠加 gitChannelBlocked —— 根包未发布(或首选候选来自索引)时不试 git(只 clone 整仓,纯白等)
|
|
387
|
+
const repoChannelAllowed = (!subpackageMode || job.packageName === null || name === job.packageName) && job.gitChannelBlocked !== true
|
|
388
|
+
// 懒惰展开的注入实现(只展开一次:expanded 由这里翻转,后续轮次传进去的就是 true)
|
|
389
|
+
const expandSubpackages = async () => {
|
|
390
|
+
if (expanded || !job.repo) return 0
|
|
391
|
+
expanded = true
|
|
392
|
+
// 批次 B-⑥:以**真实默认分支**打头(fetchSubpackageNames 内部再回退 main / master)
|
|
393
|
+
const extra = await probes.expandSubpackages({ repo: job.repo, branch: job.defaultBranch ?? 'main', auth: readGithubAuth().token, existing: candidates })
|
|
394
|
+
if (extra.length === 0) return 0
|
|
395
|
+
candidates = [...candidates, ...extra]
|
|
396
|
+
// 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
|
|
397
|
+
job.candidateTotal = candidates.length
|
|
398
|
+
if (!Array.isArray(job.subpackages)) job.subpackages = []
|
|
399
|
+
for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
|
|
400
|
+
return extra.length
|
|
401
|
+
}
|
|
402
|
+
const attempt = await tryCandidateChannels({
|
|
403
|
+
job, ch, name, profileDir, registries, repoChannelAllowed, budget,
|
|
404
|
+
baseUrl: ports.baseUrl ?? null, expanded, deadline,
|
|
405
|
+
expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
|
|
406
|
+
})
|
|
385
407
|
installedName = attempt.installedName
|
|
386
408
|
lastError = attempt.lastError
|
|
387
409
|
if (installedName !== null) break
|
|
388
|
-
// 懒惰展开:registry 与 git 通道都失败时,自动发现仓库子包继续尝试(聚合包优先),
|
|
389
|
-
// 覆盖"给了根包名但根包未发布"的场景——AI 兜底只处理真正无解的案例
|
|
390
|
-
if (!expanded && job.repo) {
|
|
391
|
-
expanded = true
|
|
392
|
-
let subs = await fetchSubpackageNames(job.repo, 'main', readGithubAuth().token)
|
|
393
|
-
if (subs.length === 0) subs = await fetchSubpackageNames(job.repo, 'master', readGithubAuth().token)
|
|
394
|
-
if (subs.length > 0) {
|
|
395
|
-
const extra = subs
|
|
396
|
-
.slice()
|
|
397
|
-
.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)))
|
|
398
|
-
.map((sub) => sub.name)
|
|
399
|
-
.filter((n) => !candidates.includes(n))
|
|
400
|
-
.slice(0, 8)
|
|
401
|
-
if (extra.length > 0) {
|
|
402
|
-
candidates = [...candidates, ...extra]
|
|
403
|
-
// 懒惰展开后候选变多:总数要跟着更新,否则面板会显示"第 9/1 个"
|
|
404
|
-
job.candidateTotal = candidates.length
|
|
405
|
-
if (!Array.isArray(job.subpackages)) job.subpackages = []
|
|
406
|
-
for (const e of extra) if (!job.subpackages.includes(e)) job.subpackages.push(e)
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
410
|
}
|
|
411
411
|
if (installedName === null) {
|
|
412
412
|
if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
|
|
@@ -415,12 +415,12 @@ async function runInstallJob(job, ports) {
|
|
|
415
415
|
job.aiPending = { lastError: lastError?.message ?? null }
|
|
416
416
|
// 等授权期间要能展示"为什么卡住、还能等多久":请求时间 + 超时上限 + 最后一个确定性错误
|
|
417
417
|
job.aiPendingSince = Date.now()
|
|
418
|
-
job.aiConsentTimeoutMs =
|
|
418
|
+
job.aiConsentTimeoutMs = aiConsentTimeoutMs
|
|
419
419
|
job.lastError = lastError?.message ?? null
|
|
420
420
|
job.aiWait = new Promise((resolve) => { job.aiPending.resolver = resolve })
|
|
421
421
|
const decision = await Promise.race([
|
|
422
422
|
job.aiWait,
|
|
423
|
-
new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }),
|
|
423
|
+
new Promise((resolve) => setTimeout(() => resolve({ approved: false, timeout: true }), aiConsentTimeoutMs)),
|
|
424
424
|
])
|
|
425
425
|
job.aiPending = null
|
|
426
426
|
job.aiWait = null
|
|
@@ -432,7 +432,7 @@ async function runInstallJob(job, ports) {
|
|
|
432
432
|
// 不能让用户面对"面板说失败、磁盘上却留了半个包和 _tmp_ 残留"的糊涂账。
|
|
433
433
|
const leftovers = cleanupAttemptedCandidates(profileDir, candidates)
|
|
434
434
|
job.leftovers = leftovers
|
|
435
|
-
job.error = aiConsentFailureText(decision, leftovers)
|
|
435
|
+
job.error = aiConsentFailureText(decision, leftovers, aiConsentTimeoutMs)
|
|
436
436
|
}
|
|
437
437
|
return
|
|
438
438
|
}
|
|
@@ -596,4 +596,4 @@ async function readExtraBundleRows(profileDir) {
|
|
|
596
596
|
} catch {}
|
|
597
597
|
return rows
|
|
598
598
|
}
|
|
599
|
-
export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET }
|
|
599
|
+
export { runInstallJob, pnpmRemove, readExtraBundleRows, cleanupAttemptedCandidates, aiConsentFailureText, tryCandidateChannels, channelImpls, RELEASE_CHANNEL_BUDGET, GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs }
|
|
@@ -536,6 +536,8 @@ function installJobView(job) {
|
|
|
536
536
|
lockVersion: job.lockVersion ?? null,
|
|
537
537
|
lockNote: job.lockNote ?? null,
|
|
538
538
|
depNote: job.depNote ?? null, // 依赖来源写回说明(缺陷②:release 专属包按 link: 记录时给出可见解释,绝不静默)
|
|
539
|
+
// 通道备注(2026-09-27 加法):通道"为什么没试/为什么只试了一部分"如实下发(release/git 预算用尽、根包未发布禁 git…)
|
|
540
|
+
channelNotes: Array.isArray(job.channelNotes) && job.channelNotes.length > 0 ? job.channelNotes : null,
|
|
539
541
|
compatNote: job.compatNote ?? null,
|
|
540
542
|
kind: job.kind ?? 'plugin',
|
|
541
543
|
skillName: job.skillName ?? null,
|
|
@@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
6
6
|
import { dirname, join } from 'node:path'
|
|
7
7
|
import { detectSkillRepo } from './skills.js'
|
|
8
8
|
import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
|
|
9
|
-
import { ENRICH_CACHE_FILE, baseDirOf, resolvePackageJson } from '../infra/paths.js'
|
|
9
|
+
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
10
10
|
|
|
11
11
|
function readEnrichCache() {
|
|
12
12
|
try {
|
|
@@ -368,16 +368,27 @@ async function readPluginDetails(moduleName, baseUrl, profileDir) {
|
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
/**
|
|
372
|
-
|
|
371
|
+
/** 读子包时要按顺序试哪些分支(纯函数,单测覆盖 —— 批次 B-⑥,2026-09-27 改错)。
|
|
372
|
+
* 旧代码把分支**写死**成 'main'(读不到再手工换 'master'):默认分支是 dev 的仓库
|
|
373
|
+
* (真机:zhu1090093659/dsh-web 的默认分支就是 dev)永远读不到子包列表,
|
|
374
|
+
* 于是「未发现子包」这类假失败又回来了。现在一律用**已经拿到的**默认分支打头,
|
|
375
|
+
* 再回退 main / master(去重、保序)。 */
|
|
376
|
+
function subpackageBranchOrder(branch, defaults = ['main', 'master']) {
|
|
377
|
+
const list = Array.isArray(branch) ? branch : [branch]
|
|
378
|
+
const merged = [...list, ...defaults].filter((b) => typeof b === 'string' && b.trim() !== '')
|
|
379
|
+
return [...new Set(merged.map((b) => b.trim()))]
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 单个分支上读子包列表(git trees 递归 + 并行读 package.json 的 name)。 */
|
|
383
|
+
async function fetchSubpackageNamesOnBranch(repo, branch, auth, { ghJson, rawText }) {
|
|
373
384
|
try {
|
|
374
|
-
const data = await
|
|
385
|
+
const data = await ghJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
|
|
375
386
|
const paths = (data.tree ?? [])
|
|
376
387
|
.filter((node) => node.type === 'blob' && /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(node.path))
|
|
377
388
|
.map((node) => node.path)
|
|
378
389
|
// 并行读取:黑洞期单条最坏 40s,24 条串行会拖到十几分钟
|
|
379
390
|
const results = await Promise.all(paths.slice(0, 24).map(async (path) => {
|
|
380
|
-
const bodyText = await
|
|
391
|
+
const bodyText = await rawText(repo, branch, path)
|
|
381
392
|
if (bodyText === null) return null
|
|
382
393
|
try {
|
|
383
394
|
const pkg = JSON.parse(bodyText)
|
|
@@ -391,6 +402,61 @@ async function fetchSubpackageNames(repo, branch, auth) {
|
|
|
391
402
|
}
|
|
392
403
|
}
|
|
393
404
|
|
|
405
|
+
/** 从市场索引里查这个仓库的**首选 npm 包名**(批次 C-⑪,2026-09-27 加法)。
|
|
406
|
+
* 数据源与 /market-index 完全一致:routes/market.js 每次成功拉到索引都会写一份落盘缓存
|
|
407
|
+
* (infra/paths.marketIndexCacheFile),这里读它 —— 与内存缓存等价、且跨重启有效。
|
|
408
|
+
* 为什么值得这一步:真机 dsh-web 的根包 private(没发布),旧流程要先探测 package.json、
|
|
409
|
+
* 再展开子包才能找到 @linxin666/dsh-web-all;而本机 api.github.com 不可达时**这两步都做不了**。
|
|
410
|
+
* 兼容性:老索引没有 npmName 字段 → 返回 null → 行为与改动前完全一致。
|
|
411
|
+
* deps(readFile/cacheFile)只为单测注入,生产调用不传。 */
|
|
412
|
+
function npmNameHintForRepo(repo, deps = {}) {
|
|
413
|
+
const full = String(repo ?? '').trim().replace(/\.git$/u, '')
|
|
414
|
+
if (full === '') return null
|
|
415
|
+
const read = deps.readFile ?? ((file) => readFileSync(file, 'utf8'))
|
|
416
|
+
const file = deps.cacheFile ?? marketIndexCacheFile()
|
|
417
|
+
try {
|
|
418
|
+
const parsed = JSON.parse(read(file))
|
|
419
|
+
const items = Array.isArray(parsed?.data?.items) ? parsed.data.items : (Array.isArray(parsed?.items) ? parsed.items : [])
|
|
420
|
+
const hit = items.find((it) => it !== null && typeof it.fullName === 'string' && it.fullName.toLowerCase() === full.toLowerCase())
|
|
421
|
+
if (hit === undefined) return null
|
|
422
|
+
const npmName = typeof hit.npmName === 'string' ? hit.npmName.trim() : ''
|
|
423
|
+
if (npmName === '') return null
|
|
424
|
+
// 只接受合法 npm 包名(索引是外部数据,不能拿它去拼命令)
|
|
425
|
+
return /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u.test(npmName) ? npmName : null
|
|
426
|
+
} catch {
|
|
427
|
+
return null
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
|
|
432
|
+
* 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
|
|
433
|
+
* 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
|
|
434
|
+
async function expandSubpackageCandidates({ repo, branch = 'main', auth = null, existing = [], limit = 8, deps = {} } = {}) {
|
|
435
|
+
const fetchNames = deps.fetchSubpackageNames ?? fetchSubpackageNames
|
|
436
|
+
const subs = await fetchNames(repo, branch, auth, deps.fetchDeps ?? {})
|
|
437
|
+
if (subs.length === 0) return []
|
|
438
|
+
const isAll = (n) => /(^|-)all$/u.test(n) || /-all-/u.test(n)
|
|
439
|
+
return subs
|
|
440
|
+
.map((sub) => sub.name)
|
|
441
|
+
.filter((n) => !existing.includes(n))
|
|
442
|
+
.sort((a, b) => Number(isAll(b)) - Number(isAll(a)))
|
|
443
|
+
.slice(0, limit)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 服务端列出仓库子包(git trees 递归 + 并行读 package.json 的 name)。
|
|
447
|
+
* branch 可以是**单个分支名**(老调用点不变)或**分支数组**(按序试);两种形态都会自动
|
|
448
|
+
* 追加 main / master 兜底(见 subpackageBranchOrder)。deps 只为单测注入,生产调用不传。 */
|
|
449
|
+
async function fetchSubpackageNames(repo, branch, auth, deps = {}) {
|
|
450
|
+
const ghJson = deps.githubJson ?? githubJson
|
|
451
|
+
const rawText = deps.rawText ?? rawTextWithFallback
|
|
452
|
+
for (const b of subpackageBranchOrder(branch)) {
|
|
453
|
+
// eslint-disable-next-line no-await-in-loop
|
|
454
|
+
const subs = await fetchSubpackageNamesOnBranch(repo, b, auth, { ghJson, rawText })
|
|
455
|
+
if (subs.length > 0) return subs
|
|
456
|
+
}
|
|
457
|
+
return []
|
|
458
|
+
}
|
|
459
|
+
|
|
394
460
|
/** 子包候选:聚合包(名字带 all)优先,上限 8 个。
|
|
395
461
|
* 2026-09-20 真装演练:`@dsh-suite/all` 这种 **scope 根形式**(`/all` 结尾)不被
|
|
396
462
|
* `(^|-)all$` 命中,聚合包没能排到最前(那次纯属仓库目录顺序碰巧第一)。补上 `/all$`。 */
|
|
@@ -406,4 +472,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
406
472
|
|
|
407
473
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
408
474
|
|
|
409
|
-
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, subpackageCandidates, ENRICH_CACHE_TTL }
|
|
475
|
+
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, ENRICH_CACHE_TTL }
|