@hyzyn/dsh-safe 0.4.0 → 0.6.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
@@ -44,8 +44,11 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
44
44
  | `dsh-safe <dsh args…>` | wrap and run dsh (swap `dsh` for `dsh-safe`) |
45
45
  | `dsh-safe -u [update options] [dsh args…]` | upgrade dsh and dsh-safe itself first (skip if latest), then boot in wrap mode; `--update` is an alias |
46
46
  | `dsh-safe update [options]` | upgrade only, no boot — options below |
47
- | `dsh-safe list [--profile <name>]` | show quarantined plugins (defaults to all profiles) |
48
- | `dsh-safe restore --profile <name> (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (after a fixed plugin upgrade) |
47
+ | `dsh-safe list [--profile <name>] [--json]` | show quarantined plugins (`--json` outputs structured JSON; defaults to all profiles) |
48
+ | `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health |
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
+ | `dsh-safe explain [--file <path>]` | interpret a failed-boot stderr with AI (read-only, needs `DSH_SAFE_AI_KEY`) |
51
+ | `dsh-safe repair <id> [--profile <name>] [--to <version>] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only; installs via `dsh plugin`'s pnpm channel) |
49
52
  | `dsh-safe help` (`-h` / `--help`) | show help |
50
53
  | `dsh-safe --version` (`-V`) | show version |
51
54
 
@@ -58,6 +61,7 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
58
61
  | `--dry-run` | Parse and report only; no files are modified |
59
62
  | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
60
63
  | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
64
+ | `--exclude <id-or-pkg>` | Quarantine exclusion list (repeatable) — matched rows are never auto-disabled; can also live in `config.json` |
61
65
 
62
66
  ### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply)
63
67
 
@@ -77,6 +81,10 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
77
81
  | `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
78
82
  | `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the at-most-daily dsh-safe new-version notice on boot |
79
83
  | `DSH_HOME` | dsh home directory (dsh's own variable; the quarantine ledger and patch paths follow it) |
84
+ | `DSH_SAFE_AI_KEY` | AI feature key (unset = AI disabled entirely); defaults to DeepSeek |
85
+ | `DSH_SAFE_AI_BASE_URL` | AI endpoint (OpenAI-compatible), default `https://api.deepseek.com` |
86
+ | `DSH_SAFE_AI_MODEL` | AI model, default `deepseek-chat` |
87
+ | `DSH_SAFE_AI_RECOVER=1` | enable AI fallback when regex signatures can't identify the broken plugin (results go through the same quarantine pipeline) |
80
88
 
81
89
  How upgrading works: `dsh-safe update` auto-detects the dsh package name and install method (npm / pnpm global installs), compares against the latest version and runs the upgrade for you, then automatically restores all quarantined plugins — any still incompatible under the new dsh will be auto-quarantined again on the next start. For daily use, just make `dsh-safe -u web` your start command: boots immediately when dsh is already latest (one version check), upgrades + restores first when an update is available, and only warns (still boots) if the update check itself fails. `-u` accepts update options (e.g. `-u -y web`) and wrapper flags (e.g. `-u --max-retries 0 web`).
82
90
 
