@noob-stupid/dsh-plugin-console 0.5.98 → 0.5.100
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 +190 -1
- package/lib/server/domain/compat-pending-records.js +100 -0
- package/lib/server/domain/compat-scan.js +106 -0
- package/lib/server/domain/framework-cleanup.js +63 -28
- package/lib/server/domain/framework-stale-repair.js +23 -8
- package/lib/server/domain/framework-stale-restore.js +215 -0
- package/lib/server/domain/patch.js +76 -7
- package/lib/server/infra/fw-root-detect.js +129 -11
- package/lib/server/infra/paths.js +32 -3
- package/lib/server/routes/compat-scan.js +123 -0
- package/lib/server/routes/compat.js +21 -5
- package/lib/server/routes/index.js +3 -3
- package/lib/server/routes/state.js +16 -2
- package/package.json +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// L1 · domain —— framework-stale-restore.js(「改名残留」能不能恢复的**判据**;0.5.99 改错)
|
|
2
|
+
//
|
|
3
|
+
// ── 真机现场(2026-11-02,用户实测)────────────────────────────────────────────────
|
|
4
|
+
// 框架包里出现大量「**真名目录存在,但是空壳**(没有 package.json),真正内容被改名成了
|
|
5
|
+
// `<name>.stale-<ver>-<ts>`」:
|
|
6
|
+
// 真名 cordis\ → 空(无 package.json)
|
|
7
|
+
// cordis.stale-4.0.4-1793185607671\ → 有内容(package.json + lib)
|
|
8
|
+
// 该目录下总计 564 项,其中 276 个是 .stale-*。后果:`@deepseek-ai/cordis` 解析失败
|
|
9
|
+
// (`ERR_MODULE_NOT_FOUND`),插件的 `import` 直接起不来;把 178 个空壳从 .stale-* 恢复后成功。
|
|
10
|
+
//
|
|
11
|
+
// ── 旧判据的缺陷(本次修的正是它)──────────────────────────────────────────────────
|
|
12
|
+
// 旧实现是「真名**缺失**(exists=false)才恢复;真名已存在就跳过」。真机现场恰恰是
|
|
13
|
+
// 「真名存在但是空壳」⇒ 被跳过 ⇒ 坏状态一直留着,框架一直起不来。
|
|
14
|
+
//
|
|
15
|
+
// ── 新判据(三条,全部可离线断言)──────────────────────────────────────────────────
|
|
16
|
+
// ① **可用**的定义是「这个目录里有 package.json」;目录存在但没有 package.json = **空壳 = 不可用**,
|
|
17
|
+
// 在判据里与"缺失"同等对待(都算"真名此刻不可用")。
|
|
18
|
+
// ② 恢复前必须校验候选的 `package.json.name` 与真名一致(`<scope>/<real>`)——
|
|
19
|
+
// 不一致一律不动,记 `failed`(防张冠李戴:把别的包改成这个名字)。
|
|
20
|
+
// ③ 三类结果分开报告:`restore`(可恢复)/ `noCandidate`(真缺内容且没有可用的 .stale-*)/
|
|
21
|
+
// `failed`(候选名字不符、候选本身不可用等)—— 调用方要能原样写进升级日志。
|
|
22
|
+
//
|
|
23
|
+
// 本模块只做**判据与计划**(读目录 + 读 package.json,纯读、零写盘);改名/读回核实/回滚在
|
|
24
|
+
// domain/framework-cleanup.js#restoreStaleBackups(动作层),两处调用方共用同一条路。
|
|
25
|
+
import { readdirSync, readFileSync, lstatSync } from 'node:fs'
|
|
26
|
+
import { basename, join } from 'node:path'
|
|
27
|
+
|
|
28
|
+
/** 空壳挪开后用的后缀(**挪开不删**,保留可回溯)。 */
|
|
29
|
+
const EMPTY_SUFFIX = '.empty-'
|
|
30
|
+
/** 改名残留的后缀(`<real>.stale-<ver>-<ts>`)。 */
|
|
31
|
+
const STALE_MARK = '.stale-'
|
|
32
|
+
|
|
33
|
+
function errText(error) {
|
|
34
|
+
return String(error?.message ?? error)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 从目录名拆出真名(`cordis.stale-4.0.4-123` → `cordis`);不是残留就返回 null。 */
|
|
38
|
+
function staleRealNameOf(name) {
|
|
39
|
+
if (typeof name !== 'string') return null
|
|
40
|
+
const at = name.indexOf(STALE_MARK)
|
|
41
|
+
if (at <= 0) return null
|
|
42
|
+
return name.slice(0, at)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 真名对应的**包名**(scope 目录下是 `@scope/real`;不是 scope 目录就用真名本身)。 */
|
|
46
|
+
function expectedPackageName(scopeDir, real) {
|
|
47
|
+
const scope = basename(String(scopeDir ?? ''))
|
|
48
|
+
return scope.startsWith('@') ? `${scope}/${real}` : real
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 空壳挪开后的名字:`<real>.empty-<stamp>`。 */
|
|
52
|
+
function emptyShellName(real, stamp) {
|
|
53
|
+
return `${real}${EMPTY_SUFFIX}${stamp}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 读 `<dir>/package.json`:`{ ok, name, version, error }`(读不到/解析不了都如实带原因)。 */
|
|
57
|
+
function packageJsonOf(dir, { readText = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readText(join(dir, 'package.json')))
|
|
60
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, name: null, version: null, error: 'package.json 不是对象' }
|
|
61
|
+
return { ok: true, name: typeof raw.name === 'string' ? raw.name : null, version: typeof raw.version === 'string' ? raw.version : null, error: null }
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { ok: false, name: null, version: null, error: errText(error) }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 名字里那个"被占用"的东西(空壳 / 链接 / 文件 / 真实目录):lstat 能立住就算被占着。 */
|
|
68
|
+
function isOccupied(dir, { lstat = lstatSync } = {}) {
|
|
69
|
+
try { lstat(dir); return true } catch { return false }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* **可用**的判据(用户 2026-11-02 定案):目录里有**能读出来的 package.json**。
|
|
74
|
+
* 注意它不看包名 —— 「是不是这个包」由 `packageJsonOf().name` 与真名比对,两件事分开判。
|
|
75
|
+
*/
|
|
76
|
+
function isUsablePackageDir(dir, deps = {}) {
|
|
77
|
+
const lstat = deps.lstat ?? lstatSync
|
|
78
|
+
let stat = null
|
|
79
|
+
try { stat = lstat(dir) } catch { return false }
|
|
80
|
+
try { if (stat?.isDirectory?.() === false && stat?.isSymbolicLink?.() !== true) return false } catch {}
|
|
81
|
+
return packageJsonOf(dir, deps).ok === true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 候选排序用的时间戳(`<real>.stale-<ver>-<ts>` 里尾巴那串数字;没有就是 0)。 */
|
|
85
|
+
function candidateStamp(name) {
|
|
86
|
+
const m = String(name ?? '').match(/-(\d{6,})$/u)
|
|
87
|
+
return m === null ? 0 : Number(m[1])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 挑要恢复的那份候选:包名必须与真名一致(防张冠李戴),多份时取**时间戳最新**的那份。
|
|
92
|
+
* 返回 `{ picked, mismatch, unusable }` —— `mismatch` 是"有 package.json 但名字不对"的候选,
|
|
93
|
+
* 它们不是可以恢复的对象,但**必须**在报告里点名(否则用户不知道为什么没恢复)。
|
|
94
|
+
*/
|
|
95
|
+
function pickRestoreCandidate({ scopeDir, real, candidates, deps = {} } = {}) {
|
|
96
|
+
const expected = expectedPackageName(scopeDir, real)
|
|
97
|
+
const usable = []
|
|
98
|
+
const mismatch = []
|
|
99
|
+
const unusable = []
|
|
100
|
+
for (const name of Array.isArray(candidates) ? candidates : []) {
|
|
101
|
+
const info = packageJsonOf(join(scopeDir, name), deps)
|
|
102
|
+
if (info.ok !== true) { unusable.push({ name, error: info.error }); continue }
|
|
103
|
+
if (info.name !== expected) { mismatch.push({ name, nameInPkg: info.name, expected }); continue }
|
|
104
|
+
usable.push({ name, version: info.version, stamp: candidateStamp(name) })
|
|
105
|
+
}
|
|
106
|
+
usable.sort((a, b) => (b.stamp - a.stamp) || (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
107
|
+
return { picked: usable.length > 0 ? usable[0] : null, usableCount: usable.length, mismatch, unusable }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 计划一次「改名残留修复」(**纯读,零写盘**)。返回每个真名一项:
|
|
112
|
+
* `{ real, action: 'restore'|'skip'|'noCandidate'|'failed', staleName, reason, detail,
|
|
113
|
+
* realOccupied, realUsable, candidates, mismatch, unusable }`
|
|
114
|
+
*
|
|
115
|
+
* 分类规则(顺序即优先级):
|
|
116
|
+
* ① 真名**可用** ⇒ `skip`(reason `real-name-exists`)—— 健康树上一个字节都不动(幂等);
|
|
117
|
+
* ② 真名不可用(缺失 **或空壳**)+ 有可用候选 ⇒ `restore`;
|
|
118
|
+
* ③ 真名不可用 + 候选里**有名字不符的** ⇒ `failed`(reason `name-mismatch`,一个都不动);
|
|
119
|
+
* ④ 真名不可用 + 候选都不可用(没 package.json)或压根没有候选 ⇒ `noCandidate`。
|
|
120
|
+
*/
|
|
121
|
+
function planStaleRestore({ scopeDir, deps = {} } = {}) {
|
|
122
|
+
const empty = { ok: false, items: [], problem: null }
|
|
123
|
+
if (typeof scopeDir !== 'string' || scopeDir === '') return { ...empty, problem: '没有给 scopeDir' }
|
|
124
|
+
let entries = []
|
|
125
|
+
try { entries = deps.readdir ? deps.readdir(scopeDir) : readdirSync(scopeDir, { withFileTypes: true }) } catch (error) {
|
|
126
|
+
const missing = error?.code === 'ENOENT'
|
|
127
|
+
return { ...empty, ok: missing, problem: missing ? null : '列不出目录:' + errText(error) }
|
|
128
|
+
}
|
|
129
|
+
const byReal = new Map()
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
132
|
+
const isDir = typeof entry === 'string' ? true : entry?.isDirectory?.() === true
|
|
133
|
+
if (typeof name !== 'string' || !isDir) continue
|
|
134
|
+
const real = staleRealNameOf(name)
|
|
135
|
+
if (real === null || real === '') continue
|
|
136
|
+
if (!byReal.has(real)) byReal.set(real, [])
|
|
137
|
+
byReal.get(real).push(name)
|
|
138
|
+
}
|
|
139
|
+
// 真名一侧也要扫:**空壳本身没有 .stale-* 兄弟**时(真缺内容)同样要报 `noCandidate`,
|
|
140
|
+
// 否则升级日志里那批"真名在、内容没了"的行一个字都不会出现(真机就是这样被漏掉的)。
|
|
141
|
+
const realNames = new Set()
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
const name = typeof entry === 'string' ? entry : entry?.name
|
|
144
|
+
const isDir = typeof entry === 'string' ? true : entry?.isDirectory?.() === true
|
|
145
|
+
if (typeof name !== 'string' || !isDir) continue
|
|
146
|
+
if (name.includes(STALE_MARK) || name.includes(EMPTY_SUFFIX)) continue // 残留/挪开的空壳不是真名
|
|
147
|
+
realNames.add(name)
|
|
148
|
+
}
|
|
149
|
+
for (const real of realNames) if (!byReal.has(real)) byReal.set(real, [])
|
|
150
|
+
const items = []
|
|
151
|
+
for (const real of [...byReal.keys()].sort()) {
|
|
152
|
+
const candidates = byReal.get(real).slice().sort()
|
|
153
|
+
const target = join(scopeDir, real)
|
|
154
|
+
const realOccupied = isOccupied(target, deps)
|
|
155
|
+
const realUsable = realOccupied && isUsablePackageDir(target, deps)
|
|
156
|
+
const base = { real, staleName: null, realOccupied, realUsable, candidates }
|
|
157
|
+
if (realUsable) {
|
|
158
|
+
// 健康行:有候选也只是"旁边留着一份旧残留",不动(幂等:健康树上零动作、零报告项)
|
|
159
|
+
if (candidates.length === 0) continue
|
|
160
|
+
items.push({ ...base, action: 'skip', reason: 'real-name-exists', detail: `真名目录里已有 package.json —— 不动旁边那份 ${candidates[0]}` })
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
const pick = pickRestoreCandidate({ scopeDir, real, candidates, deps })
|
|
164
|
+
if (pick.picked !== null) {
|
|
165
|
+
items.push({
|
|
166
|
+
...base,
|
|
167
|
+
action: 'restore',
|
|
168
|
+
staleName: pick.picked.name,
|
|
169
|
+
reason: realOccupied ? 'real-name-unusable' : 'real-name-missing',
|
|
170
|
+
detail: realOccupied
|
|
171
|
+
? `真名目录存在但没有可读的 package.json(空壳)—— 用 ${pick.picked.name} 恢复,空壳挪成 ${EMPTY_SUFFIX}<ts>`
|
|
172
|
+
: `真名缺失 —— 用 ${pick.picked.name} 恢复`,
|
|
173
|
+
mismatch: pick.mismatch,
|
|
174
|
+
unusable: pick.unusable,
|
|
175
|
+
})
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (pick.mismatch.length > 0) {
|
|
179
|
+
items.push({
|
|
180
|
+
...base,
|
|
181
|
+
action: 'failed',
|
|
182
|
+
reason: 'name-mismatch',
|
|
183
|
+
detail: `候选的 package.json.name 与真名不符(应为 ${expectedPackageName(scopeDir, real)}):${pick.mismatch.map((m) => `${m.name}=${String(m.nameInPkg)}`).join('、')} —— 一个都不动`,
|
|
184
|
+
mismatch: pick.mismatch,
|
|
185
|
+
unusable: pick.unusable,
|
|
186
|
+
})
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
items.push({
|
|
190
|
+
...base,
|
|
191
|
+
action: 'noCandidate',
|
|
192
|
+
reason: 'no-usable-stale',
|
|
193
|
+
detail: pick.unusable.length > 0
|
|
194
|
+
? `真名不可用,且 ${pick.unusable.length} 份 .stale-* 都没有可读的 package.json(不可作为候选)`
|
|
195
|
+
: '真名不可用,且没有任何 .stale-* 候选(框架真缺内容,需要重装)',
|
|
196
|
+
mismatch: pick.mismatch,
|
|
197
|
+
unusable: pick.unusable,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
return { ok: true, items, problem: null }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export {
|
|
204
|
+
EMPTY_SUFFIX,
|
|
205
|
+
STALE_MARK,
|
|
206
|
+
candidateStamp,
|
|
207
|
+
emptyShellName,
|
|
208
|
+
expectedPackageName,
|
|
209
|
+
isOccupied,
|
|
210
|
+
isUsablePackageDir,
|
|
211
|
+
packageJsonOf,
|
|
212
|
+
pickRestoreCandidate,
|
|
213
|
+
planStaleRestore,
|
|
214
|
+
staleRealNameOf,
|
|
215
|
+
}
|
|
@@ -1,20 +1,70 @@
|
|
|
1
1
|
// L1 · domain —— patch.js(由 Step 2 从 lib/index.js 原样切出,只搬移未改逻辑)
|
|
2
2
|
// 分层分组:L1 · domain(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { dirname, join } from 'node:path'
|
|
6
6
|
import { frameworkBases, isFrameworkModuleName } from '../infra/framework-root.js'
|
|
7
7
|
import { queuedWrite } from '../infra/fsx.js'
|
|
8
8
|
import { escapeRegExp } from '../infra/mask.js'
|
|
9
9
|
import { makePackageResolver } from '../infra/package-resolve.js'
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* 写补丁前先建父目录(issue #27 的**派生缺口**,0.5.99 加法)。
|
|
13
|
+
*
|
|
14
|
+
* 为什么必须补:补丁路径是从 loader 的 `cordis:include` 条目推导出来的(见 infra/paths.js),
|
|
15
|
+
* 推导一旦指向一个**还不存在**的 profile 目录,本文件里原先 7 处 `writeFile` 全是**裸写** ——
|
|
16
|
+
* 父目录不在就 ENOENT。表现分两种,都很难查:
|
|
17
|
+
* ① 调用方 catch 了异常 ⇒ 面板显示"已启用",文件里却一个字都没有(假成功);
|
|
18
|
+
* ② 异常冒到接口 ⇒ 500,但真正的原因(profile 目录不存在)没人说出来。
|
|
19
|
+
* ⇒ 现在写前 `mkdir(dirname(p), { recursive: true })`:只把"**本来该成功却失败**"的那条路补上;
|
|
20
|
+
* 原本就写得成功的路径一个字节都不变(目录已存在时 recursive mkdir 是幂等 no-op)。
|
|
21
|
+
*
|
|
22
|
+
* mkdir 自己失败时**原样抛出**(绝不吞成"写成功"):要么调用方 catch,要么接口如实报错 ——
|
|
23
|
+
* 但绝不能出现"目录没建出来、函数却正常返回"。
|
|
24
|
+
* `deps.mkdir` 只是给测试用的注入缝(模拟 EPERM),生产默认就是 node:fs/promises#mkdir。
|
|
25
|
+
*/
|
|
26
|
+
async function ensurePatchParentDir(patchPath, deps = {}) {
|
|
27
|
+
const make = typeof deps.mkdir === 'function' ? deps.mkdir : mkdir
|
|
28
|
+
await make(dirname(patchPath), { recursive: true })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 父目录是不是**真的不存在**(`readPatchState` 区分"两种缺失"的判据)。
|
|
33
|
+
*
|
|
34
|
+
* 只在 readFile 已经 ENOENT 之后才被调用 —— 文件正常读取时一次 syscall 都不多。
|
|
35
|
+
* 判据**保守**:只有确证(ENOENT / ENOTDIR)才说"父目录不存在";
|
|
36
|
+
* EPERM/EACCES 这类"读不到但不一定没有"的情况一律返回 false(宁可不说,不谎报路径错)。
|
|
37
|
+
*/
|
|
38
|
+
async function parentDirMissing(patchPath, statFn) {
|
|
39
|
+
try {
|
|
40
|
+
await statFn(dirname(patchPath))
|
|
41
|
+
return false
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 读取补丁文件并扫描:停用块与 insert 行的 id。
|
|
48
|
+
*
|
|
49
|
+
* ★ 0.5.99 加法:「文件不存在」过去被 ENOENT 一起吞成"空补丁",于是**路径推导错了**与
|
|
50
|
+
* **文件真的是空的**在返回值上完全一样 —— issue #27 那个 bug(补丁全写到废弃的 web profile、
|
|
51
|
+
* 在用的 desktop 静默收不到)正是靠这条配合才藏了三周。
|
|
52
|
+
* 现在区分两种缺失,但**一个字都不改既有语义**:
|
|
53
|
+
* ① 父目录在、文件还没建(新 profile 第一次写补丁,**正常**)⇒ 空补丁,`parentMissing: false`;
|
|
54
|
+
* ② 父目录也不在(**路径推导错了**)⇒ 仍然返回空补丁(不抛错、既有调用方行为不变),
|
|
55
|
+
* 只多带 `parentMissing: true` + `missingReason: 'parent-missing'`,让上层看得见。
|
|
56
|
+
* 健康环境下的返回值只比过去多**一个**字段(`parentMissing`)——`missingReason` 仅在缺失时出现。
|
|
57
|
+
* `deps.stat` 是给测试用的注入缝;生产默认 node:fs/promises#stat。公开调用面仍是 `readPatchState(path)`。
|
|
58
|
+
*/
|
|
59
|
+
async function readPatchState(patchPath, deps = {}) {
|
|
60
|
+
const statFn = typeof deps.stat === 'function' ? deps.stat : stat
|
|
13
61
|
let text = ''
|
|
62
|
+
let parentMissing = false
|
|
14
63
|
try {
|
|
15
64
|
text = await readFile(patchPath, 'utf8')
|
|
16
65
|
} catch (error) {
|
|
17
66
|
if (error.code !== 'ENOENT') throw error
|
|
67
|
+
parentMissing = await parentDirMissing(patchPath, statFn)
|
|
18
68
|
}
|
|
19
69
|
const disables = []
|
|
20
70
|
const forced = []
|
|
@@ -39,7 +89,11 @@ async function readPatchState(patchPath) {
|
|
|
39
89
|
if (/^ {2}disabled: true\s*$/u.test(next)) disables.push(disableRow[1])
|
|
40
90
|
else if (/^ {2}disabled: false\s*$/u.test(next)) forced.push(disableRow[1])
|
|
41
91
|
}
|
|
42
|
-
|
|
92
|
+
// ★ 只增字段:健康环境下 = 既有 4 个字段 + `parentMissing: false`;
|
|
93
|
+
// 父目录缺失时才多一个 `missingReason`(见上面的注释)。
|
|
94
|
+
const state = { disables, forced, inserts, text, parentMissing }
|
|
95
|
+
if (parentMissing) state.missingReason = 'parent-missing'
|
|
96
|
+
return state
|
|
43
97
|
}
|
|
44
98
|
|
|
45
99
|
/** 停用:追加 disabled:true 块(已存在则不动)。 */
|
|
@@ -49,6 +103,7 @@ async function disableEntry(patchPath, id) {
|
|
|
49
103
|
if (disables.includes(id)) return { changed: false }
|
|
50
104
|
const clean = sanitizePatchText(text)
|
|
51
105
|
const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
|
|
106
|
+
await ensurePatchParentDir(patchPath)
|
|
52
107
|
await writeFile(patchPath, `${next}${disableBlock(id)}`, 'utf8')
|
|
53
108
|
return { changed: true }
|
|
54
109
|
})
|
|
@@ -60,12 +115,14 @@ async function enableEntry(patchPath, id) {
|
|
|
60
115
|
const { disables, forced, text } = await readPatchState(patchPath)
|
|
61
116
|
const blockRe = new RegExp(`^- id: ${escapeRegExp(id)}\\r?\\n disabled: true\\r?\\n`, 'mu')
|
|
62
117
|
if (blockRe.test(text)) {
|
|
118
|
+
await ensurePatchParentDir(patchPath)
|
|
63
119
|
await writeFile(patchPath, sanitizePatchText(text.replace(blockRe, '')), 'utf8')
|
|
64
120
|
return { changed: true }
|
|
65
121
|
}
|
|
66
122
|
if (forced.includes(id)) return { changed: false }
|
|
67
123
|
const clean = sanitizePatchText(text)
|
|
68
124
|
const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
|
|
125
|
+
await ensurePatchParentDir(patchPath)
|
|
69
126
|
await writeFile(patchPath, `${next}- id: ${id}\n disabled: false\n`, 'utf8')
|
|
70
127
|
return { changed: true }
|
|
71
128
|
})
|
|
@@ -110,6 +167,7 @@ async function appendInsert(patchPath, entryId, packageName) {
|
|
|
110
167
|
const clean = sanitizePatchText(text)
|
|
111
168
|
const next = clean.length === 0 || clean.endsWith('\n') ? clean : `${clean}\n`
|
|
112
169
|
const block = `- insert:\n - id: ${entryId}\n name: '${packageName}'\n`
|
|
170
|
+
await ensurePatchParentDir(patchPath)
|
|
113
171
|
await writeFile(patchPath, `${next}${block}`, 'utf8')
|
|
114
172
|
return { changed: true }
|
|
115
173
|
})
|
|
@@ -123,7 +181,10 @@ async function removeInsertRow(patchPath, rowId) {
|
|
|
123
181
|
let next = text.replace(blockRe, '')
|
|
124
182
|
const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
|
|
125
183
|
next = next.replace(overrideRe, '')
|
|
126
|
-
if (next !== text)
|
|
184
|
+
if (next !== text) {
|
|
185
|
+
await ensurePatchParentDir(patchPath)
|
|
186
|
+
await writeFile(patchPath, sanitizePatchText(next), 'utf8')
|
|
187
|
+
}
|
|
127
188
|
})
|
|
128
189
|
}
|
|
129
190
|
|
|
@@ -133,7 +194,10 @@ function removeDisableBlock(patchPath, rowId) {
|
|
|
133
194
|
const { text } = await readPatchState(patchPath)
|
|
134
195
|
const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
|
|
135
196
|
const next = text.replace(overrideRe, '')
|
|
136
|
-
if (next !== text)
|
|
197
|
+
if (next !== text) {
|
|
198
|
+
await ensurePatchParentDir(patchPath)
|
|
199
|
+
await writeFile(patchPath, sanitizePatchText(next), 'utf8')
|
|
200
|
+
}
|
|
137
201
|
})
|
|
138
202
|
}
|
|
139
203
|
|
|
@@ -212,7 +276,12 @@ function parseInsertNames(text) {
|
|
|
212
276
|
async function healPatchSafety(patchPath, deps = {}) {
|
|
213
277
|
const profileDir = dirname(patchPath)
|
|
214
278
|
const readText = deps.readText ?? ((p) => readFile(p, 'utf8'))
|
|
215
|
-
|
|
279
|
+
// 默认写盘实现也走「先建父目录」:注入缝只是把 writeFile 换成别的写盘手段,
|
|
280
|
+
// 不该因此丢掉父目录保证(过去注入 readText/writeText 的调用方拿到的行为不变)。
|
|
281
|
+
const writeText = deps.writeText ?? (async (p, data) => {
|
|
282
|
+
await ensurePatchParentDir(p, deps)
|
|
283
|
+
await writeFile(p, data, 'utf8')
|
|
284
|
+
})
|
|
216
285
|
const { text } = await readPatchState(patchPath)
|
|
217
286
|
const healed = []
|
|
218
287
|
const autoDisabled = []
|
|
@@ -103,28 +103,146 @@ function fwRootFromNpxCache({ env = process.env, home = homedir(), deps = {} } =
|
|
|
103
103
|
return hits[0]
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/** 路径比较用的归一键(只用于"是不是同一个目录"的去重/比对,不用于任何业务判据)。 */
|
|
107
|
+
function dirKey(dir) {
|
|
108
|
+
if (typeof dir !== 'string' || dir === '') return ''
|
|
109
|
+
let out = dir
|
|
110
|
+
try { out = resolve(dir) } catch {}
|
|
111
|
+
out = out.replace(/[\\/]+$/u, '')
|
|
112
|
+
// Windows 路径大小写不敏感(`C:\Users` 与 `c:\users` 是同一个目录)—— 其他平台保持原样
|
|
113
|
+
return process.platform === 'win32' ? out.toLowerCase() : out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 两个路径是不是同一个目录(同上:仅用于比对)。 */
|
|
117
|
+
function sameDir(a, b) {
|
|
118
|
+
const ka = dirKey(a)
|
|
119
|
+
return ka !== '' && ka === dirKey(b)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 当前生效的补丁落在哪个 profile 目录?推导不出来返回 `null`(**绝不猜、也绝不报错**)。
|
|
124
|
+
*
|
|
125
|
+
* 为什么要它(0.5.99 加法,issue #27 的同一批现场):`<dshHome>/profiles/` 下**每个**子目录都会
|
|
126
|
+
* 变成框架树候选(包括早已废弃的那个 profile —— 真机上 desktop 在用、web 是 issue #27 的受害者)。
|
|
127
|
+
* 废弃 profile 里往往还留着旧框架的残影,`looksLikeFwRoot()` **分不出死活** ⇒ 谁先试谁被选中。
|
|
128
|
+
* ⇒ 用"当前补丁所在的 profile"给候选排个序:同一 profile 的先试(剩下的照旧)。
|
|
129
|
+
*
|
|
130
|
+
* 取值顺序(都拿不到就返回 null,排序优化自动跳过):
|
|
131
|
+
* ① `deps.derivePatchPath()`(调用方注入,一般就是 infra/paths.js#findPatchPath 的包装)
|
|
132
|
+
* ② `deps.patchPath`(补丁文件全路径)/ `deps.profileDir`(profile 目录)
|
|
133
|
+
* ③ `DSH_PROFILE_DIR`(桌面端/CLI 显式设置,最可信 —— 与 paths.js#defaultPatchPath 同源)
|
|
134
|
+
* ④ `DSH_PROFILE`(只给名字时拼 `profiles/<name>`)
|
|
135
|
+
* ★ 这里刻意**不**去调 `defaultPatchPath()`:它读的是进程级 `process.env` 与真实 home,
|
|
136
|
+
* 在注入了 `env`/`home` 的调用(测试、预检脚本)里会串味成"另一台机器的 profile"。
|
|
137
|
+
*/
|
|
138
|
+
function activeProfileDirOf({ env = process.env, dshHome, deps = {} } = {}) {
|
|
139
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '' ? v.trim() : null
|
|
140
|
+
if (typeof deps.derivePatchPath === 'function') {
|
|
141
|
+
try {
|
|
142
|
+
const p = nonEmpty(deps.derivePatchPath())
|
|
143
|
+
if (p !== null) return dirname(resolve(p))
|
|
144
|
+
} catch {}
|
|
145
|
+
}
|
|
146
|
+
const fromPatchPath = nonEmpty(deps.patchPath)
|
|
147
|
+
if (fromPatchPath !== null) return dirname(resolve(fromPatchPath))
|
|
148
|
+
const fromProfileDir = nonEmpty(deps.profileDir)
|
|
149
|
+
if (fromProfileDir !== null) return resolve(fromProfileDir)
|
|
150
|
+
const fromEnvDir = nonEmpty(env.DSH_PROFILE_DIR)
|
|
151
|
+
if (fromEnvDir !== null) return resolve(fromEnvDir)
|
|
152
|
+
const fromEnvName = nonEmpty(env.DSH_PROFILE)
|
|
153
|
+
if (fromEnvName !== null) return join(dshHome, 'profiles', fromEnvName)
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 给 `profiles/<name>/node_modules` 这批候选**排序**(只排,不删 —— 判据仍是既有的 looksLikeFwRoot)。
|
|
159
|
+
*
|
|
160
|
+
* 优先级:
|
|
161
|
+
* 0 与当前生效补丁同 profile("活着的那棵树")→ 先试;
|
|
162
|
+
* 1 像框架树(既有判据 `looksLikeFwRoot`)→ 次之;
|
|
163
|
+
* 2 其余 → 最后。
|
|
164
|
+
* 同档内保持**传入顺序**(= readdir 原顺序)⇒ 结果稳定,不随目录名排序漂移。
|
|
165
|
+
*
|
|
166
|
+
* ★ 排序器只影响**先试谁**,绝不决定"能不能选到":`fwRootFromDshHome()` 末尾仍会按**旧顺序**
|
|
167
|
+
* 把每个候选再兜一遍(见那里的 `legacy` 循环),所以任何原本能选到的树都还能选到。
|
|
168
|
+
*/
|
|
169
|
+
function rankProfileCandidates(candidates, { activeProfileDir = null, looksLike = null } = {}) {
|
|
170
|
+
const list = Array.isArray(candidates) ? candidates.filter((d) => typeof d === 'string' && d !== '') : []
|
|
171
|
+
const isTree = typeof looksLike === 'function' ? looksLike : (dir) => looksLikeFwRoot(dir)
|
|
172
|
+
const tierOf = (dir) => {
|
|
173
|
+
if (activeProfileDir !== null && sameDir(dirname(dir), activeProfileDir)) return 0
|
|
174
|
+
try { if (isTree(dir)) return 1 } catch {}
|
|
175
|
+
return 2
|
|
176
|
+
}
|
|
177
|
+
return list
|
|
178
|
+
.map((dir, index) => ({ dir, index, tier: tierOf(dir) }))
|
|
179
|
+
.sort((a, b) => (a.tier - b.tier) || (a.index - b.index))
|
|
180
|
+
.map((x) => x.dir)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 扫 `<dshHome>/profiles/**` 与各类 node_modules,找一棵像框架树的。
|
|
184
|
+
*
|
|
185
|
+
* ★ 0.5.99 加法(issue #27 的同一批现场):`profiles/<name>/node_modules` 这批候选**不再按
|
|
186
|
+
* readdir 顺序盲试**,而是先试"当前生效补丁同 profile"的那棵、再试像框架树的,其余垫底
|
|
187
|
+
* (见 `rankProfileCandidates`)。前三项(DSH_PROFILE_DIR / DSH_PROFILE / profiles/node_modules)
|
|
188
|
+
* 的顺序与判据**一个字都没动**。
|
|
189
|
+
* ⚠ 兜底:排序之后仍按**旧顺序**把每个候选再排进队列尾部(同一个循环里,已试过的自动跳过),
|
|
190
|
+
* 所以"原本能选到的那棵树"绝不会因为新排序而选不到 —— 这是本改动最重要的不变量。
|
|
191
|
+
*/
|
|
107
192
|
function fwRootFromDshHome({ env = process.env, home = homedir(), deps = {} } = {}) {
|
|
108
193
|
const exists = deps.exists ?? existsSync
|
|
109
194
|
const readdir = deps.readdir ?? ((p) => readdirSync(p))
|
|
110
195
|
const dshHome = (typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '') ? env.DSH_HOME.trim() : join(home, '.dsh')
|
|
111
196
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
197
|
+
// 同一次调用里每个候选只判定一次(排序用一次、挑选用一次,别重复扫盘)
|
|
198
|
+
const looksCache = new Map()
|
|
199
|
+
const looks = (dir) => {
|
|
200
|
+
if (!looksCache.has(dir)) looksCache.set(dir, looksLikeFwRoot(dir, { deps }))
|
|
201
|
+
return looksCache.get(dir)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const head = []
|
|
205
|
+
// ① 显式 profile 目录(桌面端/CLI 都会设 DSH_PROFILE_DIR)—— 既有最高优先级,位置不动
|
|
206
|
+
if (typeof env.DSH_PROFILE_DIR === 'string' && env.DSH_PROFILE_DIR.trim() !== '') head.push(env.DSH_PROFILE_DIR.trim())
|
|
115
207
|
// ② 按 profile 名
|
|
116
|
-
if (typeof env.DSH_PROFILE === 'string' && env.DSH_PROFILE.trim() !== '')
|
|
117
|
-
// ③
|
|
118
|
-
|
|
208
|
+
if (typeof env.DSH_PROFILE === 'string' && env.DSH_PROFILE.trim() !== '') head.push(join(dshHome, 'profiles', env.DSH_PROFILE.trim()))
|
|
209
|
+
// ③ 共享投影层(各 profile 共用)
|
|
210
|
+
head.push(join(dshHome, 'profiles', 'node_modules'))
|
|
211
|
+
|
|
212
|
+
// ④ 各 profile 自己的 node_modules(readdir 原顺序 = 旧顺序,兜底要用它)
|
|
213
|
+
const legacy = []
|
|
119
214
|
try {
|
|
120
215
|
for (const d of readdir(join(dshHome, 'profiles'))) {
|
|
121
216
|
if (d === 'node_modules') continue
|
|
122
|
-
|
|
217
|
+
legacy.push(join(dshHome, 'profiles', d, 'node_modules'))
|
|
123
218
|
}
|
|
124
219
|
} catch {}
|
|
125
220
|
|
|
126
|
-
|
|
127
|
-
|
|
221
|
+
// 排序(可注入;排序器抛错/返回怪东西时**原样退回旧顺序**,绝不因此少试一棵树)
|
|
222
|
+
let ranked = legacy
|
|
223
|
+
if (legacy.length > 0) {
|
|
224
|
+
const rank = typeof deps.rankCandidates === 'function' ? deps.rankCandidates : rankProfileCandidates
|
|
225
|
+
try {
|
|
226
|
+
const out = rank(legacy, { activeProfileDir: activeProfileDirOf({ env, dshHome, deps }), looksLike: looks, deps })
|
|
227
|
+
if (Array.isArray(out)) ranked = out.filter((d) => typeof d === 'string' && legacy.includes(d))
|
|
228
|
+
} catch { ranked = legacy }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 一个队列,三段顺序:既有前三项 → 排过序的 profile 候选 → **旧顺序兜底**
|
|
232
|
+
const ordered = []
|
|
233
|
+
const seen = new Set()
|
|
234
|
+
const enqueue = (dir) => {
|
|
235
|
+
const key = dirKey(dir)
|
|
236
|
+
if (key === '' || seen.has(key)) return
|
|
237
|
+
seen.add(key)
|
|
238
|
+
ordered.push(dir)
|
|
239
|
+
}
|
|
240
|
+
for (const dir of head) enqueue(dir)
|
|
241
|
+
for (const dir of ranked) enqueue(dir)
|
|
242
|
+
for (const dir of legacy) enqueue(dir)
|
|
243
|
+
|
|
244
|
+
for (const dir of ordered) {
|
|
245
|
+
if (!looks(dir)) continue
|
|
128
246
|
return { fwRoot: dir, via: 'dsh-home' }
|
|
129
247
|
}
|
|
130
248
|
return null
|
|
@@ -206,4 +324,4 @@ function detectFrameworkRoot({ pluginDir = null, entryHint = null, env = process
|
|
|
206
324
|
return { fwRoot: null, via: null, tried }
|
|
207
325
|
}
|
|
208
326
|
|
|
209
|
-
export { detectFrameworkRoot, fwRootFromBase, fwRootFromDshHome, fwRootFromNpxCache, looksLikeFwRoot, realRootOf }
|
|
327
|
+
export { detectFrameworkRoot, fwRootFromBase, fwRootFromDshHome, fwRootFromNpxCache, looksLikeFwRoot, realRootOf, rankProfileCandidates, activeProfileDirOf }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// L0 · infra(paths.js)—— 分层 Step 从 lib/index.js 搬出,只搬移未改逻辑
|
|
2
2
|
// 分层分组:L0 · infra(边界由 tests/test-architecture-guard.mjs 断言)
|
|
3
3
|
|
|
4
|
-
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
4
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
5
5
|
import { dirname, join, resolve } from 'node:path'
|
|
6
6
|
import { homedir } from 'node:os'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
@@ -12,10 +12,39 @@ function dshHome() {
|
|
|
12
12
|
return process.env.DSH_HOME?.trim() || join(homedir(), '.dsh')
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* 默认 profile 用户补丁层路径(无 include 条目可推导时的兜底)。
|
|
17
|
+
*
|
|
18
|
+
* 硬编码 `profiles/web` 是真实事故(issue #27):桌面端机器上 `cordis.yml` 里没有
|
|
19
|
+
* `cordis:include` 条目时兜底必然生效,于是所有补丁写入都落到早已废弃的 `web` profile,
|
|
20
|
+
* 正在用的 `desktop` 静默收不到——开关插件、改禁用都没反应,而且不报错。
|
|
21
|
+
*
|
|
22
|
+
* 现在按可信度排候选,绝不写死 profile 名:
|
|
23
|
+
* ① DSH_PROFILE_DIR(桌面端/CLI 会显式设置,最可信)
|
|
24
|
+
* ② DSH_PROFILE(只给名字时拼路径)
|
|
25
|
+
* ③ `profiles/` 下真实存在补丁文件的目录(desktop 优先,其余按名字排序保证确定性)
|
|
26
|
+
*/
|
|
16
27
|
function defaultPatchPath() {
|
|
17
28
|
const home = dshHome()
|
|
18
|
-
|
|
29
|
+
const explicitDir = process.env.DSH_PROFILE_DIR?.trim()
|
|
30
|
+
if (explicitDir !== undefined && explicitDir !== '') return join(explicitDir, 'cordis.patch.yml')
|
|
31
|
+
const candidates = []
|
|
32
|
+
const profileName = process.env.DSH_PROFILE?.trim()
|
|
33
|
+
if (profileName !== undefined && profileName !== '') candidates.push(join(home, 'profiles', profileName, 'cordis.patch.yml'))
|
|
34
|
+
candidates.push(join(home, 'profiles', 'desktop', 'cordis.patch.yml'))
|
|
35
|
+
try {
|
|
36
|
+
const base = join(home, 'profiles')
|
|
37
|
+
for (const name of readdirSync(base).sort()) {
|
|
38
|
+
if (name === 'node_modules' || name === 'desktop') continue
|
|
39
|
+
candidates.push(join(base, name, 'cordis.patch.yml'))
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// profiles 读不到就只用已知候选
|
|
43
|
+
}
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (existsSync(candidate)) return candidate
|
|
46
|
+
}
|
|
47
|
+
return candidates[0]
|
|
19
48
|
}
|
|
20
49
|
|
|
21
50
|
/** 从 loader 树推导 profile 的 cordis.patch.yml 绝对路径。 */
|