@hyzyn/dsh-safe 0.3.2 → 0.5.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,10 @@ 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`) |
49
51
  | `dsh-safe help` (`-h` / `--help`) | show help |
50
52
  | `dsh-safe --version` (`-V`) | show version |
51
53
 
@@ -58,6 +60,7 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
58
60
  | `--dry-run` | Parse and report only; no files are modified |
59
61
  | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
60
62
  | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
63
+ | `--exclude <id-or-pkg>` | Quarantine exclusion list (repeatable) — matched rows are never auto-disabled; can also live in `config.json` |
61
64
 
62
65
  ### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply)
63
66
 
@@ -67,6 +70,7 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
67
70
  | `--to <version>` | Target dsh version, also how you roll back (explicit downgrades allowed); dsh-safe itself always upgrades to the latest |
68
71
  | `--self` | Update dsh-safe itself only; dsh and quarantine state untouched |
69
72
  | `--no-restore` | Do not auto-restore quarantined plugins after upgrading dsh |
73
+ | `--no-verify` | Skip the post-upgrade parser self-check (boots the new dsh with a throwaway profile to confirm error recognition still works) |
70
74
  | `--pm <npm\|pnpm>` | Force the package manager (auto-detected by default) |
71
75
 
72
76
  ### Environment variables
@@ -76,6 +80,10 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
76
80
  | `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
77
81
  | `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the at-most-daily dsh-safe new-version notice on boot |
78
82
  | `DSH_HOME` | dsh home directory (dsh's own variable; the quarantine ledger and patch paths follow it) |
83
+ | `DSH_SAFE_AI_KEY` | AI feature key (unset = AI disabled entirely); defaults to DeepSeek |
84
+ | `DSH_SAFE_AI_BASE_URL` | AI endpoint (OpenAI-compatible), default `https://api.deepseek.com` |
85
+ | `DSH_SAFE_AI_MODEL` | AI model, default `deepseek-chat` |
86
+ | `DSH_SAFE_AI_RECOVER=1` | enable AI fallback when regex signatures can't identify the broken plugin (results go through the same quarantine pipeline) |
79
87
 
80
88
  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`).
81
89
 
@@ -86,6 +94,14 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
86
94
  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.
87
95
  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`).
88
96
 
97
+ ### AI Capabilities (optional)
98
+
99
+ Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible — swap providers via `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL`):
100
+
101
+ - **`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.
102
+ - **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.
103
+ - Privacy: home paths are redacted to `~` before sending; any AI failure degrades silently.
104
+
89
105
  ## Safety Boundaries
90
106
 
91
107
  - **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.
@@ -98,7 +114,7 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
98
114
  - If the patch file itself fails YAML parsing (e.g. broken by hand-editing), plugins cannot be identified and the failure is passed through.
99
115
  - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned).
100
116
  - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected.
101
- - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser.
117
+ - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser. Mitigation: after update/-u upgrades dsh it runs a parser self-check — boots the new dsh with a throwaway profile and confirms failures are still recognized, warning right away on mismatch (`--no-verify` skips it).
102
118
  - Windows is best-effort: update / --self / list / restore are adapted (.cmd shim parsing, shelled npm/pnpm invocations); the wrapped boot resolves the node entry embedded in dsh's .cmd/.ps1 shim on PATH and spawns `node <entry>` directly (.exe runs as-is, unparseable shims fall back to a shelled spawn), sidestepping Node's ban on spawning .cmd files. Not yet verified end-to-end on a real Windows machine — feedback welcome.
103
119
 
104
120
  ## Development
package/README.md CHANGED
@@ -44,8 +44,10 @@ 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`) |
49
51
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
50
52
  | `dsh-safe --version`(`-V`) | 显示版本 |
51
53
 
@@ -58,6 +60,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
58
60
  | `--dry-run` | 只解析与报告,不修改任何文件 |
59
61
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
60
62
  | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
63
+ | `--exclude <id或包名>` | 隔离豁免名单(可重复),命中的行永不自动禁用;也可写进 `config.json` |
61
64
 
