@noob-stupid/dsh-plugin-console 0.5.24 → 0.5.25
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 +27 -1
- package/lib/server/domain/git-channel.js +33 -2
- package/lib/server/domain/install-job.js +31 -33
- package/lib/server/domain/install-verify.js +18 -1
- package/lib/server/domain/install.js +3 -3
- package/lib/server/domain/market.js +18 -1
- package/lib/server/domain/preset-source.js +339 -0
- package/lib/server/domain/repo-size.js +136 -0
- package/lib/server/domain/repoland.js +86 -22
- package/lib/server/domain/suite.js +14 -28
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -303,6 +303,9 @@ window.__ModuleLoader__.load({
|
|
|
303
303
|
diagIntegrityNone: "来源无摘要,未校验",
|
|
304
304
|
diagIntegrityMismatch: "摘要不匹配(已安装,未拦截)",
|
|
305
305
|
diagIntegritySkip: "摘要未校验(读取失败,不影响安装)",
|
|
306
|
+
presetDot: "。预设:新建会话时选择",
|
|
307
|
+
presetInstalledMsg: "预设已装配",
|
|
308
|
+
diagChannelNotes: "通道说明",
|
|
306
309
|
direct: "(浏览器直连 GitHub;若你的浏览器打不开 GitHub,将自动回退到服务端通道)",
|
|
307
310
|
details: "详情",
|
|
308
311
|
expandDesc: "展开",
|
|
@@ -834,6 +837,9 @@ window.__ModuleLoader__.load({
|
|
|
834
837
|
diagIntegrityNone: "No digest from source; not verified",
|
|
835
838
|
diagIntegrityMismatch: "Digest mismatch (installed, not blocked)",
|
|
836
839
|
diagIntegritySkip: "Digest not verified (read failed; install unaffected)",
|
|
840
|
+
presetDot: ". Preset: pick it when starting a new session",
|
|
841
|
+
presetInstalledMsg: "Presets installed",
|
|
842
|
+
diagChannelNotes: "Channel notes",
|
|
837
843
|
direct: "(The browser calls GitHub directly; the server channel is used as fallback.)",
|
|
838
844
|
details: "Details",
|
|
839
845
|
expandDesc: "Expand",
|
|
@@ -2881,6 +2887,11 @@ window.__ModuleLoader__.load({
|
|
|
2881
2887
|
}
|
|
2882
2888
|
} else if (data.ai === true) {
|
|
2883
2889
|
setMessage(t("installed") + ":" + data.packageName + "。" + (data.aiNote ?? t("aiRepaired")) + diagMessage(data));
|
|
2890
|
+
} else if (typeof data.presetNote === "string" && data.presetNote !== "") {
|
|
2891
|
+
// 预设装配成功(2026-09-27 批次 D-③):落盘路径由服务端如实下发,这里补一句
|
|
2892
|
+
// 「预设:新建会话时选择」(点击安装的当下没有新会话,必须先说清楚下一步)。
|
|
2893
|
+
setMessage(t("presetInstalledMsg") + ":" + data.presetNote + t("presetDot") + diagMessage(data));
|
|
2894
|
+
setReloadHint(true);
|
|
2884
2895
|
} else {
|
|
2885
2896
|
setMessage(t("installed") + ":" + data.packageName + "(" + (data.entryId ?? "") + ")。" + (data.curlNote ?? (data.bundle ? t("bundleNote") : t("installNote"))) + (data.bundleNote ? "。" + data.bundleNote : "") + (data.lockNote ? "。⚠️ " + data.lockNote : "") + (data.depNote ? "。" + data.depNote : "") + diagMessage(data));
|
|
2886
2897
|
}
|
|
@@ -2892,9 +2903,12 @@ window.__ModuleLoader__.load({
|
|
|
2892
2903
|
}
|
|
2893
2904
|
if (data.bundle) {
|
|
2894
2905
|
setRestartHint(true);
|
|
2895
|
-
} else if (data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2906
|
+
} else if ((data.suggestedAction !== null && data.suggestedAction !== undefined)
|
|
2907
|
+
|| (typeof data.presetNote === "string" && data.presetNote !== "")) {
|
|
2896
2908
|
// 有建议动作时不自动刷新页面(2026-09-27 加法):旧行为 2.5 秒后 reload,
|
|
2897
2909
|
// 用户根本来不及点「执行」;这里改成给一个"请刷新"的可见提示。
|
|
2910
|
+
// 预设装配同理:只是往 ~/.dsh/.agent-presets 落盘,刷新页面没有意义,
|
|
2911
|
+
// 还会把「落盘路径 + 新建会话时选择」这句提示冲掉。
|
|
2898
2912
|
setReloadHint(true);
|
|
2899
2913
|
} else {
|
|
2900
2914
|
// 安装成功:自动刷新页面,让插件立即出现在列表里(可见的成功反馈)
|
|
@@ -3012,6 +3026,18 @@ window.__ModuleLoader__.load({
|
|
|
3012
3026
|
title: typeof diagnosis.hint === "string" ? diagnosis.hint : null,
|
|
3013
3027
|
});
|
|
3014
3028
|
}
|
|
3029
|
+
// ④ 通道备注(2026-09-27 批次 D-④):服务端一直在下发"为什么没试某个通道"(体积门禁、
|
|
3030
|
+
// 稀疏降级、release/git 预算…),但面板从未展示 → 用户只看到笼统的"失败"。
|
|
3031
|
+
// 失败时逐条显示(最多 6 条),成功时不显示(成功路径已有 presetNote/curlNote 等具体文案)。
|
|
3032
|
+
const channelNotes = Array.isArray(view.channelNotes) ? view.channelNotes.filter((n) => typeof n === "string" && n !== "") : [];
|
|
3033
|
+
if (view.status === "failed" && channelNotes.length > 0) {
|
|
3034
|
+
lines.push({
|
|
3035
|
+
key: "channelNotes",
|
|
3036
|
+
text: t("diagChannelNotes") + t("diagColon") + channelNotes.slice(0, 6).join(t("diagSemi")),
|
|
3037
|
+
level: "warn",
|
|
3038
|
+
title: channelNotes.join("\n"),
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3015
3041
|
return lines;
|
|
3016
3042
|
};
|
|
3017
3043
|
/** 诊断短句 → 消息栏后缀(空串 = 没问题):只在既有文案**末尾追加**,一个字都不替换。 */
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// 而"git 通道怎么用预算"本来就是一块自洽的策略,独立成模块后 install-job 只留一行调用。
|
|
4
4
|
// 抽出只搬移 + 收窄形参,**语义一字未改**(原实现见 commit 8deb112 之前的 tryCandidateChannels 尾段)。
|
|
5
5
|
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
6
8
|
import { gitCloneUrls } from './sources.js'
|
|
7
9
|
|
|
8
10
|
/** git 通道的**独立预算**(2026-09-27 加法):默认 120 秒,可用环境变量
|
|
@@ -38,6 +40,19 @@ function formatBudgetMs(ms) {
|
|
|
38
40
|
return n >= 10000 ? `${Math.round(n / 1000)} 秒` : `${Math.round(n)} 毫秒`
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** 读回 node_modules 里那个包**真实的** package.json.name(读不到返回 null)。
|
|
44
|
+
* 用途见 tryGitChannel 里的核实:git 规格装出来的是**仓库根包**,名字未必等于候选包名。 */
|
|
45
|
+
function installedPackageName(profileDir, name) {
|
|
46
|
+
try {
|
|
47
|
+
const p = join(profileDir, 'node_modules', name, 'package.json')
|
|
48
|
+
if (!existsSync(p)) return null
|
|
49
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'))
|
|
50
|
+
return typeof pkg?.name === 'string' && pkg.name !== '' ? pkg.name : null
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
/** git 通道(守卫③):只对**请求里那个仓库**试 git 规格,且只在"同一作业第一次"试。
|
|
42
57
|
* 返回 { installedName, lastError }(与 tryCandidateChannels 的返回同形,便于原地替换)。
|
|
43
58
|
* 预算规则(2026-09-27):
|
|
@@ -45,7 +60,7 @@ function formatBudgetMs(ms) {
|
|
|
45
60
|
* · 单个规格的 pnpm 超时 = min(60 秒, 剩余);
|
|
46
61
|
* · 预算耗尽就停手,并在 job 上留一条面板可见的原因(不静默跳过)。
|
|
47
62
|
* 依赖注入:ch(通道实现)与 budget 由调用方传入 —— 与 install-job 的注入缝同一套语义。 */
|
|
48
|
-
async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null }) {
|
|
63
|
+
async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, expanded = false, budget = {}, deadline = null, verifyName = null }) {
|
|
49
64
|
let installedName = null
|
|
50
65
|
let lastError = null
|
|
51
66
|
// gitChannelBlocked:作业层面已判定"这个仓库不该走 git"(根包未发布 / 首选候选来自市场索引)。
|
|
@@ -76,6 +91,22 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
|
|
|
76
91
|
try {
|
|
77
92
|
// eslint-disable-next-line no-await-in-loop
|
|
78
93
|
await ch.pnpmInstall(profileDir, spec, undefined, Math.min(GIT_SPEC_TIMEOUT_MS, remain))
|
|
94
|
+
// 装完**读回真实包名核实**(2026-09-27 批次 D-① 一并改掉的缺陷):git 规格装的是**仓库根包**,
|
|
95
|
+
// 候选是子包时名字根本不同(真机 dsh-routing-suite:根包 private、名字
|
|
96
|
+
// @dsh-external/dsh-super-injector,而候选是 dsh-router-standard)。旧代码无条件把 installedName
|
|
97
|
+
// 记成候选名 → 后面 appendInsert 写一条指向不存在模块的补丁行 → 下次启动服务直接崩。
|
|
98
|
+
// 读不到(null)时按"无法核实"放行(不误杀;桩实现/别名安装都可能读不到),但要留下备注。
|
|
99
|
+
const realName = typeof verifyName === 'function' ? verifyName(profileDir, name) : installedPackageName(profileDir, name)
|
|
100
|
+
if (realName === null) {
|
|
101
|
+
noteChannel(job, `git 规格装完后读不到 node_modules/${name}/package.json,无法核实包名(按成功处理,装后校验会再查)`)
|
|
102
|
+
installedName = name
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
if (realName !== name) {
|
|
106
|
+
lastError = new Error(`git 规格装成的是仓库根包 ${realName},不是候选 ${name}(git 只能装根包;子包请走 registry/release/源码装配通道)`)
|
|
107
|
+
noteChannel(job, lastError.message)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
79
110
|
installedName = name
|
|
80
111
|
break
|
|
81
112
|
} catch (gitError) {
|
|
@@ -88,4 +119,4 @@ async function tryGitChannel({ job, ch, name, profileDir, repoChannelAllowed, ex
|
|
|
88
119
|
return { installedName, lastError }
|
|
89
120
|
}
|
|
90
121
|
|
|
91
|
-
export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, tryGitChannel }
|
|
122
|
+
export { GIT_CHANNEL_BUDGET_MS, GIT_SPEC_TIMEOUT_MS, GIT_MIN_ATTEMPT_MS, gitChannelBudgetMs, noteChannel, formatBudgetMs, installedPackageName, tryGitChannel }
|
|
@@ -13,8 +13,10 @@ import { GIT_CHANNEL_BUDGET_MS, GIT_MIN_ATTEMPT_MS, GIT_SPEC_TIMEOUT_MS, gitChan
|
|
|
13
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'
|
|
14
14
|
import { classifyInstallFailure, retryPnpmOnce } from './install-diagnose.js'
|
|
15
15
|
import { jobLog } from './jobs.js'
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { gatePrivateRoot } from './repo-size.js'
|
|
17
|
+
import { tryPresetSourceChannel } from './preset-source.js'
|
|
18
|
+
import { alreadyInstalledTarget, verifyInstalledEntry } from './install-verify.js'
|
|
19
|
+
import { applyNpmNameHint, expandSubpackageCandidates, fetchRepoPackage, fetchRepoPackageEx, fetchSubpackageNames, npmNameHintForRepo, packageProbeErrorText, subpackageCandidates } from './market.js'
|
|
18
20
|
import { declareProfileDependency } from './manifest.js'
|
|
19
21
|
import { appendInsert, readPatchState, syncNameFromNote } from './patch.js'
|
|
20
22
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
@@ -67,19 +69,15 @@ function channelImpls(ports) {
|
|
|
67
69
|
* ③ git 通道:**保留 repoChannelAllowed**(它只 clone `job.repo`,候选是子包时 clone 根仓库装不出子包,
|
|
68
70
|
* 属无意义尝试)**并保留 !expanded**(同一作业里对同一个 job.repo 反复 clone 纯属浪费时间,
|
|
69
71
|
* 级联顺序也不该被破坏)。 */
|
|
70
|
-
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null }) {
|
|
72
|
+
async function tryCandidateChannels({ job, ch, name, profileDir, registries, repoChannelAllowed, budget, baseUrl = null, expanded = false, deadline = null, expand = null, sourceDeps = {} }) {
|
|
71
73
|
let installedName = null
|
|
72
74
|
let lastError = null
|
|
73
75
|
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
|
|
80
|
-
return { installedName: name, lastError: null }
|
|
81
|
-
}
|
|
82
|
-
} catch {}
|
|
76
|
+
// (2026-09-27 搬进 install-verify.js#alreadyInstalledTarget:本文件贴着 600 行架构硬顶)
|
|
77
|
+
const already = alreadyInstalledTarget(profileDir, name, job)
|
|
78
|
+
if (already !== null) {
|
|
79
|
+
job.curlNote = already
|
|
80
|
+
return { installedName: name, lastError: null }
|
|
83
81
|
}
|
|
84
82
|
// 通道 0:并行竞速(pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道)——守卫①
|
|
85
83
|
{
|
|
@@ -165,6 +163,12 @@ async function tryCandidateChannels({ job, ch, name, profileDir, registries, rep
|
|
|
165
163
|
noteChannel(job, `自动展开仓库子包失败(继续走后续通道):${error instanceof Error ? error.message : String(error)}`)
|
|
166
164
|
}
|
|
167
165
|
}
|
|
166
|
+
if (installedName === null) {
|
|
167
|
+
// 通道 n+1c(批次 D-③,2026-09-27 加法):**预设型子包的源码装配** —— 真机 dsh-routing-suite 的第三件
|
|
168
|
+
// dsh-router-standard 只存在于仓库源码里(npm 双 404、release 无资产),按包名的通道全试完才轮到它。
|
|
169
|
+
const preset = await tryPresetSourceChannel({ job, name, source: job.source, deps: sourceDeps })
|
|
170
|
+
if (preset.installed === true) { job.presetDone = true; job.presetNote = preset.note; installedName = name } else if (preset.error !== null && preset.error !== undefined) lastError = preset.error
|
|
171
|
+
}
|
|
168
172
|
if (installedName === null) {
|
|
169
173
|
// 通道 n+2:git 通道(GitHub 走加速代理+直连;Gitee 走对应平台)——守卫③
|
|
170
174
|
// 2026-09-27:实现搬进 domain/git-channel.js(独立预算 + 不得超过作业剩余预算;
|
|
@@ -213,6 +217,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
213
217
|
probes.expandSubpackages = (opts = {}) => expandSubpackageCandidates({ ...opts, deps: { fetchSubpackageNames: probes.fetchSubpackageNames } })
|
|
214
218
|
}
|
|
215
219
|
const jobBudgetMs = Number.isFinite(deps.jobBudgetMs) && deps.jobBudgetMs > 0 ? deps.jobBudgetMs : 8 * 60 * 1000
|
|
220
|
+
// 源码通道(体积门禁 + 稀疏取源码 + 预设装配)的注入缝:与 marketProbes 同一风格,生产调用方不传
|
|
221
|
+
const sourceDeps = deps.sourceDeps ?? {}
|
|
216
222
|
const aiConsentTimeoutMs = Number.isFinite(deps.aiConsentTimeoutMs) && deps.aiConsentTimeoutMs > 0 ? deps.aiConsentTimeoutMs : AI_CONSENT_TIMEOUT_MS
|
|
217
223
|
/** 「安装即声明」的收尾(三个调用点共用一个出口):清单写回结果 → job 上的可见字段。
|
|
218
224
|
* depNote / suggestedAction 也在这里落地:面板必须能看见"清单里**实际**写成了什么",以及"没写成时点哪个按钮"。 */
|
|
@@ -237,16 +243,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
237
243
|
// ★ 市场索引的首选候选(批次 C-⑪):索引带 npmName 时直接按包名走 registry,连"读根 package.json →
|
|
238
244
|
// 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通)。老索引无该字段 → 行为不变。
|
|
239
245
|
if (candidates.length === 0 && job.repo) {
|
|
240
|
-
const
|
|
241
|
-
if (
|
|
242
|
-
candidates = [npmHint]
|
|
243
|
-
job.npmNameHint = npmHint
|
|
244
|
-
job.packageName = npmHint
|
|
245
|
-
// git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
|
|
246
|
-
job.gitChannelBlocked = true
|
|
247
|
-
noteChannel(job, `市场索引给出首选候选:${npmHint}(按包名直装,省掉"探测根包 + 展开子包")`)
|
|
248
|
-
noteChannel(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
|
|
249
|
-
}
|
|
246
|
+
const hinted = applyNpmNameHint(job, probes.npmNameHint(job.repo), noteChannel)
|
|
247
|
+
if (hinted !== null) candidates = hinted
|
|
250
248
|
}
|
|
251
249
|
if (candidates.length === 0) {
|
|
252
250
|
// 套装判定(0.5.19 改错,唯一入口在 suite.js 的 shouldRunSuiteInstall):**先看根包是否已发布**,
|
|
@@ -299,12 +297,10 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
299
297
|
if (pkg.private === true) {
|
|
300
298
|
// 私有 monorepo 根:自动列出子包作为候选(把人工修复经验自动化),聚合包优先
|
|
301
299
|
subpackageMode = true
|
|
302
|
-
// 批次
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
job
|
|
306
|
-
job.gitChannelBlocked = true
|
|
307
|
-
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
300
|
+
// 批次 D-①(2026-09-27 **改错**):根包 private = 没发布到 npm → registry 通道必然 404。
|
|
301
|
+
// 源码通道能不能走改由**仓库体积**判定(批次 A-③ 的一刀切把 dsh-routing-suite 的 preset
|
|
302
|
+
// 子包一起禁掉了,而它只有 1334 KB);note 由 gatePrivateRoot 写入(巨仓/尺寸未知都写清原因与出路)。
|
|
303
|
+
await gatePrivateRoot(job, noteChannel, sourceDeps)
|
|
308
304
|
// 2026-09-20 事故(用户点装 zhu1090093659/dsh-web,报"未发现子包"):该仓库根包确实
|
|
309
305
|
// private: true,但 main/dev 各有 22 个子包目录。失败原因是当时市场索引源全挂、网络受限,
|
|
310
306
|
// subpackageCandidates() 读不到列表 —— 旧代码只有"有没有子包"一个出口,把**没读到**
|
|
@@ -343,10 +339,8 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
343
339
|
const rootPkg = await probes.fetchRepoPackage(job.repo, 'main')
|
|
344
340
|
if (rootPkg !== null && rootPkg.private === true) {
|
|
345
341
|
subpackageMode = true
|
|
346
|
-
//
|
|
347
|
-
job
|
|
348
|
-
job.gitChannelBlocked = true
|
|
349
|
-
noteChannel(job, '根包未发布到 npm(private: true),已跳过 git 克隆通道(避免白拉整个仓库)')
|
|
342
|
+
// 上游分支同一道判据(批次 D-①):按仓库体积决定源码通道能不能走
|
|
343
|
+
await gatePrivateRoot(job, noteChannel, sourceDeps)
|
|
350
344
|
const subs = await probes.subpackageCandidates(job.repo, 'main', readGithubAuth().token)
|
|
351
345
|
if (subs.length > 0) {
|
|
352
346
|
candidates = [...subs.filter((name) => !candidates.includes(name)), ...candidates]
|
|
@@ -404,13 +398,17 @@ async function runInstallJob(job, ports, deps = {}) {
|
|
|
404
398
|
}
|
|
405
399
|
const attempt = await tryCandidateChannels({
|
|
406
400
|
job, ch, name, profileDir, registries, repoChannelAllowed, budget,
|
|
407
|
-
baseUrl: ports.baseUrl ?? null, expanded, deadline,
|
|
401
|
+
baseUrl: ports.baseUrl ?? null, expanded, deadline, sourceDeps,
|
|
408
402
|
expand: (expanded || job.npmNameHint !== undefined) ? null : expandSubpackages,
|
|
409
403
|
})
|
|
410
404
|
installedName = attempt.installedName
|
|
411
405
|
lastError = attempt.lastError
|
|
412
406
|
if (installedName !== null) break
|
|
413
407
|
}
|
|
408
|
+
// 预设型子包走的是"取源码 → 装配到 ~/.dsh/.agent-presets":**没有 node_modules 包、也不该写补丁行/
|
|
409
|
+
// 声明依赖**(否则补丁行指向不存在的模块 → 启动崩溃,2026-09-06 事故同族)。candidateDone/packageName
|
|
410
|
+
// 与普通成功安装对齐,面板显示的才是真正装上的那一件。
|
|
411
|
+
if (job.presetDone === true) { job.status = 'done'; job.stage = 'done'; job.candidateDone = true; job.packageName = job.candidateName ?? job.packageName; return }
|
|
414
412
|
if (installedName === null) {
|
|
415
413
|
if (job.diagnosis == null) job.diagnosis = classifyInstallFailure(lastError?.message) // 分类提示兜底(加法)
|
|
416
414
|
// 本地 AI 兜底会调用模型 API、产生费用:挂起等待用户明确同意后再执行
|
|
@@ -218,4 +218,21 @@ function verifyTarballDigest(tarballPath, dist = null) {
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
/** 「目标包已经在 node_modules 里、且名字就是它」→ 返回面板要显示的短句;否则返回 null。
|
|
222
|
+
* 2026-09-27 从 install-job.js **原样搬出**(那边贴着 600 行架构硬顶):这套判断本来就是"装前只读校验",
|
|
223
|
+
* 与 verifyInstalledEntry 同族,只是发生在装之前。
|
|
224
|
+
* 语义一个字没改:只有 package.json 能解析、name 完全相等、且不是"更新"请求时才算命中
|
|
225
|
+
* (命中就不再重复下载,直接进入启用流程 —— 也避免再触发一次 Windows 上的 EPERM 原子替换)。 */
|
|
226
|
+
function alreadyInstalledTarget(profileDir, name, job = null) {
|
|
227
|
+
const target = join(profileDir, 'node_modules', name, 'package.json')
|
|
228
|
+
if (!existsSync(target)) return null
|
|
229
|
+
try {
|
|
230
|
+
const pkg = JSON.parse(readFileSync(target, 'utf8'))
|
|
231
|
+
if (pkg && pkg.name === name && job?.update !== true) {
|
|
232
|
+
return `已检测到本地已安装 ${name}@${pkg.version ?? '?'},跳过重复下载`
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, alreadyInstalledTarget, MIRROR_HINT }
|
|
@@ -544,7 +544,8 @@ function installJobView(job) {
|
|
|
544
544
|
skillDir: job.skillDir ?? null,
|
|
545
545
|
skillNote: job.skillNote ?? null,
|
|
546
546
|
suiteReport: job.suiteReport ?? null,
|
|
547
|
-
|
|
547
|
+
// 预设装配结果(2026-09-27 批次 D-③ 加法):落盘路径 + "新建会话时选择" + 每个预设的合并/备份明细
|
|
548
|
+
suiteNote: job.suiteNote ?? null, presetNote: job.presetNote ?? null, presetInstalled: job.presetInstalled ?? null, presetSource: job.presetSource ?? null, repoSize: job.repoSize ?? null,
|
|
548
549
|
hint: job.hint ?? null,
|
|
549
550
|
// 子包级/套装级进度(2026-09-20 真装实测缺口:11 个子包的聚合仓库跑了 19 分钟,面板只显示
|
|
550
551
|
// "安装中",用户不知道在装第几个、还剩几个)。子包通道来自 install-job.js 候选循环维护的
|
|
@@ -564,8 +565,7 @@ function installJobView(job) {
|
|
|
564
565
|
}
|
|
565
566
|
}
|
|
566
567
|
|
|
567
|
-
/**
|
|
568
|
-
* (与 #3 的 release 源解析同族);本模块不再重复导出它(全仓库无导入点,导出面由 lib/index.js 决定)。 */
|
|
568
|
+
/** 「从 GitHub Release 下载预构建 tgz 装配」已搬到 domain/release-source.js(与 #3 的 release 源解析同族)。 */
|
|
569
569
|
|
|
570
570
|
async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
|
|
571
571
|
const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
|
|
@@ -428,6 +428,23 @@ function npmNameHintForRepo(repo, deps = {}) {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/** 市场索引首选候选落地(批次 C-⑪,2026-09-27;批次 D-③ 从 install-job 搬进 market.js,
|
|
432
|
+
* 那边贴着 600 行架构硬顶)。索引带 npmName 时直接按包名走 registry,连"读根 package.json →
|
|
433
|
+
* 展开子包"这一轮都省掉(本机 api.github.com 不可达时那条路根本走不通);老索引无该字段 → null。
|
|
434
|
+
* 副作用与搬移前**一字不差**:写 job.npmNameHint / job.packageName / job.gitChannelBlocked + 两条备注。 */
|
|
435
|
+
function applyNpmNameHint(job, hint, note = null) {
|
|
436
|
+
if (typeof hint !== 'string' || hint === '') return null
|
|
437
|
+
job.npmNameHint = hint
|
|
438
|
+
job.packageName = hint
|
|
439
|
+
// git 通道只 clone 整个仓库(真机 dsh-web 429 MB / ghproxy 0 B/s),而且装不出这个子包 → 禁掉
|
|
440
|
+
job.gitChannelBlocked = true
|
|
441
|
+
if (typeof note === 'function') {
|
|
442
|
+
note(job, `市场索引给出首选候选:${hint}(按包名直装,省掉"探测根包 + 展开子包")`)
|
|
443
|
+
note(job, '已跳过 git 克隆通道:git 只 clone 整个仓库、装不出这个包(真机 dsh-web = 429 MB)')
|
|
444
|
+
}
|
|
445
|
+
return [hint]
|
|
446
|
+
}
|
|
447
|
+
|
|
431
448
|
/** 懒惰展开(批次 A-③ 的实现;2026-09-27 从 install-job.js 搬进 market.js —— 那边触到 600 行硬顶):
|
|
432
449
|
* 读仓库子包列表 → **聚合包优先**(名字以 all 结尾/-all- 形式)→ 去掉已有候选 → 限量返回。
|
|
433
450
|
* 分支由调用方给(install-job 传 meta.default_branch),fetchSubpackageNames 自己会回退 main/master。 */
|
|
@@ -472,4 +489,4 @@ async function subpackageCandidates(repo, branch, auth) {
|
|
|
472
489
|
|
|
473
490
|
const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
|
|
474
491
|
|
|
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 }
|
|
492
|
+
export { readEnrichCache, writeEnrichCache, enrichItemOne, enrichItems, normalizePlatformItems, githubRepoInfo, fetchRepoPackage, fetchRepoPackageEx, packageProbeErrorText, searchNpmPackages, searchSubpackageItems, parseRepoFromUrl, hasDirectNameHit, summarizeReadme, readPluginDetails, fetchSubpackageNames, fetchSubpackageNamesOnBranch, subpackageBranchOrder, subpackageCandidates, expandSubpackageCandidates, npmNameHintForRepo, applyNpmNameHint, ENRICH_CACHE_TTL }
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// L1 · domain —— preset-source.js(预设型子包的**源码装配**:稀疏取源码 → 定位 → 装配到 ~/.dsh/.agent-presets)
|
|
2
|
+
//
|
|
3
|
+
// 真机事故(2026-09-27,用户实测 yjh051108/dsh-routing-suite「三件套只装到两件」):
|
|
4
|
+
// 该仓库三件套 = `graded/`(release 有 tgz,已装)、`injector/`(release 有 tgz,已装)、
|
|
5
|
+
// **`preset/`(= dsh-router-standard,「思维模式路由预设」:npm 双 404、release 无资产 →
|
|
6
|
+
// 只存在于仓库源码里)**。旧流程对"根包 private"的仓库一刀切禁用源码通道(批次 A-③),
|
|
7
|
+
// 于是第三件连一次机会都没有;而"添加并启用"这条路上,按包名的通道(registry/curl/release)
|
|
8
|
+
// 对它必然是 404 —— 缺的就是"从仓库里把那一个目录取下来,按预设装配"这条能力。
|
|
9
|
+
//
|
|
10
|
+
// 本模块提供三件事(**唯一实现**,套装路径与候选路径共用,绝不复制粘贴第二套):
|
|
11
|
+
// ① findPresetDirs / isPresetDir —— 预设目录识别(从 suite.js **原样搬来**,suite.js 改为 re-export)
|
|
12
|
+
// ② assemblePreset —— 装配一个预设到 `<DSH_HOME>/.agent-presets/<name>`(**已存在同名预设时
|
|
13
|
+
// 绝不静默覆盖**:先整目录备份,再逐文件合并;同名不同内容的逐条报告)
|
|
14
|
+
// ③ fetchPresetSource / tryPresetSourceChannel —— 稀疏取源码(`--filter=blob:none --sparse`
|
|
15
|
+
// + `git sparse-checkout set <subdir>`;不支持时降级普通 clone)并装配
|
|
16
|
+
//
|
|
17
|
+
// 进程管理一律复用 repoland.js 的 runGitArgs / gitCloneRepo(kill-tree + wait-for-exit + `.tryN` +
|
|
18
|
+
// `.trash-*` 降级)—— 本模块**不新造任何进程管理**。
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { basename, dirname, join, relative, sep } from 'node:path'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { gitCloneRepo, runGitArgs } from './repoland.js'
|
|
24
|
+
import { noteChannel } from './git-channel.js'
|
|
25
|
+
import { evaluateSourceChannel } from './repo-size.js'
|
|
26
|
+
import { copyTree, disposeDir } from '../infra/fsx.js'
|
|
27
|
+
import { dshHome } from '../infra/paths.js'
|
|
28
|
+
|
|
29
|
+
/** 预设的判别文件(框架发现预设看的就是这个:`~/.dsh/.agent-presets/<name>/agent.cordis.yml`)。 */
|
|
30
|
+
const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
|
|
31
|
+
/** 预设清单(可选,面板文案用)。 */
|
|
32
|
+
const PRESET_MANIFEST_FILE = 'preset.yml'
|
|
33
|
+
/** git 只读命令(ls-tree / show / sparse-checkout)的超时:都是元数据或单个 blob。 */
|
|
34
|
+
const GIT_READ_TIMEOUT_MS = 60000
|
|
35
|
+
|
|
36
|
+
/** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。
|
|
37
|
+
* 2026-09-27 从 suite.js **原样搬来**(只搬移未改逻辑):两个调用方(套装装配 / 预设子包装配)
|
|
38
|
+
* 必须用**同一份**判据,否则"套装装得下、候选装不下"这类漂移又会回来。 */
|
|
39
|
+
function findPresetDirs(root, maxDepth = 2) {
|
|
40
|
+
const out = []
|
|
41
|
+
const walk = (dir, depth) => {
|
|
42
|
+
if (depth > maxDepth) return
|
|
43
|
+
let entries = []
|
|
44
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
45
|
+
if (existsSync(join(dir, PRESET_MANIFEST_FILE)) && existsSync(join(dir, 'agent.cordis.yml'))) {
|
|
46
|
+
out.push(dir)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
51
|
+
walk(join(dir, e.name), depth + 1)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
walk(root, 0)
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 这个目录自己就是一个 agent 预设吗(直接含 agent.cordis.yml/.yaml)。
|
|
59
|
+
* 与 findPresetDirs 的差别:findPresetDirs 还要求 preset.yml(列表展示用);这里更宽 ——
|
|
60
|
+
* 只要框架能挂载就算预设(有的预设不写 preset.yml)。 */
|
|
61
|
+
function isPresetDir(dir) {
|
|
62
|
+
return PRESET_MARKER_FILES.some((f) => existsSync(join(dir, f)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 「预设型包名」的形状判据(**纯函数**,只是**便宜的初筛**,不作最终判据)。
|
|
66
|
+
* 最终判据永远是"目录里真的有 agent.cordis.yml"(见 tryPresetSourceChannel):
|
|
67
|
+
* 名字猜错只损失一次便宜的源码探测;而拿名字当判据会把普通插件误当预设装配。
|
|
68
|
+
* 覆盖:dsh-router-standard / dsh_router_standard / router-preset / @scope/dsh-preset-* 等。 */
|
|
69
|
+
function looksLikePresetPackageName(name) {
|
|
70
|
+
return /(?:^|[-_/])(?:router|preset)(?:[-_/]|$)/iu.test(String(name ?? '').trim())
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。 */
|
|
74
|
+
function readPresetManifest(dir) {
|
|
75
|
+
try {
|
|
76
|
+
const text = readFileSync(join(dir, PRESET_MANIFEST_FILE), 'utf8')
|
|
77
|
+
const pick = (key) => {
|
|
78
|
+
const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
|
|
79
|
+
return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
|
|
80
|
+
}
|
|
81
|
+
return { name: pick('name'), description: pick('description'), order: pick('order') }
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 目录树里的文件清单(相对路径 + 字节数)——只读,best-effort,绝不抛。 */
|
|
88
|
+
function listTreeFiles(root, { maxDepth = 8 } = {}) {
|
|
89
|
+
const out = []
|
|
90
|
+
const walk = (dir, depth) => {
|
|
91
|
+
if (depth > maxDepth) return
|
|
92
|
+
let entries = []
|
|
93
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
const child = join(dir, e.name)
|
|
96
|
+
try {
|
|
97
|
+
if (e.isDirectory()) walk(child, depth + 1)
|
|
98
|
+
else if (e.isFile()) out.push({ rel: relative(root, child).split(sep).join('/'), bytes: statSync(child).size })
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
walk(String(root), 0)
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
|
|
107
|
+
* 已存在同名预设时**绝不静默覆盖**:
|
|
108
|
+
* ① 先把原目录整份备份成 `<name>.bak-<ts>`(备份失败就**不合并**,如实报冲突);
|
|
109
|
+
* ② 再逐文件合并:源里有、目标里没有的算「新增」;两边都有且内容不同的算「覆盖」并逐条列出
|
|
110
|
+
* (内容相同的算「一致」,只在统计里体现,不刷屏)。
|
|
111
|
+
* 这是**加法**:旧代码(suite.js 里那三行)是 `rmSync(dest)` + `copyTree` —— 用户手上的同名预设
|
|
112
|
+
* 会被无声抹掉(真机 `.agent-presets` 下就有 router-standard / router-spec / router-react 三个在用)。
|
|
113
|
+
* 返回 { ok, name, dest, backup, added, overwritten, identical, conflicts, bytes, note }。 */
|
|
114
|
+
function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now } = {}) {
|
|
115
|
+
const root = presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
116
|
+
const presetName = String(name ?? basename(srcDir)).trim()
|
|
117
|
+
if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
|
|
118
|
+
return { ok: false, name: presetName, dest: null, error: `预设目录名不合法:${JSON.stringify(name)}` }
|
|
119
|
+
}
|
|
120
|
+
const dest = join(root, presetName)
|
|
121
|
+
const srcFiles = listTreeFiles(srcDir)
|
|
122
|
+
if (srcFiles.length === 0) return { ok: false, name: presetName, dest, error: `源目录里没有任何文件:${srcDir}` }
|
|
123
|
+
const bytes = srcFiles.reduce((sum, f) => sum + f.bytes, 0)
|
|
124
|
+
let backup = null
|
|
125
|
+
const existed = existsSync(dest)
|
|
126
|
+
if (existed) {
|
|
127
|
+
backup = `${dest}.bak-${now()}`
|
|
128
|
+
try {
|
|
129
|
+
copyTree(dest, backup)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
name: presetName,
|
|
134
|
+
dest,
|
|
135
|
+
error: `已存在同名预设且备份失败(绝不在没备份的情况下覆盖):${error instanceof Error ? error.message : String(error)}`,
|
|
136
|
+
conflicts: srcFiles.map((f) => f.rel),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
mkdirSync(root, { recursive: true })
|
|
141
|
+
const added = []
|
|
142
|
+
const overwritten = []
|
|
143
|
+
const identical = []
|
|
144
|
+
for (const file of srcFiles) {
|
|
145
|
+
const from = join(srcDir, ...file.rel.split('/'))
|
|
146
|
+
const target = join(dest, ...file.rel.split('/'))
|
|
147
|
+
const had = existsSync(target)
|
|
148
|
+
let same = false
|
|
149
|
+
if (had) {
|
|
150
|
+
try { same = readFileSync(target).equals(readFileSync(from)) } catch { same = false }
|
|
151
|
+
}
|
|
152
|
+
if (same) { identical.push(file.rel); continue }
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
155
|
+
writeFileSync(target, readFileSync(from))
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return { ok: false, name: presetName, dest, backup, error: `合并 ${file.rel} 失败:${error instanceof Error ? error.message : String(error)}` }
|
|
158
|
+
}
|
|
159
|
+
if (had) overwritten.push(file.rel)
|
|
160
|
+
else added.push(file.rel)
|
|
161
|
+
}
|
|
162
|
+
const note = existed
|
|
163
|
+
? `预设 ${presetName}:已存在同名预设,原目录已备份到 ${backup};本次合并新增 ${added.length} 个、覆盖 ${overwritten.length} 个同名文件、内容一致 ${identical.length} 个`
|
|
164
|
+
+ (overwritten.length > 0 ? `(被覆盖的:${overwritten.slice(0, 6).join('、')}${overwritten.length > 6 ? ' 等' : ''})` : '')
|
|
165
|
+
: `预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`
|
|
166
|
+
return { ok: true, name: presetName, dest, backup, added, overwritten, identical, conflicts: overwritten, bytes, files: srcFiles.map((f) => f.rel), note }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
|
|
170
|
+
* 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
|
|
171
|
+
function gitCapture(argv, { cwd, timeout, runGit = null } = {}) {
|
|
172
|
+
const run = typeof runGit === 'function' ? runGit : runGitArgs
|
|
173
|
+
return run(argv, { cwd, timeout })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 用 git 在**本地**定位候选对应的子包目录(零额外网络):
|
|
177
|
+
* `git ls-tree -r --name-only HEAD` 列出全部路径 → 只挑第 1/2 层的 package.json(与
|
|
178
|
+
* market.js#fetchSubpackageNamesOnBranch 的检索面一致)→ `git show HEAD:<path>` 核对 name
|
|
179
|
+
* (partial clone 会按需只拉这一个 blob)。
|
|
180
|
+
* 返回 { subdir, pkg, error };subdir 为 null 表示没找到。 */
|
|
181
|
+
async function locateSubpackageViaGit({ root, candidateName, runGit = null, timeout = GIT_READ_TIMEOUT_MS }) {
|
|
182
|
+
const want = String(candidateName ?? '').trim()
|
|
183
|
+
if (want === '') return { subdir: null, pkg: null, error: '候选包名为空' }
|
|
184
|
+
const listed = await gitCapture(['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: root, timeout, runGit })
|
|
185
|
+
if (listed.code !== 0) return { subdir: null, pkg: null, error: `git ls-tree 失败:${String(listed.stderr ?? '').slice(0, 200)}` }
|
|
186
|
+
const paths = String(listed.stdout ?? '').split(/\r?\n/u)
|
|
187
|
+
.map((l) => l.trim())
|
|
188
|
+
.filter((l) => /^(?!node_modules\/)[^/]+(?:\/[^/]+)?\/package\.json$/u.test(l))
|
|
189
|
+
for (const path of paths) {
|
|
190
|
+
// eslint-disable-next-line no-await-in-loop
|
|
191
|
+
const shown = await gitCapture(['show', `HEAD:${path}`], { cwd: root, timeout, runGit })
|
|
192
|
+
if (shown.code !== 0) continue
|
|
193
|
+
try {
|
|
194
|
+
const pkg = JSON.parse(String(shown.stdout ?? ''))
|
|
195
|
+
if (pkg !== null && typeof pkg === 'object' && pkg.name === want) {
|
|
196
|
+
return { subdir: path.split('/').slice(0, -1).join('/'), pkg, error: null }
|
|
197
|
+
}
|
|
198
|
+
} catch {}
|
|
199
|
+
}
|
|
200
|
+
return { subdir: null, pkg: null, error: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 稀疏/定向取源码(**批次 D-② 的实现**):
|
|
204
|
+
* ① 骨架稀疏克隆 `git clone --depth 1 --filter=blob:none --sparse`(只取提交/树对象 + 根目录文件)
|
|
205
|
+
* —— 走 gitCloneRepo,因此自动带 `.tryN` / 探活 / kill-tree / wait-for-exit / `.trash-*` 降级 / archive 兜底;
|
|
206
|
+
* ② `git ls-tree` 在**本地**定位候选对应的子包目录(零额外网络);
|
|
207
|
+
* ③ `git sparse-checkout set <subdir>` 只把那一个目录的 blob 拉下来。
|
|
208
|
+
* 任一步"不被支持"(旧 git / 服务端不支持 partial clone)→ **降级到普通 clone** 并如实记 notes。
|
|
209
|
+
* 返回 { ok, root, subdir, sparse, downgraded, notes, error, missing }。 */
|
|
210
|
+
async function fetchPresetSource({ repo, candidateName, dest, source = 'github', timeout = undefined, deps = {} }) {
|
|
211
|
+
const gitClone = typeof deps.gitClone === 'function' ? deps.gitClone : gitCloneRepo
|
|
212
|
+
const runGit = typeof deps.runGit === 'function' ? deps.runGit : null
|
|
213
|
+
const cloneOpts = { ...(deps.cloneOpts ?? {}), sparse: [] }
|
|
214
|
+
const notes = []
|
|
215
|
+
let cloned = null
|
|
216
|
+
try {
|
|
217
|
+
cloned = await gitClone(repo, dest, source, timeout, cloneOpts)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return { ok: false, sparse: false, downgraded: false, notes, error: `稀疏取源码失败(clone):${error instanceof Error ? error.message : String(error)}` }
|
|
220
|
+
}
|
|
221
|
+
const sparse = cloned?.sparse === true
|
|
222
|
+
const downgradedFromSparse = cloned?.downgradedFromSparse === true
|
|
223
|
+
if (sparse) notes.push(`已用稀疏取源码(--filter=blob:none --sparse):只取提交/树对象 + 根目录文件${typeof cloned?.url === 'string' ? `(源:${cloned.url})` : ''}`)
|
|
224
|
+
if (downgradedFromSparse) notes.push('稀疏取源码不被支持,已降级到普通 clone')
|
|
225
|
+
const runTimeout = Number.isFinite(timeout) && timeout > 0 ? timeout : GIT_READ_TIMEOUT_MS
|
|
226
|
+
const locate = await locateSubpackageViaGit({ root: dest, candidateName, runGit, timeout: runTimeout })
|
|
227
|
+
if (locate.error !== null && locate.error !== undefined) {
|
|
228
|
+
return { ok: false, sparse, downgraded: downgradedFromSparse, notes, error: locate.error }
|
|
229
|
+
}
|
|
230
|
+
if (locate.subdir === null) {
|
|
231
|
+
return { ok: false, sparse, downgraded: downgradedFromSparse, notes, missing: true, error: `仓库里没有名为 ${candidateName} 的子包(第 1/2 层 package.json 都没匹配上)` }
|
|
232
|
+
}
|
|
233
|
+
if (!sparse) return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: false, downgraded: downgradedFromSparse, notes }
|
|
234
|
+
const set = await gitCapture(['sparse-checkout', 'set', locate.subdir], { cwd: dest, timeout: runTimeout, runGit })
|
|
235
|
+
if (set.code === 0) {
|
|
236
|
+
notes.push(`已用 git sparse-checkout set ${locate.subdir} 定向拉取该目录`)
|
|
237
|
+
return { ok: true, root: dest, subdir: locate.subdir, pkg: locate.pkg, sparse: true, downgraded: false, notes }
|
|
238
|
+
}
|
|
239
|
+
// sparse-checkout 不可用(旧 git)→ 降级普通 clone 再定位一次(不把候选判死)
|
|
240
|
+
notes.push(`git sparse-checkout 不可用(${String(set.stderr ?? '').slice(0, 160)}),已降级到普通 clone`)
|
|
241
|
+
const plainDest = `${dest}-plain`
|
|
242
|
+
try { disposeDir(plainDest) } catch {}
|
|
243
|
+
try {
|
|
244
|
+
await gitClone(repo, plainDest, source, timeout, { ...(deps.cloneOpts ?? {}), sparse: null })
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return { ok: false, sparse: true, downgraded: true, notes, error: `稀疏不被支持、降级普通 clone 也失败:${error instanceof Error ? error.message : String(error)}` }
|
|
247
|
+
}
|
|
248
|
+
const again = await locateSubpackageViaGit({ root: plainDest, candidateName, runGit, timeout: runTimeout })
|
|
249
|
+
if (again.subdir === null) {
|
|
250
|
+
return { ok: false, sparse: true, downgraded: true, notes, error: again.error ?? `普通 clone 后仍未找到子包 ${candidateName}` }
|
|
251
|
+
}
|
|
252
|
+
return { ok: true, root: plainDest, subdir: again.subdir, pkg: again.pkg, sparse: true, downgraded: true, notes }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 候选级「源码通道」:预设型子包的源码装配(install-job 在所有按包名的通道失败后调用)。
|
|
256
|
+
* 判据链(每一条都写进 job.channelNotes,用户能看到"试了什么、为什么没试"):
|
|
257
|
+
* ① 名字不像预设型(looksLikePresetPackageName)→ 直接返回,**不联网、不取源码**;
|
|
258
|
+
* ② 源码通道体积门禁(repo-size 的缓存判据)→ 不允许就带 note 返回;
|
|
259
|
+
* ③ 稀疏取源码 → 本地定位子包目录 → 目录里有 agent.cordis.yml 才算预设(最终判据);
|
|
260
|
+
* ④ 逐个 assemblePreset 到 `<DSH_HOME>/.agent-presets/<name>`。
|
|
261
|
+
* 返回 { handled, installed, name, presets, note, error }。
|
|
262
|
+
* handled=false → "这个候选与预设无关",上层照旧走后续通道;
|
|
263
|
+
* handled=true 且 installed=false → "确实是预设型但没装成",error 是**具体原因**。 */
|
|
264
|
+
async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4, deps = {} } = {}) {
|
|
265
|
+
const candidateName = String(name ?? '').trim()
|
|
266
|
+
if (!looksLikePresetPackageName(candidateName)) return { handled: false, installed: false, error: null }
|
|
267
|
+
const repo = String(job?.repo ?? '').trim()
|
|
268
|
+
if (repo === '') return { handled: false, installed: false, error: null }
|
|
269
|
+
// 同一作业里同一个候选只试一次(防止上游重入把一次源码取下来了两次)
|
|
270
|
+
const tried = Array.isArray(job?.presetTried) ? job.presetTried : []
|
|
271
|
+
if (tried.includes(candidateName)) return { handled: true, installed: false, error: null }
|
|
272
|
+
if (job !== null && job !== undefined) job.presetTried = [...tried, candidateName]
|
|
273
|
+
const gate = await evaluateSourceChannel(repo, deps.sizeDeps ?? {})
|
|
274
|
+
if (gate.allow !== true) {
|
|
275
|
+
noteChannel(job, `预设型候选 ${candidateName} 的源码装配被跳过:${gate.note}`)
|
|
276
|
+
return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName} 需要从仓库源码装配,但源码通道不可用:${gate.note}`) }
|
|
277
|
+
}
|
|
278
|
+
const tmpRoot = deps.tmpRoot ?? join(tmpdir(), `dsh-preset-${job?.id ?? 'job'}-${Date.now()}`)
|
|
279
|
+
const dest = join(tmpRoot, 'repo')
|
|
280
|
+
try { mkdirSync(tmpRoot, { recursive: true }) } catch {}
|
|
281
|
+
try {
|
|
282
|
+
const fetched = await fetchPresetSource({ repo, candidateName, dest, source, timeout: deps.cloneTimeoutMs, deps: deps.fetchDeps ?? {} })
|
|
283
|
+
for (const n of fetched.notes ?? []) noteChannel(job, n)
|
|
284
|
+
if (fetched.ok !== true) {
|
|
285
|
+
noteChannel(job, `预设型子包 ${candidateName} 的源码装配失败:${fetched.error}`)
|
|
286
|
+
return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${fetched.error}`), presets: [] }
|
|
287
|
+
}
|
|
288
|
+
const subDir = join(fetched.root, ...String(fetched.subdir).split('/'))
|
|
289
|
+
const presets = findPresetDirs(subDir, 2)
|
|
290
|
+
// 兜底:子包目录自己就是预设(有的仓库直接把 agent.cordis.yml 放在子包根)
|
|
291
|
+
if (presets.length === 0 && isPresetDir(subDir)) presets.push(subDir)
|
|
292
|
+
if (presets.length === 0) {
|
|
293
|
+
const why = `${candidateName} 的子包目录(${fetched.subdir})里没有 agent.cordis.yml —— 它不是预设型子包,不能按预设装配`
|
|
294
|
+
noteChannel(job, why)
|
|
295
|
+
return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${why}`), presets: [] }
|
|
296
|
+
}
|
|
297
|
+
const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
|
|
298
|
+
const reports = []
|
|
299
|
+
const skipped = Math.max(0, presets.length - limit)
|
|
300
|
+
for (const p of presets.slice(0, limit)) {
|
|
301
|
+
// eslint-disable-next-line no-await-in-loop
|
|
302
|
+
const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
|
|
303
|
+
reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
|
|
304
|
+
}
|
|
305
|
+
const failed = reports.filter((r) => r.ok !== true)
|
|
306
|
+
if (job !== null && job !== undefined) {
|
|
307
|
+
job.presetInstalled = reports.map((r) => ({
|
|
308
|
+
name: r.name, dest: r.dest, ok: r.ok === true, added: r.added?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
|
|
309
|
+
identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0, error: r.error ?? null,
|
|
310
|
+
marker: r.ok === true ? 'agent.cordis.yml' : null, subdir: r.subdir ?? null, sparse: r.sparse === true,
|
|
311
|
+
}))
|
|
312
|
+
job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
|
|
313
|
+
}
|
|
314
|
+
if (failed.length > 0 && failed.length === reports.length) {
|
|
315
|
+
return { handled: true, installed: false, error: new Error(`预设装配失败:${failed[0].error}`), presets: reports }
|
|
316
|
+
}
|
|
317
|
+
const okNames = reports.filter((r) => r.ok === true).map((r) => r.name)
|
|
318
|
+
// 只要**建过备份**就必须说(哪怕本次一个文件都没覆盖)——否则用户不知道多了一个 .bak 目录,
|
|
319
|
+
// 也不知道"这次其实什么都没改"。
|
|
320
|
+
const merged = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
|
|
321
|
+
const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
|
|
322
|
+
+ (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
|
|
323
|
+
+ (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
|
|
324
|
+
+ (merged.length > 0
|
|
325
|
+
? `;已存在同名预设 ${merged.map((r) => r.name).join('、')}:原目录已备份(${merged.map((r) => r.backup).join('、')}),本次按文件合并(覆盖 ${merged.reduce((s, r) => s + r.overwritten.length, 0)} 个、内容一致 ${merged.reduce((s, r) => s + r.identical.length, 0)} 个)`
|
|
326
|
+
: '')
|
|
327
|
+
+ (failed.length > 0 ? `;${failed.length} 个预设装配失败:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')
|
|
328
|
+
return { handled: true, installed: true, name: candidateName, presets: reports, note }
|
|
329
|
+
} finally {
|
|
330
|
+
// 临时目录清理复用既有降级(删不掉就改名成 .trash-*),绝不静默留下垃圾
|
|
331
|
+
try { disposeDir(tmpRoot) } catch {}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export {
|
|
336
|
+
PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
|
|
337
|
+
findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
|
|
338
|
+
assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
|
|
339
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// L1 · domain —— repo-size.js(仓库体积探测 + 「源码通道」放行判据;2026-09-27 加法,批次 D-①)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要这个模块(真机事故,2026-09-27,用户实测 yjh051108/dsh-routing-suite「三件套只装到两件」):
|
|
4
|
+
// 0.5.18 的批次 A-③ 为了治 zhu1090093659/dsh-web(429 MB)被白拉,写成
|
|
5
|
+
// 「**根包未发布(private)→ 禁用 git/archive 通道**」——判据挂在"根包 private"这一个布尔量上。
|
|
6
|
+
// 而 dsh-routing-suite 的根包恰好也是 private,于是它的源码通道被一并跳过:
|
|
7
|
+
// `graded/`、`injector/` 有 release 产物(装上了),**`preset/`(= dsh-router-standard 预设)
|
|
8
|
+
// 只存在于仓库源码里** → npm 双 404、release 无资产 → 第三件永远装不上。
|
|
9
|
+
// 一刀切的错在于:真正的成本是**仓库体积**,不是"根包是否 private"。
|
|
10
|
+
// 本模块把判据换成按体积分档(小仓库放行 / 巨仓仍禁 / 探测不到时保守但不沉默)。
|
|
11
|
+
//
|
|
12
|
+
// 探测复用既有 http 多通道(githubJson 的官方+镜像+gh 竞速、curlJson 的 curl 快通道),
|
|
13
|
+
// 与 suite.js#fetchRepoMeta 同一套写法与同一个预算常量;结果按仓库缓存(默认 6 小时)。
|
|
14
|
+
|
|
15
|
+
import { GITHUB_API, META_BUDGET_MS, curlJson, githubJson } from '../infra/http.js'
|
|
16
|
+
|
|
17
|
+
/** 源码通道的体积上限(MB,可用 `DSH_GIT_MAX_REPO_MB` 配置,夹在 1 MB ~ 4096 MB)。
|
|
18
|
+
* 20 MB 的取法:真机能装的最小聚合子包 @linxin666/dsh-web-all 是 5.97 MB,
|
|
19
|
+
* 社区插件仓库实测中位数远小于 20 MB;而 dsh-web 的 429 MB 是它的 21 倍 —— 阈值落在两簇之间,
|
|
20
|
+
* 不会因为"多几 MB 的文档/截图"把正常插件误杀,也不会放过巨仓。 */
|
|
21
|
+
const GIT_MAX_REPO_MB = 20
|
|
22
|
+
function gitMaxRepoMb(env = process.env) {
|
|
23
|
+
const raw = Number.parseInt(String(env?.DSH_GIT_MAX_REPO_MB ?? ''), 10)
|
|
24
|
+
if (!Number.isFinite(raw)) return GIT_MAX_REPO_MB
|
|
25
|
+
return Math.min(4096, Math.max(1, raw))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** KB → MB 可读写法(保留 1 位小数;只服务面板文案)。 */
|
|
29
|
+
function formatRepoSize(sizeKb) {
|
|
30
|
+
const kb = Number(sizeKb)
|
|
31
|
+
if (!Number.isFinite(kb) || kb < 0) return '未知'
|
|
32
|
+
return `${(kb / 1024).toFixed(1)} MB`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 源码通道判据(**纯函数**,边界矩阵单测覆盖):探测结果 × 阈值 → { allow, note }。
|
|
36
|
+
* · state='known' 且 sizeKb ≤ 阈值 → 放行(note 说清体积与阈值,用户知道"为什么这次允许")
|
|
37
|
+
* · state='known' 且 sizeKb > 阈值 → **仍禁**,note 必须含体积、原因与出路(仓库落地 / 本地镜像)
|
|
38
|
+
* · state='unknown'(探测失败/超时/404)→ **保守但不沉默**:保持禁令 + note 说明"尺寸未知,已保守跳过"
|
|
39
|
+
* 返回的 note 一律非空 —— 静默跳过正是这次事故的另一半(用户什么都看不到)。 */
|
|
40
|
+
function sourceChannelGate({ state = 'unknown', sizeKb = null } = {}, thresholdMb = GIT_MAX_REPO_MB) {
|
|
41
|
+
const limit = Number.isFinite(Number(thresholdMb)) && Number(thresholdMb) >= 1 ? Number(thresholdMb) : GIT_MAX_REPO_MB
|
|
42
|
+
const kb = Number(sizeKb)
|
|
43
|
+
const known = state === 'known' && Number.isFinite(kb) && kb >= 0
|
|
44
|
+
if (!known) {
|
|
45
|
+
return {
|
|
46
|
+
allow: false,
|
|
47
|
+
state: 'unknown',
|
|
48
|
+
sizeKb: null,
|
|
49
|
+
limitMb: limit,
|
|
50
|
+
note: '仓库体积未知(GitHub 元数据探测失败/超时/404):已**保守跳过**源码通道(git / archive / 稀疏取源码),避免白拉一个可能几百 MB 的仓库。请重试;或改用「仓库落地」把仓库克隆到本地目录后安装。',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (kb <= limit * 1024) {
|
|
54
|
+
return {
|
|
55
|
+
allow: true,
|
|
56
|
+
state: 'known',
|
|
57
|
+
sizeKb: kb,
|
|
58
|
+
limitMb: limit,
|
|
59
|
+
note: `仓库体积 ${formatRepoSize(kb)}(上限 ${limit} MB):已放行源码通道(git / archive / 稀疏取源码)`,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
allow: false,
|
|
64
|
+
state: 'known',
|
|
65
|
+
sizeKb: kb,
|
|
66
|
+
limitMb: limit,
|
|
67
|
+
// 同时给 MB 与 KB:刚好卡在阈值附近时(20481 KB vs 20480 KB)MB 会四舍五入成同一个数,
|
|
68
|
+
// 只写 MB 会让用户看不出"到底超了多少"。
|
|
69
|
+
note: `仓库过于庞大(${formatRepoSize(kb)}/${Math.round(kb)} KB,上限 ${limit} MB):已跳过源码通道(git / archive / 稀疏取源码)——请用「仓库落地」把仓库克隆到本地目录,或改用本地镜像/子包(已发布到 npm 的聚合包优先)。`,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 探测结果缓存(默认 6 小时):同一个作业里 git 通道与预设源码通道会各问一次,
|
|
74
|
+
* 跨作业也会反复问(市场卡片重试)。缓存键是 owner/repo(大小写不敏感)。
|
|
75
|
+
* 只缓存**成功**结果:探测失败不落缓存,下次重试有机会拿到尺寸(免得一次网络抖动把仓库
|
|
76
|
+
* 永久钉在"尺寸未知"上)。 */
|
|
77
|
+
const SIZE_CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
78
|
+
const sizeCache = new Map()
|
|
79
|
+
|
|
80
|
+
/** 仓库体积探测:`GET /repos/{owner}/{repo}` 的 `size`(KB)。
|
|
81
|
+
* 走既有 http 多通道(githubJson 官方+镜像竞速,且带 gh CLI 兜底;curlJson 走系统网络栈),
|
|
82
|
+
* 与 META_BUDGET_MS 同一个外层预算(黑洞期不卡 40 秒)。
|
|
83
|
+
* 返回 { state: 'known'|'unknown', sizeKb, note, cached }。**绝不抛**(探测失败是正常结局)。 */
|
|
84
|
+
async function fetchRepoSize(repo, deps = {}) {
|
|
85
|
+
const full = String(repo ?? '').trim().replace(/\.git$/u, '')
|
|
86
|
+
if (full === '' || !full.includes('/')) return { state: 'unknown', sizeKb: null, note: '仓库名无效', cached: false }
|
|
87
|
+
const key = full.toLowerCase()
|
|
88
|
+
const now = deps.now ?? Date.now
|
|
89
|
+
const cache = deps.cache ?? sizeCache
|
|
90
|
+
const ttl = Number.isFinite(deps.ttlMs) ? deps.ttlMs : SIZE_CACHE_TTL_MS
|
|
91
|
+
const hit = cache.get(key)
|
|
92
|
+
if (hit !== undefined && now() - hit.at < ttl) return { ...hit.value, cached: true }
|
|
93
|
+
const race = deps.race ?? (async () => Promise.race([
|
|
94
|
+
Promise.any([
|
|
95
|
+
githubJson(`${GITHUB_API}/repos/${full}`, undefined, deps.token ?? null),
|
|
96
|
+
curlJson(`${GITHUB_API}/repos/${full}`, 12000, {}, { ipv4: true }),
|
|
97
|
+
]),
|
|
98
|
+
new Promise((resolve) => setTimeout(() => resolve(null), Number.isFinite(deps.budgetMs) ? deps.budgetMs : META_BUDGET_MS)),
|
|
99
|
+
]).catch(() => null))
|
|
100
|
+
let meta = null
|
|
101
|
+
try { meta = await race() } catch { meta = null }
|
|
102
|
+
const sizeKb = Number(meta?.size)
|
|
103
|
+
if (meta !== null && meta !== undefined && Number.isFinite(sizeKb) && sizeKb >= 0) {
|
|
104
|
+
const value = { state: 'known', sizeKb, note: '', cached: false }
|
|
105
|
+
try { cache.set(key, { at: now(), value }) } catch {}
|
|
106
|
+
return value
|
|
107
|
+
}
|
|
108
|
+
return { state: 'unknown', sizeKb: null, note: '未能读到仓库体积(元数据探测失败/超时/404)', cached: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 一次算完「这个仓库的源码通道能不能走」:探测 → 纯判据。deps.probe 只为单测注入。 */
|
|
112
|
+
async function evaluateSourceChannel(repo, deps = {}) {
|
|
113
|
+
const probe = typeof deps.probe === 'function' ? deps.probe : fetchRepoSize
|
|
114
|
+
let result = null
|
|
115
|
+
try { result = await probe(repo, deps) } catch { result = null }
|
|
116
|
+
const state = result?.state === 'known' ? 'known' : 'unknown'
|
|
117
|
+
const gate = sourceChannelGate({ state, sizeKb: result?.sizeKb ?? null }, deps.thresholdMb ?? gitMaxRepoMb(deps.env ?? process.env))
|
|
118
|
+
return { ...gate, cached: result?.cached === true }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 把判据落到 job 上(唯一入口,install-job 的两处 private 根分支共用):
|
|
122
|
+
* 写 job.privateRoot / job.repoSize / job.gitChannelBlocked / job.sourceChannelBlocked,并把 note 交给调用方记档。
|
|
123
|
+
* 返回值同 evaluateSourceChannel(调用方若要给更细的下一步文案可直接用 note)。 */
|
|
124
|
+
async function gatePrivateRoot(job, note, deps = {}) {
|
|
125
|
+
const gate = await evaluateSourceChannel(job?.repo, deps)
|
|
126
|
+
if (job !== null && job !== undefined) {
|
|
127
|
+
job.privateRoot = true
|
|
128
|
+
job.repoSize = { state: gate.state, sizeKb: gate.sizeKb, limitMb: gate.limitMb }
|
|
129
|
+
job.gitChannelBlocked = gate.allow !== true
|
|
130
|
+
job.sourceChannelBlocked = gate.allow !== true
|
|
131
|
+
}
|
|
132
|
+
if (typeof note === 'function') note(job, gate.note)
|
|
133
|
+
return gate
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { GIT_MAX_REPO_MB, SIZE_CACHE_TTL_MS, sizeCache, gitMaxRepoMb, formatRepoSize, sourceChannelGate, fetchRepoSize, evaluateSourceChannel, gatePrivateRoot }
|
|
@@ -127,6 +127,8 @@ function summarizeCloneErrors(errors) {
|
|
|
127
127
|
if (e.deferred === true && e.timedOut !== true) return `${e.url}(探活失败的源,已在本轮末尾重试)`
|
|
128
128
|
if (e.skipped === true) return `${e.url}(探活失败,已降级到本轮末尾重试)`
|
|
129
129
|
if (e.retrying === true) return `${e.url}(超时,已改用更长超时重试)`
|
|
130
|
+
// 批次 D-②(2026-09-27):稀疏不被支持 → 已降级普通 clone,原因要留在清单里(不再只报"失败")
|
|
131
|
+
if (typeof e.sparseUnsupported === 'string') return `${e.url}(${e.message})`
|
|
130
132
|
if (e.timedOut === true) {
|
|
131
133
|
// 批次 B-⑧(2026-09-27):超时到底"收到多少字节"必须写出来 —— 0 B 就是镜像只连不传(
|
|
132
134
|
// 换源即可),有字节则是真在传、只是慢(值得再等)。旧文案只有"超时"两个字,用户无法判断。
|
|
@@ -232,18 +234,32 @@ function measureProgressBytes(part, deps = {}) {
|
|
|
232
234
|
return bytes > 0 ? bytes : measure(part)
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
+
/** git 稀疏/定向取源码的参数(2026-09-27 加法,批次 D-②):`--filter=blob:none` 只取提交/树对象
|
|
238
|
+
* (不取文件内容),`--sparse` 让工作区只落地根目录文件;需要哪个目录再用
|
|
239
|
+
* `git sparse-checkout set <dir>` 单独拉下来。真机实测(yjh051108/dsh-routing-suite,1334 KB):
|
|
240
|
+
* 骨架 92 KB / 3.9 秒 → `sparse-checkout set preset` 后总共 1.0 MB;而整仓 clone 是 2.34 MB / 17.3 秒。
|
|
241
|
+
* 意义不在这个仓库省了多少,而在**巨仓里的小目录**:整仓 clone 会白拉几百 MB,稀疏只拉那一个目录。 */
|
|
242
|
+
const GIT_SPARSE_FLAGS = ['--filter=blob:none', '--sparse']
|
|
243
|
+
|
|
244
|
+
/** 跑一条 git 命令:支持超时,且**超时即杀掉整棵树并等它真的退出**。
|
|
245
|
+
* 返回 { code, stdout, stderr, timedOut, pid, exited }(stdout 供 ls-tree/show 这类只读命令用)。
|
|
246
|
+
* 2026-09-27 从 runGitClone **原样抽出**(只把 argv 变成形参、cwd 变成选项):稀疏取源码的
|
|
247
|
+
* `sparse-checkout set` / `ls-tree` / `show` 与 clone 共用同一套进程管理(停滞判据 + kill-tree +
|
|
248
|
+
* wait-for-exit),**不新造第二套**。 */
|
|
249
|
+
function runGitArgs(argv, { timeout = GIT_CLONE_TIMEOUT_MS, cwd = null, spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300, stallGuard = true } = {}) {
|
|
237
250
|
return new Promise((resolve) => {
|
|
238
251
|
let settled = false
|
|
239
252
|
let timedOut = false
|
|
240
|
-
const finish = (payload) => { if (!settled) { settled = true; resolve(payload) } }
|
|
253
|
+
const finish = (payload) => { if (!settled) { settled = true; resolve({ ...payload, stdout: stdout.trim() }) } }
|
|
241
254
|
let child = null
|
|
242
255
|
let stderr = ''
|
|
256
|
+
let stdout = ''
|
|
257
|
+
const args = [...(stallGuard ? GIT_STALL_ARGS : []), ...argv]
|
|
243
258
|
try {
|
|
244
|
-
child = spawnFn('git',
|
|
259
|
+
child = spawnFn('git', args, {
|
|
245
260
|
windowsHide: true,
|
|
246
261
|
env: gitEnv(),
|
|
262
|
+
...(cwd === null ? {} : { cwd }),
|
|
247
263
|
detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
|
|
248
264
|
})
|
|
249
265
|
} catch (error) {
|
|
@@ -262,12 +278,49 @@ function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProce
|
|
|
262
278
|
finish({ code: -1, stderr: stderr.trim(), timedOut: true, pid: child.pid, exited })
|
|
263
279
|
}, timeout)
|
|
264
280
|
child.stderr?.on?.('data', (chunk) => { stderr += String(chunk) })
|
|
281
|
+
child.stdout?.on?.('data', (chunk) => { stdout += String(chunk) })
|
|
265
282
|
child.on('error', (error) => { clearTimeout(timer); if (timedOut) return; finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: child.pid, exited: true }) })
|
|
266
283
|
// 超时分支自己收尾(要先把退出等完),这里的 close 不能再抢答
|
|
267
284
|
child.on('close', (code) => { clearTimeout(timer); if (timedOut) return; finish({ code: typeof code === 'number' ? code : -1, stderr: stderr.trim(), timedOut: false, pid: child.pid, exited: true }) })
|
|
268
285
|
})
|
|
269
286
|
}
|
|
270
287
|
|
|
288
|
+
/** git 报"我不认识这个选项/子命令"的判据(纯函数,单测覆盖):稀疏取源码不被支持时要能识别,
|
|
289
|
+
* 才能**降级到普通 clone**(而不是把整个候选判死)。真机可能的三类:
|
|
290
|
+
* · 旧 git 不认 `--filter` / `--sparse`(`unknown option` / `error: unknown option`)
|
|
291
|
+
* · 服务端不支持 partial clone(git 只警告,不算失败 → 这里不误判)
|
|
292
|
+
* · `sparse-checkout` 子命令不存在(`git: 'sparse-checkout' is not a git command`) */
|
|
293
|
+
function sparseUnsupportedReason(text) {
|
|
294
|
+
const s = String(text ?? '')
|
|
295
|
+
if (/is not a git command|unknown subcommand|no such command/iu.test(s)) return 'git 不认识 sparse-checkout 子命令(版本过旧)'
|
|
296
|
+
if (/unknown option|unrecognized option|invalid option|usage: git clone/iu.test(s)) return 'git 不认识 --filter/--sparse 选项(版本过旧)'
|
|
297
|
+
return null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 跑一次 git clone:`sparse` 传数组则走稀疏取源码(`--filter=blob:none --sparse`;非空时再
|
|
301
|
+
* `sparse-checkout set` 把那几个目录拉下来,"不支持"由 sparseUnsupportedReason 识别后交给上层降级)。
|
|
302
|
+
* 返回 { code, stderr, timedOut, pid, exited, sparse, sparseUnsupported }。 */
|
|
303
|
+
async function runGitClone(url, dest, timeout, { sparse = null, spawnFn = spawn, killTree = killProcessTree, exitWaitMs = 800, exitWaitMs2 = 300 } = {}) {
|
|
304
|
+
const useSparse = Array.isArray(sparse)
|
|
305
|
+
const args = ['clone', '--depth', '1', ...(useSparse ? GIT_SPARSE_FLAGS : []), '--quiet', url, dest]
|
|
306
|
+
const res = await runGitArgs(args, { timeout, spawnFn, killTree, exitWaitMs, exitWaitMs2 })
|
|
307
|
+
if (res.code !== 0 || !useSparse || sparse.length === 0) {
|
|
308
|
+
return { ...res, sparse: useSparse, sparseUnsupported: sparseUnsupportedReason(res.stderr) }
|
|
309
|
+
}
|
|
310
|
+
// 稀疏克隆成功 → 再把这几个目录单独拉下来(同一个 runGitArgs:超时照样杀树 + 等退出)
|
|
311
|
+
const set = await runGitArgs(['sparse-checkout', 'set', ...sparse.map((d) => String(d))], { timeout, cwd: dest, spawnFn, killTree, exitWaitMs, exitWaitMs2 })
|
|
312
|
+
if (set.code === 0) return { ...res, sparse: true, sparseUnsupported: null }
|
|
313
|
+
return {
|
|
314
|
+
code: -1,
|
|
315
|
+
stderr: `${set.stderr || `git sparse-checkout set 退出码 ${set.code}`}`,
|
|
316
|
+
timedOut: set.timedOut === true,
|
|
317
|
+
pid: set.pid ?? null,
|
|
318
|
+
exited: set.exited !== false,
|
|
319
|
+
sparse: true,
|
|
320
|
+
sparseUnsupported: sparseUnsupportedReason(set.stderr),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
271
324
|
/** git 智能 HTTP 的探活地址(批次 B-⑦②,2026-09-27 改错):从 `HEAD /` 改成
|
|
272
325
|
* `GET <url>/info/refs?service=git-upload-pack` —— 后者是 git 协议的**真实入口**,
|
|
273
326
|
* 响应首行必须是以 4 位十六进制长度开头的 pkt-line(如 `001e# service=git-upload-pack`),
|
|
@@ -332,22 +385,17 @@ async function probeSourceAlive(url, timeoutMs = 4000, deps = {}) {
|
|
|
332
385
|
* 每次尝试都用**全新唯一目录** `.tryN`,成功后才 rename 到 dest —— 这样即使上一轮残留目录被占用,
|
|
333
386
|
* 也不会再出现"一个源失败 → 后面所有源都因目录非空而无效"的连锁失效。
|
|
334
387
|
* 2026-09-26 真机加法:① 超时杀树后**等进程真的退出**(runGitClone 内两轮 kill+wait);
|
|
335
|
-
* ② 失败后的清理走
|
|
336
|
-
*
|
|
337
|
-
* ③ 源策略:**同一个源超时后用更长超时(默认 1.75
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* 手动删除,也不再因为"清不掉"而放弃后面本来可用的源。`deps.removeDir` 这个注入口名字没变。
|
|
343
|
-
* 2026-09-27(本次改错):① 首轮超时 180 秒 → 60 秒(停滞判据已把"只连不传"提前到 ≈20 秒判死);
|
|
344
|
-
* ② "同源 1.75 倍长超时重试"从**无条件**改成**只对"有进度"的源**——
|
|
345
|
-
* 每个失败记录都带上 `bytesReceived`(.git/objects 落盘字节数),0 B 就说明
|
|
346
|
-
* 这个源一点都没传,再用更长超时重试只是把白等拉长(真机 ghproxy 实测 0 B/s)。
|
|
388
|
+
* ② 失败后的清理走 disposeDir(删不掉就改名降级成 `.trash-<ts>-<rand>`,见 infra/fsx.js)
|
|
389
|
+
* —— 残留目录不再需要用户手动删除,也不因"清不掉"放弃后面本来可用的源;
|
|
390
|
+
* ③ 源策略:**同一个源超时后用更长超时(默认 1.75 倍)重试一次**,并把**上次
|
|
391
|
+
* 成功过的源**提到最前来试(本机直连 github 不通、唯一可用源就是 ghproxy)。
|
|
392
|
+
* 2026-09-27(改错):① 首轮超时 180 秒 → 60 秒(停滞判据已把"只连不传"提前到 ≈20 秒判死);
|
|
393
|
+
* ② 长超时重试从**无条件**改成**只对"有进度"的源**(失败记录带 `bytesReceived`,
|
|
394
|
+
* 0 B 就是一点没传,再等只是把白等拉长 —— 真机 ghproxy 实测 0 B/s);
|
|
347
395
|
* 2026-09-27(批次 B-⑦):③ 探活判据换成 `GET <url>/info/refs?service=git-upload-pack` + pkt-line 校验,
|
|
348
|
-
*
|
|
349
|
-
* ④ **探活失败的源不再"一次定生死"
|
|
350
|
-
*
|
|
396
|
+
* 失败归因(网络不可达 / 本地代理·证书拦截)写进错误清单;
|
|
397
|
+
* ④ **探活失败的源不再"一次定生死"**:降级到**最后一轮**再试一次。
|
|
398
|
+
* 2026-09-27(批次 D-②):⑤ `deps.sparse` 走稀疏取源码;不被支持时**降级到普通 clone**(下一轮)。 */
|
|
351
399
|
async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_TIMEOUT_MS, deps = {}) {
|
|
352
400
|
const {
|
|
353
401
|
spawnFn = spawn,
|
|
@@ -366,7 +414,13 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
|
|
|
366
414
|
reuseLanded = true,
|
|
367
415
|
findLanded = findLandedRepo,
|
|
368
416
|
copyLanded = copyTree,
|
|
417
|
+
sparse = null,
|
|
369
418
|
} = deps
|
|
419
|
+
// 批次 D-②(2026-09-27 加法):`sparse` 传数组即走稀疏取源码(`--filter=blob:none --sparse`,
|
|
420
|
+
// 非空时再 `sparse-checkout set` 拉那几个目录)。**稀疏不被支持时降级到普通 clone**:
|
|
421
|
+
// 同一个源的第二轮(更长超时)改用普通 clone,并把原因如实写进错误清单。
|
|
422
|
+
const wantSparse = Array.isArray(sparse)
|
|
423
|
+
let sparseDowngraded = false
|
|
370
424
|
// ★ 已落地仓库优先复用(批次 C-⑩):命中就直接把本地那份搬到 dest,一个网络请求都不发。
|
|
371
425
|
// 跳过 .git 是有意的(copyTree 的既有语义):上层要的是仓库**内容**(package.json/.gitmodules),
|
|
372
426
|
// 本机已有完整仓库,没必要把几百 MB 的 .git 再复制一份。
|
|
@@ -405,15 +459,17 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
|
|
|
405
459
|
partSeq += 1
|
|
406
460
|
const part = `${dest}.try${partSeq}`
|
|
407
461
|
const useTimeout = round === 0 ? timeout : retryTimeout
|
|
462
|
+
// 稀疏只在第一轮试(round 0);"不被支持"或退到第二轮时一律普通 clone(降级语义)。
|
|
463
|
+
const useSparse = wantSparse && round === 0 ? sparse : null
|
|
408
464
|
try { removeDir(part) } catch {}
|
|
409
465
|
// eslint-disable-next-line no-await-in-loop
|
|
410
|
-
const res = await runGitClone(url, part, useTimeout, { spawnFn, killTree, exitWaitMs })
|
|
466
|
+
const res = await runGitClone(url, part, useTimeout, { spawnFn, killTree, exitWaitMs, sparse: useSparse })
|
|
411
467
|
if (res.code === 0) {
|
|
412
468
|
try {
|
|
413
469
|
removeDir(dest)
|
|
414
470
|
renameDir(part, dest)
|
|
415
471
|
try { writeMemo(cand.urlTemplate) } catch {}
|
|
416
|
-
return { result: { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0, deferred: isDeferred } }
|
|
472
|
+
return { result: { url, attempt: sourceIndex + 1, tries: partSeq, dir: dest, source: cand.id, retried: round > 0, deferred: isDeferred, sparse: res.sparse === true, downgradedFromSparse: sparseDowngraded } }
|
|
417
473
|
} catch (error) {
|
|
418
474
|
errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part, deferred: isDeferred })
|
|
419
475
|
return null
|
|
@@ -448,6 +504,14 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
|
|
|
448
504
|
})
|
|
449
505
|
return null // 清不掉的残留和这个源绑着,换下一个源(新目录不受影响)
|
|
450
506
|
}
|
|
507
|
+
// ★ 批次 D-②(2026-09-27):稀疏取源码**不被支持**(旧 git 不认 --filter/--sparse,或没有
|
|
508
|
+
// sparse-checkout 子命令)→ **降级到普通 clone**:同一轮的下一轮(更长超时)改用普通 clone,
|
|
509
|
+
// 并把原因如实写进错误清单(不静默降级,用户能在失败/备注里看到"为什么走了整仓 clone")。
|
|
510
|
+
if (round === 0 && typeof res.sparseUnsupported === 'string') {
|
|
511
|
+
sparseDowngraded = true
|
|
512
|
+
errors.push({ url, message: `稀疏取源码不被支持(${res.sparseUnsupported}),已降级到普通 clone 重试`, stderr: res.stderr, sparseUnsupported: res.sparseUnsupported, sparse: true, bytesReceived, dir: part, deferred: isDeferred, ...trashField })
|
|
513
|
+
continue
|
|
514
|
+
}
|
|
451
515
|
if (res.timedOut === true && round === 0 && hasProgress) {
|
|
452
516
|
// ★ 同源、更长超时,重试一次 —— **只对真有进度的源**(0 B 的源再等一次只是把白等拉长)
|
|
453
517
|
errors.push({ url, message: `克隆超时(${useTimeout}ms,本次已收到 ${bytesReceived} B),改用 ${retryTimeout}ms 同源重试`, stderr: res.stderr, timedOut: true, retrying: true, bytesReceived, dir: part, deferred: isDeferred, ...trashField })
|
|
@@ -530,4 +594,4 @@ async function gitCloneRepo(repo, dest, source = 'github', timeout = GIT_CLONE_T
|
|
|
530
594
|
throw new Error(summarizeCloneErrors(errors))
|
|
531
595
|
}
|
|
532
596
|
|
|
533
|
-
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, findLandedRepo, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive, probeSourceAliveDetail, gitInfoRefsUrl, classifyProbeFailure, GIT_STALL_ARGS, GIT_CLONE_TIMEOUT_MS, measureDirBytes, measureProgressBytes }
|
|
597
|
+
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, findLandedRepo, gitCloneRepo, summarizeCloneErrors, orderGitCandidates, readGitSourceMemo, rememberGitSource, killProcessTree, probeSourceAlive, probeSourceAliveDetail, gitInfoRefsUrl, classifyProbeFailure, runGitArgs, runGitClone, sparseUnsupportedReason, GIT_STALL_ARGS, GIT_SPARSE_FLAGS, waitChildExit, GIT_CLONE_TIMEOUT_MS, measureDirBytes, measureProgressBytes }
|
|
@@ -6,6 +6,7 @@ import { dirname, join, basename } from 'node:path'
|
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import { appendInsert } from './patch.js'
|
|
8
8
|
import { gitCloneRepo } from './repoland.js'
|
|
9
|
+
import { findPresetDirs, assemblePreset } from './preset-source.js'
|
|
9
10
|
import { deriveEntryId, listEntries } from './runtime.js'
|
|
10
11
|
import { noteChannel } from './git-channel.js'
|
|
11
12
|
import { orderedRegistries, readSources } from './sources.js'
|
|
@@ -36,26 +37,6 @@ function readGitmodules(dir) {
|
|
|
36
37
|
return subs.filter((s) => s.path !== '' && s.url !== '')
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
/** 递归找含 preset.yml + agent.cordis.yml 的 agent 预设目录(深度 ≤ maxDepth)。 */
|
|
40
|
-
function findPresetDirs(root, maxDepth = 2) {
|
|
41
|
-
const out = []
|
|
42
|
-
const walk = (dir, depth) => {
|
|
43
|
-
if (depth > maxDepth) return
|
|
44
|
-
let entries = []
|
|
45
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
46
|
-
if (existsSync(join(dir, 'preset.yml')) && existsSync(join(dir, 'agent.cordis.yml'))) {
|
|
47
|
-
out.push(dir)
|
|
48
|
-
return
|
|
49
|
-
}
|
|
50
|
-
for (const e of entries) {
|
|
51
|
-
if (!e.isDirectory() || e.name.startsWith('.')) continue
|
|
52
|
-
walk(join(dir, e.name), depth + 1)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
walk(root, 0)
|
|
56
|
-
return out
|
|
57
|
-
}
|
|
58
|
-
|
|
59
40
|
/** 包是否有构建产物(main/module/exports/bin 或 lib/index.js 任一存在)。
|
|
60
41
|
* 排除类型声明(.d.ts)与 package.json 自身——exports 的 "./package.json" 是合法导出但不是运行时入口。 */
|
|
61
42
|
function packageEntryExists(dir, pkg) {
|
|
@@ -296,16 +277,21 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
|
|
|
296
277
|
}
|
|
297
278
|
// c. agent 预设(含 preset.yml + agent.cordis.yml 的目录;预设优先于普通 npm 包——
|
|
298
279
|
// 如 dsh-router-standard 既是 npm 包又带预设目录,install.ps1 意图是复制预设)
|
|
280
|
+
// 2026-09-27(批次 D-③ 改错):装配走 preset-source.js#assemblePreset(**唯一实现**),
|
|
281
|
+
// 与"预设型子包"候选路径共用。旧代码这里是 `rmSync(dest)` + `copyTree` —— **静默覆盖**
|
|
282
|
+
// 用户手上的同名预设;现在改为"先整目录备份、再逐文件合并",并把备份路径与覆盖清单写进报告。
|
|
299
283
|
const presets = findPresetDirs(subDir, 2)
|
|
300
284
|
for (const p of presets) {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
285
|
+
const assembled = assemblePreset(p, basename(p))
|
|
286
|
+
report.push({
|
|
287
|
+
component: sub.name,
|
|
288
|
+
type: 'preset',
|
|
289
|
+
ok: assembled.ok === true,
|
|
290
|
+
note: assembled.ok === true ? `${assembled.note}(新建会话时选择)` : `预设装配失败:${assembled.error}`,
|
|
291
|
+
presetPath: assembled.dest ?? null,
|
|
292
|
+
backup: assembled.backup ?? null,
|
|
293
|
+
})
|
|
294
|
+
if (assembled.ok === true) handled = true
|
|
309
295
|
}
|
|
310
296
|
// d. 普通 npm 插件(无 bundle 且无预设/技能)
|
|
311
297
|
if (!handled && pkg && typeof pkg.name === 'string') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.25",
|
|
4
4
|
"description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|