@hyzyn/dsh-safe 0.12.0 → 0.13.0

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.en.md CHANGED
@@ -48,7 +48,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
48
48
  | `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health |
49
49
  | `dsh-safe restore [--profile <name>] (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (omit `--profile` to cover every profile in the ledger) |
50
50
  | `dsh-safe explain [id] [--profile <name> \| --file <path>]` | interpret failure info with AI: an `id` interprets that quarantine record (with repair advice); defaults to the last failure log, falling back to the ledger; `--file`/stdin read any log (needs `DSH_SAFE_AI_KEY`) |
51
- | `dsh-safe repair [id] [--profile <name>] [--to <version>] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (failures a reinstall/upgrade may fix: package resolution, export mismatches; installs via `dsh plugin`'s pnpm channel) |
51
+ | `dsh-safe repair [id] [--profile <name>] [--to <version>] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (failures a reinstall/upgrade may fix: package resolution, export mismatches; installs via `dsh plugin`'s pnpm channel); duplicate mounts support auto-dedup (interactively pick which source to keep) |
52
52
  | `dsh-safe help` (`-h` / `--help`) | show help |
53
53
  | `dsh-safe --version` (`-V`) | show version |
54
54
 
package/README.md CHANGED
@@ -48,7 +48,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
48
48
  | `dsh-safe doctor` | 环境体检:版本、DSH_HOME、profiles、台账、各 patch 健康度 |
49
49
  | `dsh-safe restore [--profile <名>] (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(省略 `--profile` 时遍历台账全部 profile) |
50
50
  | `dsh-safe explain [id] [--profile <名> \| --file <路径>]` | 用 AI 解读失败信息:指定 `id` 解读该条隔离记录(给 repair 建议);默认解读最近一次失败日志,无日志则解读隔离台账;`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
51
- | `dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限重装/升级可能修复的失败:包解析失败、导出版本不匹配等;经 `dsh plugin` 的 pnpm 通道安装) |
51
+ | `dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限重装/升级可能修复的失败:包解析失败、导出版本不匹配等;经 `dsh plugin` 的 pnpm 通道安装);重复挂载类支持自动去重(交互选择保留哪个来源,从 bundles 移除冗余) |
52
52
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
53
53
  | `dsh-safe --version`(`-V`) | 显示版本 |
54
54
 
package/lib/i18n.js CHANGED
@@ -136,6 +136,12 @@ const ZH = {
136
136
  repairUnsupported: '[dsh-safe] 该失败类型不支持自动修复(可修复范围:包解析失败、导出版本不匹配等重装/升级可能修复的问题)。原因: {reason}\n 可用 dsh-safe explain 查看解读。',
137
137
  repairPlan: '[dsh-safe] 将在 profile {profile} 中修复 {name}:执行 {command},成功后自动摘除隔离行',
138
138
  repairDuplicate: '[dsh-safe] 这是重复挂载问题(同一 id 被多个 bundle/行挂载),重装包无法修复。请从 profile 的 bundles 清单或 patch 中移除多余的挂载来源,然后 dsh-safe restore 恢复。',
139
+ repairDedupePick: '[dsh-safe] {id} 同时由以下 bundle 挂载,保留哪个来源?',
140
+ repairDedupeDefault: '(bundles 中先声明,缺省保留)',
141
+ repairDedupeChoose: '保留哪个?输入编号 [1]: ',
142
+ repairDedupePlan: '[dsh-safe] 修复方案:保留 {keep} 的挂载,从 bundles 移除 {remove},并摘除 {id} 的禁用行',
143
+ repairDedupeNoManifest: '[dsh-safe] profile 清单中没有可编辑的 bundles 数组,请手动处理。',
144
+ repairDedupeDone: '[dsh-safe] 去重完成:已从 bundles 移除 {removed};{keep} 的挂载保留、禁用行已摘除。跑 dsh-safe <启动命令> 验证。',
139
145
  repairInstallFailed: '[dsh-safe] 插件安装失败(退出码 {code}),隔离状态保持不变。',
140
146
  repairDone: '[dsh-safe] 修复完成:跑 dsh-safe <启动命令> 验证;若仍失败会被自动重新隔离。',
141
147
  }
@@ -274,6 +280,12 @@ Notes:
274
280
  repairUnsupported: '[dsh-safe] this failure type cannot be auto-repaired (repairable: package-resolution failures, export mismatches — problems a reinstall/upgrade may fix). Reason: {reason}\n try dsh-safe explain for an interpretation.',
275
281
  repairPlan: '[dsh-safe] repairing {name} in profile {profile}: running {command}, then the quarantine row is removed automatically',
276
282
  repairDuplicate: '[dsh-safe] this is a duplicate-mount issue (the same id is mounted by multiple bundles/rows); reinstalling cannot fix it. Remove the redundant mount source from the profile bundles/patch, then run dsh-safe restore.',
283
+ repairDedupePick: '[dsh-safe] {id} is mounted by the following bundles; which source to keep?',
284
+ repairDedupeDefault: ' (declared first in bundles; default keep)',
285
+ repairDedupeChoose: 'which one to keep? number [1]: ',
286
+ repairDedupePlan: '[dsh-safe] fix plan: keep {keep} mounted, remove {remove} from bundles, and drop the disabled row for {id}',
287
+ repairDedupeNoManifest: '[dsh-safe] the profile manifest has no editable bundles array; handle it manually.',
288
+ repairDedupeDone: '[dsh-safe] dedupe finished: removed {removed} from bundles; {keep} stays mounted and the disabled row was dropped. Run dsh-safe <boot command> to verify.',
277
289
  repairInstallFailed: '[dsh-safe] plugin install failed (exit code {code}); quarantine state left unchanged.',
278
290
  repairDone: '[dsh-safe] repair finished: run dsh-safe <boot command> to verify; a still-broken plugin will be auto-quarantined again.',
279
291
  }