62
65
  ### update / -u 选项(写在 `-u` 或 `update` 之后;其前的包装旗标照常生效)
63
66
 
@@ -67,6 +70,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
67
70
  | `--to <版本>` | 指定 dsh 的目标版本,也是回滚方式(显式允许降级);dsh-safe 自身始终升到最新 |
68
71
  | `--self` | 只更新 dsh-safe 自身,不动 dsh 与隔离状态 |
69
72
  | `--no-restore` | 升级 dsh 后不自动恢复被隔离的插件 |
73
+ | `--no-verify` | 跳过升级后的解析器自校验(临时 profile 试启新版 dsh,验证报错识别仍有效) |
70
74
  | `--pm <npm\|pnpm>` | 强制指定包管理器(缺省自动探测) |
71
75
 
72
76
  ### 环境变量
@@ -76,6 +80,10 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
76
80
  | `DSH_SAFE_LANG=zh\|en` | 强制提示信息语言(缺省跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
77
81
  | `DSH_SAFE_NO_UPDATE_CHECK=1` | 关闭启动时每天最多一次的 dsh-safe 新版提示 |
78
82
  | `DSH_HOME` | dsh 的 home 目录(dsh 自己的环境变量;隔离台账与各 patch 路径随之) |
83
+ | `DSH_SAFE_AI_KEY` | AI 功能 key(未设置 = AI 整体禁用);默认对接 DeepSeek |
84
+ | `DSH_SAFE_AI_BASE_URL` | AI 接口地址(OpenAI 兼容),默认 `https://api.deepseek.com` |
85
+ | `DSH_SAFE_AI_MODEL` | AI 模型,默认 `deepseek-chat` |
86
+ | `DSH_SAFE_AI_RECOVER=1` | 正则识别不出坏插件时启用 AI 兜底(结果仍走同一隔离管线) |
79
87
 
80
88
  升级行为:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
81
89
 
@@ -86,6 +94,14 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
86
94
  3. **写入托管区块**:在对应 patch 文件末尾追加带标记注释的区块(与 `dsh-mcp-config managed` 同款约定),把命中的行置为 `disabled: true`。用户已有内容与注释原样保留;全新 profile 的 `[]` 模板会被正确替换成块序列。
87
95
  4. **台账与恢复**:隔离记录存 `$DSH_HOME/dsh-safe/quarantine.json`。插件升级修复后用 `dsh-safe restore --profile web --all` 摘除区块恢复挂载(`patchReload: live` 的 profile 热生效)。
88
96
 
97
+ ### AI 能力(可选)
98
+
99
+ 设置 `DSH_SAFE_AI_KEY` 后启用(默认对接 DeepSeek,OpenAI 兼容接口,可用 `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL` 换任何兼容服务):
100
+
101
+ - **`dsh-safe explain [--file <路径>]`**:读一段启动失败的 stderr(stdin 或文件),输出人话解读与修复建议。纯只读,不碰任何文件。
102
+ - **AI 兜底识别**(`DSH_SAFE_AI_RECOVER=1`):正则特征识别不出坏插件时(如 dsh 升级换格式),让 AI 从 stderr 里挑元凶——**结果必须仍走同一验证管线**(对照真实 patch 行、第一方保护、dry-run 预览),命中不了照旧透传。仅在启动失败时调用。
103
+ - 隐私:发送前 home 路径脱敏为 `~`;AI 任何失败都静默降级。
104
+
89
105
  ## 安全边界
90
106
 
91
107
  - **第一方保护**:`@deepseek-ai/*` 的行默认跳过(禁用 `dsh-web-app` 这类插件会让 dsh 失去核心能力),需要 `--allow-first-party` 才会动。
@@ -98,7 +114,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
98
114
  - patch 文件本身 YAML 解析错误(如手改坏了)时无法识别插件,只会透传。
99
115
  - `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
100
116
  - 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
101
- - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
117
+ - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。缓解:update/-u 升级 dsh 后会自动做解析器自校验——临时 profile 试启新版 dsh 并确认报错仍可识别,失配当场告警(`--no-verify` 跳过)。
102
118
  - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm);包装启动会把 PATH 上 dsh 的 .cmd/.ps1 shim 解析出内嵌的 node 入口、改为 `node <入口>` 直接启动(.exe 直接运行,shim 解析失败退回 shell 方式),绕开 Node 禁止 spawn .cmd 的限制。尚未在真实 Windows 上端到端验证,欢迎反馈。
103
119
 
104
120
  ## 开发
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,12 @@
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 { printDoctor } from './doctor.js'
16
+ import { aiEnabled, explainFailure } from './ai.js'
14
17
  import { t } from './i18n.js'
15
18
 
16
19
  const require = createRequire(import.meta.url)
@@ -24,6 +27,53 @@ function printVersion() {
24
27
  process.stdout.write(`${version}\n`)
25
28
  }
26
29
 
30
+ /**
31
+ * `dsh-safe explain [--file <path>]`:AI 解读一段启动失败的 stderr。
32
+ * 纯只读——不写任何文件;未配置 DSH_SAFE_AI_KEY 时给出启用指引。
33
+ */
34
+ async function cmdExplain(args) {
35
+ if (args.includes('-h') || args.includes('--help')) {
36
+ printHelp()
37
+ return 0
38
+ }
39
+ let file
40
+ for (let i = 0; i < args.length; i++) {
41
+ if (args[i] === '--file') file = args[++i]
42
+ else if (args[i].startsWith('--file=')) file = args[i].slice('--file='.length)
43
+ }
44
+ if (!aiEnabled()) {
45
+ process.stderr.write(`${t('aiDisabled')}\n`)
46
+ return 1
47
+ }
48
+ let input = ''
49
+ if (file !== undefined) {
50
+ try {
51
+ input = readFileSync(file, 'utf8')
52
+ } catch {
53
+ process.stderr.write(`${t('aiFileUnreadable', { file })}\n`)
54
+ return 2
55
+ }
56
+ } else {
57
+ if (process.stdin.isTTY) process.stderr.write(`${t('explainStdinHint')}\n`)
58
+ try {
59
+ input = readFileSync(0, 'utf8')
60
+ } catch {
61
+ input = ''
62
+ }
63
+ }
64
+ if (!input.trim()) {
65
+ process.stderr.write(`${t('aiNoInput')}\n`)
66
+ return 2
67
+ }
68
+ const answer = await explainFailure(input)
69
+ if (!answer) {
70
+ process.stderr.write(`${t('aiExplainFailed')}\n`)
71
+ return 1
72
+ }
73
+ process.stdout.write(`${answer}\n`)
74
+ return 0
75
+ }
76
+
27
77
  /** 解析 `--profile <名>` / `--profile=<名>`,返回 [值, 剩余参数]。 */
28
78
  function takeProfile(args) {
29
79
  let profile
@@ -58,8 +108,13 @@ function takeIds(args) {
58
108
  }
59
109
 
60
110
  function cmdList(args) {
61
- const [profile] = takeProfile(args)
111
+ const [profile, rest] = takeProfile(args)
62
112
  const ledger = loadLedger()
113
+ if (rest.includes('--json')) {
114
+ const profiles = profile ? { [profile]: ledger.profiles[profile] ?? [] } : ledger.profiles
115
+ process.stdout.write(`${JSON.stringify({ version: ledger.version ?? 1, profiles }, null, 2)}\n`)
116
+ return 0
117
+ }
63
118
  const profiles = profile ? [profile] : Object.keys(ledger.profiles).sort()
64
119
  let found = 0
65
120
  for (const p of profiles) {
@@ -82,21 +137,27 @@ function cmdRestore(args) {
82
137
  const [ids0, rest1] = takeIds(rest0)
83
138
  const dryRun = rest1.includes('--dry-run')
84
139
  const all = rest1.includes('--all')
85
- const profile = profile0
86
- if (!profile) {
87
- process.stderr.write(`${t('restoreNeedsProfile')}\n`)
88
- return 2
89
- }
90
140
  if (!all && ids0.length === 0) {
91
141
  process.stderr.write(`${t('restoreNeedsId')}\n`)
92
142
  return 2
93
143
  }
94
- const { restored, kept } = restoreQuarantine(profile, all ? 'all' : ids0, dryRun)
144
+ // 省略 --profile 时遍历台账里所有有记录的 profile(与 update 的恢复对齐)
145
+ const ledger = loadLedger()
146
+ const profiles = profile0
147
+ ? [profile0]
148
+ : Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
95
149
  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`)
