@noob-stupid/dsh-plugin-console 0.5.77 → 0.5.87
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 +490 -20
- package/lib/server/domain/dev-roots.js +12 -0
- package/lib/server/domain/disk-audit.js +330 -0
- package/lib/server/domain/enrich-throttle.js +242 -0
- package/lib/server/domain/install-box.js +77 -0
- package/lib/server/domain/install-channels.js +242 -0
- package/lib/server/domain/install-diagnose.js +9 -4
- package/lib/server/domain/install-finalize.js +3 -1
- package/lib/server/domain/install-job.js +49 -175
- package/lib/server/domain/install-jobs-store.js +72 -0
- package/lib/server/domain/install-version.js +65 -0
- package/lib/server/domain/install.js +18 -71
- package/lib/server/domain/link-inventory.js +41 -4
- package/lib/server/domain/link-self-sufficiency.js +67 -0
- package/lib/server/domain/lock-autofix.js +310 -0
- package/lib/server/domain/manifest.js +17 -3
- package/lib/server/domain/market.js +13 -7
- package/lib/server/domain/persist.js +128 -3
- package/lib/server/domain/restart-launcher.js +110 -0
- package/lib/server/domain/restart.js +84 -10
- package/lib/server/domain/selfupdate.js +6 -1
- package/lib/server/domain/skills.js +9 -5
- package/lib/server/domain/unpin-dev-dir.js +149 -0
- package/lib/server/infra/egress.js +34 -1
- package/lib/server/routes/disk.js +59 -0
- package/lib/server/routes/framework.js +63 -2
- package/lib/server/routes/index.js +19 -3
- package/lib/server/routes/install.js +6 -0
- package/lib/server/routes/link-actions.js +120 -0
- package/lib/server/routes/state.js +23 -1
- package/package.json +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// L1 · domain —— lock-autofix.js(清单 ↔ lock 口径不一致的**后台自动对账**;0.5.84 加法)
|
|
2
|
+
// 分层分组:L1 · domain(不认识 cordis ctx;pnpm 执行与读盘都可注入)
|
|
3
|
+
//
|
|
4
|
+
// ── 为什么(用户 2026-10 反馈)──────────────────────────────────────────────────
|
|
5
|
+
// profile 的 `package.json` 与 `pnpm-lock.yaml` 经常对不上(改过依赖没重装 / 装完没写 lock / 手改过清单),
|
|
6
|
+
// 而唯一会把它收敛的动作是用户自己去点「重建 lock」。不点时每一次 pnpm 操作都要重新解析,
|
|
7
|
+
// 还可能在 frozen-lockfile 下直接 `ERR_PNPM_OUTDATED_LOCKFILE`(安装失败的头号原因之一)。
|
|
8
|
+
// 所以:**检测到就自己修**,不必等用户。
|
|
9
|
+
//
|
|
10
|
+
// ── 三条硬约束(用户明确要求,任何一条都不能松)──────────────────────────────────
|
|
11
|
+
// ① **后台**:调用方 `void scheduleLockAutofix(...)` 即返回,**绝不 await**、绝不阻塞 /state。
|
|
12
|
+
// 这里所有慢动作(registry 探测 + pnpm)都在自己的 promise 里跑,异常一律被吞进状态字段。
|
|
13
|
+
// ② **冷却窗**:两次尝试之间至少 `COOLDOWN_MS`(10 分钟);期间任何触发都直接跳过(不排队、不重试)。
|
|
14
|
+
// ③ **失败就停**:一旦失败,**不再自动重试** —— 除非"不一致的样子"变了(signature 变化,
|
|
15
|
+
// 即用户又改了清单/lock)。失败原因与 pnpm 输出尾部如实留在状态里,面板据此提示,不静默。
|
|
16
|
+
//
|
|
17
|
+
// ── 为什么可以整份 `--lockfile-only`(以及为什么**只**在这个场景可以)──────────────
|
|
18
|
+
// 仓库既有事实(见 domain/lock-importer.js 顶部那段实测):本机 pnpm 的 `--lockfile-only`
|
|
19
|
+
// 会把**无关条目**的 peer 后缀剥掉 ⇒ 它**不是**"定点补一条"的无损工具。
|
|
20
|
+
// 但本模块是**整包对账**:argv 由 lockfile-health.js#repairArgsFor 唯一产出
|
|
21
|
+
// (`install --lockfile-only --no-frozen-lockfile --registry …`),让 pnpm 按清单重算整份 lock ——
|
|
22
|
+
// 这正是它擅长的场景;而"定点补条目"走 lock-importer 的纯文本外科手术,两条路**不混用**。
|
|
23
|
+
// 另外还守住一条:**只要不一致里出现来源型依赖(link:/file:/git/URL)就不自动修** ——
|
|
24
|
+
// 那类条目除了 importer 两行还有 packages/snapshots 的解析记录,整份重算可能把它们改写成别的形态,
|
|
25
|
+
// 交给「一键钉住」(persist.js 的收口)才对。
|
|
26
|
+
//
|
|
27
|
+
// ── 复用的是既有唯一实现,不新造第二条修 lock 的路 ────────────────────────────────
|
|
28
|
+
// 检测:lockfile-health.js#diffLockfile(纯函数,清单 vs lock vs 磁盘三方对账)
|
|
29
|
+
// 修: lockfile-health.js#runLockfileRepair(用户显式点「重建」时走的就是它:先只读体检、
|
|
30
|
+
// 有 404/探测不到就 blocked 一个文件都不写、跑完复检、按 install-diagnose 分类如实回报)
|
|
31
|
+
// 本模块只加"什么时候该跑 / 多久跑一次 / 失败了怎么办"这三件调度上的事。
|
|
32
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
33
|
+
import { dirname, join } from 'node:path'
|
|
34
|
+
import { diffLockfile, isSourceSpec, runLockfileRepair } from './lockfile-health.js'
|
|
35
|
+
import { isTestProcess } from '../infra/egress.js'
|
|
36
|
+
import { dshHome } from '../infra/paths.js'
|
|
37
|
+
|
|
38
|
+
/** 两次自动尝试之间的最小间隔(冷却窗)。 */
|
|
39
|
+
const COOLDOWN_MS = 10 * 60 * 1000
|
|
40
|
+
/** 状态落盘文件(跨重启也有记忆,避免一重启就再跑一次)。 */
|
|
41
|
+
const STATE_FILE_NAME = 'lock-autofix.json'
|
|
42
|
+
|
|
43
|
+
/** 进程内状态(`__resetLockAutofix()` 供单测还原)。 */
|
|
44
|
+
let state = null
|
|
45
|
+
const blank = () => ({
|
|
46
|
+
inFlight: false,
|
|
47
|
+
lastAt: null, // 上次尝试的时间戳
|
|
48
|
+
lastResult: null, // 上次结论(如实下发,含原因/包名/pnpm 输出尾部)
|
|
49
|
+
failures: 0, // 连续失败次数(>0 ⇒ 已停)
|
|
50
|
+
stopped: false, // 失败后停止自动重试
|
|
51
|
+
stoppedReason: null,
|
|
52
|
+
stoppedSignature: null // 停下时那份"不一致的样子";样子变了才允许再试
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const stateFile = () => join(dshHome(), 'plugin-console', STATE_FILE_NAME)
|
|
56
|
+
|
|
57
|
+
function readStateFile() {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(stateFile(), 'utf8'))
|
|
60
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
61
|
+
return raw
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeStateFile(snapshot) {
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(stateFile()), { recursive: true })
|
|
70
|
+
writeFileSync(stateFile(), JSON.stringify(snapshot, null, 2), 'utf8')
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 取(必要时从磁盘恢复)状态。 */
|
|
75
|
+
function stateNow() {
|
|
76
|
+
if (state !== null) return state
|
|
77
|
+
state = blank()
|
|
78
|
+
const saved = readStateFile()
|
|
79
|
+
if (saved !== null) {
|
|
80
|
+
state.lastAt = typeof saved.lastAt === 'number' ? saved.lastAt : null
|
|
81
|
+
state.lastResult = saved.lastResult ?? null
|
|
82
|
+
state.stopped = saved.stopped === true
|
|
83
|
+
state.stoppedReason = typeof saved.stoppedReason === 'string' ? saved.stoppedReason : null
|
|
84
|
+
state.stoppedSignature = typeof saved.stoppedSignature === 'string' ? saved.stoppedSignature : null
|
|
85
|
+
state.failures = Number.isFinite(saved.failures) ? saved.failures : 0
|
|
86
|
+
}
|
|
87
|
+
return state
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 单测还原:清空内存状态(落盘文件由调用方自己决定要不要清)。 */
|
|
91
|
+
function __resetLockAutofix() {
|
|
92
|
+
state = null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 「这次不一致」的样子(用于"失败后不重试,除非样子变了")。
|
|
97
|
+
* 只由**不一致本身**构成:缺哪些、specifier 怎么漂、版本怎么不符 —— 修好或再变都会改变它。
|
|
98
|
+
*/
|
|
99
|
+
function mismatchSignature(outdated) {
|
|
100
|
+
const miss = Array.isArray(outdated?.missing) ? [...outdated.missing].sort() : []
|
|
101
|
+
const spec = Array.isArray(outdated?.specifierMismatch)
|
|
102
|
+
? outdated.specifierMismatch.map((x) => `${x.name}@${x.spec}/${x.lockSpecifier ?? ''}`).sort() : []
|
|
103
|
+
const ver = Array.isArray(outdated?.versionMismatch)
|
|
104
|
+
? outdated.versionMismatch.map((x) => `${x.name}@${x.spec}/${x.lockVersion ?? ''}`).sort() : []
|
|
105
|
+
return JSON.stringify({ miss, spec, ver })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 该不该自动修(**纯判据**,不写盘、不发网络、不 spawn)。
|
|
110
|
+
* @returns {{ applicable: boolean, reason: string|null, signature: string|null, outdated: object|null, sourceSpecNames: string[] }}
|
|
111
|
+
* · applicable=false 时 reason 说明为什么不动手(面板可原样展示,**不许**含糊成"跳过了")
|
|
112
|
+
*/
|
|
113
|
+
function planLockAutofix({ profileDir, readFile = (p) => readFileSync(p, 'utf8'), exists = existsSync, now = Date.now() } = {}) {
|
|
114
|
+
const dir = typeof profileDir === 'string' && profileDir !== '' ? profileDir : null
|
|
115
|
+
if (dir === null) return { applicable: false, reason: 'profile 目录未知,未自动对账', signature: null, outdated: null, sourceSpecNames: [] }
|
|
116
|
+
const read = (name) => {
|
|
117
|
+
try {
|
|
118
|
+
const file = join(dir, name)
|
|
119
|
+
return exists(file) ? readFile(file) : null
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const manifestText = read('package.json')
|
|
125
|
+
if (manifestText === null) return { applicable: false, reason: 'profile 没有 package.json(清单都读不到,无从对账)', signature: null, outdated: null, sourceSpecNames: [] }
|
|
126
|
+
let manifest = null
|
|
127
|
+
try {
|
|
128
|
+
manifest = JSON.parse(manifestText)
|
|
129
|
+
} catch {
|
|
130
|
+
return { applicable: false, reason: 'package.json 不是合法 JSON —— 不碰它(那是用户的文件),请先修好清单', signature: null, outdated: null, sourceSpecNames: [] }
|
|
131
|
+
}
|
|
132
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
133
|
+
return { applicable: false, reason: 'package.json 不是对象 —— 不碰它', signature: null, outdated: null, sourceSpecNames: [] }
|
|
134
|
+
}
|
|
135
|
+
const lockText = read('pnpm-lock.yaml')
|
|
136
|
+
const diff = diffLockfile({ manifestText, lockText })
|
|
137
|
+
const signature = mismatchSignature(diff)
|
|
138
|
+
const outdated = {
|
|
139
|
+
lockfileMissing: !diff.lockfilePresent,
|
|
140
|
+
missing: diff.missing,
|
|
141
|
+
specifierMismatch: diff.specifierMismatch,
|
|
142
|
+
versionMismatch: diff.versionMismatch,
|
|
143
|
+
drift: diff.drift,
|
|
144
|
+
}
|
|
145
|
+
const mismatchNames = [...new Set([
|
|
146
|
+
...diff.missing,
|
|
147
|
+
...diff.specifierMismatch.map((x) => x.name),
|
|
148
|
+
...diff.versionMismatch.map((x) => x.name),
|
|
149
|
+
])]
|
|
150
|
+
if (mismatchNames.length === 0) {
|
|
151
|
+
return { applicable: false, reason: null, signature, outdated, sourceSpecNames: [] }
|
|
152
|
+
}
|
|
153
|
+
// 来源型依赖一旦出现在不一致清单里 ⇒ 整份重算不是无损工具(见文件头那段),**不自动修**
|
|
154
|
+
const specsByName = new Map()
|
|
155
|
+
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
156
|
+
const block = manifest[section]
|
|
157
|
+
if (block === null || typeof block !== 'object') continue
|
|
158
|
+
for (const [name, spec] of Object.entries(block)) if (typeof spec === 'string') specsByName.set(name, spec)
|
|
159
|
+
}
|
|
160
|
+
const sourceSpecNames = mismatchNames.filter((name) => isSourceSpec(specsByName.get(name) ?? ''))
|
|
161
|
+
if (sourceSpecNames.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
applicable: false,
|
|
164
|
+
reason: `不一致里有来源型依赖(${sourceSpecNames.slice(0, 4).join('、')})—— 整份 pnpm install --lockfile-only 可能改写它们的解析记录,已改为交给「一键钉住」处理,本次不自动对账`,
|
|
165
|
+
signature, outdated, sourceSpecNames,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { applicable: true, reason: null, signature, outdated, sourceSpecNames: [] }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 后台调度一次自动对账(**fire-and-forget**:调用方不要 await)。
|
|
173
|
+
* 所有前置条件(冷却窗 / 已停 / 在跑 / 无不一致)都只**读**内存状态 —— 快速返回,绝不阻塞 HTTP 响应。
|
|
174
|
+
* @returns {{ started: boolean, reason: string|null }}
|
|
175
|
+
*/
|
|
176
|
+
function scheduleLockAutofix({ profileDir, registries = [], now = Date.now(), deps = {} } = {}) {
|
|
177
|
+
// ★ 测试进程里一律不自动跑 pnpm:它会重写 fixture 的 pnpm-lock.yaml,而多套测试对
|
|
178
|
+
// "lock 是否被写过"有断言(例如 test-route-inventory 的只读体检那条)。判据来自
|
|
179
|
+
// infra/egress.js#isTestProcess(看 process.argv[1],测试自己解不开这条保护)。
|
|
180
|
+
// 注入了 repair 替身 = 调用方自己承担(离线单测就是这样覆盖全流程的),此时不拦。
|
|
181
|
+
const repairImpl = typeof deps.repair === 'function' ? deps.repair : null
|
|
182
|
+
if (repairImpl === null && isTestProcess()) {
|
|
183
|
+
return { started: false, reason: '测试进程:不自动跑 pnpm(见 infra/egress.js#isTestProcess)' }
|
|
184
|
+
}
|
|
185
|
+
const st = stateNow()
|
|
186
|
+
const plan = planLockAutofix({ profileDir, now, ...(deps.plan ?? {}) })
|
|
187
|
+
if (plan.applicable !== true) {
|
|
188
|
+
// 没有不一致时 quiet(reason=null);有但不动手时如实说明
|
|
189
|
+
return { started: false, reason: plan.reason }
|
|
190
|
+
}
|
|
191
|
+
if (st.inFlight) return { started: false, reason: '上一次自动对账还在跑,本次跳过' }
|
|
192
|
+
if (st.stopped && st.stoppedSignature === plan.signature) {
|
|
193
|
+
// 说清"停了"与"什么情况下才会再试",再带上上次的真实原因(不让人以为它还会自己动)
|
|
194
|
+
const why = st.stoppedReason ?? '上次自动对账未成功'
|
|
195
|
+
return { started: false, reason: `已停止自动重试(清单/lock 变化后会再试)—— ${why}` }
|
|
196
|
+
}
|
|
197
|
+
if (st.lastAt !== null && now - st.lastAt < COOLDOWN_MS) {
|
|
198
|
+
return { started: false, reason: `冷却中(${Math.max(1, Math.round((COOLDOWN_MS - (now - st.lastAt)) / 1000))}s 后才会再试)` }
|
|
199
|
+
}
|
|
200
|
+
st.inFlight = true
|
|
201
|
+
st.lastAt = now
|
|
202
|
+
if (st.stopped && st.stoppedSignature !== plan.signature) {
|
|
203
|
+
// 样子变了 ⇒ 解除停止(但失败计数留着,让人看得出历史上失败过)
|
|
204
|
+
st.stopped = false
|
|
205
|
+
st.stoppedReason = null
|
|
206
|
+
st.stoppedSignature = null
|
|
207
|
+
}
|
|
208
|
+
const repair = repairImpl ?? runLockfileRepair
|
|
209
|
+
// ★ 绝不在调用栈里 await:让 HTTP 立刻返回,pnpm 在自己的 promise 里跑
|
|
210
|
+
void (async () => {
|
|
211
|
+
let result = null
|
|
212
|
+
try {
|
|
213
|
+
const out = await repair({ profileDir, registries, now: Date.now(), ...(deps.repairDeps ?? {}) })
|
|
214
|
+
const action = typeof out?.action === 'string' ? out.action : 'unknown'
|
|
215
|
+
const ok = out?.ok === true
|
|
216
|
+
result = {
|
|
217
|
+
at: Date.now(),
|
|
218
|
+
action,
|
|
219
|
+
ok,
|
|
220
|
+
kind: out?.kind ?? null,
|
|
221
|
+
reason: out?.reason ?? null,
|
|
222
|
+
hint: out?.hint ?? null,
|
|
223
|
+
packages: Array.isArray(out?.packages) ? out.packages.slice(0, 8) : [],
|
|
224
|
+
stderrTail: typeof out?.stderrTail === 'string' ? out.stderrTail.slice(-600) : null,
|
|
225
|
+
command: out?.command ?? null,
|
|
226
|
+
signature: plan.signature,
|
|
227
|
+
}
|
|
228
|
+
// blocked / noop 不是"失败":blocked 是真做不了(如实提示,但**不改**停止位以免反复试),
|
|
229
|
+
// noop = 已经没有需要修的(不动停止位);只有 failed / partial 才算失败并停。
|
|
230
|
+
if (action === 'failed' || action === 'partial' || (ok !== true && action === 'unknown')) {
|
|
231
|
+
st.failures += 1
|
|
232
|
+
st.stopped = true
|
|
233
|
+
st.stoppedSignature = plan.signature
|
|
234
|
+
st.stoppedReason = action === 'failed'
|
|
235
|
+
? `自动对账失败:${result.kind ?? '未知原因'} —— ${(result.stderrTail ?? result.reason ?? '').slice(-200)}`
|
|
236
|
+
: `自动对账只修好一部分:${result.reason ?? result.hint ?? '仍有差距'}`
|
|
237
|
+
} else if (action === 'blocked') {
|
|
238
|
+
st.failures += 1
|
|
239
|
+
st.stopped = true
|
|
240
|
+
st.stoppedSignature = plan.signature
|
|
241
|
+
st.stoppedReason = `自动对账被拦下(未改任何文件):${result.reason ?? result.kind ?? '未知原因'}`
|
|
242
|
+
} else {
|
|
243
|
+
st.stopped = false
|
|
244
|
+
st.stoppedReason = null
|
|
245
|
+
st.stoppedSignature = null
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
result = {
|
|
249
|
+
at: Date.now(),
|
|
250
|
+
action: 'failed',
|
|
251
|
+
ok: false,
|
|
252
|
+
kind: 'exception',
|
|
253
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
254
|
+
hint: null,
|
|
255
|
+
packages: [],
|
|
256
|
+
stderrTail: null,
|
|
257
|
+
command: null,
|
|
258
|
+
signature: plan.signature,
|
|
259
|
+
}
|
|
260
|
+
st.failures += 1
|
|
261
|
+
st.stopped = true
|
|
262
|
+
st.stoppedSignature = plan.signature
|
|
263
|
+
st.stoppedReason = `自动对账抛错:${result.reason}`
|
|
264
|
+
} finally {
|
|
265
|
+
st.inFlight = false
|
|
266
|
+
st.lastResult = result
|
|
267
|
+
writeStateFile({ lastAt: st.lastAt, lastResult: st.lastResult, failures: st.failures, stopped: st.stopped, stoppedReason: st.stoppedReason, stoppedSignature: st.stoppedSignature })
|
|
268
|
+
}
|
|
269
|
+
})()
|
|
270
|
+
return { started: true, reason: null }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 面板/诊断用的状态快照(纯读,不触发任何动作)。 */
|
|
274
|
+
function lockAutofixStatus({ profileDir = null, now = Date.now() } = {}) {
|
|
275
|
+
const st = stateNow()
|
|
276
|
+
let mismatch = null
|
|
277
|
+
if (typeof profileDir === 'string' && profileDir !== '') {
|
|
278
|
+
try {
|
|
279
|
+
const plan = planLockAutofix({ profileDir, now })
|
|
280
|
+
mismatch = plan.outdated
|
|
281
|
+
} catch {
|
|
282
|
+
mismatch = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
cooldownMs: COOLDOWN_MS,
|
|
287
|
+
inFlight: st.inFlight,
|
|
288
|
+
lastAt: st.lastAt,
|
|
289
|
+
lastResult: st.lastResult,
|
|
290
|
+
failures: st.failures,
|
|
291
|
+
stopped: st.stopped,
|
|
292
|
+
stoppedReason: st.stoppedReason,
|
|
293
|
+
mismatch,
|
|
294
|
+
note: st.lastResult === null
|
|
295
|
+
? null
|
|
296
|
+
: (st.lastResult.ok === true
|
|
297
|
+
? `已自动对账:${st.lastResult.action === 'repaired' ? 'lock 已按清单重写' : st.lastResult.action}`
|
|
298
|
+
: `自动对账未成功(${st.lastResult.action}):${st.lastResult.reason ?? st.lastResult.stoppedReason ?? '原因未知'}`),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export {
|
|
303
|
+
COOLDOWN_MS,
|
|
304
|
+
STATE_FILE_NAME,
|
|
305
|
+
lockAutofixStatus,
|
|
306
|
+
mismatchSignature,
|
|
307
|
+
planLockAutofix,
|
|
308
|
+
scheduleLockAutofix,
|
|
309
|
+
__resetLockAutofix,
|
|
310
|
+
}
|
|
@@ -120,7 +120,7 @@ async function specOf(profileDir, packageName) {
|
|
|
120
120
|
* 由 `link-self-sufficiency.js` 补齐依赖与框架 peer 垫片根。
|
|
121
121
|
* 优先级刻意保持"registry 可解析 → 版本号"在最前:既有 registry 流程一字不改(不倒退)。
|
|
122
122
|
*/
|
|
123
|
-
async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null }) {
|
|
123
|
+
async function planDependencySpec({ profileDir, packageName, version, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, registryProven = false }) {
|
|
124
124
|
const doProbe = typeof probe === 'function' ? probe : probeRegistryPackage
|
|
125
125
|
const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
|
|
126
126
|
const linkable = () => materializePackageForLink(profileDir, packageName, home === undefined ? {} : { home })
|
|
@@ -155,6 +155,20 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
155
155
|
result = { resolvable: false, hasVersion: false, tried: [error instanceof Error ? error.message : String(error)] }
|
|
156
156
|
}
|
|
157
157
|
if (result?.resolvable === true && result.hasVersion !== false) return { form: 'version', spec: version, note: null, suggestion: null }
|
|
158
|
+
// ★ 0.5.87 加法(用户红线:「正常下载安装/更新绝不能走 link:」):这份物是**我们自己从 registry
|
|
159
|
+
// 下载成功**的(curl 通道拿到的 dist.tarball,摘要已校验)⇒ "registry 查无此包/此版本"的探测结论
|
|
160
|
+
// 只能是**探测这次没探成**(镜像元数据滞后/CDN 边缘不一致),不是事实。此时按版本号写回,
|
|
161
|
+
// 绝不物化成 link:(那会把一个正常下载安装的包变成"开发式安装",下次 pnpm 操作还会被改形态)。
|
|
162
|
+
// 没传 registryProven 时(默认 false)行为与改动前**逐字相同**。
|
|
163
|
+
if (registryProven === true) {
|
|
164
|
+
return {
|
|
165
|
+
form: 'version',
|
|
166
|
+
spec: version,
|
|
167
|
+
note: `${packageName}:本次是**从 registry 实际下载成功**的(tarball 已校验摘要),故按版本号 ${version} 记录;`
|
|
168
|
+
+ 'registry 元数据探测这次没能确认它(多为镜像/元数据滞后)——下载成功即证明该版本在,**不**据此写成 link:/本地副本',
|
|
169
|
+
suggestion: null,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
158
172
|
// 来源型:调用方给了真实来源规格 ⇒ 默认真装(不写 link:)。拿不到真实规格才退回 link:。
|
|
159
173
|
if (probeFailureKind(result) === 'fetch-404' && realSpec !== null) return asSource()
|
|
160
174
|
if (probeFailureKind(result) === 'fetch-404') {
|
|
@@ -184,7 +198,7 @@ async function planDependencySpec({ profileDir, packageName, version, registries
|
|
|
184
198
|
* lockSynced: boolean, lockNote: string|null, depNote: string|null, suggested: object|null }}
|
|
185
199
|
*/
|
|
186
200
|
async function declareProfileDependency(profileDir, packageName, version = null, options = {}) {
|
|
187
|
-
const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null } = options
|
|
201
|
+
const { syncLock = true, registries = [], probe = null, home = undefined, profile = null, sourceSpec = null, installSource = null, sourceOrigin = null, sourceKind = null, registryProven = false } = options
|
|
188
202
|
const empty = { changed: false, version: null, spec: null, form: null, reason: null, lockSynced: false, lockNote: null, depNote: null, suggested: null, sourceInstall: null, selfSufficiency: null }
|
|
189
203
|
const realSpec = typeof sourceSpec === 'string' && sourceSpec.trim() !== '' ? sourceSpec.trim() : null
|
|
190
204
|
const homeOpt = home === undefined ? {} : { home }
|
|
@@ -217,7 +231,7 @@ async function declareProfileDependency(profileDir, packageName, version = null,
|
|
|
217
231
|
note: `${packageName}:**源码安装(本地物化)** —— 该包在 npm registry 上找不到,只能拿到源码;已按路径型来源记录(${realSpec})。不经 registry 解析、不经 tarball 完整性校验,缺失依赖与框架 peer 由 link 自足性补齐`,
|
|
218
232
|
suggestion: null,
|
|
219
233
|
}
|
|
220
|
-
: await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec })
|
|
234
|
+
: await planDependencySpec({ profileDir, packageName, version: resolved, registries, probe, home, profile, sourceSpec: installSpec, registryProven })
|
|
221
235
|
// ★ 本轮接线(2026-10-06):盘上那份是**取样通道**铺的(curl / GitHub release 资产 / 源码装配),
|
|
222
236
|
// 而不是包管理器装的 —— 而 registry 又查无此包(plan 判成来源型)⇒ **补一次真装**,
|
|
223
237
|
// 把依赖交给 pnpm(与官方 dsh plugin add 同一条通道)。
|
|
@@ -13,6 +13,12 @@ import { repoMatchedRegistryChannels } from './repo-package-match.js'
|
|
|
13
13
|
import { probeChannelLine, probeFailureLines, composeProbeFailureText } from './probe-outcome.js'
|
|
14
14
|
import { FETCH_OK, FETCH_UNREACHABLE, GITHUB_API, channelLabel, channelFailureSummary, curlJson, curlText, fetchJsonUrl, githubJson, looksLikeGitmodules, rawTextFetch, rawTextWithFallback } from '../infra/http.js'
|
|
15
15
|
import { ENRICH_CACHE_FILE, baseDirOf, marketIndexCacheFile, resolvePackageJson } from '../infra/paths.js'
|
|
16
|
+
// 0.5.84 加法:富化请求全部过**同一个闸门**(并发 4 / 失败 10 分钟不再试 / 成功短缓存 5 分钟)。
|
|
17
|
+
// 见 domain/enrich-throttle.js 顶部那段真机症状;这里只做接线,判定一个字没改。
|
|
18
|
+
import { ENRICH_CONCURRENCY, throttledCurlJson, throttledCurlText, throttledRawText } from './enrich-throttle.js'
|
|
19
|
+
|
|
20
|
+
/** 富化里"还在用裸出网"的第三路(技能探测)也过同一道闸:把两个出口作为依赖传下去。 */
|
|
21
|
+
const ENRICH_EGRESS = { curlText: throttledCurlText, curlJson: throttledCurlJson }
|
|
16
22
|
|
|
17
23
|
function readEnrichCache() {
|
|
18
24
|
try {
|
|
@@ -41,11 +47,11 @@ async function enrichItemOne(item) {
|
|
|
41
47
|
const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
|
|
42
48
|
const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
|
|
43
49
|
Promise.any([
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
throttledCurlText(base),
|
|
51
|
+
throttledCurlText(`https://ghproxy.net/${base}`),
|
|
46
52
|
]),
|
|
47
|
-
detectSkillRepo(item.fullName, branch),
|
|
48
|
-
|
|
53
|
+
detectSkillRepo(item.fullName, branch, ENRICH_EGRESS),
|
|
54
|
+
throttledRawText(item.fullName, branch, '.gitmodules'),
|
|
49
55
|
])
|
|
50
56
|
// 套装判定必须过内容校验:代理/CDN 对不存在的 .gitmodules 也可能回 2xx 空 body,
|
|
51
57
|
// 只判"探测非 null"会把普通插件标成套装置仓库(2026-09-19 事故)。
|
|
@@ -64,13 +70,13 @@ async function enrichItemOne(item) {
|
|
|
64
70
|
if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
|
|
65
71
|
aggregate = true
|
|
66
72
|
try {
|
|
67
|
-
const tree = await
|
|
73
|
+
const tree = await throttledCurlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`)
|
|
68
74
|
const pkgPaths = (tree.tree ?? [])
|
|
69
75
|
.filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
|
|
70
76
|
.map((n) => n.path)
|
|
71
77
|
.slice(0, 12)
|
|
72
78
|
if (pkgPaths.length > 0) {
|
|
73
|
-
const subs = await Promise.all(pkgPaths.map((p) =>
|
|
79
|
+
const subs = await Promise.all(pkgPaths.map((p) => throttledCurlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`)
|
|
74
80
|
.then((t) => { try { return JSON.parse(t) } catch { return null } })
|
|
75
81
|
.catch(() => null)))
|
|
76
82
|
if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
|
|
@@ -138,7 +144,7 @@ async function enrichItems(items) {
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
}
|
|
141
|
-
await Promise.all(Array.from({ length: Math.min(
|
|
147
|
+
await Promise.all(Array.from({ length: Math.min(ENRICH_CONCURRENCY, items.length) }, worker))
|
|
142
148
|
if (cacheDirty) writeEnrichCache(cache)
|
|
143
149
|
return out
|
|
144
150
|
}
|