package/lib/repair.js CHANGED
@@ -12,9 +12,11 @@
12
12
  */
13
13
  import { spawnSync } from 'node:child_process'
14
14
  import { createInterface } from 'node:readline/promises'
15
+ import { readFileSync, writeFileSync } from 'node:fs'
15
16
  import { loadLedger, restoreQuarantine } from './quarantine.js'
16
17
  import { summarizeLine } from './failures.js'
17
- import { resolveDshSpawnTarget } from './dshpaths.js'
18
+ import { profileManifestPath, readIfExists, resolveDshSpawnTarget } from './dshpaths.js'
19
+ import { collectKnownRows } from './knownrows.js'
18
20
  import { t } from './i18n.js'
19
21
 
20
22
  const err = (line) => process.stderr.write(`${line}\n`)
@@ -113,10 +115,10 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
113
115
  const { profile, entry } = matches[0]
114
116
  const name = entry.name ?? entry.id
115
117
 
116
- // 类别门禁:重复挂载是配置层问题(同一 id 多处挂载),重装无意义,给针对性指引
118
+ // 类别门禁:重复挂载是配置层问题(同一 id 多处挂载),重装无意义——
119
+ // 但可以自动去重:从 profile 的 bundles 移除一个挂载来源
117
120
  if (/duplicate loader entry id/i.test(entry.reason ?? '')) {
118
- log(t('repairDuplicate'))
119
- return 1
121
+ return dedupeForDuplicate({ id: opts.id, profile, opts, spawn, log, write })
120
122
  }
121
123
  // 只修"包的安装状态/版本适配"类
122
124
  if (!REPAIRABLE_RE.test(entry.reason ?? '')) {
@@ -180,3 +182,100 @@ function askConfirm(question) {
180
182
  },
181
183
  )
182
184
  }