@@ -87,6 +95,14 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
87
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.
88
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`).
89
97
 
98
+ ### AI Capabilities (optional)
99
+
100
+ Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible — swap providers via `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL`):
101
+
102
+ - **`dsh-safe explain [--file <path>]`**: feed it a failed-boot stderr (stdin or file) and get a plain-language interpretation plus fix suggestions. Strictly read-only.
103
+ - **AI fallback identification** (`DSH_SAFE_AI_RECOVER=1`): when the regex signatures can't identify the broken plugin (e.g. after a dsh upgrade changes formats), the AI picks the culprit from the stderr — **its output must pass the exact same validation pipeline** (match against real patch rows, first-party protection, dry-run preview); unmatched picks are passed through as before. Only invoked on startup failure.
104
+ - Privacy: home paths are redacted to `~` before sending; any AI failure degrades silently.
105
+
90
106
  ## Safety Boundaries
91
107
 
92
108
  - **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.
package/README.md CHANGED
@@ -44,8 +44,11 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
44
44
  | `dsh-safe <dsh 参数…>` | 包装运行 dsh(把平时的 `dsh` 换成 `dsh-safe`) |
45
45
  | `dsh-safe -u [update 选项] [dsh 参数…]` | 先升级 dsh 与 dsh-safe 自身(已最新则跳过),再按包装模式启动;`--update` 等价 |
46
46
  | `dsh-safe update [选项]` | 只升级不启动,选项见下 |
47
- | `dsh-safe list [--profile <名>]` | 查看隔离名单(缺省列出全部 profile) |
48
- | `dsh-safe restore --profile <名> (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(升级修复后使用) |
47
+ | `dsh-safe list [--profile <名>] [--json]` | 查看隔离名单(`--json` 输出结构化 JSON,缺省全部 profile) |
48
+ | `dsh-safe doctor` | 环境体检:版本、DSH_HOME、profiles、台账、各 patch 健康度 |
49
+ | `dsh-safe restore [--profile <名>] (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(省略 `--profile` 时遍历台账全部 profile) |
50
+ | `dsh-safe explain [--file <路径>]` | 用 AI 解读一段启动失败 stderr(纯只读,需 `DSH_SAFE_AI_KEY`) |
51
+ | `dsh-safe repair <id> [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限模块解析失败类;经 `dsh plugin` 的 pnpm 通道安装) |
49
52
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
50
53
  | `dsh-safe --version`(`-V`) | 显示版本 |
51
54
 
@@ -58,6 +61,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
58
61
  | `--dry-run` | 只解析与报告,不修改任何文件 |
59
62
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
60
63
  | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
64
+ | `--exclude <id或包名>` | 隔离豁免名单(可重复),命中的行永不自动禁用;也可写进 `config.json` |
61
65
 
62
66
  ### update / -u 选项(写在 `-u` 或 `update` 之后;其前的包装旗标照常生效)
63
67
 
@@ -77,6 +81,10 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
77
81
  | `DSH_SAFE_LANG=zh\|en` | 强制提示信息语言(缺省跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
78
82
  | `DSH_SAFE_NO_UPDATE_CHECK=1` | 关闭启动时每天最多一次的 dsh-safe 新版提示 |
79
83
  | `DSH_HOME` | dsh 的 home 目录(dsh 自己的环境变量;隔离台账与各 patch 路径随之) |
84
+ | `DSH_SAFE_AI_KEY` | AI 功能 key(未设置 = AI 整体禁用);默认对接 DeepSeek |
85
+ | `DSH_SAFE_AI_BASE_URL` | AI 接口地址(OpenAI 兼容),默认 `https://api.deepseek.com` |
86
+ | `DSH_SAFE_AI_MODEL` | AI 模型,默认 `deepseek-chat` |
87
+ | `DSH_SAFE_AI_RECOVER=1` | 正则识别不出坏插件时启用 AI 兜底(结果仍走同一隔离管线) |
80
88
 
81
89
  升级行为:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
82
90
 
@@ -87,6 +95,14 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
87
95
  3. **写入托管区块**:在对应 patch 文件末尾追加带标记注释的区块(与 `dsh-mcp-config managed` 同款约定),把命中的行置为 `disabled: true`。用户已有内容与注释原样保留;全新 profile 的 `[]` 模板会被正确替换成块序列。
88
96
  4. **台账与恢复**:隔离记录存 `$DSH_HOME/dsh-safe/quarantine.json`。插件升级修复后用 `dsh-safe restore --profile web --all` 摘除区块恢复挂载(`patchReload: live` 的 profile 热生效)。
89
97
 
98
+ ### AI 能力(可选)
99
+
100
+ 设置 `DSH_SAFE_AI_KEY` 后启用(默认对接 DeepSeek,OpenAI 兼容接口,可用 `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL` 换任何兼容服务):
101
+
102
+ - **`dsh-safe explain [--file <路径>]`**:读一段启动失败的 stderr(stdin 或文件),输出人话解读与修复建议。纯只读,不碰任何文件。
103
+ - **AI 兜底识别**(`DSH_SAFE_AI_RECOVER=1`):正则特征识别不出坏插件时(如 dsh 升级换格式),让 AI 从 stderr 里挑元凶——**结果必须仍走同一验证管线**(对照真实 patch 行、第一方保护、dry-run 预览),命中不了照旧透传。仅在启动失败时调用。
104
+ - 隐私:发送前 home 路径脱敏为 `~`;AI 任何失败都静默降级。
105
+
90
106
  ## 安全边界
91
107
 
92
108
  - **第一方保护**:`@deepseek-ai/*` 的行默认跳过(禁用 `dsh-web-app` 这类插件会让 dsh 失去核心能力),需要 `--allow-first-party` 才会动。
package/lib/ai.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — AI 能力(可选,零依赖,Node 20 全局 fetch)。
3
+ *
4
+ * 走 OpenAI 兼容的 chat completions 接口,默认指向 DeepSeek:
5
+ * DSH_SAFE_AI_KEY API key(未设置 = AI 能力整体禁用)
6
+ * DSH_SAFE_AI_BASE_URL 默认 https://api.deepseek.com
7
+ * DSH_SAFE_AI_MODEL 默认 deepseek-chat
8
+ *
9
+ * 原则:任何失败(无 key / 网络 / 超时 / 响应异常)都静默返回 null,绝不
10
+ * 影响主流程;模型只是"多一个识别器/解释器",不获得任何写权限——兜底识别
11
+ * 的输出必须由调用方经 matchFailures 对照真实 patch 行后才生效。
12
+ * 发送前脱敏:用户 home 目录路径替换为 ~。
13
+ */
14
+ import { homedir } from 'node:os'
15
+ import { getLocale } from './i18n.js'
16
+
17
+ export const aiEnabled = () => Boolean(process.env.DSH_SAFE_AI_KEY)
18
+
19
+ /** 发送前脱敏:home 绝对路径 → ~。 */
20
+ export function redact(text) {
21
+ return String(text ?? '').split(homedir()).join('~')
22
+ }
23
+
24
+ /** OpenAI 兼容 chat completions;任何失败返回 null。 */
25
+ async function chat(messages, { timeoutMs = 30_000 } = {}) {
26
+ const key = process.env.DSH_SAFE_AI_KEY
27
+ if (!key) return null
28
+ const base = (process.env.DSH_SAFE_AI_BASE_URL ?? 'https://api.deepseek.com').replace(/\/+$/, '')
29
+ const model = process.env.DSH_SAFE_AI_MODEL ?? 'deepseek-chat'
30
+ const controller = new AbortController()
31
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
32
+ try {
33
+ const res = await fetch(`${base}/chat/completions`, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
36
+ body: JSON.stringify({ model, messages, temperature: 0, stream: false }),
37
+ signal: controller.signal,
38
+ })
39
+ if (!res.ok) return null
40
+ const data = await res.json()
41
+ const content = data?.choices?.[0]?.message?.content
42
+ return typeof content === 'string' && content.trim() ? content.trim() : null
43
+ } catch {
44
+ return null
45
+ } finally {
46
+ clearTimeout(timer)
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 解释一段启动失败的 stderr:失败原因 + 可疑插件 + 修复建议(纯文本)。
52
+ * @param {string} stderr
53
+ * @returns {Promise<string | null>}
54
+ */
55
+ export async function explainFailure(stderr) {
56
+ const lang = getLocale() === 'zh' ? '中文' : 'English'
57
+ const prompt = [
58
+ getLocale() === 'zh'
59
+ ? `以下是一段 dsh(DeepSeek Harness)启动失败时捕获的 stderr。请用中文回答,控制在 200 字内:`
60
+ : `The following stderr was captured from a failed dsh (DeepSeek Harness) boot. Answer in English, within 200 words:`,
61
+ `1) ${getLocale() === 'zh' ? '一句话说明最可能的失败原因;' : 'One sentence on the most likely cause;'}`,
62
+ `2) ${getLocale() === 'zh' ? '指出可疑的插件包名或配置项;' : 'Point out the suspicious plugin package or config;'}`,
63
+ `3) ${getLocale() === 'zh' ? '给出 1-3 条具体修复建议(命令或配置修改)。不要编造不存在的插件。' : 'Give 1-3 concrete fixes (commands or config changes). Do not invent plugins that are not present.'}`,
64
+ '',
65
+ '--- stderr ---',
66
+ redact(stderr),
67
+ ].join('\n')
68
+ return chat([{ role: 'user', content: prompt }])
69
+ }
70
+
71
+ /**
72
+ * AI 兜底识别:正则特征匹配不到坏插件时,让模型从 stderr 里挑出元凶。
73
+ * 只允许在 knownRows(行 id ↔ 包名对照)里选择,降低幻觉;返回结构化
74
+ * 候选 [{ packageName?, entryId?, reason? }],由调用方经 matchFailures
75
+ * 对照真实行后才生效。
76
+ * @param {string} stderr
77
+ * @param {Array<{ id: string, name: string | null }>} knownRows
78
+ * @returns {Promise<Array<{ packageName?: string, entryId?: string, reason?: string }> | null>}
79
+ * null = AI 不可用或调用失败(调用方照旧透传)
80
+ */
81
+ export async function detectFailureWithAI(stderr, knownRows) {
82
+ const choices = knownRows.filter((r) => r.name || r.id).map((r) => ({ id: r.id, name: r.name ?? undefined }))
83
+ if (!choices.length) return []
84
+ const prompt = [
85
+ 'The following stderr is from a failed dsh (DeepSeek Harness) boot. Regex signatures failed to identify the broken plugin.',
86
+ 'From the allowed list below, pick the plugin row(s) most likely responsible. Reply with ONLY a JSON array like [{"packageName":"@scope/name","entryId":"row-id","reason":"short why"}]. Use values from the allowed list verbatim; return [] if none plausibly match.',
87
+ 'Allowed rows (id ↔ name):',
88
+ JSON.stringify(choices),
89
+ '',
90
+ '--- stderr ---',
91
+ redact(stderr),
92
+ ].join('\n')
93
+ const content = await chat([{ role: 'user', content: prompt }])
94
+ if (!content) return null
95
+ const m = /\[[\s\S]*\]/.exec(content)
96
+ if (!m) return []
97
+ try {
98
+ const arr = JSON.parse(m[0])
99
+ if (!Array.isArray(arr)) return []
100
+ return arr.filter(
101
+ (x) => x && typeof x === 'object' && (typeof x.packageName === 'string' || typeof x.entryId === 'string'),
102
+ )
103
+ } catch {
104
+ return []
105
+ }
106
+ }
package/lib/cli.js CHANGED
@@ -8,9 +8,13 @@
8
8
  * dsh-safe help | --version
9
9
  */
10
10
  import { createRequire } from 'node:module'
11
+ import { readFileSync } 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'
15
+ import { cmdRepair } from './repair.js'
16
+ import { printDoctor } from './doctor.js'
17
+ import { aiEnabled, explainFailure } from './ai.js'
14
18
  import { t } from './i18n.js'
15
19
 
16
20
  const require = createRequire(import.meta.url)
@@ -24,6 +28,53 @@ function printVersion() {
24
28
  process.stdout.write(`${version}\n`)
25
29
  }
26
30
 
31
+ /**
32
+ * `dsh-safe explain [--file <path>]`:AI 解读一段启动失败的 stderr。
33
+ * 纯只读——不写任何文件;未配置 DSH_SAFE_AI_KEY 时给出启用指引。
34
+ */
35
+ async function cmdExplain(args) {
36
+ if (args.includes('-h') || args.includes('--help')) {
37
+ printHelp()
38
+ return 0
39
+ }
40
+ let file
41
+ for (let i = 0; i < args.length; i++) {
42
+ if (args[i] === '--file') file = args[++i]
43
+ else if (args[i].startsWith('--file=')) file = args[i].slice('--file='.length)
44
+ }
45
+ if (!aiEnabled()) {
46
+ process.stderr.write(`${t('aiDisabled')}\n`)
47
+ return 1
48
+ }
49
+ let input = ''
50
+ if (file !== undefined) {
51
+ try {
52
+ input = readFileSync(file, 'utf8')
53
+ } catch {
54
+ process.stderr.write(`${t('aiFileUnreadable', { file })}\n`)
55
+ return 2
56
+ }
57
+ } else {
58
+ if (process.stdin.isTTY) process.stderr.write(`${t('explainStdinHint')}\n`)
59
+ try {
60
+ input = readFileSync(0, 'utf8')
61
+ } catch {
62
+ input = ''
63
+ }
64
+ }
65
+ if (!input.trim()) {
66
+ process.stderr.write(`${t('aiNoInput')}\n`)
67
+ return 2
68
+ }
69
+ const answer = await explainFailure(input)
70
+ if (!answer) {
71
+ process.stderr.write(`${t('aiExplainFailed')}\n`)
72
+ return 1
73
+ }
74
+ process.stdout.write(`${answer}\n`)
75
+ return 0
76
+ }
77
+
27
78
  /** 解析 `--profile <名>` / `--profile=<名>`,返回 [值, 剩余参数]。 */
28
79
  function takeProfile(args) {
29
80
  let profile
@@ -58,8 +109,13 @@ function takeIds(args) {
58
109
  }
59
110
 
60
111
  function cmdList(args) {
61
- const [profile] = takeProfile(args)
112
+ const [profile, rest] = takeProfile(args)
62
113
  const ledger = loadLedger()
114
+ if (rest.includes('--json')) {
115
+ const profiles = profile ? { [profile]: ledger.profiles[profile] ?? [] } : ledger.profiles
116
+ process.stdout.write(`${JSON.stringify({ version: ledger.version ?? 1, profiles }, null, 2)}\n`)
117
+ return 0
118
+ }
63
119
  const profiles = profile ? [profile] : Object.keys(ledger.profiles).sort()
64
120
  let found = 0
65
121
  for (const p of profiles) {
@@ -82,21 +138,27 @@ function cmdRestore(args) {
82
138
  const [ids0, rest1] = takeIds(rest0)
83
139
  const dryRun = rest1.includes('--dry-run')
84
140
  const all = rest1.includes('--all')
85
- const profile = profile0
86
- if (!profile) {
87
- process.stderr.write(`${t('restoreNeedsProfile')}\n`)
88
- return 2
89
- }
90
141
  if (!all && ids0.length === 0) {
91
142
  process.stderr.write(`${t('restoreNeedsId')}\n`)
92
143
  return 2
93
144
  }
94
- const { restored, kept } = restoreQuarantine(profile, all ? 'all' : ids0, dryRun)
145
+ // 省略 --profile 时遍历台账里所有有记录的 profile(与 update 的恢复对齐)
146
+ const ledger = loadLedger()
147
+ const profiles = profile0
148
+ ? [profile0]
149
+ : Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
95
150
  const verb = t(dryRun ? 'restoredDry' : 'restored')
96
- for (const e of restored) process.stdout.write(`[dsh-safe] ${verb} ${e.name ?? e.id} (id: ${e.id})\n`)
97
- if (!restored.length) process.stdout.write(`${t('noMatching')}\n`)
98
- else if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile, count: kept.length })}\n`)
99
- else process.stdout.write(`${t('ledgerCleared')}\n`)
151
+ let anyRestored = false
152
+ for (const p of profiles) {
153
+ const { restored, kept } = restoreQuarantine(p, all ? 'all' : ids0, dryRun)
154
+ for (const e of restored) process.stdout.write(`[dsh-safe] ${verb} ${e.name ?? e.id} (id: ${e.id})\n`)
155
+ if (restored.length) {
156
+ anyRestored = true
157
+ if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile: p, count: kept.length })}\n`)
158
+ else process.stdout.write(`${t('ledgerCleared')}\n`)
159
+ }
160
+ }
161
+ if (!anyRestored) process.stdout.write(`${t('noMatching')}\n`)
100
162
  return 0
101
163
  }
