@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 +22 -4
- package/README.md +21 -4
- package/bin/dsh-safe.js +0 -0
- package/lib/cli.js +58 -8
- package/lib/dedupe.js +139 -0
- package/lib/dshpaths.js +12 -1
- package/lib/i18n.js +24 -6
- package/lib/knownrows.js +7 -1
- package/lib/patchfile.js +15 -0
- package/lib/repair.js +84 -211
- package/lib/wrap.js +44 -9
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
93
|
+
for (const p of scanProfiles) {
|
|
74
94
|
const entries = ledger.profiles[p] ?? []
|
|
75
|
-
|
|
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
|
|
123
|
+
// 省略 --profile 时:--all 遍历台账 profile 与全部 profile 目录(孤儿托管
|
|
124
|
+
// 区块可能存在于任何 profile——克隆产物台账里没有),否则只遍历台账里有
|
|
125
|
+
// 记录的 profile(与 update 的恢复对齐)
|
|
98
126
|
const ledger = loadLedger()
|
|
99
127
|
const profiles = profile0
|
|
100
128
|
? [profile0]
|
|
101
|
-
:
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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: ' (
|
|
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
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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`
|
|
22
|
-
*
|
|
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 {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
100
|
-
if (
|
|
101
|
-
log(t('
|
|
102
|
-
|
|
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:
|
|
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
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
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)
|
|
286
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
*
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
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}`)
|