@noob-stupid/dsh-plugin-console 0.5.23 → 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/README.md +19 -0
- package/README.zh.md +18 -0
- package/lib/client.js +94 -9
- package/lib/server/domain/allow-builds.js +330 -0
- package/lib/server/domain/dep-source.js +19 -1
- package/lib/server/domain/git-channel.js +33 -2
- package/lib/server/domain/install-diagnose.js +47 -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/plugin-actions.js +95 -9
- 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
|
@@ -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 }
|