@hyzyn/dsh-safe 0.14.2 → 0.15.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,8 +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 -r [dsh args…]` | repair all repairable quarantined plugins first, then boot in wrap mode (`--repair` alias; mirrors `-u`) |
52
- | `dsh-safe repair [id] [--all] [--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) |
51
+ | `dsh-safe repair [id] [--all] [--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); well suited to being run by the AI agent in the web UI |
53
52
  | `dsh-safe help` (`-h` / `--help`) | show help |
54
53
  | `dsh-safe --version` (`-V`) | show version |
55
54
 
@@ -61,7 +60,7 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
61
60
  | --- | --- |
62
61
  | `--dry-run` | Parse and report only; no files are modified |
63
62
  | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
64
- | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
63
+ | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins and removing official bundles during duplicate dedupe (skipped by default; handle manually) |
65
64
  | `--exclude <id-or-pkg>` | Quarantine exclusion list (repeatable) — matched rows are never auto-disabled; can also live in `config.json` |
66
65
 
67
66
  ### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply)
@@ -96,6 +95,25 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
96
95
  3. **Managed block writing**: it appends a marker-commented managed block at the end of the matching patch file (same convention as `dsh-mcp-config managed`), setting matched rows to `disabled: true`. Existing user content and comments are preserved; a fresh profile's `[]` template is correctly replaced with a block sequence.
97
96
  4. **Ledger & restore**: quarantine records live in `$DSH_HOME/dsh-safe/quarantine.json`. Once a plugin upgrade fixes the issue, `dsh-safe restore --profile web --all` removes the managed block and re-mounts the plugin (hot-applied for profiles with `patchReload: live`).
98
97
 
98
+ ### Let the AI agent in the web UI take over repairs (recommended)
99
+
100
+ The wrapped boot already owns keep-alive end to end: failing plugins are auto-quarantined and retried, duplicate mounts are auto-deduped and retried. After boot, the ledger records left behind are the "fix-me list" — hand repairs to the AI agent in the web UI. Add the following snippet to a dsh agent preset (or system prompt) so the agent can discover and fix issues proactively:
101
+
102
+ ```text
103
+ dsh-safe is installed on this machine (a startup fuse for dsh). When the user
104
+ reports a missing plugin feature, a boot warning, or misbehaving plugins:
105
+ 1. Run dsh-safe list --json to inspect the quarantine ledger (which plugins
106
+ are auto-disabled, why, when).
107
+ 2. If interpretation is needed, run dsh-safe explain <id> (requires DSH_SAFE_AI_KEY).
108
+ 3. Repair based on the cause:
109
+ - missing/stale package → dsh-safe repair <id> -y (reinstall latest)
110
+ - known compatible version → dsh-safe repair <id> --to <version> -y
111
+ - duplicate mount → dsh-safe repair <id> -y (redundant source removed)
112
+ - no compatible upstream release yet → keep it disabled and say so.
113
+ 4. Verify with dsh-safe --profile <name>; still-broken plugins are
114
+ auto-quarantined again and never take down the boot.
115
+ ```
116
+
99
117
  ### AI Capabilities (optional)
100
118
 
101
119
  Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible — swap providers via `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL`):
@@ -106,7 +124,7 @@ Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible
106
124
 
107
125
  ## Safety Boundaries
108
126
 
109
- - **First-party protection**: rows of `@deepseek-ai/*` plugins are skipped by default (disabling plugins like `dsh-web-app` would strip dsh of its core capabilities); pass `--allow-first-party` to touch them.
127
+ - **First-party protection**: rows of `@deepseek-ai/*` plugins are skipped by default (disabling plugins like `dsh-web-app` would strip dsh of its core capabilities); pass `--allow-first-party` to touch them. Duplicate dedupe is guarded the same way: official sources are kept by default, and removing an official bundle requires an explicit flag or interactive confirmation, so official rows like webserver are never unmounted as collateral.
110
128
  - **Startup-phase failures only**: module resolution failures / `apply` throws / timed-out service injection. Uncaught runtime exceptions are still handled by dsh's own fail-loud policy and are out of scope for boot quarantine.
111
129
  - **Auditable**: every write records the reason and a timestamp; `--dry-run` previews which plugins would be disabled.
112
130
  - **Faithful pass-through**: when no broken plugin can be identified, the retry limit is exceeded, or for `dsh plugin` (pnpm forwarding), the exit code is passed through untouched and no files are modified.
package/README.md CHANGED
@@ -48,8 +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 -r [dsh 参数…]` | 先修复全部可修复的隔离记录,再按包装模式启动(`--repair` 等价;与 `-u` 对称) |
52
- | `dsh-safe repair [id] [--all] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限重装/升级可能修复的失败:包解析失败、导出版本不匹配等;经 `dsh plugin` 的 pnpm 通道安装);重复挂载类支持自动去重(交互选择保留哪个来源,从 bundles 移除冗余) |
51
+ | `dsh-safe repair [id] [--all] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限重装/升级可能修复的失败:包解析失败、导出版本不匹配等;经 `dsh plugin` pnpm 通道安装);重复挂载类支持自动去重(交互选择保留哪个来源,从 bundles 移除冗余);适合交给 web UI 里的 AI agent 执行 |
53
52
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
54
53
  | `dsh-safe --version`(`-V`) | 显示版本 |
55
54
 
@@ -61,7 +60,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
61
60
  | --- | --- |
62
61
  | `--dry-run` | 只解析与报告,不修改任何文件 |
63
62
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
64
- | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
63
+ | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件,duplicate 去重时允许移除官方 bundle(默认跳过,需手动处理) |
65
64
  | `--exclude <id或包名>` | 隔离豁免名单(可重复),命中的行永不自动禁用;也可写进 `config.json` |
66
65
 
67
66
  ### update / -u 选项(写在 `-u` 或 `update` 之后;其前的包装旗标照常生效)
@@ -104,9 +103,27 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
104
103
  - **AI 兜底识别**(`DSH_SAFE_AI_RECOVER=1`):正则特征识别不出坏插件时(如 dsh 升级换格式),让 AI 从 stderr 里挑元凶——**结果必须仍走同一验证管线**(对照真实 patch 行、第一方保护、dry-run 预览),命中不了照旧透传。仅在启动失败时调用。
105
104
  - 隐私:发送前 home 路径脱敏为 `~`;AI 任何失败都静默降级。
106
105
 
106
+ ### 让 web UI 里的 AI agent 接管修复(推荐)
107
+
108
+ 包装启动自带完整的保活:插件失败自动隔离重试,重复挂载自动去重后重试。启动成功后,台账里留下的记录就是"待修复清单"——把修复交给 web UI 里的 AI agent。将下面片段加入 dsh 的 Agent 预设(或系统提示),agent 就能主动发现并修复:
109
+
110
+ ```text
111
+ 本机装有 dsh-safe(dsh 的启动保险丝)。当用户报告插件功能缺失、启动
112
+ 警告或插件行为异常时:
113
+ 1. 运行 dsh-safe list --json 查看隔离台账(哪些插件被自动禁用、原因、时间)。
114
+ 2. 需要解读时运行 dsh-safe explain <id>(设置 DSH_SAFE_AI_KEY 后可用)。
115
+ 3. 按原因修复:
116
+ - 包缺失/版本落后 → dsh-safe repair <id> -y(重装 latest)
117
+ - 已知兼容版本 → dsh-safe repair <id> --to <版本> -y(降级/指定版本)
118
+ - 重复挂载 → dsh-safe repair <id> -y(自动移除冗余挂载来源)
119
+ - 上游暂无兼容版本 → 保持禁用,并向用户说明。
120
+ 4. 修复后用 dsh-safe --profile <名> 重启验证;仍失败的会被自动重新隔离,
121
+ 不会拖垮启动。
122
+ ```
123
+
107
124
  ## 安全边界
108
125
 
109
- - **第一方保护**:`@deepseek-ai/*` 的行默认跳过(禁用 `dsh-web-app` 这类插件会让 dsh 失去核心能力),需要 `--allow-first-party` 才会动。
126
+ - **第一方保护**:`@deepseek-ai/*` 的行默认跳过(禁用 `dsh-web-app` 这类插件会让 dsh 失去核心能力),需要 `--allow-first-party` 才会动;duplicate 自动去重同受保护——缺省保留官方来源,移除官方 bundle 需显式允许或交互确认,避免连带卸载 webserver 等官方行。
110
127
  - **只动启动期失败**:模块解析失败 / `apply` 抛错 / 等不到注入服务。运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理,不属于启动隔离范围。
111
128
  - **可审计**:每次写入都带原因与时间戳;`--dry-run` 可以先看会禁用谁。
112
129
  - **原样透传**:识别不出坏插件、超过重试上限、`dsh plugin`(pnpm 转发)等情况,退出码原样透传,不做任何修改。
package/bin/dsh-safe.js CHANGED
File without changes
package/lib/cli.js CHANGED
@@ -8,12 +8,15 @@
8
8
  * dsh-safe help | --version
9
9
  */
