@noob-stupid/dsh-plugin-console 0.5.99 → 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.
@@ -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
- /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
12
- async function readPatchState(patchPath) {
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
- return { disables, forced, inserts, text }
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) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
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) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
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
- const writeText = deps.writeText ?? ((p, data) => writeFile(p, data, 'utf8'))
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
- /** 扫 `<dshHome>/profiles/**` 与各类 node_modules,找一棵像框架树的。 */
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
- const cands = []
113
- // ① 显式 profile 目录(桌面端/CLI 都会设 DSH_PROFILE_DIR)
114
- if (typeof env.DSH_PROFILE_DIR === 'string' && env.DSH_PROFILE_DIR.trim() !== '') cands.push(env.DSH_PROFILE_DIR.trim())
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() !== '') cands.push(join(dshHome, 'profiles', env.DSH_PROFILE.trim()))
117
- // ③ 共享投影层与各 profile
118
- cands.push(join(dshHome, 'profiles', 'node_modules'))
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
- cands.push(join(dshHome, 'profiles', d, 'node_modules'))
217
+ legacy.push(join(dshHome, 'profiles', d, 'node_modules'))
123
218
  }
124
219
  } catch {}
125
220
 
126
- for (const dir of cands) {
127
- if (!looksLikeFwRoot(dir, { deps })) continue
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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.99",
3
+ "version": "0.5.100",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",