185
+
186
+ const readJsonIfExists = (path) => {
187
+ const raw = readIfExists(path)
188
+ if (raw === undefined) return undefined
189
+ try {
190
+ return JSON.parse(raw)
191
+ } catch {
192
+ return undefined
193
+ }
194
+ }
195
+
196
+ /**
197
+ * duplicate 类的自动去重:同一 id 被多个 bundle 挂载时,从 profile 清单的
198
+ * bundles 数组移除一个来源(保留的来源继续提供插件),并摘除无效的禁用行。
199
+ * 编辑的是用户自己的 profile 清单(与 cordis.patch.yml 托管区块同信任级别),
200
+ * 有确认环节、可逆(bundles 加回来即可)。
201
+ */
202
+ async function dedupeForDuplicate({ id, profile, opts, spawn, log, write }) {
203
+ const known = collectKnownRows(profile)
204
+ // 挂载来源只统计 bundle 层;profile/home 层的同 id 是 override,不是重复来源
205
+ const sources = new Set()
206
+ for (const row of known.rows) {
207
+ if (row.id !== id) continue
208
+ if (row.source === 'profile' || row.source === 'home') continue
209
+ sources.add(row.source)
210
+ }
211
+ const sourceList = [...sources]
212
+ if (sourceList.length < 2) {
213
+ // 重复不在 bundle 层(可能在 include 文件或用户层),自动去重帮不上,给指引
214
+ log(t('repairDuplicate'))
215
+ return 1
216
+ }
217
+
218
+ const manifestPath = profileManifestPath(profile)
219
+ const manifest = readJsonIfExists(manifestPath)
220
+ const bundles = manifest?.dsh?.profile?.bundles
221
+ if (!Array.isArray(bundles)) {
222
+ log(t('repairDedupeNoManifest'))
223
+ return 1
224
+ }
225
+ // 按 bundles 数组声明顺序排序:先声明的是既有来源,缺省保留
226
+ const ordered = [...sourceList].sort((a, b) => {
227
+ const ia = bundles.indexOf(a)
228
+ const ib = bundles.indexOf(b)
229
+ return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib)
230
+ })
231
+
232
+ const keepDefault = ordered[0]
233
+ const planLine = () => log(t('repairDedupePlan', { keep: keep, remove: remove.join(', '), id }))
234
+ if (opts.dryRun) {
235
+ log(t('repairDedupePlan', { keep: keepDefault, remove: ordered.slice(1).join(', '), id }))
236
+ log(t('dryRunNotice'))
237
+ return 0
238
+ }
239
+
240
+ let keep
241
+ if (!opts.yes && !process.stdin.isTTY) {
242
+ log(t('updateNonInteractive'))
243
+ return 1
244
+ }
245
+ if (opts.yes) {
246
+ keep = keepDefault // -y:保留先声明的来源
247
+ } else {
248
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
249
+ log(t('repairDedupePick', { id }))
250
+ ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
251
+ const answer = await rl.question(t('repairDedupeChoose'))
252
+ rl.close()
253
+ const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
254
+ keep = ordered[pick - 1]
255
+ }
256
+ const remove = ordered.filter((b) => b !== keep)
257
+ planLine()
258
+
259
+ const fresh = readJsonIfExists(manifestPath)
260
+ const current = fresh?.dsh?.profile?.bundles
261
+ if (!Array.isArray(current)) {
262
+ log(t('repairDedupeNoManifest'))
263
+ return 1
264
+ }
265
+ const next = current.filter((b) => !remove.includes(b))
266
+ if (next.length === current.length) {
267
+ log(t('repairDedupeNoManifest'))
268
+ return 1
269
+ }
270
+ fresh.dsh.profile.bundles = next
271
+ writeFileSync(manifestPath, `${JSON.stringify(fresh, null, 2)}\n`)
272
+
273
+ const { restored } = restoreQuarantine(profile, [id], false)
274
+ if (restored.length) {
275
+ const e = restored[0]
276
+ write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
277
+ }
278
+ log(t('repairDedupeDone', { removed: remove.join(', '), keep }))
279
+ log(t('repairDone'))
280
+ return 0
281
+ }
package/lib/wrap.js CHANGED
@@ -117,6 +117,14 @@ export async function runWrapped(options) {
117
117
  return code
118
118
  }
119
119
  if (!dryRun) persistFailure(invocation.profile, stderr)
120
+ // 重复挂载(同一 id 被多个 bundle/行挂载)发生在 include 挂载阶段,
121
+ // 早于 profile 层 disabled 覆盖的合并——禁用行管不住它,隔离无效。
122
+ // 直接给针对性指引并透传,不做无效的"禁用+重试"。
123
+ if (stderr.includes('duplicate loader entry id')) {
124
+ log(t('repairDuplicate'))
125
+ log(t('explainHint'))
126
+ return code
127
+ }
120
128
  const known = collectKnownRows(invocation.profile)
121
129
  const report = parseFailureReport(stderr)
122
130
  let hits = matchFailures(report, known)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",