10
10
  import { createRequire } from 'node:module'
11
+ import { writeFileSync } from 'node:fs'
11
12
  import { loadLedger, restoreQuarantine } from './quarantine.js'
12
13
  import { runWrapped } from './wrap.js'
13
14
  import { cmdUpdate, cmdUpdateAndBoot, maybeNotifySelfUpdate } from './update.js'
14
- import { cmdRepair, cmdRepairAndBoot } from './repair.js'
15
+ import { cmdRepair } from './repair.js'
15
16
  import { printDoctor } from './doctor.js'
16
17
  import { cmdExplain } from './ai.js'
18
+ import { profileDirs, profilePatchPath, readIfExists } from './dshpaths.js'
19
+ import { applyManagedBlock, managedBlockText, scanPatchRows } from './patchfile.js'
17
20
  import { t } from './i18n.js'
18
21
 
19
22
  const require = createRequire(import.meta.url)
@@ -60,19 +63,37 @@ function takeIds(args) {
60
63
  return [ids, rest]
61
64
  }
62
65
 
66
+ /**
67
+ * 孤儿禁用行:托管区块里有、台账里没有的行。克隆 profile 会把 patch(含
68
+ * 托管区块)复制过来,而台账按 profile 记录、不会跟着克隆——这些行既不可
69
+ * 见也不可恢复,只能靠这里暴露。
70
+ */
71
+ function orphanRows(profile, ledger) {
72
+ const block = managedBlockText(readIfExists(profilePatchPath(profile)) ?? '')
73
+ if (block === null) return []
74
+ const ledgerIds = new Set((ledger.profiles[profile] ?? []).map((e) => e.id))
75
+ return scanPatchRows(block).filter((r) => !ledgerIds.has(r.id))
76
+ }
77
+
63
78
  function cmdList(args) {
64
79
  const [profile, rest] = takeProfile(args)
65
80
  const ledger = loadLedger()
81
+ const scanProfiles = profile ? [profile] : [...new Set([...Object.keys(ledger.profiles), ...profileDirs()])]
82
+ const orphansByProfile = {}
83
+ for (const p of scanProfiles) {
84
+ const orphans = orphanRows(p, ledger)
85
+ if (orphans.length) orphansByProfile[p] = orphans.map((r) => ({ id: r.id, name: r.name }))
86
+ }
66
87
  if (rest.includes('--json')) {
67
88
  const profiles = profile ? { [profile]: ledger.profiles[profile] ?? [] } : ledger.profiles
68
- process.stdout.write(`${JSON.stringify({ version: ledger.version ?? 1, profiles }, null, 2)}\n`)
89
+ process.stdout.write(`${JSON.stringify({ version: ledger.version ?? 1, profiles, orphans: orphansByProfile }, null, 2)}\n`)
69
90
  return 0
70
91
  }
71
- const profiles = profile ? [profile] : Object.keys(ledger.profiles).sort()
72
92
  let found = 0
73
- for (const p of profiles) {
93
+ for (const p of scanProfiles) {
74
94
  const entries = ledger.profiles[p] ?? []
75
- if (!entries.length) continue
95
+ const orphans = orphansByProfile[p] ?? []
96
+ if (!entries.length && !orphans.length) continue
76
97
  process.stdout.write(`${p}:\n`)
77
98
  for (const e of entries) {
78
99
  found++
@@ -80,6 +101,11 @@ function cmdList(args) {
80
101
  ` - ${e.name ?? t('unknownName')} (id: ${e.id})\n${t('quarantinedAt', { time: e.quarantinedAt })}\n${t('reasonLine', { reason: e.reason })}\n${t('locationLine', { file: e.file })}\n`,
81
102
  )
82
103
  }
104
+ for (const o of orphans) {
105
+ found++
106
+ process.stdout.write(`${t('listOrphanRow', { id: o.id, name: o.name ?? t('unknownName') })}\n`)
107
+ }
108
+ if (orphans.length) process.stdout.write(`${t('listOrphanHint')}\n`)
83
109
  }
84
110
  if (!found) process.stdout.write(profile ? `${t('noRecordsProfile', { profile })}\n` : `${t('noRecords')}\n`)
85
111
  return 0
@@ -94,11 +120,15 @@ function cmdRestore(args) {
94
120
  process.stderr.write(`${t('restoreNeedsId')}\n`)
95
121
  return 2
96
122
  }
97
- // 省略 --profile 时遍历台账里所有有记录的 profile(与 update 的恢复对齐)
123
+ // 省略 --profile 时:--all 遍历台账 profile 与全部 profile 目录(孤儿托管
124
+ // 区块可能存在于任何 profile——克隆产物台账里没有),否则只遍历台账里有
125
+ // 记录的 profile(与 update 的恢复对齐)
98
126
  const ledger = loadLedger()
99
127
  const profiles = profile0
100
128
  ? [profile0]
101
- : Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
129
+ : all
130
+ ? [...new Set([...Object.keys(ledger.profiles), ...profileDirs()])]
131
+ : Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
102
132
  const verb = t(dryRun ? 'restoredDry' : 'restored')
103
133
  let anyRestored = false
104
134
  for (const p of profiles) {
@@ -109,6 +139,20 @@ function cmdRestore(args) {
109
139
  if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile: p, count: kept.length })}\n`)
110
140
  else process.stdout.write(`${t('ledgerCleared')}\n`)
111
141
  }
142
+ // 孤儿托管区块:恢复后该 profile 台账已空,patch 里却仍有托管区块——
143
+ // 区块由 dsh-safe 全权生成,台账为空即整体失效(克隆/手删台账的产物),
144
+ // 直接摘除,让那些禁用行重新可见、可修复。
145
+ const remaining = loadLedger().profiles[p] ?? []
146
+ const block = managedBlockText(readIfExists(profilePatchPath(p)) ?? '')
147
+ const orphans = block === null ? [] : scanPatchRows(block).filter((r) => !remaining.some((e) => e.id === r.id))
148
+ if (orphans.length) {
149
+ anyRestored = true
150
+ const params = { profile: p, count: orphans.length }
151
+ if (!dryRun) {
152
+ writeFileSync(profilePatchPath(p), applyManagedBlock(readIfExists(profilePatchPath(p)), null).text)
153
+ }
154
+ process.stdout.write(`${t(dryRun ? 'orphanBlockRemovedDry' : 'orphanBlockRemoved', params)}\n`)
155
+ }
112
156
  }
113
157
  if (!anyRestored) process.stdout.write(`${t('noMatching')}\n`)
114
158
  return 0
@@ -142,7 +186,13 @@ export async function main(argv) {
142
186
  return 0
143
187
  }
144
188
  if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
145
- if (cmd === '-r' || cmd === '--repair') return cmdRepairAndBoot(argv.slice(1), { boot: runWrapperMode })
189
+ // 0.15.0 起移除:保活由包装启动全权承担(隔离重试 + duplicate 自愈),
190
+ // 修复走 repair 原语(用户手动或 web UI 里的 AI agent)。留一句迁移提示,
191
+ // 避免老命令直接落进包装模式被 dsh 当成未知参数。
192
+ if (cmd === '-r' || cmd === '--repair') {
193
+ process.stderr.write(`${t('repairFlagRemoved')}\n`)
194
+ return 2
195
+ }
146
196
 
147
197
  // 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
148
198
  // 其余原样转发给 dsh。
package/lib/dedupe.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — 重复挂载(duplicate loader entry id)的去重。
3
+ *
4
+ * 同一 id 被多个 bundle 挂载时,加载器在 include 挂载阶段整体拒绝启动;
5
+ * 它发生在 profile 层 disabled 覆盖合并之前,禁用行管不住,隔离无效。
6
+ * 去重 = 保留一个来源,从 manifest bundles 移除其余来源(包本身保留在
7
+ * node_modules,聚合包的挂载行仍解析到根安装的版本)。保留哪一个:
8
+ * 交互终端让用户选;非交互(-y、dry-run、无 TTY)缺省保留官方 bundle
9
+ * (@deepseek-ai/*,社区复刻与官方同 id 重复挂载时不能把官方挂载下线),
10
+ * 没有官方来源则保留先声明者。第一方守卫:移除方案含官方 bundle 时,
11
+ * 仅 allowFirstParty 或交互终端显式确认才放行,否则拒绝并由调用方给
12
+ * 手动指引——移除一个 bundle 会把它挂载的其它条目一并卸载,官方 bundle
13
+ * 背后往往是 webserver 等核心行。
14
+ *
15
+ * repair 与 wrap 两条路径共用:repair 负责额外的台账恢复,wrap 在启动
16
+ * 失败自愈时调用。注意 dsh 会在每次 plugin add 时把 dependencies 里的
17
+ * bundle 型包重新 reconcile 进 bundles——去重的成果可能被下一次 add
18
+ * 复活,属于 dsh 的设计行为(见 repair/wrap 的调用处说明)。
19
+ */
20
+ import { createInterface } from 'node:readline/promises'
21
+ import { readFileSync, writeFileSync } from 'node:fs'
22
+ import { profileManifestPath, readIfExists } from './dshpaths.js'
23
+ import { collectKnownRows } from './knownrows.js'
24
+ import { t } from './i18n.js'
25
+
26
+ const err = (line) => process.stderr.write(`${line}\n`)
27
+
28
+ export const FIRST_PARTY_PREFIX = '@deepseek-ai/'
29
+
30
+ /** 第一方(dsh 官方)包/bundle:@deepseek-ai/* 域下的名字。 */
31
+ export const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
32
+
33
+ /** 该 id 的 bundle 挂载来源(profile/home 层是 override,不算挂载来源)。 */
34
+ export function bundleMountSources(known, id) {
35
+ return [
36
+ ...new Set(
37
+ known.rows.filter((r) => r.id === id && r.source !== 'profile' && r.source !== 'home').map((r) => r.source),
38
+ ),
39
+ ]
40
+ }
41
+
42
+ /** 从 manifest bundles 移除指定来源;没有变化返回 false。 */
43
+ export function removeFromManifestBundles(profile, removeNames) {
44
+ const manifestPath = profileManifestPath(profile)
45
+ let manifest
46
+ try {
47
+ manifest = JSON.parse(readIfExists(manifestPath) ?? 'null')
48
+ } catch {
49
+ return false
50
+ }
51
+ const current = manifest?.dsh?.profile?.bundles
52
+ if (!Array.isArray(current)) return false
53
+ const next = current.filter((b) => !removeNames.includes(b))
54
+ if (next.length === current.length) return false
55
+ manifest.dsh.profile.bundles = next
56
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
57
+ return true
58
+ }
59
+
60
+ /**
61
+ * 去重一个被多来源挂载的 id。
62
+ * @param {string} profile
63
+ * @param {string} id
64
+ * @param {{
65
+ * yes?: boolean, // 跳过交互选择(缺省保留官方来源,否则保留先声明者)
66
+ * tty?: boolean, // 非交互终端不提示,直接按缺省来源去重(默认 false;
67
+ * // 注意调用方传 undefined 也会落到默认值)
68
+ * dryRun?: boolean,
69
+ * allowFirstParty?: boolean, // 允许移除官方 bundle(默认保护)
70
+ * log?: (line: string) => void,
71
+ * }} [opts]
72
+ * @returns {Promise<{ kept: string, removed: string[] } | { error: 'no-duplicate' | 'no-manifest' | 'first-party' }>}
73
+ * no-duplicate = 该 id 实际只有 ≤1 个可识别的挂载来源(扫描器盲区或已修复)
74
+ * first-party = 保留方案会移除官方 bundle 且未获允许/确认
75
+ */
76
+ export async function dedupeMountSources(
77
+ profile,
78
+ id,
79
+ { yes = false, tty = false, dryRun = false, allowFirstParty = false, log = err } = {},
80
+ ) {
81
+ const sources = bundleMountSources(collectKnownRows(profile), id)
82
+ if (sources.length < 2) return { error: 'no-duplicate' }
83
+
84
+ // 按 manifest bundles 的声明顺序排序:先声明者优先保留
85
+ let manifest
86
+ try {
87
+ manifest = JSON.parse(readIfExists(profileManifestPath(profile)) ?? 'null')
88
+ } catch {
89
+ manifest = null
90
+ }
91
+ const declared = manifest?.dsh?.profile?.bundles
92
+ const ordered = Array.isArray(declared)
93
+ ? [...sources].sort(
94
+ (a, b) =>
95
+ (declared.indexOf(a) < 0 ? 999 : declared.indexOf(a)) - (declared.indexOf(b) < 0 ? 999 : declared.indexOf(b)),
96
+ )
97
+ : sources
98
+
99
+ // 缺省保留官方 bundle:社区复刻与官方同 id 重复挂载时,保官方、去社区
100
+ const firstPartySources = ordered.filter(isFirstParty)
101
+ const defaultKeep = firstPartySources.length ? firstPartySources[0] : ordered[0]
102
+
103
+ let keep = defaultKeep
104
+ if (!yes && !dryRun && tty) {
105
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
106
+ log(t('repairDedupePick', { id }))
107
+ ordered.forEach((b, i) => log(`${i + 1}. ${b}${b === defaultKeep ? t('repairDedupeDefault') : ''}`))
108
+ const answer = await rl.question(t('repairDedupeChoose'))
109
+ rl.close()
110
+ const fallback = ordered.indexOf(defaultKeep) + 1
111
+ const pick = Math.min(Math.max(parseInt(answer, 10) || fallback, 1), ordered.length)
112
+ keep = ordered[pick - 1]
113
+ }
114
+ const remove = ordered.filter((b) => b !== keep)
115
+
116
+ log(t('repairDedupePlan', { keep, remove: remove.join(', '), id }))
117
+ // 第一方守卫:移除官方 bundle 会连带卸载它挂载的其它条目(webserver 等
118
+ // 核心行),仅在显式允许或交互确认后放行
119
+ const removeFirstParty = remove.filter(isFirstParty)
120
+ if (removeFirstParty.length && !allowFirstParty) {
121
+ if (!yes && !dryRun && tty) {
122
+ log(t('dedupeFirstPartyWarn', { bundles: removeFirstParty.join(', ') }))
123
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
124
+ const answer = await rl.question(t('dedupeFirstPartyConfirm'))
125
+ rl.close()
126
+ if (!/^(y|yes)$/i.test(answer.trim())) {
127
+ log(t('dedupeFirstPartyBlocked', { bundles: removeFirstParty.join(', ') }))
128
+ return { error: 'first-party' }
129
+ }
130
+ } else {
131
+ log(t('dedupeFirstPartyBlocked', { bundles: removeFirstParty.join(', ') }))
132
+ return { error: 'first-party' }
133
+ }
134
+ }
135
+ if (dryRun) return { kept: keep, removed: remove }
136
+
137
+ if (!removeFromManifestBundles(profile, remove)) return { error: 'no-manifest' }
138
+ return { kept: keep, removed: remove }
139
+ }
package/lib/dshpaths.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * 与 dsh 运行时(@deepseek-ai/dsh-home-paths)保持一致的 home 解析规则:
5
5
  * `DSH_HOME` 环境变量优先(支持 `~` 前缀展开),默认 `~/.dsh`。
6
6
  */
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
8
8
  import { homedir } from 'node:os'
9
9
  import { dirname, resolve, join } from 'node:path'
10
10
 
@@ -22,6 +22,17 @@ export const profileDir = (profile) => join(dshHome(), 'profiles', profile)
22
22
  export const profilePatchPath = (profile) => join(profileDir(profile), 'cordis.patch.yml')
23
23
  export const profileManifestPath = (profile) => join(profileDir(profile), 'package.json')
24
24
 
25
+ /** $DSH_HOME/profiles 下的全部 profile 目录名;目录不存在返回空数组。 */
26
+ export function profileDirs() {
27
+ try {
28
+ return readdirSync(join(dshHome(), 'profiles'), { withFileTypes: true })
29
+ .filter((e) => e.isDirectory())
30
+ .map((e) => e.name)
31
+ } catch {
32
+ return []
33
+ }
34
+ }
35
+
25
36
  /** dsh home 层的公共 patch(对所有 profile 生效)。 */
26
37
  export const homePatchPath = () => join(dshHome(), 'cordis.patch.yml')
27
38
 
package/lib/i18n.js CHANGED
@@ -26,8 +26,7 @@ const ZH = {
26
26
  升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
27
27
  dsh-safe explain [id] [--profile <名> | --file <路径>] 用 AI 解读:最近失败日志 / 隔离台账 / 任意日志
28
28
  dsh-safe repair [id] [--all] [--profile <名>] [--to <版本>] [-y] [--dry-run]
29
- 重装/升级被隔离的插件并自动恢复(--all 批量;限可自动修复类;省略 id 时只有一条记录则直接修)
30
- dsh-safe -r [dsh 参数…] 先修复全部可修复的隔离记录,再启动(--repair 等价)
29
+ 重装/升级被隔离的插件并自动恢复(--all 批量;限重装/升级可能修复的失败;省略 id 时只有一条记录则直接修;可交给 web UI 里的 AI agent 执行)
31
30
  dsh-safe help 显示本帮助
32
31
  dsh-safe --version 显示版本
33
32
 
@@ -139,11 +138,14 @@ const ZH = {
139
138
  repairDuplicate: '[dsh-safe] 这是重复挂载问题(id: {id})。一键修复:dsh-safe repair {id}(自动移除冗余来源并恢复挂载)',
140
139
  repairDuplicateManual: '[dsh-safe] 这是重复挂载问题(同一 id 被多个 bundle/行挂载),重装包无法修复。请从 profile 的 bundles 清单或 patch 中移除多余的挂载来源,然后 dsh-safe restore 恢复。',
141
140
  repairDedupePick: '[dsh-safe] {id} 同时由以下 bundle 挂载,保留哪个来源?',
142
- repairDedupeDefault: '(bundles 中先声明,缺省保留)',
141
+ repairDedupeDefault: '(缺省保留)',
143
142
  repairDedupeChoose: '保留哪个?输入编号 [1]: ',
144
143
  repairDedupePlan: '[dsh-safe] 修复方案:保留 {keep} 的挂载,从 bundles 移除 {remove},并摘除 {id} 的禁用行',
145
144
  repairDedupeNoManifest: '[dsh-safe] profile 清单中没有可编辑的 bundles 数组,请手动处理。',
146
145
  repairDedupeDone: '[dsh-safe] 去重完成:已从 bundles 移除 {removed};{keep} 的挂载保留、禁用行已摘除。跑 dsh-safe <启动命令> 验证。',
146
+ dedupeFirstPartyWarn: '[dsh-safe] 注意:该方案会移除官方 bundle {bundles}(@deepseek-ai/*),由它挂载的其它条目也会一并下线。',
147
+ dedupeFirstPartyConfirm: '确认移除官方 bundle?(y/N) ',
148
+ dedupeFirstPartyBlocked: '[dsh-safe] 已跳过自动去重:保留方案会移除官方 bundle {bundles}(@deepseek-ai/*,默认保护)。请手动编辑 profile manifest 的 bundles 移除冗余来源,或在交互终端确认后重试。',
147
149
  repairAllConflict: '[dsh-safe] --all 与 <id> 不能同时使用。',
148
150
  repairUnknownArg: '[dsh-safe] repair 无法识别的参数: {arg}',
149
151
  repairBatchPlan: '[dsh-safe] 将修复 {count} 条隔离记录: {ids}',
@@ -151,6 +153,13 @@ const ZH = {
151
153
  repairBatchDone: '[dsh-safe] 批量修复完成:成功 {repaired} 条,失败 {failed} 条。',
152
154
  repairInstallFailed: '[dsh-safe] 插件安装失败(退出码 {code}),隔离状态保持不变。',
153
155
  repairDone: '[dsh-safe] 修复完成:跑 dsh-safe <启动命令> 验证;若仍失败会被自动重新隔离。',
156
+ repairFlagRemoved: '[dsh-safe] -r/--repair 已移除(0.15.0 起):保活由包装启动全权承担(隔离重试 + duplicate 自愈);修复用 dsh-safe repair --all,或交给 web UI 里的 AI agent 执行。',
157
+ ledgerReminder: '[dsh-safe] 提醒: 本 profile 有 {count} 个插件处于自动禁用状态(查看: dsh-safe list,修复: dsh-safe repair --all)',
158
+ bootQuarantineSummary: '[dsh-safe] 本次启动已自动禁用 {count} 个插件: {names}\n 查看: dsh-safe list 修复: dsh-safe repair --all AI 解读: dsh-safe explain',
159
+ orphanBlockRemoved: '[dsh-safe] 已摘除孤儿托管区块:{count} 行禁用行在台账无对应记录(profile: {profile})',
160
+ orphanBlockRemovedDry: '[dsh-safe] (dry-run)将摘除孤儿托管区块:{count} 行禁用行在台账无对应记录(profile: {profile})',
161
+ listOrphanRow: ' - {id}({name})孤儿禁用行(托管区块有、台账无记录)',
162
+ listOrphanHint: '[dsh-safe] 孤儿禁用行不在台账里,repair/restore 不会碰它们;dsh-safe restore --profile <名> --all 可整体摘除',
154
163
  }
155
164
 
156
165
  const EN = {
@@ -174,8 +183,7 @@ Usage:
174
183
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
175
184
  dsh-safe explain [id] [--profile <name> | --file <path>] interpret with AI: last failure log / ledger / any log
176
185
  dsh-safe repair [id] [--all] [--profile <name>] [--to <ver>] [-y] [--dry-run]
177
- reinstall/upgrade quarantined plugins and auto-restore (--all for batch; repairable failures only; omit id when there is exactly one record)
178
- dsh-safe -r [dsh args…] repair all repairable quarantined plugins, then boot (--repair alias)
186
+ reinstall/upgrade quarantined plugins and auto-restore (--all for batch; repairable failures only; omit id when there is exactly one record; hand it to the AI agent in the web UI)
179
187
  dsh-safe help show this help
180
188
  dsh-safe --version show version
181
189
 
@@ -290,11 +298,14 @@ Notes:
290
298
  repairDuplicate: '[dsh-safe] duplicate-mount issue (id: {id}). One-command fix: dsh-safe repair {id} (removes the redundant source and restores automatically)',
291
299
  repairDuplicateManual: '[dsh-safe] 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.',
292
300
  repairDedupePick: '[dsh-safe] {id} is mounted by the following bundles; which source to keep?',
293
- repairDedupeDefault: ' (declared first in bundles; default keep)',
301
+ repairDedupeDefault: ' (default keep)',
294
302
  repairDedupeChoose: 'which one to keep? number [1]: ',
295
303
  repairDedupePlan: '[dsh-safe] fix plan: keep {keep} mounted, remove {remove} from bundles, and drop the disabled row for {id}',
296
304
  repairDedupeNoManifest: '[dsh-safe] the profile manifest has no editable bundles array; handle it manually.',
297
305
  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.',
306
+ dedupeFirstPartyWarn: '[dsh-safe] warning: this plan removes official bundle(s) {bundles} (@deepseek-ai/*); everything else they mount goes offline too.',
307
+ dedupeFirstPartyConfirm: 'remove the official bundle(s)? (y/N) ',
308
+ dedupeFirstPartyBlocked: '[dsh-safe] dedupe skipped: the plan would remove official bundle(s) {bundles} (@deepseek-ai/*, protected by default). Edit the profile manifest bundles manually, or retry from an interactive terminal to confirm.',
298
309
  repairAllConflict: '[dsh-safe] --all and <id> are mutually exclusive.',
299
310
  repairUnknownArg: '[dsh-safe] unrecognized argument for repair: {arg}',
300
311
  repairBatchPlan: '[dsh-safe] repairing {count} quarantine record(s): {ids}',
@@ -302,6 +313,13 @@ Notes:
302
313
  repairBatchDone: '[dsh-safe] batch repair finished: {repaired} succeeded, {failed} failed.',
303
314
  repairInstallFailed: '[dsh-safe] plugin install failed (exit code {code}); quarantine state left unchanged.',
304
315
  repairDone: '[dsh-safe] repair finished: run dsh-safe <boot command> to verify; a still-broken plugin will be auto-quarantined again.',
316
+ repairFlagRemoved: '[dsh-safe] -r/--repair has been removed (since 0.15.0): the wrapped boot fully owns keep-alive (quarantine retries + duplicate self-heal); repair via `dsh-safe repair --all` or the AI agent in the web UI.',
317
+ ledgerReminder: '[dsh-safe] reminder: {count} plugin(s) are auto-disabled in this profile (inspect: dsh-safe list, repair: dsh-safe repair --all)',
318
+ bootQuarantineSummary: '[dsh-safe] auto-disabled {count} plugin(s) during this boot: {names}\n inspect: dsh-safe list repair: dsh-safe repair --all interpret: dsh-safe explain',
319
+ orphanBlockRemoved: '[dsh-safe] stripped orphan managed block: {count} disabled row(s) absent from the ledger (profile: {profile})',
320
+ orphanBlockRemovedDry: '[dsh-safe] (dry-run) would strip orphan managed block: {count} disabled row(s) absent from the ledger (profile: {profile})',
321
+ listOrphanRow: ' - {id} ({name}) orphan disabled row (in managed block, absent from ledger)',
322
+ listOrphanHint: '[dsh-safe] orphan disabled rows are not in the ledger; repair/restore skip them — dsh-safe restore --profile <name> --all strips them all',
305
323
  }
306
324
 
307
325
  const CATALOG = { zh: ZH, en: EN }
package/lib/knownrows.js CHANGED
@@ -64,6 +64,10 @@ export function collectKnownRows(profile) {
64
64
  * 把失败报告对照到真实存在的 patch 行。
65
65
  * 包名命中该包的全部行(一个包可能挂多个 id);行 id 命中对应行。
66
66
  * 同一 id 在多层重复出现时,取合成顺序里最后出现的行(profile/托管区块覆盖 home/bundle)。
67
+ * 该行没有包名时,回退到同 id 其它层行声明的包名(合成顺序里最后一个非空者):
68
+ * profile 覆盖行通常只写 config 不重述 name(如 webserver 的 host/port 覆盖),
69
+ * 若照搬本层行会把包名丢成 null,wrap 的第一方保护(@deepseek-ai/* 前缀)
70
+ * 随之失效、误禁官方插件。
67
71
  * @param {{ names: Array<[string, string]>, entryIds: Array<[string, string]> }} report
68
72
  * @param {{ rows: Array<{ id: string, name: string|null, disabled: boolean, file: string }> }} known
69
73
  * @returns {Array<{ id: string, name: string|null, disabled: boolean, file: string, line: string }>}
@@ -71,9 +75,11 @@ export function collectKnownRows(profile) {
71
75
  export function matchFailures(report, known) {
72
76
  const byId = new Map()
73
77
  const byName = new Map()
78
+ const nameById = new Map()
74
79
  for (const row of known.rows) {
75
80
  byId.set(row.id, row) // 同 id 后写覆盖
76
81
  if (row.name) {
82
+ nameById.set(row.id, row.name)
77
83
  if (!byName.has(row.name)) byName.set(row.name, [])
78
84
  byName.get(row.name).push(row)
79
85
  }
@@ -84,7 +90,7 @@ export function matchFailures(report, known) {
84
90
  }
85
91
  for (const [id, line] of report.entryIds) {
86
92
  const row = byId.get(id)
87
- if (row) hits.set(row.id, { ...row, line })
93
+ if (row) hits.set(row.id, { ...row, name: row.name ?? nameById.get(id) ?? null, line })
88
94
  }
89
95
  return [...hits.values()]
90
96
  }
package/lib/patchfile.js CHANGED
@@ -92,6 +92,21 @@ export function stripManagedBlocks(text) {
92
92
  return kept.join('\n').replace(/\n+$/, '')
93
93
  }
94
94
 
95
+ /**
96
+ * 提取托管区块内的文本(首尾标记行之间,不含标记行);没有区块返回 null。
97
+ * 用于发现"区块在、台账无记录"的孤儿禁用行(克隆 profile / 手删台账)。
98
+ * @param {string} text patch 文件内容
99
+ * @returns {string | null}
100
+ */
101
+ export function managedBlockText(text) {
102
+ const lines = text.split(/\r?\n/)
103
+ const start = lines.findIndex((l) => l.trim() === MANAGED_START)
104
+ if (start < 0) return null
105
+ const end = lines.findIndex((l, i) => i > start && l.trim() === MANAGED_END)
106
+ if (end < 0) return null
107
+ return lines.slice(start + 1, end).join('\n')
108
+ }
109
+
95
110
  /**
96
111
  * 在 patch 文本上应用托管区块:先移除旧区块,再追加新区块(block 为 null 只移除)。
97
112
  *
package/lib/repair.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @hyzyn/dsh-safe — `dsh-safe repair` 与 `dsh-safe -r`:修复被隔离的插件。
2
+ * @hyzyn/dsh-safe — `dsh-safe repair`:修复被隔离的插件。
3
3
  *
4
4
  * repair 可修复的失败特征:问题出在"包的安装状态或版本适配"上,重装/升级
5
5
  * 插件包可能修复——
@@ -8,67 +8,80 @@
8
8
  * 当前 dsh API 版本不兼容,升级插件到适配版本可能修复(真实案例:
9
9
  * describe-image 隔离于 dsh-settings 导出变更)。修复失败不恢复、无损。
10
10
  * 注意:台账 reason 经 summarizeLine 截断过(160 字符),关键短语可能被
11
- * "…" 切断,因此导出不匹配的特征只匹配到 "does not provide an" 为止。
11
+ * "…" 切断,因此导出不匹配的特征只匹配到 "does not provide an" 为止;
12
+ * 但 id/包名特别长时(如 dsh-client-ui-web-ui-settings)连这个短语都会被
13
+ * 切掉,只剩 "does not p…"——所以再补 "The requested module"(ESM 具名
14
+ * 导出报错的固定开头,位置靠前不会被截掉)。
12
15
  *
13
16
  * duplicate(同一 id 被多个 bundle 挂载)是配置层问题,重装无意义——但
14
- * 支持自动去重:从 profile 清单的 bundles 移除一个来源(交互选择保留哪个,
15
- * -y 保留先声明者),并摘除无效的禁用行。include 子文件里的重复不在
16
- * bundle 层对照表内,自动去重帮不上,给手动指引。
17
+ * 支持自动去重(见 dedupe.js):从 profile 清单的 bundles 移除一个来源
18
+ * (交互选择保留哪个;非交互缺省保留官方 bundle,否则保留先声明者;移除
19
+ * 官方 bundle 受第一方守卫约束),并摘除无效的禁用行。include
20
+ * 子文件里的重复不在 bundle 层对照表内,自动去重帮不上,给手动指引。
17
21
  *
18
22
  * 安装路径修复完成后会复查该 id 的挂载来源:修复的插件若同时被聚合包等
19
23
  * 其它 bundle 挂载,恢复后下次启动会 duplicate——自动去重防复发。
24
+ * 注意 dsh 会在每次 plugin add 时把 dependencies 里的 bundle 型包重新
25
+ * reconcile 进 bundles,去重成果可能被复活——包装启动的自愈(wrap.js)
26
+ * 与本命令的预检都会再次兜住。
20
27
  *
21
- * `--all` 批量修复台账里全部可修复记录;`-r/--repair` = 批量修复 + 启动
22
- * (与 -u 对称:位置参数全部是 dsh 启动参数)。
28
+ * `--all` 批量修复台账里全部可修复记录(含未入账 duplicate 预检)。
29
+ * (0.15.0 `-r/--repair` 组合命令已移除:保活全部由包装启动承担——隔离
30
+ * 重试与 duplicate 自愈见 wrap.js;修复交给 repair 原语,由用户手动调用
31
+ * 或 web UI 里的 AI agent 执行。)
23
32
  */
24
33
  import { spawnSync } from 'node:child_process'
25
34
  import { createInterface } from 'node:readline/promises'
26
- import { readFileSync, writeFileSync } from 'node:fs'
27
35
  import { loadLedger, restoreQuarantine } from './quarantine.js'
28
36
  import { summarizeLine } from './failures.js'
29
- import { profileManifestPath, readIfExists, resolveDshSpawnTarget } from './dshpaths.js'
37
+ import { profileDirs, resolveDshSpawnTarget } from './dshpaths.js'
30
38
  import { collectKnownRows } from './knownrows.js'
39
+ import { dedupeMountSources } from './dedupe.js'
31
40
  import { t } from './i18n.js'
32
41
 
33
42
  const err = (line) => process.stderr.write(`${line}\n`)
34
43
  const out = (line) => process.stdout.write(`${line}\n`)
35
44
 
36
45
  const REPAIRABLE_RE =
37
- /Cannot find package|could not be resolved|ERR_MODULE_NOT_FOUND|does not provide an( (?:export|import binding))?/i
46
+ /Cannot find package|could not be resolved|ERR_MODULE_NOT_FOUND|The requested module|does not provide an( (?:export|import binding))?/i
38
47
  const DUPLICATE_RE = /duplicate loader entry id/i
39
48
 
40
49
  const isRepairable = (reason) => REPAIRABLE_RE.test(reason ?? '') || DUPLICATE_RE.test(reason ?? '')
41
50
 
42
- const readJsonIfExists = (path) => {
43
- const raw = readIfExists(path)
44
- if (raw === undefined) return undefined
45
- try {
46
- return JSON.parse(raw)
47
- } catch {
48
- return undefined
51
+ /**
52
+ * 预检未入账的 duplicate:duplicate 失败不产生台账条目(0.12.1 起的设计),
53
+ * 只能按挂载来源发现——某 profile 下该 id 被 ≥2 个 bundle 挂载即视为目标。
54
+ * @param {Array<{ profile: string, entry: { id: string } }>} targets 已收集的修复目标(其中的 id 视为已入账,不重复预检)
55
+ * @param {string=} profileFilter 指定 profile 时只扫它
56
+ * @param {{ allProfiles?: boolean }} [opts] 无 profileFilter 时是否补扫全部 profile 目录(repair --all 用)
57
+ * @returns {Array<{ profile: string, entry: { id: string, name: string|null, reason: string } }>}
58
+ */
59
+ function findUnledgeredDuplicates(targets, profileFilter, { allProfiles = false } = {}) {
60
+ const ledgerIds = new Map()
61
+ for (const t of targets) {
62
+ if (!ledgerIds.has(t.profile)) ledgerIds.set(t.profile, new Set())
63
+ ledgerIds.get(t.profile).add(t.entry.id)
49
64
  }
50
- }
51
-
52
- /** id bundle 挂载来源(profile/home 层是 override,不算挂载来源)。 */
53
- function bundleMountSources(known, id) {
54
- return [
55
- ...new Set(
56
- known.rows.filter((r) => r.id === id && r.source !== 'profile' && r.source !== 'home').map((r) => r.source),
57
- ),
58
- ]
59
- }
60
-
61
- /** 从 profile 清单的 bundles 数组移除指定来源;没有变化返回 false。 */
62
- function removeFromManifestBundles(profile, removeNames) {
63
- const manifestPath = profileManifestPath(profile)
64
- const manifest = readJsonIfExists(manifestPath)
65
- const current = manifest?.dsh?.profile?.bundles
66
- if (!Array.isArray(current)) return false
67
- const next = current.filter((b) => !removeNames.includes(b))
68
- if (next.length === current.length) return false
69
- manifest.dsh.profile.bundles = next
70
- writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
71
- return true
65
+ const scanProfiles = profileFilter
66
+ ? [profileFilter]
67
+ : [...new Set([...targets.map((t) => t.profile), ...(allProfiles ? profileDirs() : [])])]
68
+ const found = []
69
+ for (const p of scanProfiles) {
70
+ const known = collectKnownRows(p)
71
+ const byId = new Map()
72
+ for (const row of known.rows) {
73
+ if (row.source === 'profile' || row.source === 'home') continue
74
+ if (!byId.has(row.id)) byId.set(row.id, new Set())
75
+ byId.get(row.id).add(row.source)
76
+ }
77
+ const ledgered = ledgerIds.get(p) ?? new Set()
78
+ for (const [dupId, srcs] of byId) {
79
+ if (srcs.size < 2 || ledgered.has(dupId)) continue
80
+ const nameRow = known.rows.find((r) => r.id === dupId)
81
+ found.push({ profile: p, entry: { id: dupId, name: nameRow?.name ?? null, reason: 'duplicate loader entry id' } })
82
+ }
83
+ }
84
+ return found
72
85
  }
73
86
 
74
87
  function askConfirm(question) {
@@ -94,51 +107,25 @@ function askConfirm(question) {
94
107
  async function repairEntry({ profile, entry, to = undefined, yes = false, tty = true, dryRun = false, spawn = spawnSync, log = err, write = out }) {
95
108
  const id = entry.id
96
109
 
97
- // duplicate:自动去重(多 bundle 来源),来源不足给手动指引
110
+ // duplicate:自动去重(多 bundle 来源),来源不足给手动指引;
111
+ // 保留方案涉及官方 bundle 时被 dedupe 的第一方守卫拒绝(原因由其打印)
98
112
  if (DUPLICATE_RE.test(entry.reason ?? '')) {
99
- const sources = bundleMountSources(collectKnownRows(profile), id)
100
- if (sources.length < 2) {
101
- log(t('repairDuplicateManual'))
102
- return { code: 1, outcome: 'skipped' }
113
+ const res = await dedupeMountSources(profile, id, { yes, tty, dryRun, log })
114
+ if ('error' in res) {
115
+ if (res.error === 'no-manifest') log(t('repairDedupeNoManifest'))
116
+ else if (res.error !== 'first-party') log(t('repairDuplicateManual'))
117
+ return { code: 1, outcome: res.error === 'no-manifest' ? 'failed' : 'skipped' }
103
118
  }
104
- const manifest = readJsonIfExists(profileManifestPath(profile))
105
- const declared = manifest?.dsh?.profile?.bundles
106
- const ordered = Array.isArray(declared)
107
- ? [...sources].sort(
108
- (a, b) =>
109
- (declared.indexOf(a) < 0 ? 999 : declared.indexOf(a)) - (declared.indexOf(b) < 0 ? 999 : declared.indexOf(b)),
110
- )
111
- : sources
112
- let keep = ordered[0]
113
- if (!yes && !dryRun) {
114
- if (!tty) {
115
- log(t('updateNonInteractive'))
116
- return { code: 1, outcome: 'failed' }
117
- }
118
- const rl = createInterface({ input: process.stdin, output: process.stderr })
119
- log(t('repairDedupePick', { id }))
120
- ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
121
- const answer = await rl.question(t('repairDedupeChoose'))
122
- rl.close()
123
- const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
124
- keep = ordered[pick - 1]
125
- }
126
- const remove = ordered.filter((b) => b !== keep)
127
- log(t('repairDedupePlan', { keep, remove: remove.join(', '), id }))
128
119
  if (dryRun) {
129
120
  log(t('dryRunNotice'))
130
121
  return { code: 0, outcome: 'deduped' }
131
122
  }
132
- if (!removeFromManifestBundles(profile, remove)) {
133
- log(t('repairDedupeNoManifest'))
134
- return { code: 1, outcome: 'failed' }
135
- }
136
123
  const { restored } = restoreQuarantine(profile, [id], false)
137
124
  if (restored.length) {
138
125
  const e = restored[0]
139
126
  write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
140
127
  }
141
- log(t('repairDedupeDone', { removed: remove.join(', '), keep }))
128
+ log(t('repairDedupeDone', { keep: res.kept, removed: res.removed.join(', ') }))
142
129
  log(t('repairDone'))
143
130
  return { code: 0, outcome: 'deduped' }
144
131
  }
@@ -178,32 +165,10 @@ async function repairEntry({ profile, entry, to = undefined, yes = false, tty =
178
165
  }
179
166
 
180
167
  // 防复发:修复的插件若同时被多个 bundle 挂载(如聚合包已内置同一插件),
181
- // 恢复后下次启动会 duplicate——自动去重(交互选择保留来源,-y 保留先声明者)
182
- const knownAfter = collectKnownRows(profile)
183
- const sources = bundleMountSources(knownAfter, id)
184
- if (sources.length >= 2) {
185
- const manifest = readJsonIfExists(profileManifestPath(profile))
186
- const declared = manifest?.dsh?.profile?.bundles
187
- const ordered = Array.isArray(declared)
188
- ? [...sources].sort(
189
- (a, b) =>
190
- (declared.indexOf(a) < 0 ? 999 : declared.indexOf(a)) - (declared.indexOf(b) < 0 ? 999 : declared.indexOf(b)),
191
- )
192
- : sources
193
- let keep = ordered[0]
194
- if (!yes) {
195
- const rl = createInterface({ input: process.stdin, output: process.stderr })
196
- log(t('repairDedupePick', { id }))
197
- ordered.forEach((b, i) => log(`${i + 1}. ${b}${i === 0 ? t('repairDedupeDefault') : ''}`))
198
- const answer = await rl.question(t('repairDedupeChoose'))
199
- rl.close()
200
- const pick = Math.min(Math.max(parseInt(answer, 10) || 1, 1), ordered.length)
201
- keep = ordered[pick - 1]
202
- }
203
- const removed = ordered.filter((b) => b !== keep)
204
- if (removeFromManifestBundles(profile, removed)) {
205
- log(t('repairDedupeDone', { removed: removed.join(', '), keep }))
206
- }
168
+ // 恢复后下次启动会 duplicate——自动去重(缺省保留官方来源,否则先声明来源)
169
+ const res = await dedupeMountSources(profile, id, { yes, tty, log })
170
+ if (!('error' in res)) {
171
+ log(t('repairDedupeDone', { keep: res.kept, removed: res.removed.join(', ') }))
207
172
  }
208
173
 
209
174
  log(t('repairDone'))
@@ -247,7 +212,8 @@ function collectTargets(opts, ledger) {
247
212
  return matches
248
213
  }
249
214
 
250
- /** 批量修复:一次确认,逐条执行,汇总结果。返回失败的条数。 */
215
+ /** 批量修复:一次确认,逐条执行,汇总结果。返回 { failed, repaired, done },
216
+ * done 只收安装修复成功的条目(去重成功不算——去重后暴露出的新失败仍值得再修)。 */
251
217
  async function repairBatch(targets, { to = undefined, yes = false, dryRun = false, spawn = spawnSync, log = err, write = out }) {
252
218
  const repairable = targets.filter((t) => isRepairable(t.entry.reason))
253
219
  const unsupported = targets.filter((t) => !repairable.includes(t))
@@ -265,28 +231,33 @@ async function repairBatch(targets, { to = undefined, yes = false, dryRun = fals
265
231
  await repairEntry({ profile: t.profile, entry: t.entry, to: to, yes: true, tty: true, dryRun: true, spawn, log, write })
266
232
  }
267
233
  log(t('dryRunNotice'))
268
- return { failed: 0 }
234
+ return { failed: 0, repaired: 0, done: [] }
269
235
  }
270
236
  if (!yes) {
271
237
  if (!process.stdin.isTTY) {
272
238
  log(t('updateNonInteractive'))
273
- return { failed: targets.length }
239
+ return { failed: targets.length, repaired: 0, done: [] }
274
240
  }
275
241
  const ok = await askConfirm(t('updateConfirm'))
276
242
  if (!ok) {
277
243
  log(t('updateAborted'))
278
- return { failed: 0 }
244
+ return { failed: 0, repaired: 0, done: [] }
279
245
  }
280
246
  }
281
247
  let repaired = 0
282
248
  let failed = 0
249
+ const done = []
283
250
  for (const t of repairable) {
284
- const { code } = await repairEntry({ profile: t.profile, entry: t.entry, to: to, yes: true, tty: true, spawn, log, write })
285
- if (code === 0) repaired++
286
- else failed++
251
+ const { code, outcome } = await repairEntry({ profile: t.profile, entry: t.entry, to: to, yes: true, tty: true, spawn, log, write })
252
+ if (code === 0) {
253
+ repaired++
254
+ if (outcome === 'repaired') done.push({ profile: t.profile, id: t.entry.id })
255
+ } else {
256
+ failed++
257
+ }
287
258
  }
288
259
  log(t('repairBatchDone', { repaired, failed }))
289
- return { failed }
260
+ return { failed, repaired, done }
290
261
  }
291
262
 
292
263
  /**
@@ -312,8 +283,10 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
312
283
  const ledger = loadLedger()
313
284
  const matches = collectTargets(opts, ledger)
314
285
 
315
- // --all:批量修复(一次确认,默认策略)
286
+ // --all:批量修复(一次确认,默认策略);先预检未入账的 duplicate
287
+ // (与包装启动的自愈对齐:duplicate 不产生台账条目,台账为空也可能有修复目标)
316
288
  if (opts.all) {
289
+ matches.push(...findUnledgeredDuplicates(matches, opts.profile, { allProfiles: true }))
317
290
  if (!matches.length) {
318
291
  log(t('noRecords'))
319
292
  return 0
@@ -342,27 +315,9 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
342
315
  if (!matches.length) {
343
316
  // duplicate 类失败不会产生台账条目(0.12.1 起的设计)——台账查无记录时,
344
317
  // 按挂载来源直接支持去重:该 id 被 ≥2 个 bundle 挂载即视为重复挂载目标。
345
- const candidates = opts.profile
346
- ? [opts.profile]
347
- : (() => {
348
- try {
349
- return readdirSync(join(dshHome(), 'profiles'), { withFileTypes: true })
350
- .filter((e) => e.isDirectory())
351
- .map((e) => e.name)
352
- } catch {
353
- return []
354
- }
355
- })()
356
- for (const p of candidates) {
357
- if (bundleMountSources(collectKnownRows(p), opts.id).length >= 2) {
358
- const rows = collectKnownRows(p).rows.filter((r) => r.id === opts.id)
359
- matches.push({
360
- profile: p,
361
- entry: { id: opts.id, name: rows[0]?.name ?? null, reason: 'duplicate loader entry id' },
362
- })
363
- break
364
- }
365
- }
318
+ // 不指定 --profile 时扫全部 profile 目录(与修复目标跨 profile 的语义一致)。
319
+ const dup = findUnledgeredDuplicates([], opts.profile, { allProfiles: true }).find((m) => m.entry.id === opts.id)
320
+ if (dup) matches.push(dup)
366
321
  }
367
322
  if (!matches.length) {
368
323
  log(t('repairEntryMissing', { id: opts.id }))
@@ -388,85 +343,3 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
388
343
  const { code } = await repairEntry({ profile, entry, to: opts.to, yes: opts.yes || !opts.dryRun ? true : false, tty: true, dryRun: opts.dryRun, spawn, log, write })
389
344
  return code
390
345
  }
391
-
392
- /**
393
- * `-r/--repair [dsh 参数…]`:批量修复全部可修复的隔离记录,再按包装模式
394
- * 启动(与 -u 对称:位置参数全部是 dsh 启动参数)。
395
- * @param {string[]} args
396
- * @param {{
397
- * spawn?: typeof spawnSync,
398
- * boot?: (args: string[]) => Promise<number>,
399
- * log?: (line: string) => void,
400
- * write?: (line: string) => void,
401
- * isTTY?: boolean,
402
- * readStdin?: never,
403
- * }} [hooks]
404
- * @returns {Promise<number>} 退出码
405
- */
406
- export async function cmdRepairAndBoot(args, { boot, spawn = spawnSync, log = err, write = out, isTTY = process.stdin.isTTY } = {}) {
407
- if (args.includes('-h') || args.includes('--help')) {
408
- out(t('helpText', {}))
409
- return 0
410
- }
411
- // 位置参数全部是 dsh 启动参数(修全部可修复记录);前缀旗标为 repair 选项
412
- const opts = { profile: undefined, to: undefined, yes: false, dryRun: false }
413
- let i = 0
414
- for (; i < args.length; i++) {
415
- const a = args[i]
416
- if (a === '-y' || a === '--yes') opts.yes = true
417
- else if (a === '--dry-run') opts.dryRun = true
418
- else if (a === '--profile' && args[i + 1] !== undefined) opts.profile = args[++i]
419
- else if (a.startsWith('--profile=')) opts.profile = a.slice('--profile='.length)
420
- else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
421
- else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
422
- else break
423
- }
424
- if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
425
- log(t('updateToInvalid'))
426
- return 2
427
- }
428
- const bootArgs = args.slice(i)
429
-
430
- const ledger = loadLedger()
431
- const targets = []
432
- for (const [profile, entries] of Object.entries(ledger.profiles)) {
433
- if (opts.profile && profile !== opts.profile) continue
434
- for (const entry of entries ?? []) targets.push({ profile, entry })
435
- }
436
-
437
- // 预检:duplicate 失败不产生台账条目——按挂载来源找出未入账的重复挂载,
438
- // 并入修复目标(否则 -r 对这类 profile 空手而归)
439
- const scanProfiles = opts.profile ? [opts.profile] : [...new Set(targets.map((t) => t.profile))]
440
- for (const p of scanProfiles) {
441
- const known = collectKnownRows(p)
442
- const ledgerIds = new Set(targets.filter((t) => t.profile === p).map((t) => t.entry.id))
443
- const byId = new Map()
444
- for (const row of known.rows) {
445
- if (row.source === 'profile' || row.source === 'home') continue
446
- if (!byId.has(row.id)) byId.set(row.id, new Set())
447
- byId.get(row.id).add(row.source)
448
- }
449
- for (const [dupId, srcs] of byId) {
450
- if (srcs.size < 2 || ledgerIds.has(dupId)) continue
451
- const nameRow = known.rows.find((r) => r.id === dupId)
452
- targets.unshift({ profile: p, entry: { id: dupId, name: nameRow?.name ?? null, reason: 'duplicate loader entry id' } })
453
- }
454
- }
455
-
456
- // --profile 同时作用于修复与启动:启动参数未显式给 --profile 时自动补上
457
- const finalBootArgs =
458
- opts.profile && !bootArgs.some((a) => a === '--profile' || a.startsWith('--profile='))
459
- ? ['--profile', opts.profile, ...bootArgs]
460
- : bootArgs
461
-
462
- if (!targets.length) {
463
- log(t('noRecords'))
464
- if (bootArgs.length) return boot(finalBootArgs)
465
- return 0
466
- }
467
-
468
- const { failed } = await repairBatch(targets, { to: opts.to, yes: opts.yes, dryRun: opts.dryRun, spawn, log, write })
469
-
470
- if (bootArgs.length) return boot(finalBootArgs)
471
- return failed ? 1 : 0
472
- }
package/lib/wrap.js CHANGED
@@ -4,8 +4,10 @@
4
4
  * 运行 dsh(stderr 进管道并同步回显),进程退出后:
5
5
  * - 正常退出(0)→ 结束;
6
6
  * - 失败退出 → 从 stderr 解析坏插件,对照 patch 行后把对应行置为 disabled
7
- * (写入托管区块 + 台账),然后重试;识别不出、超过重试上限、或命中
8
- * 第一方插件(@deepseek-ai/*,默认保护)时原样透传退出码。
7
+ * (写入托管区块 + 台账),然后重试;duplicate loader entry id 是禁用行
8
+ * 管不住的硬失败,自动去重冗余来源后重试(去重同样受第一方保护,见
9
+ * dedupe.js);识别不出、超过重试上限、或命中第一方插件(@deepseek-ai/*,
10
+ * 默认保护)时原样透传退出码。
9
11
  *
10
12
  * Windows 上经 resolveDshSpawnTarget 把 dsh 的 .cmd shim 解析成 `node <入口>`
11
13
  * 再启动(见 dshpaths.js),其余平台原样 spawn。
@@ -16,15 +18,13 @@ import { dirname } from 'node:path'
16
18
  import { summarizeLine, parseFailureReport } from './failures.js'
17
19
  import { collectKnownRows, matchFailures } from './knownrows.js'
18
20
  import { detectInvocation, lastFailureFile, resolveDshSpawnTarget } from './dshpaths.js'
19
- import { writeQuarantine } from './quarantine.js'
21
+ import { loadLedger, writeQuarantine } from './quarantine.js'
22
+ import { dedupeMountSources, isFirstParty } from './dedupe.js'
20
23
  import { loadConfig } from './config.js'
21
24
  import { aiEnabled, detectFailureWithAI } from './ai.js'
22
25
  import { t } from './i18n.js'
23
26
 
24
27
  const CAPTURE_LIMIT = 512 * 1024
25
- const FIRST_PARTY_PREFIX = '@deepseek-ai/'
26
-
27
- const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
28
28
 
29
29
  /** 启动失败时把捕获的 stderr 存到 last-failure-<profile>.log(供 explain 默认解读与人工翻阅)。 */
30
30
  function persistFailure(profile, stderr) {
@@ -105,9 +105,26 @@ export async function runWrapped(options) {
105
105
  detect = null,
106
106
  } = options
107
107
  const invocation = detectInvocation(forwardArgs)
108
+ // 台账非空提醒:让"有插件处于自动禁用状态"在每次启动都可见(否则用户
109
+ // 只会感知到"某个功能没了",不知道是隔离造成的、也不知道怎么恢复)
110
+ if (!dryRun && invocation.profile) {
111
+ const count = (loadLedger().profiles[invocation.profile] ?? []).length
112
+ if (count) log(t('ledgerReminder', { count }))
113
+ }
114
+ const quarantined = [] // 本次运行期间被自动禁用的插件(成功后汇总)
108
115
  for (let attempt = 0; ; attempt++) {
109
116
  const { code, stderr } = await spawnFn(forwardArgs)
110
- if (code === 0) return 0
117
+ if (code === 0) {
118
+ if (quarantined.length) {
119
+ log(
120
+ t('bootQuarantineSummary', {
121
+ count: quarantined.length,
122
+ names: quarantined.map((target) => target.name ?? target.id).join(', '),
123
+ }),
124
+ )
125
+ }
126
+ return 0
127
+ }
111
128
  if (invocation.mode === 'plugin') {
112
129
  log(t('pluginPassthrough'))
113
130
  return code
@@ -119,10 +136,27 @@ export async function runWrapped(options) {
119
136
  if (!dryRun) persistFailure(invocation.profile, stderr)
120
137
  // 重复挂载(同一 id 被多个 bundle/行挂载)发生在 include 挂载阶段,
121
138
  // 早于 profile 层 disabled 覆盖的合并——禁用行管不住它,隔离无效。
122
- // 给出一键修复指引(repair 会移除冗余来源并恢复)并透传。
139
+ // 自动去重(交互选来源 / 非交互保留先声明者)后重试;识别不出可去重
140
+ // 来源(扫描器盲区)、dry-run 或超过重试上限时给一键修复指引并透传。
123
141
  if (stderr.includes('duplicate loader entry id')) {
124
142
  const m = /duplicate loader entry id: ([\w:\-]+)/.exec(stderr)
125
- log(t('repairDuplicate', { id: m ? m[1] : '' }))
143
+ const dupId = m ? m[1] : ''
144
+ let fixed = null
145
+ if (!dryRun && attempt < maxRetries) {
146
+ const res = await dedupeMountSources(invocation.profile, dupId, {
147
+ tty: process.stdin.isTTY,
148
+ log,
149
+ allowFirstParty,
150
+ })
151
+ if (!('error' in res)) fixed = res
152
+ }
153
+ if (fixed) {
154
+ log(t('repairDedupeDone', { keep: fixed.kept, removed: fixed.removed.join(', ') }))
155
+ log(t('retrying'))
156
+ continue
157
+ }
158
+ if (!dryRun && attempt >= maxRetries) log(t('maxRetriesReached', { count: maxRetries }))
159
+ log(t('repairDuplicate', { id: dupId }))
126
160
  log(t('explainHint'))
127
161
  return code
128
162
  }
@@ -167,6 +201,7 @@ export async function runWrapped(options) {
167
201
  file: hit.file,
168
202
  }))
169
203
  writeQuarantine(invocation.profile, targets, dryRun)
204
+ quarantined.push(...targets)
170
205
  for (const target of targets) {
171
206
  const verb = t(dryRun ? 'willDisable' : 'disabled')
172
207
  log(`[dsh-safe] ${verb} ${target.name ?? target.id} (id: ${target.id}) → ${target.file}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",