@noob-stupid/dsh-plugin-console 0.5.12 → 0.5.13
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 +152 -6
- package/lib/server/domain/ai-run.js +3 -2
- package/lib/server/domain/install-diagnose.js +113 -0
- package/lib/server/domain/install-job.js +22 -6
- package/lib/server/domain/install-verify.js +221 -0
- package/lib/server/domain/install.js +19 -21
- package/lib/server/domain/repoland.js +103 -33
- package/lib/server/infra/exec.js +208 -6
- package/package.json +1 -1
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// L1 · domain —— install-verify.js(装后校验 + 下载物摘要校验;2026-09-26 新增,**纯加法**)
|
|
2
|
+
//
|
|
3
|
+
// 解决什么:装完就报成功,用户要到重启后才发现"入口文件不在 / 补丁行指向别的包 / bundle 引用缺失",
|
|
4
|
+
// 而失败原因常常只是**镜像没同步完整**(本控制台主源是 registry.npmmirror.com)——这类问题重装/换源即可,
|
|
5
|
+
// 但面板在此之前完全不告诉用户。
|
|
6
|
+
//
|
|
7
|
+
// 两条硬规矩(写在最前面,改代码时别越线):
|
|
8
|
+
// ① verifyInstalledEntry 只**读**、只产出结构化诊断:**绝不影响既有成功判定** —— 校验失败也只是提示,
|
|
9
|
+
// 不把已经装成功的作业改判失败(安装是否成功由 install-job.js 的既有逻辑决定)。
|
|
10
|
+
// ② verifyTarballDigest 拿不到摘要时如实记「来源无摘要,未校验」,**绝不因此拒装**(离线/内网源常见);
|
|
11
|
+
// 摘要不匹配也只记录 + 强烈提示,不拒装(部分镜像会重打包 tarball,拒装会直接破坏这些源的可用性)。
|
|
12
|
+
//
|
|
13
|
+
// 报告里的 mirrorHint 是刻意固化的:主源是镜像站,"文件/引用缺失"最常见的解释就是镜像未同步完整。
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { parseInsertNames } from './patch.js'
|
|
18
|
+
import { deriveEntryId } from './runtime.js'
|
|
19
|
+
|
|
20
|
+
/** 固定的镜像提示(面板会原样显示给用户)。 */
|
|
21
|
+
const MIRROR_HINT = '镜像可能未同步完整(本控制台主源是 registry.npmmirror.com):可稍后重试,'
|
|
22
|
+
+ '或在「源管理」里换用 registry.npmjs.org / 其它镜像后重装'
|
|
23
|
+
|
|
24
|
+
/** 入口字段归一:main(字符串)优先,其次 exports['.'](字符串或 {default})。
|
|
25
|
+
* 返回 { field, value } 或 null(包没声明入口 —— 不算问题,很多 bundle/皮肤包没有入口)。 */
|
|
26
|
+
function entryFieldOf(pkg) {
|
|
27
|
+
if (typeof pkg?.main === 'string' && pkg.main !== '') return { field: 'main', value: pkg.main }
|
|
28
|
+
const dot = pkg?.exports?.['.']
|
|
29
|
+
if (typeof dot === 'string' && dot !== '') return { field: 'exports["."]', value: dot }
|
|
30
|
+
if (dot !== null && typeof dot === 'object' && typeof dot.default === 'string' && dot.default !== '') {
|
|
31
|
+
return { field: 'exports["."].default', value: dot.default }
|
|
32
|
+
}
|
|
33
|
+
if (typeof pkg?.exports === 'string' && pkg.exports !== '') return { field: 'exports', value: pkg.exports }
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 读 profile 的补丁行(id → 包名);读不到就当没有,不抛。 */
|
|
38
|
+
function readPatchRows(profileDir) {
|
|
39
|
+
const patchPath = join(profileDir, 'cordis.patch.yml')
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(patchPath)) return { patchPath, rows: new Map() }
|
|
42
|
+
return { patchPath, rows: parseInsertNames(readFileSync(patchPath, 'utf8')) }
|
|
43
|
+
} catch {
|
|
44
|
+
return { patchPath, rows: new Map() }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** profile 清单里声明的 bundle 层(读不到就空数组)。 */
|
|
49
|
+
function readBundles(profileDir) {
|
|
50
|
+
try {
|
|
51
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'))
|
|
52
|
+
const bundles = manifest?.dsh?.profile?.bundles
|
|
53
|
+
return Array.isArray(bundles) ? bundles : []
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 装后校验(只读、永不抛):读回刚装的 package.json,检查
|
|
61
|
+
* ① 入口(main / exports)指向的文件确实存在;
|
|
62
|
+
* ② profile 补丁里与这个包相关的行**指向的就是这个包名**(id 派生一致但 name 不同 → 明确报错);
|
|
63
|
+
* ③ 若该包声明了 dsh.bundle.patch(或已被写进 profile bundles):补丁文件存在、且其中引用的包都能解析。
|
|
64
|
+
* 返回结构化诊断(ok / problems / mirrorHint / suggestions / entry / patch / bundle / version …)。
|
|
65
|
+
*/
|
|
66
|
+
function verifyInstalledEntry(profileDir, packageName) {
|
|
67
|
+
const safeName = typeof packageName === 'string' ? packageName : String(packageName ?? '')
|
|
68
|
+
const report = {
|
|
69
|
+
ok: false,
|
|
70
|
+
checked: false,
|
|
71
|
+
packageName,
|
|
72
|
+
// 用 String() 兜住非法输入:本函数的契约是"永不抛",连 path.join 的参数类型错误都不许漏出去
|
|
73
|
+
packageDir: join(String(profileDir ?? ''), 'node_modules', ...safeName.split('/')),
|
|
74
|
+
version: null,
|
|
75
|
+
entry: null,
|
|
76
|
+
patch: { patchPath: null, rows: [], matched: [], mismatched: [] },
|
|
77
|
+
bundle: { declared: false, inBundles: false, patchFile: null, refs: 0, missingRefs: [] },
|
|
78
|
+
problems: [],
|
|
79
|
+
mirrorHint: null,
|
|
80
|
+
suggestions: [],
|
|
81
|
+
error: null,
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const pkgPath = join(report.packageDir, 'package.json')
|
|
85
|
+
if (!existsSync(pkgPath)) {
|
|
86
|
+
report.problems.push(`读不到 ${packageName} 的 package.json(${pkgPath}):可能被 pnpm 还原/删除,或安装目录被占用`)
|
|
87
|
+
report.mirrorHint = MIRROR_HINT
|
|
88
|
+
report.suggestions.push('重装一次;若仍失败请确认该包在 registry 上确实存在该版本')
|
|
89
|
+
report.checked = true
|
|
90
|
+
report.ok = false
|
|
91
|
+
return report
|
|
92
|
+
}
|
|
93
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
94
|
+
report.checked = true
|
|
95
|
+
report.version = typeof pkg.version === 'string' ? pkg.version : null
|
|
96
|
+
if (pkg.name !== packageName) {
|
|
97
|
+
report.problems.push(`package.json 里的包名(${pkg.name})与安装目标(${packageName})不一致:`
|
|
98
|
+
+ '多半是镜像把同名/错误产物发了下来')
|
|
99
|
+
report.mirrorHint = MIRROR_HINT
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ① 入口文件存在性
|
|
103
|
+
const entry = entryFieldOf(pkg)
|
|
104
|
+
if (entry === null) {
|
|
105
|
+
report.entry = { field: null, value: null, exists: null, note: '该包未声明 main/exports(bundle/皮肤类包常见),跳过入口检查' }
|
|
106
|
+
} else {
|
|
107
|
+
const target = join(report.packageDir, ...entry.value.split('/'))
|
|
108
|
+
const exists = existsSync(target)
|
|
109
|
+
report.entry = { field: entry.field, value: entry.value, exists, note: exists ? null : `入口文件缺失:${target}` }
|
|
110
|
+
if (!exists) {
|
|
111
|
+
report.problems.push(`入口文件缺失:${entry.field} = ${entry.value},但 ${target} 不存在(装了也加载不起来)`)
|
|
112
|
+
report.mirrorHint = MIRROR_HINT
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ② 补丁行与包名一致
|
|
117
|
+
const { patchPath, rows } = readPatchRows(profileDir)
|
|
118
|
+
report.patch.patchPath = patchPath
|
|
119
|
+
const derivedId = deriveEntryId(packageName, new Set())
|
|
120
|
+
for (const [id, name] of rows) {
|
|
121
|
+
report.patch.rows.push({ id, name })
|
|
122
|
+
const related = id === derivedId || id.startsWith(`${derivedId}-`) || name === packageName
|
|
123
|
+
if (!related) continue
|
|
124
|
+
if (name === packageName) report.patch.matched.push({ id, name })
|
|
125
|
+
else report.patch.mismatched.push({ id, name })
|
|
126
|
+
}
|
|
127
|
+
if (report.patch.mismatched.length > 0) {
|
|
128
|
+
report.problems.push(`补丁行与包名不一致:${report.patch.mismatched.map((r) => `id=${r.id} → name=${r.name}`).join('、')}`
|
|
129
|
+
+ `(期望 name=${packageName})—— 会让 DSH 启动时加载到别的模块或直接缺失`)
|
|
130
|
+
report.suggestions.push(`修正 profile 的 cordis.patch.yml:把 ${report.patch.mismatched.map((r) => r.id).join('、')} 行的 name 改成 ${packageName}(或重装一次让控制台重写该行)`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ③ bundle 引用一致性(只在"该包声明了 bundle 或已在 profile bundles 里"时检查,避免误报)
|
|
134
|
+
const bundles = readBundles(profileDir)
|
|
135
|
+
report.bundle.declared = typeof pkg?.dsh?.bundle?.patch === 'string'
|
|
136
|
+
report.bundle.inBundles = bundles.includes(packageName)
|
|
137
|
+
if (report.bundle.declared || report.bundle.inBundles) {
|
|
138
|
+
const rel = typeof pkg?.dsh?.bundle?.patch === 'string' ? pkg.dsh.bundle.patch : null
|
|
139
|
+
const patchFile = rel === null ? null : join(report.packageDir, rel)
|
|
140
|
+
report.bundle.patchFile = patchFile
|
|
141
|
+
if (rel !== null && !existsSync(patchFile)) {
|
|
142
|
+
report.problems.push(`bundle 补丁文件缺失:dsh.bundle.patch = ${rel}(${patchFile} 不存在)`)
|
|
143
|
+
report.mirrorHint = MIRROR_HINT
|
|
144
|
+
} else if (patchFile !== null) {
|
|
145
|
+
const refs = [...parseInsertNames(readFileSync(patchFile, 'utf8')).values()]
|
|
146
|
+
report.bundle.refs = refs.length
|
|
147
|
+
for (const name of refs) {
|
|
148
|
+
if (name.startsWith('cordis:')) continue
|
|
149
|
+
if (!existsSync(join(profileDir, 'node_modules', ...name.split('/')))) report.bundle.missingRefs.push(name)
|
|
150
|
+
}
|
|
151
|
+
if (report.bundle.missingRefs.length > 0) {
|
|
152
|
+
report.problems.push(`bundle 引用的包缺失:${report.bundle.missingRefs.join('、')}(启动时会因为缺模块崩溃/自动禁用该行)`)
|
|
153
|
+
report.mirrorHint = MIRROR_HINT
|
|
154
|
+
report.suggestions.push('等网络恢复后在面板里重新安装该聚合包(控制台会自动补齐/禁用缺失行)')
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (report.problems.length > 0) {
|
|
160
|
+
report.suggestions.push('重装该插件(pnpm 通道会重新下载);若重复出现同样的缺失,优先怀疑镜像未同步,换源后再装')
|
|
161
|
+
if (report.mirrorHint !== null && !report.suggestions.includes(MIRROR_HINT)) report.suggestions.unshift(MIRROR_HINT)
|
|
162
|
+
}
|
|
163
|
+
report.ok = report.problems.length === 0
|
|
164
|
+
return report
|
|
165
|
+
} catch (error) {
|
|
166
|
+
report.checked = true
|
|
167
|
+
report.ok = false
|
|
168
|
+
report.error = error instanceof Error ? error.message : String(error)
|
|
169
|
+
report.problems.push(`装后校验本身出错(不影响安装结果):${report.error}`)
|
|
170
|
+
return report
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 下载物摘要校验(只读、永不抛):算出 sha256 并尽量与 registry 元数据比对。
|
|
176
|
+
* · 元数据有 dist.integrity(SRI,通常 sha512)→ 按该算法校验并记录;
|
|
177
|
+
* · 只有 dist.shasum(sha1)→ 按 sha1 校验并记录;
|
|
178
|
+
* · 两者都没有 → ok=null + note「来源无摘要,未校验」,**绝不因此拒装**(离线/内网源的常态)。
|
|
179
|
+
* 摘要不匹配**也不拒装**(部分镜像是重打包的 tarball),只把 ok=false 与提示记进 job 供用户判断。
|
|
180
|
+
*/
|
|
181
|
+
function verifyTarballDigest(tarballPath, dist = null) {
|
|
182
|
+
const out = { checked: false, ok: null, algorithm: null, expected: null, actual: null, sha256: null, note: null }
|
|
183
|
+
try {
|
|
184
|
+
const buf = readFileSync(tarballPath)
|
|
185
|
+
out.sha256 = createHash('sha256').update(buf).digest('hex')
|
|
186
|
+
out.checked = true
|
|
187
|
+
const sri = typeof dist?.integrity === 'string' ? dist.integrity.trim().split(/\s+/u)[0] : null
|
|
188
|
+
const shasum = typeof dist?.shasum === 'string' ? dist.shasum.trim() : null
|
|
189
|
+
if (sri !== null && /^[a-z0-9]+-[A-Za-z0-9+/=]+$/u.test(sri)) {
|
|
190
|
+
const [algorithm, expected] = [sri.slice(0, sri.indexOf('-')), sri.slice(sri.indexOf('-') + 1)]
|
|
191
|
+
const actual = createHash(algorithm).update(buf).digest('base64')
|
|
192
|
+
out.algorithm = algorithm
|
|
193
|
+
out.expected = sri
|
|
194
|
+
out.actual = `${algorithm}-${actual}`
|
|
195
|
+
out.ok = actual === expected
|
|
196
|
+
out.note = out.ok === true
|
|
197
|
+
? `已按 registry 元数据的 ${algorithm} 摘要校验通过(sha256=${out.sha256})`
|
|
198
|
+
: `摘要不匹配:registry 元数据声明 ${algorithm} 摘要,但下载物算出来不同(疑似镜像重打包或下载损坏)——按"不拒装、只记录"处理,建议重试或换源`
|
|
199
|
+
return out
|
|
200
|
+
}
|
|
201
|
+
if (shasum !== null) {
|
|
202
|
+
const actual = createHash('sha1').update(buf).digest('hex')
|
|
203
|
+
out.algorithm = 'sha1'
|
|
204
|
+
out.expected = shasum
|
|
205
|
+
out.actual = actual
|
|
206
|
+
out.ok = actual === shasum
|
|
207
|
+
out.note = out.ok === true
|
|
208
|
+
? `已按 registry 元数据的 sha1 摘要校验通过(sha256=${out.sha256})`
|
|
209
|
+
: `摘要不匹配:registry 元数据的 sha1 与下载物不同(疑似镜像重打包或下载损坏)——按"不拒装、只记录"处理`
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
out.ok = null
|
|
213
|
+
out.note = `来源无摘要,未校验(已记录 sha256=${out.sha256};离线/内网源常见,不影响安装)`
|
|
214
|
+
return out
|
|
215
|
+
} catch (error) {
|
|
216
|
+
out.note = `摘要校验无法完成(不影响安装):${error instanceof Error ? error.message : String(error)}`
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { verifyInstalledEntry, verifyTarballDigest, entryFieldOf, MIRROR_HINT }
|
|
@@ -8,11 +8,12 @@ import { dirname, join, resolve } from 'node:path'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { sanitizePatchText } from './patch.js'
|
|
10
10
|
import { orderedRegistries, readSources } from './sources.js'
|
|
11
|
-
import {
|
|
11
|
+
import { runPnpmAdd, execFileAsync } from '../infra/exec.js'
|
|
12
12
|
import { copyTree, queuedWrite } from '../infra/fsx.js'
|
|
13
13
|
import { fetchJsonUrl } from '../infra/http.js'
|
|
14
14
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
15
15
|
import { downloadReleaseArtifact, releaseInstallTarget, selectReleaseInstall } from './release-source.js'
|
|
16
|
+
import { verifyTarballDigest } from './install-verify.js'
|
|
16
17
|
|
|
17
18
|
/** bundle 包判定:声明 dsh.bundle 的包一律按官方 `dsh plugin add` 行为追加为
|
|
18
19
|
* profile bundle 层(其 cordis.patch.yml 的插入行在下次启动时组合进树)。
|
|
@@ -108,29 +109,18 @@ function readGithubAuth() {
|
|
|
108
109
|
return { loggedIn: false, login: null, token: null }
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/** 安装通道的 pnpm 默认超时(毫秒)。install-job.js 的定向重试要按它算出"更长超时",故导出为常量。 */
|
|
113
|
+
const PNPM_INSTALL_TIMEOUT_MS = 90000
|
|
114
|
+
|
|
111
115
|
/**
|
|
112
116
|
* 插件安装:与官方 `dsh plugin add` 使用同一管理器——corepack → pnpm add。
|
|
113
117
|
* profile 目录由 pnpm 管理;若用 npm 写入会与 pnpm 的目录重建互相破坏
|
|
114
118
|
* (曾导致入口链接丢失、DSH 启动崩溃)。registry 走国内镜像。
|
|
119
|
+
* 2026-09-26:执行细节(健壮性 env + pnpm add 选项 + 不支持选项时降级)搬进 infra/exec.js#runPnpmAdd,
|
|
120
|
+
* 本函数保持"一个通道入口"的角色,签名与成功/失败语义不变(`deps` 只是单测注入口)。
|
|
115
121
|
*/
|
|
116
|
-
async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout =
|
|
117
|
-
|
|
118
|
-
const opts = {
|
|
119
|
-
cwd: profileDir,
|
|
120
|
-
timeout,
|
|
121
|
-
windowsHide: true,
|
|
122
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
123
|
-
env: {
|
|
124
|
-
...process.env,
|
|
125
|
-
COREPACK_NPM_REGISTRY: registry,
|
|
126
|
-
// git 通道禁止交互式凭据:避免 Git Credential Manager 弹登录窗(匿名失败即静默失败)
|
|
127
|
-
GIT_TERMINAL_PROMPT: '0',
|
|
128
|
-
GCM_INTERACTIVE: 'never',
|
|
129
|
-
},
|
|
130
|
-
}
|
|
131
|
-
// 跨平台定位 corepack/pnpm(Windows 布局 / Linux npm 全局布局 / PATH 兜底),
|
|
132
|
-
// 旧代码只认 Windows 布局,Linux 上会生成 MODULE_NOT_FOUND 的命令(2026-09-20 事故)
|
|
133
|
-
await runPnpmWithFallback(args, { execOpts: opts })
|
|
122
|
+
async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = PNPM_INSTALL_TIMEOUT_MS, signal = null, deps = {}) {
|
|
123
|
+
await runPnpmAdd({ profileDir, spec, registry, timeout, signal, deps })
|
|
134
124
|
}
|
|
135
125
|
|
|
136
126
|
/**
|
|
@@ -163,6 +153,9 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
|
|
|
163
153
|
try {
|
|
164
154
|
const tgz = join(tmp, 'pkg.tgz')
|
|
165
155
|
await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, tarball], { timeout: 70000, windowsHide: true, ...(signal ? { signal } : {}) })
|
|
156
|
+
// ④ 下载物摘要(加法):算 sha256 并与 registry 元数据的 dist.integrity/shasum 比对;拿不到摘要就如实记
|
|
157
|
+
// "来源无摘要,未校验",**绝不因此拒装**(见 domain/install-verify.js 顶部两条硬规矩)
|
|
158
|
+
const integrity = verifyTarballDigest(tgz, meta.versions?.[version]?.dist)
|
|
166
159
|
await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 30000, windowsHide: true, ...(signal ? { signal } : {}) })
|
|
167
160
|
let pkgPath = join(tmp, 'package')
|
|
168
161
|
if (!existsSync(join(pkgPath, 'package.json'))) {
|
|
@@ -186,7 +179,7 @@ async function curlManualInstall(profileDir, packageName, registries, signal = n
|
|
|
186
179
|
try {
|
|
187
180
|
writeFileSync(join(target, '.dsh-installed-at'), String(Date.now()), 'utf8')
|
|
188
181
|
} catch {}
|
|
189
|
-
return { version, missingDeps, boxNote: box.note }
|
|
182
|
+
return { version, missingDeps, boxNote: box.note, integrity }
|
|
190
183
|
} finally {
|
|
191
184
|
rmSync(tmp, { recursive: true, force: true })
|
|
192
185
|
}
|
|
@@ -561,6 +554,11 @@ function installJobView(job) {
|
|
|
561
554
|
: null,
|
|
562
555
|
// 授权请求(等本地 AI 兜底同意):面板要显示倒计时 + 同意/取消,所以时间与最后错误一起下发
|
|
563
556
|
aiConsent: { pending: job.aiPending != null, since: job.aiPendingSince ?? null, timeoutMs: job.aiConsentTimeoutMs ?? AI_CONSENT_TIMEOUT_MS, lastError: job.aiPending?.lastError ?? job.lastError ?? null },
|
|
557
|
+
// 失败分类提示(2026-09-26 加法):{ kind, hint, retry }(可能带 retried/retryTimeoutMs),
|
|
558
|
+
// 由 install-diagnose.js 的纯函数产出;只影响展示与"是否已定向重试"的可见性,不参与成功判定
|
|
559
|
+
diagnosis: job.diagnosis ?? null,
|
|
560
|
+
// 装后校验与下载物摘要(2026-09-26 加法,见 install-verify.js):**只提示**,不改变既有成功判定
|
|
561
|
+
entryCheck: job.entryCheck ?? null, integrity: job.integrity ?? null,
|
|
564
562
|
}
|
|
565
563
|
}
|
|
566
564
|
|
|
@@ -596,4 +594,4 @@ async function syncAggregateSubpackageVersions(profileDir, packageName, registri
|
|
|
596
594
|
return updated
|
|
597
595
|
}
|
|
598
596
|
|
|
599
|
-
export { AI_CONSENT_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
|
|
597
|
+
export { AI_CONSENT_TIMEOUT_MS, PNPM_INSTALL_TIMEOUT_MS, DEFAULT_BUNDLES, detectBundleOnly, addBundleToManifest, removeBundleFromManifest, readExtraBundleOwners, readGithubAuth, pnpmInstall, curlManualInstall, raceInstallChannels, verifyPackageBox, parseBundlePatchRefs, backfillMissingDeps, githubReleaseInstall, readBundlePatchRefNames, ensureBundlePatchIntegrity, syncAggregateSubpackageVersions, installJobView }
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// L1 · domain —— repoland.js(仓库落地:落地目录配置 / 已落地列表 / 克隆;分层 Step 4 从 lib/index.js 搬出,只搬移未改逻辑)
|
|
2
2
|
// 分组见 D:\dsh\dsh-plugin-hub-plan\architecture.zh.md 三
|
|
3
3
|
|
|
4
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, renameSync } from 'node:fs'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
5
6
|
import { dirname, join } from 'node:path'
|
|
6
7
|
import { homedir } from 'node:os'
|
|
7
8
|
import { gitCloneUrls } from './sources.js'
|
|
8
|
-
import { execFileAsync, gitEnv } from '../infra/exec.js'
|
|
9
|
+
import { execFileAsync, gitEnv, killProcessTree } from '../infra/exec.js'
|
|
9
10
|
import { removeDirVerified } from '../infra/fsx.js'
|
|
10
11
|
import { repoLandConfFile } from '../infra/paths.js'
|
|
11
12
|
|
|
@@ -71,49 +72,118 @@ function gitErrorDetail(error) {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
/** 逐条尝试的错误汇总(纯函数,单测覆盖):报**第一个**错误(真实原因)+ 尝试清单。
|
|
74
|
-
*
|
|
75
|
-
* `
|
|
76
|
-
*
|
|
77
|
-
*
|
|
75
|
+
* 2026-09-26 真机(官方桌面端里装 git 源插件):ghproxy 卡死 → 我们的超时到了但**没杀 git 进程**,
|
|
76
|
+
* 于是 `git clone` / `git remote-https` / `index-pack --shallow-file …\.git\shallow.lock` 常驻,
|
|
77
|
+
* Windows 不允许删除被打开的文件 → 目标目录清不掉 → 旧代码把它写成"环境禁止删除"并**放弃后续源**。
|
|
78
|
+
* 现在措辞如实:说清"仍有 git 占用(已尝试结束)",并给出可复制的手动删除命令。 */
|
|
78
79
|
function summarizeCloneErrors(errors) {
|
|
79
80
|
const first = errors[0]
|
|
80
|
-
const tried = errors.map((e) =>
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const tried = errors.map((e) => {
|
|
82
|
+
if (e.unclean === true) return `${e.url}(残留目录被占用,已跳过重试)`
|
|
83
|
+
if (e.skipped === true) return `${e.url}(探活失败,已跳过)`
|
|
84
|
+
if (e.timedOut === true) return `${e.url}(超时,进程已结束)`
|
|
85
|
+
return /already exists and is not an empty directory/u.test(e.message) ? `${e.url}(目录非空)` : e.url
|
|
86
|
+
}).join(';')
|
|
83
87
|
const detail = gitErrorDetail(first)
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const stuck = errors.filter((e) => e.unclean === true)
|
|
89
|
+
const stuckNote = stuck.length === 0
|
|
90
|
+
? ''
|
|
91
|
+
: `;注意:${stuck[0].message}。可手动删除后重试:Remove-Item -Recurse -Force '${stuck[0].dir}'`
|
|
92
|
+
return `git clone 失败(首个错误:${first?.message ?? '未知'}${detail !== '' ? `;git 说:${detail}` : ''});已尝试 ${errors.length} 个源:${tried}${stuckNote}`
|
|
88
93
|
}
|
|
89
94
|
|
|
90
|
-
/** git
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
break
|
|
103
|
-
}
|
|
95
|
+
/** 结束**整棵**进程树。超时/中断后必须做:git 会派生 remote-https / index-pack 子进程,
|
|
96
|
+
* 只 kill 父进程会留下孤儿继续占着 .git 里的文件(2026-09-26 实测占住 pack 临时文件与 shallow.lock)。
|
|
97
|
+
* 2026-09-26(本次):实现搬进 infra/exec.js(pnpm 通道复用同一份,domain 不再各留一份拷贝),
|
|
98
|
+
* 这里保留同名 re-export —— **对外导出名与调用点一个字都没变**。 */
|
|
99
|
+
|
|
100
|
+
/** 跑一次 git clone:支持超时,且**超时即杀掉整棵树**。返回 { code, stderr, timedOut, pid }。 */
|
|
101
|
+
function runGitClone(url, dest, timeout, { spawnFn = spawn, killTree = killProcessTree } = {}) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
let settled = false
|
|
104
|
+
const finish = (payload) => { if (!settled) { settled = true; resolve(payload) } }
|
|
105
|
+
let child = null
|
|
106
|
+
let stderr = ''
|
|
104
107
|
try {
|
|
105
|
-
|
|
106
|
-
await execFileAsync('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
107
|
-
timeout,
|
|
108
|
+
child = spawnFn('git', ['clone', '--depth', '1', '--quiet', url, dest], {
|
|
108
109
|
windowsHide: true,
|
|
109
110
|
env: gitEnv(),
|
|
111
|
+
detached: process.platform !== 'win32', // POSIX:自成进程组,便于 -pid 整体杀
|
|
110
112
|
})
|
|
111
|
-
return { url, attempt: attempt + 1 }
|
|
112
113
|
} catch (error) {
|
|
113
|
-
|
|
114
|
+
finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: null })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
killTree(child.pid)
|
|
119
|
+
finish({ code: -1, stderr: stderr.trim(), timedOut: true, pid: child.pid })
|
|
120
|
+
}, timeout)
|
|
121
|
+
child.stderr?.on?.('data', (chunk) => { stderr += String(chunk) })
|
|
122
|
+
child.on('error', (error) => { clearTimeout(timer); finish({ code: -1, stderr: String(error?.message ?? error), timedOut: false, pid: child.pid }) })
|
|
123
|
+
child.on('close', (code) => { clearTimeout(timer); finish({ code: typeof code === 'number' ? code : -1, stderr: stderr.trim(), timedOut: false, pid: child.pid }) })
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 源探活:镜像站"连得上但传不动"探不出来,但**域名挂掉/被墙**能提前识别,省掉一整个克隆超时的白等。 */
|
|
128
|
+
async function probeSourceAlive(url, timeoutMs = 4000) {
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) })
|
|
131
|
+
return res.ok || res.status === 403 || res.status === 405 // 部分站点不支持 HEAD,按"活着"处理
|
|
132
|
+
} catch { return false }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** git clone(镜像→直连;gitee 直连),返回 { url, attempt, dir } 或抛错。
|
|
136
|
+
* 每次尝试都用**全新唯一目录** `.tryN`,成功后才 rename 到 dest —— 这样即使上一轮残留目录被占用,
|
|
137
|
+
* 也不会再出现"一个源失败 → 后面所有源都因目录非空而无效"的连锁失效。 */
|
|
138
|
+
async function gitCloneRepo(repo, dest, source = 'github', timeout = 180000, deps = {}) {
|
|
139
|
+
const {
|
|
140
|
+
spawnFn = spawn,
|
|
141
|
+
killTree = killProcessTree,
|
|
142
|
+
probe = probeSourceAlive,
|
|
143
|
+
removeDir = removeDirVerified,
|
|
144
|
+
renameDir = renameSync,
|
|
145
|
+
} = deps
|
|
146
|
+
const urls = gitCloneUrls(repo, source)
|
|
147
|
+
const errors = []
|
|
148
|
+
for (const [attempt, url] of urls.entries()) {
|
|
149
|
+
const part = `${dest}.try${attempt + 1}`
|
|
150
|
+
// eslint-disable-next-line no-await-in-loop
|
|
151
|
+
const alive = await probe(url)
|
|
152
|
+
if (!alive) {
|
|
153
|
+
errors.push({ url, message: `源探活失败(连不上):${url}`, skipped: true, dir: part })
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
try { removeDir(part) } catch {}
|
|
157
|
+
// eslint-disable-next-line no-await-in-loop
|
|
158
|
+
const res = await runGitClone(url, part, timeout, { spawnFn, killTree })
|
|
159
|
+
if (res.code === 0) {
|
|
160
|
+
try {
|
|
161
|
+
removeDir(dest)
|
|
162
|
+
renameDir(part, dest)
|
|
163
|
+
return { url, attempt: attempt + 1, dir: dest }
|
|
164
|
+
} catch (error) {
|
|
165
|
+
errors.push({ url, message: `克隆成功但落地失败(${error instanceof Error ? error.message : String(error)})`, dir: part })
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// 失败:先尽力清掉半成品目录;清不掉就**如实说明**(这次不再谎报"环境禁止删除")
|
|
170
|
+
const cleared = removeDir(part)
|
|
171
|
+
if (cleared && cleared.ok === false) {
|
|
172
|
+
errors.push({
|
|
173
|
+
url,
|
|
174
|
+
message: res.timedOut === true
|
|
175
|
+
? `克隆超时,且残留目录仍被 git 占用(进程已尝试结束):${part}`
|
|
176
|
+
: `克隆失败,且残留目录无法清理(可能仍被 git 占用):${part}`,
|
|
177
|
+
stderr: res.stderr,
|
|
178
|
+
unclean: true,
|
|
179
|
+
timedOut: res.timedOut === true,
|
|
180
|
+
dir: part,
|
|
181
|
+
})
|
|
182
|
+
continue // **继续试下一个源**(新目录不受影响),不再像旧代码那样直接 break
|
|
114
183
|
}
|
|
184
|
+
errors.push({ url, message: res.timedOut === true ? `克隆超时(${url})` : (res.stderr !== '' ? res.stderr : `git clone 退出码 ${res.code}`), stderr: res.stderr, timedOut: res.timedOut === true, dir: part })
|
|
115
185
|
}
|
|
116
186
|
throw new Error(summarizeCloneErrors(errors))
|
|
117
187
|
}
|
|
118
188
|
|
|
119
|
-
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors }
|
|
189
|
+
export { reposDirCache, getReposDir, setReposDir, listLandedRepos, gitCloneRepo, summarizeCloneErrors, killProcessTree, probeSourceAlive }
|