102
164
 
@@ -120,7 +182,13 @@ export async function main(argv) {
120
182
  }
121
183
  if (cmd === 'list') return cmdList(argv.slice(1))
122
184
  if (cmd === 'restore') return cmdRestore(argv.slice(1))
185
+ if (cmd === 'repair') return cmdRepair(argv.slice(1))
123
186
  if (cmd === 'update') return cmdUpdate(argv.slice(1))
187
+ if (cmd === 'explain') return cmdExplain(argv.slice(1))
188
+ if (cmd === 'doctor') {
189
+ printDoctor()
190
+ return 0
191
+ }
124
192
  if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
125
193
 
126
194
  // 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
@@ -132,6 +200,7 @@ export async function main(argv) {
132
200
  async function runWrapperMode(argv) {
133
201
  maybeNotifySelfUpdate()
134
202
  const forwardArgs = []
203
+ const excludeArgs = []
135
204
  let dryRun = false
136
205
  let maxRetries = 2
137
206
  let allowFirstParty = false
@@ -140,6 +209,8 @@ async function runWrapperMode(argv) {
140
209
  const a = argv[i]
141
210
  if (a === '--dry-run') { dryRun = true; continue }
142
211
  if (a === '--allow-first-party') { allowFirstParty = true; continue }
212
+ if (a === '--exclude') { const v = argv[++i]; if (v !== undefined) excludeArgs.push(v); continue }
213
+ if (a.startsWith('--exclude=')) { excludeArgs.push(a.slice('--exclude='.length)); continue }
143
214
  if (a === '--max-retries') {
144
215
  const raw = argv[++i]
145
216
  const v = raw === undefined || raw === '' ? NaN : Number(raw)
@@ -168,5 +239,5 @@ async function runWrapperMode(argv) {
168
239
  return 0
169
240
  }
170
241
  if (dryRun) process.stderr.write(`${t('dryRunNotice')}\n`)
171
- return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty })
242
+ return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty, exclude: excludeArgs })
172
243
  }
