@noob-stupid/dsh-plugin-console 0.5.32 → 0.5.34
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 +40 -10
- package/README.zh.md +31 -5
- package/lib/client.js +87 -55
- package/lib/server/domain/compat-verdict.js +241 -0
- package/lib/server/domain/compat.js +88 -74
- package/lib/server/domain/framework-cleanup.js +225 -24
- package/lib/server/domain/framework-residuals.js +52 -3
- package/lib/server/domain/peer-veto.js +103 -0
- package/lib/server/domain/settings-api-scan.js +266 -0
- package/lib/server/routes/plugins.js +25 -12
- package/lib/server/routes/state.js +31 -6
- package/package.json +1 -1
|
@@ -5,15 +5,36 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 三条安全约定(对齐仓库既有纪律):
|
|
7
7
|
// ① **一律先改名备份**:顶层旧目录 → `<name>.stale-<ver>-<ts>`,绝不直接删(可回滚)。
|
|
8
|
-
// ②
|
|
9
|
-
// 推不到或目标不存在就只改名,并如实记进 `relinkSkipped`(不假装成功)。
|
|
8
|
+
// ② 改名与重建 junction **原子化**(0.5.34 改错,见下);重建失败 → 立刻把改名改回去。
|
|
10
9
|
// ③ 框架备份只删 `fw-tree/<ts>` 目录,**绝不碰** `dsh-package-backup` 与目录下的快照文件。
|
|
10
|
+
//
|
|
11
|
+
// ── 0.5.34 改错(**真机停机事故 2026-10-01**:web 服务进程 exit code=1,起不来)──────────────
|
|
12
|
+
// 现场:框架树顶层 `@deepseek-ai/cordis` 被改名成 `cordis.stale-4.0.4-<ts>`,**但没重建 junction**
|
|
13
|
+
// ⇒ 启动包 `dsh-app-boot` 的 `import '@deepseek-ai/cordis'` 解析失败 ⇒ 服务进程退出。
|
|
14
|
+
// 真根因(`git blame` 定位到旧 `:77` 改名 / 旧 `:92` 本应重建):重建链接只在
|
|
15
|
+
// 「`dsh` junction 推得出运行树 **且** 目标存在」时才做,其余分支(`no-running-scope` /
|
|
16
|
+
// `target-missing`)**只改名不重建**;真机上顶层 `dsh` 是**真实目录**而非 junction
|
|
17
|
+
// ⇒ `runningScopeDir = null` ⇒ 10 条包全部落进这条"只改名"的支路,且只在 `relinkSkipped` 里
|
|
18
|
+
// 留一行 JSON(面板不留痕、用户看不到),框架就再也起不来了。
|
|
19
|
+
// 现在(四条硬规则,全部有离线测试钉死):
|
|
20
|
+
// ① **改名后立刻重建 junction**;重建失败 → **立刻回滚改名**(改回原名)→ 记 `failed` 并写明原因。
|
|
21
|
+
// `relink:false`(显式关闭重建)不再"只改名"——那种模式正是事故本体,一律不改名并如实记 `relink-off`。
|
|
22
|
+
// ② 找不到任何可重建目标(多基准候选全不存在)→ **连名字都不动**(先探目标、再改名),记
|
|
23
|
+
// `relinkSkipped`(带原因 + 试过哪些基准)—— 宁可什么都不做,也不让框架顶层少一个包名。
|
|
24
|
+
// ③ 整批结束后做一次**框架可解析性验证**(至少 `@deepseek-ai/cordis` + `@deepseek-ai/dsh-app-boot`,
|
|
25
|
+
// 多基准:框架树顶层 + `.pnpm/node_modules`;解析结果必须落在框架树内)→ **验证失败则把本轮所有
|
|
26
|
+
// 改名回滚**(恢复到操作前状态)并返回失败原因。这条就是"防止把服务搞到起不来"的硬闸。
|
|
27
|
+
// ④ **绝不删除**任何 `.stale-*` 备份;**幂等**:`.stale-*` 不再参与判据(见 framework-residuals.js),
|
|
28
|
+
// 已建好的 junction 与同 stamp 的备份名都会让重复运行零改名、零新建链接。
|
|
29
|
+
// 返回值:`renamed`(**留在原地**的改名,含 target/via)/ `relinked` / `relinkSkipped`(含原因)/
|
|
30
|
+
// `rolledBack`(含哪些与为什么)/ `verify`(多基准解析验证结果)/ `failed`(可读原因,绝不静默吞)。
|
|
11
31
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
32
|
+
import { createRequire } from 'node:module'
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, statSync, symlinkSync, unlinkSync } from 'node:fs'
|
|
34
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
14
35
|
import { removeDirVerifiedAsync } from '../infra/fsx.js'
|
|
15
36
|
import { dshHome, resolvePackageJson } from '../infra/paths.js'
|
|
16
|
-
import { classifyTopLevel, planBackupRetention } from './framework-residuals.js'
|
|
37
|
+
import { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, planBackupRetention, relinkTargetCandidates } from './framework-residuals.js'
|
|
17
38
|
|
|
18
39
|
/** 目录体积(失败返回 0;只用于报告「能释放多少」)。 */
|
|
19
40
|
function dirSizeBytes(dir) {
|
|
@@ -39,18 +60,142 @@ function isLinkEntry(full, dirent, readLink = readlinkSync) {
|
|
|
39
60
|
try { readLink(full); return true } catch { return false }
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
/** 删掉一个**链接**(Windows 的 junction 用 rmdir 拆;符号链接用 unlink 兜底)。绝不删真实目录。 */
|
|
64
|
+
function removeLinkSafe(full, deps = {}) {
|
|
65
|
+
const { rmdir = rmdirSync, unlink = unlinkSync } = deps
|
|
66
|
+
const errors = []
|
|
67
|
+
try { rmdir(full); return { ok: true, via: 'rmdir' } } catch (error) { errors.push(String(error?.message ?? error)) }
|
|
68
|
+
try { unlink(full); return { ok: true, via: 'unlink' } } catch (error) { errors.push(String(error?.message ?? error)) }
|
|
69
|
+
return { ok: false, error: errors.join(' / ') }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function errText(error) {
|
|
73
|
+
return String(error?.message ?? error)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 把「改名 → 重建链接」这一步回滚:先拆掉本轮建的链接(若已建),再把备份名改回原名。
|
|
78
|
+
* 返回 `{ ok, problem }`;**失败必须带可读原因**(回滚失败是最严重的一类,绝不能假装成功)。
|
|
79
|
+
*/
|
|
80
|
+
function rollbackRename({ full, backupFull, linkAtName = false, deps = {} } = {}) {
|
|
81
|
+
const { exists = existsSync, rename = renameSync, readLink = readlinkSync, removeLink = removeLinkSafe } = deps
|
|
82
|
+
const problems = []
|
|
83
|
+
let occupied = false
|
|
84
|
+
try { occupied = exists(full) } catch (error) { problems.push('检查原位置失败:' + errText(error)) }
|
|
85
|
+
if (occupied) {
|
|
86
|
+
// 原位置此刻只可能是**本轮刚建的链接**(原名一刻前才被改名让出)。
|
|
87
|
+
// 真实目录一律不动 —— 宁可留下链接并如实报错,也绝不误删用户数据。
|
|
88
|
+
let linkNow = linkAtName
|
|
89
|
+
if (!linkNow) { try { readLink(full); linkNow = true } catch { linkNow = false } }
|
|
90
|
+
if (!linkNow) problems.push('原位置出现了非链接目录,拒绝覆盖')
|
|
91
|
+
else {
|
|
92
|
+
const rm = removeLink(full)
|
|
93
|
+
if (rm?.ok !== true) problems.push('拆除本轮链接失败:' + String(rm?.error ?? '未知'))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (problems.length === 0) {
|
|
97
|
+
try { rename(backupFull, full) } catch (error) { problems.push('改回原名失败:' + errText(error)) }
|
|
98
|
+
}
|
|
99
|
+
return { ok: problems.length === 0, problem: problems.join(';') }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 解析一条残留该重建到哪个真实目录(多基准;第一个存在的候选胜出),或如实说明为什么不能重建。 */
|
|
103
|
+
function resolveRelinkTarget({ fwRoot, scopeName, name, version, runningScopeDir, deps = {} } = {}) {
|
|
104
|
+
const { exists = existsSync, readdir = readdirSync } = deps
|
|
105
|
+
const storeHoistDir = fwRoot === null ? null : join(fwRoot, '.pnpm', 'node_modules')
|
|
106
|
+
const tried = []
|
|
107
|
+
for (const candidate of relinkTargetCandidates({ runningScopeDir, storeHoistDir, scopeName, name })) {
|
|
108
|
+
tried.push({ path: candidate.path, via: candidate.via })
|
|
109
|
+
let hit = false
|
|
110
|
+
try { hit = exists(candidate.path) } catch { hit = false }
|
|
111
|
+
if (hit) return { target: candidate.path, via: candidate.via, tried }
|
|
112
|
+
}
|
|
113
|
+
// 兜底:pnpm 虚拟 store 里的实体本体 `<scope>+<name>@<version>*`(版本必须**与刚改名的那份相同**,
|
|
114
|
+
// 绝不借机换版本;同版本多个 hash 时按目录名排序取第一个,确定性且语义等价)。
|
|
115
|
+
const storeDir = fwRoot === null ? null : join(fwRoot, '.pnpm')
|
|
116
|
+
if (storeDir !== null && version !== null && version !== undefined && String(version) !== '') {
|
|
117
|
+
const prefix = `${scopeName}+${name}@${version}`
|
|
118
|
+
let dirs = []
|
|
119
|
+
try { dirs = readdir(storeDir, { withFileTypes: true }).filter((d) => d.isDirectory() && (d.name === prefix || d.name.startsWith(prefix + '_'))).map((d) => d.name).sort() } catch (error) { tried.push({ path: join(storeDir, prefix + '*'), via: 'pnpm-store-entity', error: errText(error) }) }
|
|
120
|
+
for (const dir of dirs) {
|
|
121
|
+
const candidate = join(storeDir, dir, 'node_modules', scopeName, name)
|
|
122
|
+
tried.push({ path: candidate, via: 'pnpm-store-entity' })
|
|
123
|
+
let hit = false
|
|
124
|
+
try { hit = exists(candidate) } catch { hit = false }
|
|
125
|
+
if (hit) return { target: candidate, via: 'pnpm-store-entity', tried }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { target: null, via: null, tried }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 框架**可解析性**验证(多基准,只读):从「框架树顶层」与「`.pnpm/node_modules`」两个基准分别解析
|
|
133
|
+
* 关键启动包,且要求解析结果**落在框架树内**(否则是从上层别处的同名包,等于框架自己还是解析不到)。
|
|
134
|
+
* 默认用 `createRequire(base).resolve(name)` 真解析;`deps.resolveFrom` 可注入(离线测试用)。
|
|
135
|
+
*/
|
|
136
|
+
function verifyFrameworkTreeResolvable({ fwRoot, packages = FRAMEWORK_BOOT_PACKAGES, deps = {} } = {}) {
|
|
137
|
+
const { resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name), real = realpathSync } = deps
|
|
138
|
+
const treeRoot = fwRoot === null || fwRoot === undefined ? null : dirname(fwRoot)
|
|
139
|
+
const bases = treeRoot === null ? [] : [
|
|
140
|
+
{ id: 'framework-top', dir: treeRoot, file: join(treeRoot, 'package.json') },
|
|
141
|
+
{ id: 'pnpm-store', dir: join(fwRoot, '.pnpm', 'node_modules'), file: join(fwRoot, '.pnpm', 'node_modules', 'package.json') },
|
|
142
|
+
]
|
|
143
|
+
const norm = (p) => {
|
|
144
|
+
try { return real(p) } catch { return resolve(p) }
|
|
145
|
+
}
|
|
146
|
+
const inside = (child, parent) => {
|
|
147
|
+
const c = norm(child)
|
|
148
|
+
const p = norm(parent)
|
|
149
|
+
return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep)
|
|
150
|
+
}
|
|
151
|
+
const checks = []
|
|
152
|
+
for (const base of bases) {
|
|
153
|
+
for (const short of packages) {
|
|
154
|
+
const name = short.includes('/') ? short : `@deepseek-ai/${short}`
|
|
155
|
+
try {
|
|
156
|
+
const resolved = resolveFrom(base.file, name)
|
|
157
|
+
if (typeof resolved !== 'string' || resolved === '') throw new Error('解析器没有返回路径')
|
|
158
|
+
if (treeRoot !== null && !inside(resolved, treeRoot)) {
|
|
159
|
+
checks.push({ base: base.id, name, ok: false, path: resolved, error: `解析到框架树之外(${resolved})——框架自己仍然解析不到它` })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
checks.push({ base: base.id, name, ok: true, path: resolved, error: null })
|
|
163
|
+
} catch (error) {
|
|
164
|
+
checks.push({ base: base.id, name, ok: false, path: null, error: errText(error) })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const bad = checks.filter((c) => c.ok !== true)
|
|
169
|
+
const ok = bases.length > 0 && bad.length === 0
|
|
170
|
+
return {
|
|
171
|
+
ok,
|
|
172
|
+
phase: 'post',
|
|
173
|
+
bases: bases.map((b) => ({ id: b.id, dir: b.dir })),
|
|
174
|
+
packages: packages.map((s) => (s.includes('/') ? s : `@deepseek-ai/${s}`)),
|
|
175
|
+
checks,
|
|
176
|
+
error: ok ? null : (bases.length === 0
|
|
177
|
+
? '拿不到框架树根(无法验证可解析性)'
|
|
178
|
+
: `框架可解析性验证失败:${bad.slice(0, 3).map((c) => `${c.name}@${c.base}(${c.error})`).join(';')}${bad.length > 3 ? ` 等 ${bad.length} 条` : ''}`),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
42
182
|
/**
|
|
43
|
-
* A 档:顶层投影里的旧版本真实目录 → 改名备份(+
|
|
44
|
-
* `topScopeDir` = `<
|
|
183
|
+
* A 档:顶层投影里的旧版本真实目录 → 改名备份(+ **立刻**重建 junction;失败即回滚)。
|
|
184
|
+
* `topScopeDir` = `<fwRoot>/<scope>`(如 `…/node_modules/@deepseek-ai`);`dshLinkPath` = 同目录下的 `dsh`。
|
|
185
|
+
* 可选 `frameworkRoot`(默认 `dirname(topScopeDir)`)、`verifyPackages`(默认启动关键包)。
|
|
45
186
|
*/
|
|
46
|
-
async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, deps = {} } = {}) {
|
|
187
|
+
async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, runningVersion = null, relink = true, dryRun = false, stamp = null, frameworkRoot = null, verifyPackages = null, deps = {} } = {}) {
|
|
47
188
|
const {
|
|
48
189
|
exists = existsSync, readdir = readdirSync, readText = (f) => readFileSync(f, 'utf8'),
|
|
49
190
|
rename = renameSync, link = symlinkSync, readLink = readlinkSync,
|
|
191
|
+
resolveFrom = (baseFile, name) => createRequire(baseFile).resolve(name),
|
|
50
192
|
} = deps
|
|
193
|
+
const empty = { renamed: [], relinked: [], relinkSkipped: [], rolledBack: [], failed: [], verify: null }
|
|
51
194
|
if (topScopeDir === null || topScopeDir === undefined || !exists(topScopeDir)) {
|
|
52
|
-
return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)',
|
|
195
|
+
return { ok: false, error: '框架顶层目录不存在(拿不到 @deepseek-ai/* 的真实位置)', ...empty }
|
|
53
196
|
}
|
|
197
|
+
const fwRoot = frameworkRoot ?? dirname(topScopeDir)
|
|
198
|
+
const scopeName = basename(topScopeDir)
|
|
54
199
|
const entries = []
|
|
55
200
|
for (const dirent of readdir(topScopeDir, { withFileTypes: true })) {
|
|
56
201
|
const full = join(topScopeDir, dirent.name)
|
|
@@ -72,40 +217,96 @@ async function cleanFrameworkTopLevel({ topScopeDir, dshLinkPath = null, running
|
|
|
72
217
|
const renamed = []
|
|
73
218
|
const relinked = []
|
|
74
219
|
const relinkSkipped = []
|
|
220
|
+
const rolledBack = []
|
|
75
221
|
const failed = []
|
|
76
|
-
|
|
222
|
+
const hints = []
|
|
223
|
+
if (runningScopeDir === null && dshLinkPath !== null) hints.push('顶层 `' + basename(dshLinkPath) + '` 不是 junction(推不出运行树)——重建链接改用 .pnpm 多基准候选')
|
|
224
|
+
const io = { exists, rename, readLink }
|
|
225
|
+
|
|
226
|
+
for (const item of classified.stale) {
|
|
227
|
+
if (dryRun === true) break // 只出 planned(函数尾部统一填),一个字节都不动
|
|
228
|
+
// 显式关闭重建(relink:false):**一律不改名**。旧行为是"只改名不重建",那正是 2026-10-01 停机事故本体。
|
|
229
|
+
if (relink !== true) {
|
|
230
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'relink-off', detail: '调用方显式关闭重建链接(relink:false):改名后无法指回真实包体,故不改名(旧行为会只改名 → 框架解析不到包)' })
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
77
233
|
const backupName = item.name + '.stale-' + item.version + '-' + ts
|
|
234
|
+
const backupFull = join(topScopeDir, backupName)
|
|
235
|
+
const full = join(topScopeDir, item.name)
|
|
236
|
+
let backupExists = false
|
|
237
|
+
try { backupExists = exists(backupFull) } catch { backupExists = false }
|
|
238
|
+
if (backupExists) {
|
|
239
|
+
// 幂等护栏:同一个 stamp 的备份已经在 → 说明这一条早就改过名了,重复运行不产生新改名。
|
|
240
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'backup-exists', detail: `备份 ${backupName} 已存在 —— 疑似重复运行,本次不改名、不改链接` })
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
// **先探目标再改名**:找不到可指回的真实包体时连名字都不动(少一个"名字短暂空缺"的窗口)。
|
|
244
|
+
const resolvedTarget = resolveRelinkTarget({ fwRoot, scopeName, name: item.name, version: item.version, runningScopeDir, deps: { exists, readdir } })
|
|
245
|
+
if (resolvedTarget.target === null) {
|
|
246
|
+
relinkSkipped.push({ name: item.name, version: item.version, reason: 'no-target', detail: '多基准候选全不存在,无法重建链接 —— 不改名(判不准就不动)', tried: resolvedTarget.tried })
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
78
249
|
try {
|
|
79
|
-
rename(
|
|
250
|
+
rename(full, backupFull)
|
|
80
251
|
} catch (error) {
|
|
81
|
-
failed.push({ name: item.name, error:
|
|
252
|
+
failed.push({ name: item.name, error: '改名备份失败:' + errText(error) })
|
|
82
253
|
continue
|
|
83
254
|
}
|
|
84
|
-
renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null })
|
|
85
|
-
if (relink !== true) { relinkSkipped.push({ name: item.name, reason: 'relink-off' }); continue }
|
|
86
|
-
if (runningScopeDir === null) { relinkSkipped.push({ name: item.name, reason: 'no-running-scope' }); continue }
|
|
87
|
-
const target = join(runningScopeDir, item.name)
|
|
88
|
-
let targetExists = false
|
|
89
|
-
try { targetExists = exists(target) } catch { targetExists = false }
|
|
90
|
-
if (!targetExists) { relinkSkipped.push({ name: item.name, reason: 'target-missing' }); continue }
|
|
91
255
|
try {
|
|
92
|
-
link(target,
|
|
93
|
-
relinked.push({ name: item.name, target })
|
|
256
|
+
link(resolvedTarget.target, full, 'junction')
|
|
94
257
|
} catch (error) {
|
|
95
|
-
|
|
258
|
+
const rb = rollbackRename({ full, backupFull, deps: io })
|
|
259
|
+
const why = `重建链接失败(目标 ${resolvedTarget.target}):${errText(error)}`
|
|
260
|
+
if (rb.ok) {
|
|
261
|
+
rolledBack.push({ name: item.name, version: item.version, backup: backupName, reason: 'relink-failed' })
|
|
262
|
+
failed.push({ name: item.name, error: `${why};已把改名回滚(现场未变)` })
|
|
263
|
+
} else {
|
|
264
|
+
failed.push({ name: item.name, error: `${why};**回滚改名也失败**(现场可能不一致):${rb.problem}` })
|
|
265
|
+
}
|
|
266
|
+
continue
|
|
96
267
|
}
|
|
268
|
+
renamed.push({ name: item.name, version: item.version, backup: backupName, sizeBytes: item.sizeBytes ?? null, target: resolvedTarget.target, via: resolvedTarget.via })
|
|
269
|
+
relinked.push({ name: item.name, target: resolvedTarget.target, via: resolvedTarget.via })
|
|
97
270
|
}
|
|
271
|
+
|
|
272
|
+
// ③ 整批验证(硬闸):只在**本轮真的改过名**时才具回滚意义;没改过就只如实报告当前状态。
|
|
273
|
+
const verify = verifyFrameworkTreeResolvable({ fwRoot, packages: verifyPackages ?? FRAMEWORK_BOOT_PACKAGES, deps: { resolveFrom } })
|
|
274
|
+
verify.phase = dryRun === true ? 'pre' : 'post'
|
|
275
|
+
if (verify.ok !== true && renamed.length > 0) {
|
|
276
|
+
const all = [...renamed].reverse()
|
|
277
|
+
renamed.length = 0
|
|
278
|
+
relinked.length = 0
|
|
279
|
+
for (const item of all) {
|
|
280
|
+
const rb = rollbackRename({ full: join(topScopeDir, item.name), backupFull: join(topScopeDir, item.backup), linkAtName: true, deps: io })
|
|
281
|
+
if (rb.ok) rolledBack.push({ name: item.name, version: item.version, backup: item.backup, reason: 'verify-failed' })
|
|
282
|
+
else failed.push({ name: item.name, error: `验证失败后的回滚未完成(现场可能不一致):${rb.problem}` })
|
|
283
|
+
}
|
|
284
|
+
verify.rolledBackAll = true
|
|
285
|
+
verify.error = `${verify.error} → 已把本轮全部 ${all.length} 条改名回滚(恢复到操作前状态)`
|
|
286
|
+
} else if (verify.ok !== true) {
|
|
287
|
+
verify.rolledBackAll = false
|
|
288
|
+
hints.push('本轮没有改名(dryRun / relink-off):验证结果只反映**操作前**的现场状态,未触发回滚')
|
|
289
|
+
}
|
|
290
|
+
if (relinkSkipped.some((s) => s.reason === 'no-target')) hints.push(`有 ${relinkSkipped.filter((s) => s.reason === 'no-target').length} 条找不到可指回的真实包体:**连名字都没动**(判不准就不动)`)
|
|
291
|
+
if (rolledBack.length > 0) hints.push(`本轮回滚 ${rolledBack.length} 条(原因见 rolledBack)`)
|
|
292
|
+
const ok = failed.length === 0 && verify.ok !== false
|
|
98
293
|
return {
|
|
99
|
-
ok
|
|
294
|
+
ok,
|
|
100
295
|
renamed,
|
|
101
296
|
relinked,
|
|
102
297
|
relinkSkipped,
|
|
298
|
+
rolledBack,
|
|
103
299
|
failed,
|
|
300
|
+
hints,
|
|
104
301
|
staleCount: classified.stale.length,
|
|
105
302
|
sameVersionCount: classified.sameVersion.length,
|
|
106
303
|
missingVersion: classified.missingVersion,
|
|
107
304
|
dryRun,
|
|
108
305
|
planned: classified.stale.map((s) => ({ name: s.name, version: s.version, sizeBytes: s.sizeBytes ?? null })),
|
|
306
|
+
verify,
|
|
307
|
+
error: ok ? null : (failed.length > 0
|
|
308
|
+
? `有 ${failed.length} 条没能安全落地:${failed.slice(0, 3).map((f) => `${f.name}(${f.error})`).join(';')}`
|
|
309
|
+
: String(verify.error ?? '未知失败')),
|
|
109
310
|
}
|
|
110
311
|
}
|
|
111
312
|
|
|
@@ -222,4 +423,4 @@ function readRollbackReferenced({ home = undefined, deps = {} } = {}) {
|
|
|
222
423
|
return [raw?.checkpointDir, raw?.cliBackupDir].filter((p) => typeof p === 'string' && p !== '')
|
|
223
424
|
} catch { return [] }
|
|
224
425
|
}
|
|
225
|
-
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, reportFrameworkEntities, resolveFrameworkLayout }
|
|
426
|
+
export { cleanFrameworkBackups, cleanFrameworkTopLevel, dirSizeBytes, isLinkEntry, readRollbackReferenced, removeLinkSafe, reportFrameworkEntities, resolveFrameworkLayout, verifyFrameworkTreeResolvable }
|
|
@@ -8,15 +8,40 @@
|
|
|
8
8
|
// 而回滚只需要**回滚点引用的那一条** + **最近 N 条**。
|
|
9
9
|
//
|
|
10
10
|
// 本模块只做纯判据(IO 全由调用方注入),动作在 routes/plugins.js 的 clean-residuals scope 里:
|
|
11
|
-
// · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"
|
|
11
|
+
// · `classifyTopLevel` → 哪些顶层真实目录属于"旧版本残留"(同版本/链接/备份一律不动 —— 宁可少动)
|
|
12
12
|
// · `planBackupRetention` → 保留「回滚点那一条 ∪ 最近 N 条」,其余进删除清单 + 可释放字节
|
|
13
|
+
// · `relinkTargetCandidates` → 改名之后该把 junction 指回哪里(多基准候选,按优先级)
|
|
14
|
+
// · `isStaleBackupName` → 备份目录名判据(幂等:备份绝不能再被当成残留改名)
|
|
13
15
|
//
|
|
14
16
|
// 行数预算:独立模块,留在 600 行架构硬顶内。
|
|
15
17
|
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `.stale-*` 备份目录名判据(**幂等必需**)。
|
|
22
|
+
*
|
|
23
|
+
* 动作层把旧目录改名成 `<name>.stale-<version>-<ts>`;那些备份里**还带着 package.json**
|
|
24
|
+
* (version = 被备份的那个版本),所以不排除的话:
|
|
25
|
+
* 第二次运行 → 备份自己又被判成"旧版本残留" → 改成 `<...>.stale-<v>-<ts2>`(越堆越深),
|
|
26
|
+
* 而且会给这个根本不存在的名字建 junction。旧行为正是如此(真机 `cordis.stale-4.0.4-<ts>` 一眼可见)。
|
|
27
|
+
* 误判方向是**保守**的:真有一个叫 `foo.stale-bar` 的包时我们只会"不动它",不做危险动作。
|
|
28
|
+
*/
|
|
29
|
+
const STALE_BACKUP_RE = /\.stale-/u
|
|
30
|
+
function isStaleBackupName(name) {
|
|
31
|
+
return typeof name === 'string' && STALE_BACKUP_RE.test(name)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 框架**启动关键包**(相对 scope 目录名):整批改名结束后必须仍能解析,否则全量回滚。
|
|
36
|
+
* 这两个就是 2026-10-01 停机事故现场:`dsh-app-boot` 顶层 `import '@deepseek-ai/cordis'` 解析不到
|
|
37
|
+
* ⇒ web 服务进程 exit code=1。判据只认"能解析"这件事,与版本号无关。
|
|
38
|
+
*/
|
|
39
|
+
const FRAMEWORK_BOOT_PACKAGES = ['cordis', 'dsh-app-boot']
|
|
40
|
+
|
|
16
41
|
/**
|
|
17
42
|
* 顶层条目分类(纯函数)。
|
|
18
43
|
* `entries` 形如 `[{ name, version, isLink, sizeBytes }]`;`isLink` 为真表示已被 junction/符号链接接管。
|
|
19
|
-
* **保守取舍**:同版本保留;缺版本(读不到 package.json
|
|
44
|
+
* **保守取舍**:同版本保留;缺版本(读不到 package.json)保留并单列;`.stale-*` 备份一律不碰 —— 判不准就不动。
|
|
20
45
|
*/
|
|
21
46
|
function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
22
47
|
const stale = []
|
|
@@ -26,6 +51,7 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
|
26
51
|
if (entry === null || typeof entry !== 'object' || typeof entry.name !== 'string' || entry.name === '') continue
|
|
27
52
|
if (entry.name.startsWith('.')) continue
|
|
28
53
|
if (entry.isLink === true) continue
|
|
54
|
+
if (isStaleBackupName(entry.name)) continue // 备份(含备份的备份)永不参与判断 —— 见 isStaleBackupName
|
|
29
55
|
const version = entry.version === undefined || entry.version === null ? '' : String(entry.version)
|
|
30
56
|
if (version === '') { missingVersion.push({ name: entry.name, sizeBytes: entry.sizeBytes ?? null }); continue }
|
|
31
57
|
if (runningVersion !== null && version === String(runningVersion)) { sameVersion.push({ name: entry.name, version }); continue }
|
|
@@ -34,6 +60,29 @@ function classifyTopLevel(entries, { runningVersion = null } = {}) {
|
|
|
34
60
|
return { stale, sameVersion, missingVersion }
|
|
35
61
|
}
|
|
36
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 改名之后 junction 该指向哪里(纯函数:只**列候选**,存不存在由动作层问 IO)。
|
|
65
|
+
*
|
|
66
|
+
* 多基准(真机实证:2026-10-01 事故里 `dsh` 顶层是**真实目录**而不是 junction,
|
|
67
|
+
* 老判据只从 `dsh` junction 推运行树 → `runningScopeDir = null` → 10 条包全部"只改名、没重建"):
|
|
68
|
+
* ① `runningScopeDir/<name>` —— `dsh` 是 junction 时的运行树同级目录(老语义,保留)
|
|
69
|
+
* ② `<fwRoot>/.pnpm/node_modules/<scope>/<name>` —— pnpm store 的 hoisted 投影
|
|
70
|
+
* (真机 10 条手工补回的 junction 指的正是这里,逐条读过 Target)
|
|
71
|
+
* ③ `<fwRoot>/.pnpm/node_modules/<name>` —— 非作用域名的同款投影(防御性兜底)
|
|
72
|
+
* 顺序即优先级:先运行树(框架自己正在用的那份),再 store 投影。返回 `[{ path, via }]`。
|
|
73
|
+
*/
|
|
74
|
+
function relinkTargetCandidates({ runningScopeDir = null, storeHoistDir = null, scopeName = null, name = null } = {}) {
|
|
75
|
+
if (typeof name !== 'string' || name === '') return []
|
|
76
|
+
const out = []
|
|
77
|
+
if (typeof runningScopeDir === 'string' && runningScopeDir !== '') out.push({ path: join(runningScopeDir, name), via: 'running-scope' })
|
|
78
|
+
if (typeof storeHoistDir === 'string' && storeHoistDir !== '') {
|
|
79
|
+
if (typeof scopeName === 'string' && scopeName !== '') out.push({ path: join(storeHoistDir, scopeName, name), via: 'pnpm-hoist' })
|
|
80
|
+
out.push({ path: join(storeHoistDir, name), via: 'pnpm-hoist-flat' })
|
|
81
|
+
}
|
|
82
|
+
const seen = new Set()
|
|
83
|
+
return out.filter((c) => (seen.has(c.path) ? false : (seen.add(c.path), true)))
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
/**
|
|
38
87
|
* 备份保留策略(纯函数):保留集合 = 「回滚点引用的路径」∪「按 mtime 最近的 N 条」。
|
|
39
88
|
* `keep` 非法/缺失时按 1 处理(最小保留)。返回 `{ kept, remove, freedBytes }`,
|
|
@@ -56,4 +105,4 @@ function planBackupRetention(snapshots, { referenced = [], keep = 1 } = {}) {
|
|
|
56
105
|
return { kept, remove, freedBytes }
|
|
57
106
|
}
|
|
58
107
|
|
|
59
|
-
export { classifyTopLevel, planBackupRetention }
|
|
108
|
+
export { FRAMEWORK_BOOT_PACKAGES, classifyTopLevel, isStaleBackupName, planBackupRetention, relinkTargetCandidates }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// L1 · domain —— peer-veto.js(框架 peer 预检「否决」的**本地复算**;0.5.33 加法,诊断缺口 D-⑧)
|
|
2
|
+
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
|
+
//
|
|
4
|
+
// ── 要解决什么 ────────────────────────────────────────────────────────────────────
|
|
5
|
+
// 真机症状:「补丁里明明写着启用(或压根没写 disabled)、运行时却没挂载(fiberPhase === null),
|
|
6
|
+
// 面板上一个字都不说为什么」。真实机制(框架侧,不是我们的 bug):
|
|
7
|
+
// `@deepseek-ai/dsh-app-boot/lib/index.js`
|
|
8
|
+
// · `evaluatePluginCompatibility(manifest, exemptions, runtimeVersion)`(:286-313)
|
|
9
|
+
// —— 只看 **peerDependencies**,只看 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*`,
|
|
10
|
+
// `workspace:^|~|*` 视为「等于当前运行时」,其余按 semver(includePrerelease)判;
|
|
11
|
+
// · `preflight()`(:2063-2109)—— 判不满足就在**内存里** `row.disabled = true`(**不写用户补丁**),
|
|
12
|
+
// 原因只 `process.stderr.write` 一行。
|
|
13
|
+
// 本机典型现场:profile 顶层压着 `@deepseek-ai/dsh-schedule@0.0.1-rc.3` 残影,它的 peer 写
|
|
14
|
+
// `@deepseek-ai/dsh-agent@^0.0.1-rc.3` 等 7 项 → 对运行中的 0.2.0-rc.2 **必然不满足** → 启动即被否决,
|
|
15
|
+
// 而补丁尾部还是 `- id: schedule / disabled: false`,面板只显示「未挂载」。
|
|
16
|
+
//
|
|
17
|
+
// ── 本模块做什么(**加法**:不改任何既有判据与开关,只补一句原因 + 一条出路)────────────
|
|
18
|
+
// 在同一判据下本地复算一遍:读该行**实际解析到的包** manifest 的 peerDependencies × 当前框架版本。
|
|
19
|
+
// 判定不满足 → 给短句原因 + 出路;包解析不到 → **另给**「包不存在」原因(两者绝不混为一谈)。
|
|
20
|
+
//
|
|
21
|
+
// ── 保守性(宁可不说,不可错说)────────────────────────────────────────────────
|
|
22
|
+
// 框架用的是 node-semver 的 `{ includePrerelease: true }`;仓库既有的 semver 工具里,
|
|
23
|
+
// `semverRangeMatchLoose` 与它最接近但**偏宽松**(把它当"满足"的少数情形,框架可能判不满足)。
|
|
24
|
+
// 这里**故意用宽松的那一支**:只有连宽松读法都不满足时才说话 —— 与 ①-1「不误报」同一纪律。
|
|
25
|
+
// 代价是可能漏报少数否决(表现回退成"什么都不显示",不会错说原因)。
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
28
|
+
import { resolvePackageJson } from '../infra/paths.js'
|
|
29
|
+
import { semverRangeMatchLoose } from '../infra/semver.js'
|
|
30
|
+
|
|
31
|
+
/** 框架预检只看这个前缀的 peer(与 dsh-app-boot 的 `name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')` 对齐)。 */
|
|
32
|
+
const PEER_PREFIX = '@deepseek-ai/dsh'
|
|
33
|
+
|
|
34
|
+
/** `workspace:^` / `workspace:~` / `workspace:*` 在框架里等价于「当前运行时版本」→ 永远满足。 */
|
|
35
|
+
const WORKSPACE_RANGES = new Set(['workspace:^', 'workspace:~', 'workspace:*'])
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 纯判据:返回**不满足**的 peer 列表(空数组 = 没有相关 peer 声明,或全部满足)。
|
|
39
|
+
* 与 `dsh-app-boot` 的 `evaluatePluginCompatibility` 逐条对齐(见文件头)。
|
|
40
|
+
*/
|
|
41
|
+
function unsatisfiedPeers(manifest, runtimeVersion) {
|
|
42
|
+
if (manifest === null || typeof manifest !== 'object') return []
|
|
43
|
+
if (!Object.hasOwn(manifest, 'peerDependencies')) return []
|
|
44
|
+
const deps = manifest.peerDependencies
|
|
45
|
+
if (deps === null || typeof deps !== 'object' || Array.isArray(deps)) return []
|
|
46
|
+
if (typeof runtimeVersion !== 'string' || runtimeVersion === '') return []
|
|
47
|
+
const out = []
|
|
48
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
49
|
+
if (name !== PEER_PREFIX && !name.startsWith(`${PEER_PREFIX}-`)) continue
|
|
50
|
+
if (typeof range !== 'string') { out.push({ name, range: String(range) }); continue }
|
|
51
|
+
const requirement = WORKSPACE_RANGES.has(range) ? runtimeVersion : range
|
|
52
|
+
if (requirement.trim() === '' || !semverRangeMatchLoose(runtimeVersion, requirement)) out.push({ name, range })
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 短句化的「需要什么」:一项给全名,多项给首项 + 总数(面板一行放得下)。 */
|
|
58
|
+
function describePeers(peers) {
|
|
59
|
+
const first = `${peers[0].name}@${peers[0].range}`
|
|
60
|
+
return peers.length === 1 ? first : `${first} 等 ${peers.length} 项`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 行级复算(IO 可注入,便于离线断言)。
|
|
65
|
+
*
|
|
66
|
+
* 入参:`{ moduleName, profileDir, frameworkVersion }`
|
|
67
|
+
* 返回:`null`(无话可说)|`{ kind:'peers'|'missing', reason, hint }`
|
|
68
|
+
* · `kind:'peers'` —— 本地复算判定 peer 不满足(大概率就是框架否决它的原因)
|
|
69
|
+
* · `kind:'missing'` —— 包解析不到 / 清单读不出来(**另一种原因**,措辞与出路都不同)
|
|
70
|
+
*/
|
|
71
|
+
function peerVetoFor({ moduleName, profileDir, frameworkVersion, deps = {} } = {}) {
|
|
72
|
+
const { resolvePkg = resolvePackageJson, readText = (p) => readFileSync(p, 'utf8') } = deps
|
|
73
|
+
if (typeof moduleName !== 'string' || moduleName === '' || moduleName.startsWith('cordis:')) return null
|
|
74
|
+
if (typeof profileDir !== 'string' || profileDir === '') return null
|
|
75
|
+
if (typeof frameworkVersion !== 'string' || frameworkVersion === '') return null
|
|
76
|
+
let pkgPath = null
|
|
77
|
+
try { pkgPath = resolvePkg(moduleName, profileDir) } catch { pkgPath = null }
|
|
78
|
+
if (pkgPath === null) {
|
|
79
|
+
return {
|
|
80
|
+
kind: 'missing',
|
|
81
|
+
reason: `被框架否决:包不存在(${moduleName} 在本 profile 解析不到)`,
|
|
82
|
+
hint: `出路:这一行指向的包已经不在本 profile 里(升级/卸载后的残留行)——重装或更新该插件即可;确实不需要它时,在补丁里删掉或禁用它。`,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let manifest = null
|
|
86
|
+
try { manifest = JSON.parse(readText(pkgPath)) } catch { manifest = null }
|
|
87
|
+
if (manifest === null || typeof manifest !== 'object') {
|
|
88
|
+
return {
|
|
89
|
+
kind: 'missing',
|
|
90
|
+
reason: `被框架否决:包清单读不出来(${moduleName})`,
|
|
91
|
+
hint: '出路:重装该插件——package.json 读不出来时框架自己也无法校验它,只能按最坏情况跳过挂载。',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const peers = unsatisfiedPeers(manifest, frameworkVersion)
|
|
95
|
+
if (peers.length === 0) return null
|
|
96
|
+
return {
|
|
97
|
+
kind: 'peers',
|
|
98
|
+
reason: `被框架否决:peer 不满足(需要 ${describePeers(peers)},当前 ${frameworkVersion})`,
|
|
99
|
+
hint: `出路:把 ${moduleName} 更新到兼容当前框架的版本(首选);若它是升级后残留的旧包,从 profile 里清掉;要保留这个精确版本,就得用框架的精确版本豁免(dsh plugin allow-version / 插件管理器 → profile 的 compatibility.json)。`,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { PEER_PREFIX, WORKSPACE_RANGES, unsatisfiedPeers, peerVetoFor }
|