@hyzyn/dsh-safe 0.4.0 → 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 +17 -2
- package/README.md +17 -2
- package/lib/ai.js +106 -0
- package/lib/cli.js +81 -12
- package/lib/config.js +31 -0
- package/lib/doctor.js +62 -0
- package/lib/i18n.js +48 -4
- package/lib/wrap.js +36 -1
- package/package.json +1 -1
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
|
|
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
|
|
|
@@ -77,6 +80,10 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
|
|
|
77
80
|
| `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
|
|
78
81
|
| `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the at-most-daily dsh-safe new-version notice on boot |
|
|
79
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) |
|
|
80
87
|
|
|
81
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`).
|
|
82
89
|
|
|
@@ -87,6 +94,14 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
|
|
|
87
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.
|
|
88
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`).
|
|
89
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
|
+
|
|
90
105
|
## Safety Boundaries
|
|
91
106
|
|
|
92
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.
|
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 <名>]` |
|
|
48
|
-
| `dsh-safe
|
|
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
|
|
|
@@ -77,6 +80,10 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
77
80
|
| `DSH_SAFE_LANG=zh\|en` | 强制提示信息语言(缺省跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
|
|
78
81
|
| `DSH_SAFE_NO_UPDATE_CHECK=1` | 关闭启动时每天最多一次的 dsh-safe 新版提示 |
|
|
79
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 兜底(结果仍走同一隔离管线) |
|
|
80
87
|
|
|
81
88
|
升级行为:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
|
|
82
89
|
|
|
@@ -87,6 +94,14 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
87
94
|
3. **写入托管区块**:在对应 patch 文件末尾追加带标记注释的区块(与 `dsh-mcp-config managed` 同款约定),把命中的行置为 `disabled: true`。用户已有内容与注释原样保留;全新 profile 的 `[]` 模板会被正确替换成块序列。
|
|
88
95
|
4. **台账与恢复**:隔离记录存 `$DSH_HOME/dsh-safe/quarantine.json`。插件升级修复后用 `dsh-safe restore --profile web --all` 摘除区块恢复挂载(`patchReload: live` 的 profile 热生效)。
|
|
89
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
|
+
|
|
90
105
|
## 安全边界
|
|
91
106
|
|
|
92
107
|
- **第一方保护**:`@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,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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 <名>]
|
|
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)
|
|
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)将恢复',
|
|
@@ -100,6 +102,26 @@ const ZH = {
|
|
|
100
102
|
updateUnknownFlag: '[dsh-safe] update 无法识别的参数: {arg}',
|
|
101
103
|
updatePmInvalid: '[dsh-safe] --pm 只支持 npm 或 pnpm。',
|
|
102
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: '更新检查 从未执行',
|
|
103
125
|
}
|
|
104
126
|
|
|
105
127
|
const EN = {
|
|
@@ -114,11 +136,14 @@ ledger), and retries automatically.
|
|
|
114
136
|
Usage:
|
|
115
137
|
dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
|
|
116
138
|
dsh-safe -u [<dsh args…>] upgrade dsh first (skip if latest), then boot
|
|
117
|
-
dsh-safe list [--profile <name>]
|
|
139
|
+
dsh-safe list [--profile <name>] [--json]
|
|
140
|
+
show quarantined plugins (--json outputs JSON)
|
|
141
|
+
dsh-safe doctor environment check (versions / ledger / patch health)
|
|
118
142
|
dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
|
|
119
143
|
re-enable auto-disabled plugins (after a fixed plugin upgrade)
|
|
120
144
|
dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
|
|
121
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)
|
|
122
147
|
dsh-safe help show this help
|
|
123
148
|
dsh-safe --version show version
|
|
124
149
|
|
|
@@ -145,7 +170,6 @@ Notes:
|
|
|
145
170
|
locationLine: ' location: {file}',
|
|
146
171
|
noRecordsProfile: '{profile}: no quarantine records',
|
|
147
172
|
noRecords: 'no quarantine records',
|
|
148
|
-
restoreNeedsProfile: '[dsh-safe] restore requires --profile <name>',
|
|
149
173
|
restoreNeedsId: '[dsh-safe] restore requires --id <id> (repeatable) or --all',
|
|
150
174
|
restored: 'restored',
|
|
151
175
|
restoredDry: '(dry-run) would restore',
|
|
@@ -200,6 +224,26 @@ Notes:
|
|
|
200
224
|
updateUnknownFlag: '[dsh-safe] unrecognized argument for update: {arg}',
|
|
201
225
|
updatePmInvalid: '[dsh-safe] --pm only accepts npm or pnpm.',
|
|
202
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',
|
|
203
247
|
}
|
|
204
248
|
|
|
205
249
|
const CATALOG = { zh: ZH, en: EN }
|
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
|
-
|
|
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) {
|