package/lib/config.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — 用户配置:$DSH_HOME/dsh-safe/config.json(JSON)。
3
+ *
4
+ * 目前只有 exclude(隔离豁免名单:行 id 或插件包名,命中的行永不自动禁用,
5
+ * 适合自己开发中、或明知兼容但偶发报错的插件)。文件损坏时按空配置处理,
6
+ * 绝不影响主流程。
7
+ */
8
+ import { mkdirSync, writeFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { dshHome, readIfExists } from './dshpaths.js'
11
+
12
+ export const configPath = () => join(dshHome(), 'dsh-safe', 'config.json')
13
+
14
+ /** @returns {{ exclude: string[] }} */
15
+ export function loadConfig() {
16
+ const raw = readIfExists(configPath())
17
+ if (raw === undefined) return { exclude: [] }
18
+ try {
19
+ const parsed = JSON.parse(raw)
20
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.exclude)) {
21
+ return { exclude: parsed.exclude.filter((x) => typeof x === 'string') }
22
+ }
23
+ } catch {}
24
+ return { exclude: [] }
25
+ }
26
+
27
+ export function saveConfig(config) {
28
+ const file = configPath()
29
+ mkdirSync(dirname(file), { recursive: true })
30
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`)
31
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — `dsh-safe doctor`:环境体检。
3
+ *
4
+ * 汇总 self/dsh 版本、DSH_HOME、profiles、隔离台账、各 patch 文件的行数与
5
+ * 托管区块状态、AI 开关、更新检查时间。纯只读,不改任何文件。
6
+ */
7
+ import { existsSync, readdirSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { dshHome, homePatchPath, profileDir, readIfExists, updateCheckFile } from './dshpaths.js'
10
+ import { loadLedger } from './quarantine.js'
11
+ import { MANAGED_START, scanPatchRows } from './patchfile.js'
12
+ import { resolveDshPackage, resolveSelfPackage } from './update.js'
13
+ import { aiEnabled } from './ai.js'
14
+ import { t } from './i18n.js'
15
+
16
+ export function printDoctor({ log = (line) => process.stdout.write(`${line}\n`) } = {}) {
17
+ const self = resolveSelfPackage()
18
+ log(t('doctorSelf', { version: self?.version ?? '?' }))
19
+ const dsh = resolveDshPackage()
20
+ log(dsh ? t('doctorDsh', { name: dsh.name, version: dsh.version }) : t('doctorDshMissing'))
21
+ log(t('doctorHome', { home: dshHome() }))
22
+
23
+ const profilesRoot = join(dshHome(), 'profiles')
24
+ let profiles = []
25
+ try {
26
+ if (existsSync(profilesRoot)) {
27
+ profiles = readdirSync(profilesRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort()
28
+ }
29
+ } catch {}
30
+ log(t('doctorProfiles', { profiles: profiles.join(', ') || '-' }))
31
+
32
+ const ledger = loadLedger()
33
+ const entries = Object.values(ledger.profiles).flat()
34
+ if (entries.length) {
35
+ const oldest = entries.map((e) => e.quarantinedAt).filter(Boolean).sort()[0] ?? '-'
36
+ log(t('doctorLedger', { count: entries.length, oldest, profiles: Object.keys(ledger.profiles).join(', ') }))
37
+ } else {
38
+ log(t('doctorLedgerEmpty'))
39
+ }
40
+
41
+ for (const p of profiles) reportPatch(join(profileDir(p), 'cordis.patch.yml'), log)
42
+ reportPatch(homePatchPath(), log)
43
+
44
+ log(aiEnabled() ? t('doctorAIOn', { model: process.env.DSH_SAFE_AI_MODEL ?? 'deepseek-chat' }) : t('doctorAIOff'))
45
+
46
+ let lastCheckAt
47
+ try {
48
+ lastCheckAt = JSON.parse(readIfExists(updateCheckFile()) ?? '{}')?.lastCheckAt
49
+ } catch {}
50
+ log(lastCheckAt ? t('doctorCheckLast', { time: lastCheckAt }) : t('doctorCheckNever'))
51
+ }
52
+
53
+ function reportPatch(path, log) {
54
+ const text = readIfExists(path)
55
+ if (text === undefined) {
56
+ log(t('doctorPatchMissing', { file: path }))
57
+ return
58
+ }
59
+ const rows = scanPatchRows(text)
60
+ const managed = text.includes(MANAGED_START) ? '✓' : '—'
61
+ log(t('doctorPatchOk', { file: path, rows: rows.length, managed }))
62
+ }
package/lib/i18n.js CHANGED
@@ -17,11 +17,16 @@ const ZH = {
17
17
  用法:
18
18
  dsh-safe <dsh 参数…> 包装运行 dsh,例: dsh-safe web
19
19
  dsh-safe -u [<dsh 参数…>] 先升级 dsh(已最新则跳过),再启动
20
- dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
20
+ dsh-safe list [--profile <名>] [--json]
21
+ 查看隔离名单(--json 输出 JSON)
22
+ dsh-safe doctor 环境体检(版本 / 台账 / patch 健康)
21
23
  dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
22
24
  恢复被自动禁用的插件(升级修复后使用)
23
25
  dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
24
26
  升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
27
+ dsh-safe explain [--file <路径>] 用 AI 解读一段启动失败的 stderr(需 DSH_SAFE_AI_KEY)
28
+ dsh-safe repair <id> [--profile <名>] [--to <版本>] [-y] [--dry-run]
29
+ 重装/升级被隔离的插件并自动恢复(限模块解析失败类)
25
30
  dsh-safe help 显示本帮助
26
31
  dsh-safe --version 显示版本
27
32
 
@@ -46,7 +51,6 @@ const ZH = {
46
51
  locationLine: ' 位置: {file}',
47
52
  noRecordsProfile: '{profile}: 没有隔离记录',
48
53
  noRecords: '没有隔离记录',
49
- restoreNeedsProfile: '[dsh-safe] restore 需要 --profile <名>',
50
54
  restoreNeedsId: '[dsh-safe] restore 需要 --id <id>(可重复)或 --all',
51
55
  restored: '已恢复',
52
56
  restoredDry: '(dry-run)将恢复',
@@ -100,6 +104,33 @@ const ZH = {
100
104
  updateUnknownFlag: '[dsh-safe] update 无法识别的参数: {arg}',
101
105
  updatePmInvalid: '[dsh-safe] --pm 只支持 npm 或 pnpm。',
102
106
  updateToInvalid: '[dsh-safe] --to 需要合法的版本号(如 1.2.3 或 1.2.3-rc.1)。',
107
+ aiDisabled: '[dsh-safe] AI 功能未启用:设置环境变量 DSH_SAFE_AI_KEY 后可用(默认对接 DeepSeek,见 README 环境变量表)。',
108
+ aiNoInput: '[dsh-safe] 没有读到任何 stderr 内容:用 --file <路径>,或从 stdin 粘贴后按 Ctrl-D 结束。',
109
+ aiExplainFailed: '[dsh-safe] AI 解读失败(网络或接口异常);未修改任何文件。',
110
+ aiFileUnreadable: '[dsh-safe] 无法读取文件: {file}',
111
+ explainStdinHint: '[dsh-safe] 正在从 stdin 读取:粘贴 stderr 后按 Ctrl-D(EOF)结束;也可以改用 --file <路径>。',
112
+ aiRecovered: '[dsh-safe] AI 兜底识别出 {count} 个可疑坏插件(结果仍走同一隔离管线)',
113
+ excludedByList: '[dsh-safe] 按豁免名单跳过 {label}(不自动禁用)',
114
+ doctorSelf: 'dsh-safe {version}',
115
+ doctorDsh: 'dsh {name} {version}',
116
+ doctorDshMissing: 'dsh 未在 PATH 上找到',
117
+ doctorHome: 'DSH_HOME {home}',
118
+ doctorProfiles: 'profiles {profiles}',
119
+ doctorLedger: '隔离台账 {count} 条(最早 {oldest};profile: {profiles})',
120
+ doctorLedgerEmpty: '隔离台账 无记录',
121
+ doctorPatchOk: 'patch {file}({rows} 行;托管区块 {managed})',
122
+ doctorPatchMissing: 'patch {file}(不存在)',
123
+ doctorAIOn: 'AI 已启用(模型 {model})',
124
+ doctorAIOff: 'AI 未启用(DSH_SAFE_AI_KEY 未设置)',
125
+ doctorCheckLast: '更新检查 上次 {time}',
126
+ doctorCheckNever: '更新检查 从未执行',
127
+ repairIdRequired: '[dsh-safe] 需要提供要修复的隔离行 id(dsh-safe list 可查)。',
128
+ repairEntryMissing: '[dsh-safe] 台账里没有找到 {id} 对应的隔离记录。',
129
+ repairAmbiguous: '[dsh-safe] {id} 在多个 profile 中都有隔离记录({profiles}),请用 --profile <名> 指定。',
130
+ repairUnsupported: '[dsh-safe] 该失败类型不支持自动修复(仅限模块解析失败类)。原因: {reason}\n 可用 dsh-safe explain 查看解读。',
131
+ repairPlan: '[dsh-safe] 将在 profile {profile} 中修复 {name}:执行 {command},成功后自动摘除隔离行',
132
+ repairInstallFailed: '[dsh-safe] 插件安装失败(退出码 {code}),隔离状态保持不变。',
133
+ repairDone: '[dsh-safe] 修复完成:跑 dsh-safe <启动命令> 验证;若仍失败会被自动重新隔离。',
103
134
  }
104
135
 
105
136
  const EN = {
@@ -114,11 +145,16 @@ ledger), and retries automatically.
114
145
  Usage:
115
146
  dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
116
147
  dsh-safe -u [<dsh args…>] upgrade dsh first (skip if latest), then boot
117
- dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
148
+ dsh-safe list [--profile <name>] [--json]
149
+ show quarantined plugins (--json outputs JSON)
150
+ dsh-safe doctor environment check (versions / ledger / patch health)
118
151
  dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
119
152
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
120
153
  dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
121
154
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
155
+ dsh-safe explain [--file <path>] interpret a failed-boot stderr with AI (needs DSH_SAFE_AI_KEY)
156
+ dsh-safe repair <id> [--profile <name>] [--to <ver>] [-y] [--dry-run]
157
+ reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only)
122
158
  dsh-safe help show this help
123
159
  dsh-safe --version show version
124
160
 
@@ -145,7 +181,6 @@ Notes:
145
181
  locationLine: ' location: {file}',
146
182
  noRecordsProfile: '{profile}: no quarantine records',
147
183
  noRecords: 'no quarantine records',
148
- restoreNeedsProfile: '[dsh-safe] restore requires --profile <name>',
149
184
  restoreNeedsId: '[dsh-safe] restore requires --id <id> (repeatable) or --all',
150
185
  restored: 'restored',
151
186
  restoredDry: '(dry-run) would restore',
@@ -200,6 +235,33 @@ Notes:
200
235
  updateUnknownFlag: '[dsh-safe] unrecognized argument for update: {arg}',
201
236
  updatePmInvalid: '[dsh-safe] --pm only accepts npm or pnpm.',
202
237
  updateToInvalid: '[dsh-safe] --to requires a valid version (e.g. 1.2.3 or 1.2.3-rc.1).',
238
+ aiDisabled: '[dsh-safe] AI is not enabled: set the DSH_SAFE_AI_KEY environment variable (defaults to DeepSeek, see the env table in the README).',
239
+ aiNoInput: '[dsh-safe] no stderr content was read: use --file <path>, or paste to stdin and press Ctrl-D.',
240
+ aiExplainFailed: '[dsh-safe] AI interpretation failed (network or API error); no files were modified.',
241
+ aiFileUnreadable: '[dsh-safe] cannot read file: {file}',
242
+ explainStdinHint: '[dsh-safe] reading from stdin: paste the stderr and press Ctrl-D (EOF) to finish, or use --file <path>.',
243
+ aiRecovered: '[dsh-safe] AI fallback identified {count} suspected broken plugin(s); the same quarantine pipeline applies',
244
+ excludedByList: '[dsh-safe] skipped {label} per the exclusion list (never auto-disabled)',
245
+ doctorSelf: 'dsh-safe {version}',
246
+ doctorDsh: 'dsh {name} {version}',
247
+ doctorDshMissing: 'dsh not found on PATH',
248
+ doctorHome: 'DSH_HOME {home}',
249
+ doctorProfiles: 'profiles {profiles}',
250
+ doctorLedger: 'ledger {count} quarantined (oldest {oldest}; profiles: {profiles})',
251
+ doctorLedgerEmpty: 'ledger empty',
252
+ doctorPatchOk: 'patch {file} ({rows} rows; managed block {managed})',
253
+ doctorPatchMissing: 'patch {file} (missing)',
254
+ doctorAIOn: 'AI enabled (model {model})',
255
+ doctorAIOff: 'AI disabled (DSH_SAFE_AI_KEY not set)',
256
+ doctorCheckLast: 'update-check last ran at {time}',
257
+ doctorCheckNever: 'update-check never ran',
258
+ repairIdRequired: '[dsh-safe] an isolated row id is required (see dsh-safe list).',
259
+ repairEntryMissing: '[dsh-safe] no quarantine record found for {id}.',
260
+ repairAmbiguous: '[dsh-safe] {id} is quarantined in multiple profiles ({profiles}); specify one with --profile <name>.',
261
+ repairUnsupported: '[dsh-safe] this failure type cannot be auto-repaired (module-resolution failures only). Reason: {reason}\n try dsh-safe explain for an interpretation.',
262
+ repairPlan: '[dsh-safe] repairing {name} in profile {profile}: running {command}, then the quarantine row is removed automatically',
263
+ repairInstallFailed: '[dsh-safe] plugin install failed (exit code {code}); quarantine state left unchanged.',
264
+ repairDone: '[dsh-safe] repair finished: run dsh-safe <boot command> to verify; a still-broken plugin will be auto-quarantined again.',
203
265
  }
204
266
 
205
267
  const CATALOG = { zh: ZH, en: EN }
package/lib/repair.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — `dsh-safe repair <id>`:半自动修复被隔离的插件。
3
+ *
4
+ * 只处理"模块解析失败"类(Cannot find package / could not be resolved /
5
+ * ERR_MODULE_NOT_FOUND)——这类问题重装/升级插件包即可修复。其它类型
6
+ * (apply 抛错、pending 服务等待等)属于代码或配置问题,明确拒接。
7
+ *
8
+ * 流程:台账定位条目 → 类别门禁 → 展示计划 → 确认 → 经 `dsh plugin add`
9
+ * (dsh 官方的 pnpm 转发通道)安装 → 自动摘除隔离行。不内置启动:装好后
10
+ * 由用户跑 dsh-safe 验证,再失败会自动回到隔离管线。安装失败则保持隔离
11
+ * 状态不变,退出码透传。
12
+ */
13
+ import { spawnSync } from 'node:child_process'
14
+ import { createInterface } from 'node:readline/promises'
15
+ import { loadLedger, restoreQuarantine } from './quarantine.js'
16
+ import { summarizeLine } from './failures.js'
17
+ import { resolveDshSpawnTarget } from './dshpaths.js'
18
+ import { t } from './i18n.js'
19
+
20
+ const err = (line) => process.stderr.write(`${line}\n`)
21
+ const out = (line) => process.stdout.write(`${line}\n`)
22
+
23
+ /** 台账 reason 里判定"模块解析失败"的特征。 */
24
+ const RESOLVE_FAIL_RE = /Cannot find package|could not be resolved|ERR_MODULE_NOT_FOUND/i
25
+
26
+ function parseRepairArgs(args) {
27
+ const opts = { id: undefined, profile: undefined, to: undefined, yes: false, dryRun: false }
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i]
30
+ if (a === '-y' || a === '--yes') opts.yes = true
31
+ else if (a === '--dry-run') opts.dryRun = true
32
+ else if (a === '--profile') {
33
+ const v = args[++i]
34
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--profile' } } }
35
+ opts.profile = v
36
+ } else if (a.startsWith('--profile=')) opts.profile = a.slice('--profile='.length)
37
+ else if (a === '--to') {
38
+ const v = args[++i]
39
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--to' } } }
40
+ opts.to = v
41
+ } else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
42
+ else if (!a.startsWith('-') && opts.id === undefined) opts.id = a
43
+ else return { error: { key: 'updateUnknownFlag', params: { arg: a } } }
44
+ }
45
+ if (opts.id === undefined) return { error: { key: 'repairIdRequired' } }
46
+ if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) return { error: { key: 'updateToInvalid' } }
47
+ return { opts }
48
+ }
49
+
50
+ /**
51
+ * @param {string[]} args
52
+ * @param {{
53
+ * spawn?: typeof spawnSync,
54
+ * log?: (line: string) => void,
55
+ * write?: (line: string) => void,
56
+ * }} [hooks] spawn/log/write 可注入(测试)
57
+ * @returns {Promise<number>} 退出码
58
+ */
59
+ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = out } = {}) {
60
+ if (args.includes('-h') || args.includes('--help')) {
61
+ out(t('helpText', {}))
62
+ return 0
63
+ }
64
+ const { opts, error } = parseRepairArgs(args)
65
+ if (error) {
66
+ log(t(error.key, error.params))
67
+ return 2
68
+ }
69
+
70
+ // 台账定位:省略 --profile 时跨 profile 搜索
71
+ const ledger = loadLedger()
72
+ const matches = []
73
+ for (const [profile, entries] of Object.entries(ledger.profiles)) {
74
+ if (opts.profile && profile !== opts.profile) continue
75
+ for (const entry of entries ?? []) {
76
+ if (entry.id === opts.id) matches.push({ profile, entry })
77
+ }
78
+ }
79
+ if (!matches.length) {
80
+ log(t('repairEntryMissing', { id: opts.id }))
81
+ return 1
82
+ }
83
+ if (matches.length > 1) {
84
+ log(t('repairAmbiguous', { id: opts.id, profiles: matches.map((m) => m.profile).join(', ') }))
85
+ return 2
86
+ }
87
+ const { profile, entry } = matches[0]
88
+ const name = entry.name ?? entry.id
89
+
90
+ // 类别门禁:只修"模块解析失败"类
91
+ if (!RESOLVE_FAIL_RE.test(entry.reason ?? '')) {
92
+ log(t('repairUnsupported', { reason: summarizeLine(entry.reason) }))
93
+ return 1
94
+ }
95
+
96
+ const target = opts.to ?? 'latest'
97
+ const spec = `${name}@${target}`
98
+ const spawnTarget = resolveDshSpawnTarget('dsh')
99
+ const commandDesc = `dsh plugin --profile ${profile} add ${spec}`
100
+ log(t('repairPlan', { profile, name, command: commandDesc }))
101
+ if (opts.dryRun) {
102
+ log(t('dryRunNotice'))
103
+ return 0
104
+ }
105
+ if (!opts.yes) {
106
+ if (!process.stdin.isTTY) {
107
+ log(t('updateNonInteractive'))
108
+ return 1
109
+ }
110
+ const ok = await askConfirm(t('updateConfirm'))
111
+ if (!ok) {
112
+ log(t('updateAborted'))
113
+ return 0
114
+ }
115
+ }
116
+
117
+ const { status } = spawn(spawnTarget.file, [...spawnTarget.prefix, 'plugin', '--profile', profile, 'add', spec], {
118
+ stdio: 'inherit',
119
+ shell: spawnTarget.shell,
120
+ env: process.env,
121
+ })
122
+ if (status !== 0) {
123
+ log(t('repairInstallFailed', { code: status ?? '?' }))
124
+ return status ?? 1
125
+ }
126
+
127
+ const { restored } = restoreQuarantine(profile, [opts.id], false)
128
+ if (!restored.length) {
129
+ log(t('noMatching'))
130
+ } else {
131
+ const e = restored[0]
132
+ write(`[dsh-safe] ${t('restored')} ${e.name ?? e.id} (id: ${e.id})`)
133
+ }
134
+ log(t('repairDone'))
135
+ return 0
136
+ }
137
+
138
+ function askConfirm(question) {
139
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
140
+ return rl.question(question).then(
141
+ (answer) => {
142
+ rl.close()
143
+ const a = answer.trim().toLowerCase()
144
+ return a === 'y' || a === 'yes'
145
+ },
146
+ () => {
147
+ rl.close()
148
+ return false
149
+ },
150
+ )
151
+ }
package/lib/wrap.js CHANGED
@@ -15,6 +15,8 @@ import { summarizeLine, parseFailureReport } from './failures.js'
15
15
  import { collectKnownRows, matchFailures } from './knownrows.js'
16
16
  import { detectInvocation, resolveDshSpawnTarget } from './dshpaths.js'
17
17
  import { writeQuarantine } from './quarantine.js'
18
+ import { loadConfig } from './config.js'
19
+ import { aiEnabled, detectFailureWithAI } from './ai.js'
18
20
  import { t } from './i18n.js'
19
21
 
20
22
  const CAPTURE_LIMIT = 512 * 1024
@@ -22,6 +24,26 @@ const FIRST_PARTY_PREFIX = '@deepseek-ai/'
22
24
 
23
25
  const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
24
26
 
27
+ const aiRecoverEnabled = () => process.env.DSH_SAFE_AI_RECOVER === '1' && aiEnabled()
28
+
29
+ /**
30
+ * AI 兜底默认实现:让模型从 stderr 里挑元凶,输出被转换成一份"报告"再走
31
+ * matchFailures——与正则识别完全相同的对照管线(真实行校验、第一方保护、
32
+ * disabled 跳过),命中不了就照旧透传,模型没有任何写权限。
33
+ */
34
+ function picksToHits(picks, known, stderr, log) {
35
+ if (!picks?.length) return []
36
+ const fallbackReason = summarizeLine(stderr)
37
+ const names = picks.filter((p) => p.packageName).map((p) => [p.packageName, p.reason ?? fallbackReason])
38
+ const entryIds = picks.filter((p) => p.entryId).map((p) => [p.entryId, p.reason ?? fallbackReason])
39
+ const hits = matchFailures({ names, entryIds }, known)
40
+ if (hits.length) log(t('aiRecovered', { count: hits.length }))
41
+ return hits
42
+ }
43
+
44
+ const defaultDetect = (stderr, known) =>
45
+ detectFailureWithAI(stderr, known.rows.map(({ id, name }) => ({ id, name })))
46
+
25
47
  /** shell 方式兜底时给含空白的参数补引号(正常路径不走 shell,不受影响)。 */
26
48
  const quoteShellArg = (a) => (/\s/.test(a) && !/^".*"$/.test(a) ? `"${a}"` : a)
27
49
 
@@ -55,6 +77,7 @@ export function spawnDsh(args, { command = 'dsh' } = {}) {
55
77
  * allowFirstParty?: boolean,
56
78
  * log?: (...args: any[]) => void,
57
79
  * spawn?: typeof spawnDsh,
80
+ * detect?: (stderr: string, known: object, log: (line: string) => void) => Promise<Array<{ packageName?: string, entryId?: string, reason?: string }>>,
58
81
  * }} options
59
82
  * @returns {Promise<number>} 最终退出码
60
83
  */
@@ -64,8 +87,10 @@ export async function runWrapped(options) {
64
87
  dryRun = false,
65
88
  maxRetries = 2,
66
89
  allowFirstParty = false,
90
+ exclude = [],
67
91
  log = (line) => process.stderr.write(`${line}\n`),
68
92
  spawn: spawnFn = spawnDsh,
93
+ detect = null,
69
94
  } = options
70
95
  const invocation = detectInvocation(forwardArgs)
71
96
  for (let attempt = 0; ; attempt++) {
@@ -81,12 +106,22 @@ export async function runWrapped(options) {
81
106
  }
82
107
  const known = collectKnownRows(invocation.profile)
83
108
  const report = parseFailureReport(stderr)
84
- const hits = matchFailures(report, known)
109
+ let hits = matchFailures(report, known)
110
+ const detectFn = detect ?? (aiRecoverEnabled() ? defaultDetect : null)
111
+ if (!hits.length && detectFn) {
112
+ // detect 返回 AI 候选(picks),一律经 picksToHits 对照真实行后才成为 hits
113
+ const picks = await detectFn(stderr, known, log)
114
+ hits = picksToHits(picks, known, stderr, log)
115
+ }
85
116
  const quarantinable = []
86
117
  const firstParty = []
118
+ // 豁免名单:config.json 的 exclude + 包装旗标 --exclude(行 id 或包名),永不自动禁用
119
+ const excluded = new Set([...loadConfig().exclude, ...exclude])
120
+ const isExcluded = (hit) => excluded.has(hit.id) || (hit.name != null && excluded.has(hit.name))
87
121
  for (const hit of hits) {
88
122
  if (hit.disabled) continue // 已经是禁用状态
89
123
  if (isFirstParty(hit.name) && !allowFirstParty) firstParty.push(hit)
124
+ else if (isExcluded(hit)) log(t('excludedByList', { label: hit.name ?? hit.id }))
90
125
  else quarantinable.push(hit)
91
126
  }
92
127
  for (const hit of firstParty) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",