150
+ let anyRestored = false
151
+ for (const p of profiles) {
152
+ const { restored, kept } = restoreQuarantine(p, all ? 'all' : ids0, dryRun)
153
+ for (const e of restored) process.stdout.write(`[dsh-safe] ${verb} ${e.name ?? e.id} (id: ${e.id})\n`)
154
+ if (restored.length) {
155
+ anyRestored = true
156
+ if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile: p, count: kept.length })}\n`)
157
+ else process.stdout.write(`${t('ledgerCleared')}\n`)
158
+ }
159
+ }
160
+ if (!anyRestored) process.stdout.write(`${t('noMatching')}\n`)
100
161
  return 0
101
162
  }
102
163
 
@@ -121,6 +182,11 @@ export async function main(argv) {
121
182
  if (cmd === 'list') return cmdList(argv.slice(1))
122
183
  if (cmd === 'restore') return cmdRestore(argv.slice(1))
123
184
  if (cmd === 'update') return cmdUpdate(argv.slice(1))
185
+ if (cmd === 'explain') return cmdExplain(argv.slice(1))
186
+ if (cmd === 'doctor') {
187
+ printDoctor()
188
+ return 0
189
+ }
124
190
  if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
125
191
 
126
192
  // 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
@@ -132,6 +198,7 @@ export async function main(argv) {
132
198
  async function runWrapperMode(argv) {
133
199
  maybeNotifySelfUpdate()
134
200
  const forwardArgs = []
201
+ const excludeArgs = []
135
202
  let dryRun = false
136
203
  let maxRetries = 2
137
204
  let allowFirstParty = false
@@ -140,6 +207,8 @@ async function runWrapperMode(argv) {
140
207
  const a = argv[i]
141
208
  if (a === '--dry-run') { dryRun = true; continue }
142
209
  if (a === '--allow-first-party') { allowFirstParty = true; continue }
210
+ if (a === '--exclude') { const v = argv[++i]; if (v !== undefined) excludeArgs.push(v); continue }
211
+ if (a.startsWith('--exclude=')) { excludeArgs.push(a.slice('--exclude='.length)); continue }
143
212
  if (a === '--max-retries') {
144
213
  const raw = argv[++i]
145
214
  const v = raw === undefined || raw === '' ? NaN : Number(raw)
@@ -168,5 +237,5 @@ async function runWrapperMode(argv) {
168
237
  return 0
169
238
  }
170
239
  if (dryRun) process.stderr.write(`${t('dryRunNotice')}\n`)
171
- return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty })
240
+ return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty, exclude: excludeArgs })
172
241
  }
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,14 @@ 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
- dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--pm npm|pnpm]
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)
25
28
  dsh-safe help 显示本帮助
26
29
  dsh-safe --version 显示版本
27
30
 
@@ -46,7 +49,6 @@ const ZH = {
46
49
  locationLine: ' 位置: {file}',
47
50
  noRecordsProfile: '{profile}: 没有隔离记录',
48
51
  noRecords: '没有隔离记录',
49
- restoreNeedsProfile: '[dsh-safe] restore 需要 --profile <名>',
50
52
  restoreNeedsId: '[dsh-safe] restore 需要 --id <id>(可重复)或 --all',
51
53
  restored: '已恢复',
52
54
  restoredDry: '(dry-run)将恢复',
@@ -89,12 +91,37 @@ const ZH = {
89
91
  updateRestoreSkipped: '[dsh-safe] 已按 --no-restore 跳过恢复;可稍后用 dsh-safe restore --profile <名> --all 恢复。',
90
92
  updateVerifyHint: '[dsh-safe] 请启动 dsh 验证;仍不兼容的插件会自动再次隔离。',
91
93
  updateRollbackHint: '[dsh-safe] 如需回滚: dsh-safe update --to {version}',
94
+ verifyRunning: '[dsh-safe] 正在自校验解析器:用临时 profile 试启新版 dsh(预期失败)…',
95
+ verifyPassed: '[dsh-safe] 解析器自校验通过:新版 dsh 的报错能被识别,自动隔离可用。',
96
+ verifyFailNoHit: '[dsh-safe] 警告:解析器自校验未通过——无法从新版 dsh 的报错里识别坏插件,自动隔离可能失效;建议回滚(dsh-safe update --to {version})并反馈给 dsh-safe。',
97
+ verifyFailBoot: '[dsh-safe] 警告:解析器自校验未通过——坏插件试启意外成功,未触发报错,自动隔离未验证。',
98
+ verifyFailTimeout: '[dsh-safe] 警告:解析器自校验未完成(试启超时或无法启动),自动隔离未验证。',
92
99
  selfUpdateLagHint: '[dsh-safe] dsh-safe 已更新: {old} → {new}(本次运行仍是旧版,下次启动生效)',
93
100
  updateNotify: '[dsh-safe] 提示: dsh-safe 有新版本 {new}(当前 {old})',
94
101
  updateNotifyHow: ' 升级: npm i -g {name} 或 dsh-safe update --self',
95
102
  updateUnknownFlag: '[dsh-safe] update 无法识别的参数: {arg}',
96
103
  updatePmInvalid: '[dsh-safe] --pm 只支持 npm 或 pnpm。',
97
104
  updateToInvalid: '[dsh-safe] --to 需要合法的版本号(如 1.2.3 或 1.2.3-rc.1)。',
105
+ aiDisabled: '[dsh-safe] AI 功能未启用:设置环境变量 DSH_SAFE_AI_KEY 后可用(默认对接 DeepSeek,见 README 环境变量表)。',
106
+ aiNoInput: '[dsh-safe] 没有读到任何 stderr 内容:用 --file <路径>,或从 stdin 粘贴后按 Ctrl-D 结束。',
107
+ aiExplainFailed: '[dsh-safe] AI 解读失败(网络或接口异常);未修改任何文件。',
108
+ aiFileUnreadable: '[dsh-safe] 无法读取文件: {file}',
109
+ explainStdinHint: '[dsh-safe] 正在从 stdin 读取:粘贴 stderr 后按 Ctrl-D(EOF)结束;也可以改用 --file <路径>。',
110
+ aiRecovered: '[dsh-safe] AI 兜底识别出 {count} 个可疑坏插件(结果仍走同一隔离管线)',
111
+ excludedByList: '[dsh-safe] 按豁免名单跳过 {label}(不自动禁用)',
112
+ doctorSelf: 'dsh-safe {version}',
113
+ doctorDsh: 'dsh {name} {version}',
114
+ doctorDshMissing: 'dsh 未在 PATH 上找到',
115
+ doctorHome: 'DSH_HOME {home}',
116
+ doctorProfiles: 'profiles {profiles}',
117
+ doctorLedger: '隔离台账 {count} 条(最早 {oldest};profile: {profiles})',
118
+ doctorLedgerEmpty: '隔离台账 无记录',
119
+ doctorPatchOk: 'patch {file}({rows} 行;托管区块 {managed})',
120
+ doctorPatchMissing: 'patch {file}(不存在)',
121
+ doctorAIOn: 'AI 已启用(模型 {model})',
122
+ doctorAIOff: 'AI 未启用(DSH_SAFE_AI_KEY 未设置)',
123
+ doctorCheckLast: '更新检查 上次 {time}',
124
+ doctorCheckNever: '更新检查 从未执行',
98
125
  }
99
126
 
100
127
  const EN = {
@@ -109,11 +136,14 @@ ledger), and retries automatically.
109
136
  Usage:
110
137
  dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
111
138
  dsh-safe -u [<dsh args…>] upgrade dsh first (skip if latest), then boot
112
- dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
139
+ dsh-safe list [--profile <name>] [--json]
140
+ show quarantined plugins (--json outputs JSON)
141
+ dsh-safe doctor environment check (versions / ledger / patch health)
113
142
  dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
114
143
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
115
- dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--pm npm|pnpm]
144
+ dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
116
145
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
146
+ dsh-safe explain [--file <path>] interpret a failed-boot stderr with AI (needs DSH_SAFE_AI_KEY)
117
147
  dsh-safe help show this help
118
148
  dsh-safe --version show version
119
149
 
@@ -140,7 +170,6 @@ Notes:
140
170
  locationLine: ' location: {file}',
141
171
  noRecordsProfile: '{profile}: no quarantine records',
142
172
  noRecords: 'no quarantine records',
143
- restoreNeedsProfile: '[dsh-safe] restore requires --profile <name>',
144
173
  restoreNeedsId: '[dsh-safe] restore requires --id <id> (repeatable) or --all',
145
174
  restored: 'restored',
146
175
  restoredDry: '(dry-run) would restore',
@@ -184,12 +213,37 @@ Notes:
184
213
  updateRestoreSkipped: '[dsh-safe] restore skipped due to --no-restore; restore later with dsh-safe restore --profile <name> --all.',
185
214
  updateVerifyHint: '[dsh-safe] start dsh to verify; plugins still incompatible will be auto-quarantined again.',
186
215
  updateRollbackHint: '[dsh-safe] to roll back: dsh-safe update --to {version}',
216
+ verifyRunning: '[dsh-safe] verifying the parser: booting the new dsh with a throwaway profile (expected to fail)…',
217
+ verifyPassed: '[dsh-safe] parser self-check passed: failures from the new dsh are recognized, auto-quarantine is available.',
218
+ verifyFailNoHit: '[dsh-safe] warning: parser self-check failed — the broken plugin could not be recognized in the new dsh errors; auto-quarantine may be broken. Consider rolling back (dsh-safe update --to {version}) and reporting it to dsh-safe.',
219
+ verifyFailBoot: '[dsh-safe] warning: parser self-check failed — the deliberately broken profile booted unexpectedly, no error was triggered; auto-quarantine unverified.',
220
+ verifyFailTimeout: '[dsh-safe] warning: parser self-check did not finish (boot timed out or could not start); auto-quarantine unverified.',
187
221
  selfUpdateLagHint: '[dsh-safe] dsh-safe updated: {old} → {new} (this run still uses the old version; takes effect on the next run)',
188
222
  updateNotify: '[dsh-safe] notice: a new dsh-safe version is available: {new} (current {old})',
189
223
  updateNotifyHow: ' to upgrade: npm i -g {name} or dsh-safe update --self',
190
224
  updateUnknownFlag: '[dsh-safe] unrecognized argument for update: {arg}',
191
225
  updatePmInvalid: '[dsh-safe] --pm only accepts npm or pnpm.',
192
226
  updateToInvalid: '[dsh-safe] --to requires a valid version (e.g. 1.2.3 or 1.2.3-rc.1).',
227
+ 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).',
228
+ aiNoInput: '[dsh-safe] no stderr content was read: use --file <path>, or paste to stdin and press Ctrl-D.',
229
+ aiExplainFailed: '[dsh-safe] AI interpretation failed (network or API error); no files were modified.',
230
+ aiFileUnreadable: '[dsh-safe] cannot read file: {file}',
231
+ explainStdinHint: '[dsh-safe] reading from stdin: paste the stderr and press Ctrl-D (EOF) to finish, or use --file <path>.',
232
+ aiRecovered: '[dsh-safe] AI fallback identified {count} suspected broken plugin(s); the same quarantine pipeline applies',
233
+ excludedByList: '[dsh-safe] skipped {label} per the exclusion list (never auto-disabled)',
234
+ doctorSelf: 'dsh-safe {version}',
235
+ doctorDsh: 'dsh {name} {version}',
236
+ doctorDshMissing: 'dsh not found on PATH',
237
+ doctorHome: 'DSH_HOME {home}',
238
+ doctorProfiles: 'profiles {profiles}',
239
+ doctorLedger: 'ledger {count} quarantined (oldest {oldest}; profiles: {profiles})',
240
+ doctorLedgerEmpty: 'ledger empty',
241
+ doctorPatchOk: 'patch {file} ({rows} rows; managed block {managed})',
242
+ doctorPatchMissing: 'patch {file} (missing)',
243
+ doctorAIOn: 'AI enabled (model {model})',
244
+ doctorAIOff: 'AI disabled (DSH_SAFE_AI_KEY not set)',
245
+ doctorCheckLast: 'update-check last ran at {time}',
246
+ doctorCheckNever: 'update-check never ran',
193
247
  }
194
248
 
195
249
  const CATALOG = { zh: ZH, en: EN }
package/lib/update.js CHANGED
@@ -13,6 +13,7 @@ import { createInterface } from 'node:readline/promises'
13
13
  import { delimiter, dirname, join } from 'node:path'
14
14
  import { loadLedger, restoreQuarantine } from './quarantine.js'
15
15
  import { readIfExists, updateCheckFile } from './dshpaths.js'
16
+ import { verifyParser } from './verify.js'
16
17
  import { t } from './i18n.js'
17
18
 
18
19
  const err = (line) => process.stderr.write(`${line}\n`)
@@ -235,11 +236,12 @@ export async function cmdUpdateAndBoot(args, { boot } = {}) {
235
236
  /**
236
237
  * 更新主体;bootArgs 非空时更新成功(或已最新/检查失败)后继续启动。
237
238
  * 默认同时检查 dsh 与 dsh-safe 自身,谁旧升谁(一条安装命令);--self 只更新自身。
238
- * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean }} opts
239
+ * dsh 更新成功后默认跑解析器自校验(--no-verify 跳过)。
240
+ * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean, noVerify?: boolean }} opts
239
241
  * @param {string[] | null} bootArgs
240
- * @param {{ boot?: (args: string[]) => Promise<number> }} hooks
242
+ * @param {{ boot?: (args: string[]) => Promise<number>, verify?: typeof verifyParser }} hooks
241
243
  */
242
- async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
244
+ async function updateAndMaybeBoot(opts, bootArgs, { boot, verify = verifyParser } = {}) {
243
245
  const dshPkg = opts.selfOnly ? null : resolveDshPackage()
244
246
  const selfPkg = resolveSelfPackage()
245
247
  if (!dshPkg && !selfPkg) {
@@ -317,6 +319,9 @@ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
317
319
  err(t('selfUpdateLagHint', { old: p.pkg.version, new: p.target }))
318
320
  }
319
321
  }
322
+ if (dshUpdated && !opts.noVerify) {
323
+ await verify({ rollbackVersion: plans.find((p) => p.pkg === dshPkg)?.pkg.version, log: err })
324
+ }
320
325
 
321
326
  if (opts.restore && dshUpdated) {
322
327
  const ledger = loadLedger()
@@ -343,12 +348,13 @@ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
343
348
 
344
349
  /** 解析 update 子命令参数;出错返回 { error: { key, params? } } 供 i18n。 */
345
350
  function parseUpdateArgs(args) {
346
- const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
351
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false, noVerify: false }
347
352
  for (let i = 0; i < args.length; i++) {
348
353
  const a = args[i]
349
354
  if (a === '-y' || a === '--yes') opts.yes = true
350
355
  else if (a === '--no-restore') opts.restore = false
351
356
  else if (a === '--self') opts.selfOnly = true
357
+ else if (a === '--no-verify') opts.noVerify = true
352
358
  else if (a === '--to') {
353
359
  const v = args[++i]
354
360
  if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--to' } } }
@@ -375,13 +381,14 @@ function parseUpdateArgs(args) {
375
381
  * dsh 启动参数(宽松处理——不像子命令那样对未知参数报错)。
376
382
  */
377
383
  function parseLeadingUpdateArgs(args) {
378
- const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
384
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false, noVerify: false }
379
385
  let i = 0
380
386
  for (; i < args.length; i++) {
381
387
  const a = args[i]
382
388
  if (a === '-y' || a === '--yes') opts.yes = true
383
389
  else if (a === '--no-restore') opts.restore = false
384
390
  else if (a === '--self') opts.selfOnly = true
391
+ else if (a === '--no-verify') opts.noVerify = true
385
392
  else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
386
393
  else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
387
394
  else if (a === '--pm' && args[i + 1] !== undefined) opts.pm = args[++i]
package/lib/verify.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — 升级 dsh 后的解析器自校验。
3
+ *
4
+ * 风险背景:报错解析器与 dsh 的 stderr 格式耦合,dsh 升级可能让特征失配,
5
+ * 保险丝静默失效。自校验在临时 DSH_HOME 里搭一次性 profile(引用现场生成
6
+ * 的坏插件),用 PATH 上新装的 dsh 试启——预期启动失败,再确认解析器能从
7
+ * 报错里识别出坏插件。识别失败只告警(附回滚命令),不阻断后续启动。
8
+ */
9
+ import { spawnSync } from 'node:child_process'
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { parseFailureReport } from './failures.js'
14
+ import { matchFailures } from './knownrows.js'
15
+ import { resolveDshSpawnTarget } from './dshpaths.js'
16
+ import { t } from './i18n.js'
17
+
18
+ const BAD_ID = 'dsh-safe-verify'
19
+ const BAD_NAME = '@dsh-safe-verify/broken'
20
+
21
+ /**
22
+ * @param {{
23
+ * rollbackVersion?: string,
24
+ * log?: (line: string) => void,
25
+ * timeoutMs?: number,
26
+ * spawn?: typeof spawnSync,
27
+ * }} [options]
28
+ * @returns {Promise<boolean>} 自校验是否通过
29
+ */
30
+ export async function verifyParser({ rollbackVersion, log = (line) => process.stderr.write(`${line}\n`), timeoutMs = 60_000, spawn = spawnSync } = {}) {
31
+ log(t('verifyRunning'))
32
+ const home = mkdtempSync(join(tmpdir(), 'dsh-safe-verify-'))
33
+ try {
34
+ // 一次性 profile:patch 行引用现场生成的坏插件(模块级 throw → import 失败)
35
+ const profileDir = join(home, 'profiles', 'verify')
36
+ const modDir = join(profileDir, 'node_modules', BAD_NAME)
37
+ mkdirSync(join(modDir, 'lib'), { recursive: true })
38
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-verify', private: true }))
39
+ const patchPath = join(profileDir, 'cordis.patch.yml')
40
+ writeFileSync(patchPath, `- id: ${BAD_ID}\n name: '${BAD_NAME}'\n`)
41
+ writeFileSync(
42
+ join(modDir, 'package.json'),
43
+ JSON.stringify({ name: BAD_NAME, version: '0.0.1', type: 'module', main: 'lib/index.js' }),
44
+ )
45
+ writeFileSync(join(modDir, 'lib', 'index.js'), `throw new Error('dsh-safe parser self-check')\n`)
46
+
47
+ const target = resolveDshSpawnTarget('dsh')
48
+ const { status, stderr } = spawn(target.file, [...target.prefix, '--profile', 'verify'], {
49
+ env: { ...process.env, DSH_HOME: home },
50
+ stdio: ['ignore', 'ignore', 'pipe'],
51
+ timeout: timeoutMs,
52
+ shell: target.shell,
53
+ encoding: 'utf8',
54
+ })
55
+
56
+ if (status === 0) {
57
+ log(t('verifyFailBoot'))
58
+ return false
59
+ }
60
+ if (status === null) {
61
+ // 超时被杀或无法启动试启进程
62
+ log(t('verifyFailTimeout'))
63
+ return false
64
+ }
65
+ const report = parseFailureReport(stderr ?? '')
66
+ const hits = matchFailures(report, {
67
+ rows: [{ id: BAD_ID, name: BAD_NAME, disabled: false, file: patchPath }],
68
+ })
69
+ if (hits.length) {
70
+ log(t('verifyPassed'))
71
+ return true
72
+ }
73
+ log(t('verifyFailNoHit', { version: rollbackVersion ?? '' }))
74
+ return false
75
+ } finally {
76
+ rmSync(home, { recursive: true, force: true })
77
+ }
78
+ }
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.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",