@hyzyn/dsh-safe 0.6.0 → 0.8.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
@@ -47,8 +47,8 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
47
47
  | `dsh-safe list [--profile <name>] [--json]` | show quarantined plugins (`--json` outputs structured JSON; defaults to all profiles) |
48
48
  | `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health |
49
49
  | `dsh-safe restore [--profile <name>] (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (omit `--profile` to cover every profile in the ledger) |
50
- | `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) |
50
+ | `dsh-safe explain [--profile <name> \| --file <path>]` | interpret a boot failure with AI: defaults to the last failure record, `--profile` boots the profile live, `--file`/stdin read any log (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) |
52
52
  | `dsh-safe help` (`-h` / `--help`) | show help |
53
53
  | `dsh-safe --version` (`-V`) | show version |
54
54
 
@@ -99,7 +99,7 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
99
99
 
100
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
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.
102
+ - **`dsh-safe explain [--profile <name> | --file <path>]`**: interprets the most recent boot failure by default (stderr is persisted to `$DSH_HOME/dsh-safe/last-failure-<profile>.log` on every failed wrapped boot, for humans too); `--profile` boots that profile live and interprets (60s timeout); `--file`/stdin read any log. Strictly read-only — never touches the patch or ledger.
103
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
104
  - Privacy: home paths are redacted to `~` before sending; any AI failure degrades silently.
105
105
 
package/README.md CHANGED
@@ -47,8 +47,8 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
47
47
  | `dsh-safe list [--profile <名>] [--json]` | 查看隔离名单(`--json` 输出结构化 JSON,缺省全部 profile) |
48
48
  | `dsh-safe doctor` | 环境体检:版本、DSH_HOME、profiles、台账、各 patch 健康度 |
49
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 通道安装) |
50
+ | `dsh-safe explain [--profile <名> \| --file <路径>]` | 用 AI 解读启动失败:默认解读最近一次失败记录,`--profile` 现场试启并解读,`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
51
+ | `dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]` | 重装/升级被隔离的插件并自动恢复(限模块解析失败类;经 `dsh plugin` 的 pnpm 通道安装) |
52
52
  | `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
53
53
  | `dsh-safe --version`(`-V`) | 显示版本 |
54
54
 
@@ -99,7 +99,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
99
99
 
100
100
  设置 `DSH_SAFE_AI_KEY` 后启用(默认对接 DeepSeek,OpenAI 兼容接口,可用 `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL` 换任何兼容服务):
101
101
 
102
- - **`dsh-safe explain [--file <路径>]`**:读一段启动失败的 stderrstdin 或文件),输出人话解读与修复建议。纯只读,不碰任何文件。
102
+ - **`dsh-safe explain [--profile <名> | --file <路径>]`**:默认解读最近一次启动失败(失败时 stderr 自动持久化到 `$DSH_HOME/dsh-safe/last-failure-<profile>.log`,人也可直接翻阅);`--profile` 现场试启该 profile 并解读(60 秒超时);`--file`/stdin 读任意日志。纯只读,不碰 patch/台账。
103
103
  - **AI 兜底识别**(`DSH_SAFE_AI_RECOVER=1`):正则特征识别不出坏插件时(如 dsh 升级换格式),让 AI 从 stderr 里挑元凶——**结果必须仍走同一验证管线**(对照真实 patch 行、第一方保护、dry-run 预览),命中不了照旧透传。仅在启动失败时调用。
104
104
  - 隐私:发送前 home 路径脱敏为 `~`;AI 任何失败都静默降级。
105
105
 
package/lib/ai.js CHANGED
@@ -11,8 +11,12 @@
11
11
  * 的输出必须由调用方经 matchFailures 对照真实 patch 行后才生效。
12
12
  * 发送前脱敏:用户 home 目录路径替换为 ~。
13
13
  */
14
+ import { spawnSync } from 'node:child_process'
15
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
14
16
  import { homedir } from 'node:os'
15
- import { getLocale } from './i18n.js'
17
+ import { join } from 'node:path'
18
+ import { dshHome, resolveDshSpawnTarget } from './dshpaths.js'
19
+ import { getLocale, t } from './i18n.js'
16
20
 
17
21
  export const aiEnabled = () => Boolean(process.env.DSH_SAFE_AI_KEY)
18
22
 
@@ -104,3 +108,109 @@ export async function detectFailureWithAI(stderr, knownRows) {
104
108
  return []
105
109
  }
106
110
  }
111
+
112
+ /** 试启一个 profile 并捕获 stderr(60s 超时;超时留下的部分 stderr 也可解读)。 */
113
+ function bootProfileForExplain(profile, { spawn }) {
114
+ const target = resolveDshSpawnTarget('dsh')
115
+ const { status, stderr } = spawn(target.file, [...target.prefix, '--profile', profile], {
116
+ stdio: ['ignore', 'ignore', 'pipe'],
117
+ env: process.env,
118
+ shell: target.shell,
119
+ timeout: 60_000,
120
+ encoding: 'utf8',
121
+ })
122
+ return { code: status ?? 'timeout', stderr: stderr ?? '' }
123
+ }
124
+
125
+ /** $DSH_HOME/dsh-safe/ 下最近修改的 last-failure-*.log。 */
126
+ function findLatestFailureFile() {
127
+ const dir = join(dshHome(), 'dsh-safe')
128
+ let best
129
+ try {
130
+ for (const name of readdirSync(dir)) {
131
+ if (!name.startsWith('last-failure-') || !name.endsWith('.log')) continue
132
+ const path = join(dir, name)
133
+ const mtime = statSync(path).mtimeMs
134
+ if (!best || mtime > best.mtime) best = { path, mtime }
135
+ }
136
+ } catch {}
137
+ return best
138
+ }
139
+
140
+ /**
141
+ * `dsh-safe explain`:解读启动失败。输入优先级:
142
+ * --file <路径> > --profile <名>(现场试启并解读) >
143
+ * 最近一次失败记录(包装启动失败时自动持久化的 last-failure-*.log) >
144
+ * stdin 管道。纯只读——不碰 patch/台账。
145
+ * @param {string[]} args
146
+ * @param {{
147
+ * spawn?: typeof spawnSync,
148
+ * isTTY?: boolean,
149
+ * readStdin?: () => string,
150
+ * log?: (line: string) => void,
151
+ * write?: (line: string) => void,
152
+ * }} [hooks] 可注入(测试)
153
+ * @returns {Promise<number>} 退出码
154
+ */
155
+ export async function cmdExplain(args, {
156
+ spawn = spawnSync,
157
+ isTTY = process.stdin.isTTY,
158
+ readStdin = () => readFileSync(0, 'utf8'),
159
+ log = (line) => process.stderr.write(`${line}\n`),
160
+ write = (line) => process.stdout.write(`${line}\n`),
161
+ } = {}) {
162
+ if (args.includes('-h') || args.includes('--help')) {
163
+ write(t('helpText', {}))
164
+ return 0
165
+ }
166
+ let file
167
+ let profile
168
+ for (let i = 0; i < args.length; i++) {
169
+ if (args[i] === '--file') file = args[++i]
170
+ else if (args[i].startsWith('--file=')) file = args[i].slice('--file='.length)
171
+ else if (args[i] === '--profile') profile = args[++i]
172
+ else if (args[i].startsWith('--profile=')) profile = args[i].slice('--profile='.length)
173
+ }
174
+ if (!aiEnabled()) {
175
+ log(t('aiDisabled'))
176
+ return 1
177
+ }
178
+ let input = ''
179
+ if (file !== undefined) {
180
+ try {
181
+ input = readFileSync(file, 'utf8')
182
+ } catch {
183
+ log(t('aiFileUnreadable', { file }))
184
+ return 2
185
+ }
186
+ } else if (profile !== undefined) {
187
+ log(t('explainBooting', { profile }))
188
+ const { code, stderr } = bootProfileForExplain(profile, { spawn })
189
+ if (code === 0 || !stderr?.trim()) {
190
+ log(t('explainBootOk', { profile }))
191
+ return 0
192
+ }
193
+ input = stderr
194
+ } else if (!isTTY) {
195
+ input = readStdin()
196
+ } else {
197
+ const latest = findLatestFailureFile()
198
+ if (!latest) {
199
+ log(t('explainNoSource'))
200
+ return 2
201
+ }
202
+ log(t('explainUsingLast', { file: latest.path }))
203
+ input = readFileSync(latest.path, 'utf8')
204
+ }
205
+ if (!input.trim()) {
206
+ log(t('aiNoInput'))
207
+ return 2
208
+ }
209
+ const answer = await explainFailure(input)
210
+ if (!answer) {
211
+ log(t('aiExplainFailed'))
212
+ return 1
213
+ }
214
+ write(answer)
215
+ return 0
216
+ }
package/lib/cli.js CHANGED
@@ -8,13 +8,12 @@
8
8
  * dsh-safe help | --version
9
9
  */
10
10
  import { createRequire } from 'node:module'
11
- import { readFileSync } from 'node:fs'
12
11
  import { loadLedger, restoreQuarantine } from './quarantine.js'
13
12
  import { runWrapped } from './wrap.js'
14
13
  import { cmdUpdate, cmdUpdateAndBoot, maybeNotifySelfUpdate } from './update.js'
15
14
  import { cmdRepair } from './repair.js'
16
15
  import { printDoctor } from './doctor.js'
17
- import { aiEnabled, explainFailure } from './ai.js'
16
+ import { cmdExplain } from './ai.js'
18
17
  import { t } from './i18n.js'
19
18
 
20
19
  const require = createRequire(import.meta.url)
@@ -28,53 +27,6 @@ function printVersion() {
28
27
  process.stdout.write(`${version}\n`)
29
28
  }
30
29
 
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
-
78
30
  /** 解析 `--profile <名>` / `--profile=<名>`,返回 [值, 剩余参数]。 */
79
31
  function takeProfile(args) {
80
32
  let profile
package/lib/dshpaths.js CHANGED
@@ -28,6 +28,9 @@ export const homePatchPath = () => join(dshHome(), 'cordis.patch.yml')
28
28
  /** dsh-safe 的隔离台账。 */
29
29
  export const ledgerFile = () => join(dshHome(), 'dsh-safe', 'quarantine.json')
30
30
 
31
+ /** 启动失败 stderr 的持久化文件(按 profile 一份,供 explain 默认解读)。 */
32
+ export const lastFailureFile = (profile) => join(dshHome(), 'dsh-safe', `last-failure-${profile}.log`)
33
+
31
34
  /** dsh-safe 的更新检查时间戳缓存(每天最多提示一次新版)。 */
32
35
  export const updateCheckFile = () => join(dshHome(), 'dsh-safe', 'update-check.json')
33
36
 
package/lib/i18n.js CHANGED
@@ -24,9 +24,9 @@ const ZH = {
24
24
  恢复被自动禁用的插件(升级修复后使用)
25
25
  dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
26
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
- 重装/升级被隔离的插件并自动恢复(限模块解析失败类)
27
+ dsh-safe explain [--profile <名> | --file <路径>] 用 AI 解读启动失败(默认解读最近一次失败)
28
+ dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]
29
+ 重装/升级被隔离的插件并自动恢复(限模块解析失败类;省略 id 时只有一条记录则直接修)
30
30
  dsh-safe help 显示本帮助
31
31
  dsh-safe --version 显示版本
32
32
 
@@ -108,8 +108,12 @@ const ZH = {
108
108
  aiNoInput: '[dsh-safe] 没有读到任何 stderr 内容:用 --file <路径>,或从 stdin 粘贴后按 Ctrl-D 结束。',
109
109
  aiExplainFailed: '[dsh-safe] AI 解读失败(网络或接口异常);未修改任何文件。',
110
110
  aiFileUnreadable: '[dsh-safe] 无法读取文件: {file}',
111
- explainStdinHint: '[dsh-safe] 正在从 stdin 读取:粘贴 stderr 后按 Ctrl-D(EOF)结束;也可以改用 --file <路径>。',
112
111
  aiRecovered: '[dsh-safe] AI 兜底识别出 {count} 个可疑坏插件(结果仍走同一隔离管线)',
112
+ explainHint: '[dsh-safe] 可运行 dsh-safe explain 解读这次失败',
113
+ explainBooting: '[dsh-safe] 正在试启 profile {profile}(60 秒超时;不修改任何文件)…',
114
+ explainBootOk: '[dsh-safe] profile {profile} 当前能正常启动,没有失败可解读。',
115
+ explainUsingLast: '[dsh-safe] 解读最近一次失败记录: {file}',
116
+ explainNoSource: '[dsh-safe] 没有可解读的内容。用法:dsh-safe explain [--profile <名> | --file <路径>],或从 stdin 管道输入。\n 包装启动失败时 stderr 会自动存到 $DSH_HOME/dsh-safe/last-failure-<profile>.log。',
113
117
  excludedByList: '[dsh-safe] 按豁免名单跳过 {label}(不自动禁用)',
114
118
  doctorSelf: 'dsh-safe {version}',
115
119
  doctorDsh: 'dsh {name} {version}',
@@ -124,7 +128,8 @@ const ZH = {
124
128
  doctorAIOff: 'AI 未启用(DSH_SAFE_AI_KEY 未设置)',
125
129
  doctorCheckLast: '更新检查 上次 {time}',
126
130
  doctorCheckNever: '更新检查 从未执行',
127
- repairIdRequired: '[dsh-safe] 需要提供要修复的隔离行 id(dsh-safe list 可查)。',
131
+ repairPickOne: '[dsh-safe] {count} 条隔离记录,指定要修复的 id(dsh-safe repair <id>):',
132
+ repairNotSupportedTag: '(不支持自动修复)',
128
133
  repairEntryMissing: '[dsh-safe] 台账里没有找到 {id} 对应的隔离记录。',
129
134
  repairAmbiguous: '[dsh-safe] {id} 在多个 profile 中都有隔离记录({profiles}),请用 --profile <名> 指定。',
130
135
  repairUnsupported: '[dsh-safe] 该失败类型不支持自动修复(仅限模块解析失败类)。原因: {reason}\n 可用 dsh-safe explain 查看解读。',
@@ -152,9 +157,9 @@ Usage:
152
157
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
153
158
  dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
154
159
  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)
160
+ dsh-safe explain [--profile <name> | --file <path>] interpret the last boot failure with AI
161
+ dsh-safe repair [id] [--profile <name>] [--to <ver>] [-y] [--dry-run]
162
+ reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only; omit id when there is exactly one record)
158
163
  dsh-safe help show this help
159
164
  dsh-safe --version show version
160
165
 
@@ -239,8 +244,12 @@ Notes:
239
244
  aiNoInput: '[dsh-safe] no stderr content was read: use --file <path>, or paste to stdin and press Ctrl-D.',
240
245
  aiExplainFailed: '[dsh-safe] AI interpretation failed (network or API error); no files were modified.',
241
246
  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
247
  aiRecovered: '[dsh-safe] AI fallback identified {count} suspected broken plugin(s); the same quarantine pipeline applies',
248
+ explainHint: '[dsh-safe] run dsh-safe explain to interpret this failure',
249
+ explainBooting: '[dsh-safe] booting profile {profile} (60s timeout; no files are modified)…',
250
+ explainBootOk: '[dsh-safe] profile {profile} boots fine right now — nothing failed to interpret.',
251
+ explainUsingLast: '[dsh-safe] interpreting the last failure record: {file}',
252
+ explainNoSource: '[dsh-safe] nothing to interpret. Usage: dsh-safe explain [--profile <name> | --file <path>], or pipe via stdin.\n On wrapped-boot failures stderr is saved to $DSH_HOME/dsh-safe/last-failure-<profile>.log automatically.',
244
253
  excludedByList: '[dsh-safe] skipped {label} per the exclusion list (never auto-disabled)',
245
254
  doctorSelf: 'dsh-safe {version}',
246
255
  doctorDsh: 'dsh {name} {version}',
@@ -255,7 +264,8 @@ Notes:
255
264
  doctorAIOff: 'AI disabled (DSH_SAFE_AI_KEY not set)',
256
265
  doctorCheckLast: 'update-check last ran at {time}',
257
266
  doctorCheckNever: 'update-check never ran',
258
- repairIdRequired: '[dsh-safe] an isolated row id is required (see dsh-safe list).',
267
+ repairPickOne: '[dsh-safe] {count} quarantined record(s); specify the id to repair (dsh-safe repair <id>):',
268
+ repairNotSupportedTag: ' (not auto-repairable)',
259
269
  repairEntryMissing: '[dsh-safe] no quarantine record found for {id}.',
260
270
  repairAmbiguous: '[dsh-safe] {id} is quarantined in multiple profiles ({profiles}); specify one with --profile <name>.',
261
271
  repairUnsupported: '[dsh-safe] this failure type cannot be auto-repaired (module-resolution failures only). Reason: {reason}\n try dsh-safe explain for an interpretation.',
package/lib/repair.js CHANGED
@@ -42,7 +42,6 @@ function parseRepairArgs(args) {
42
42
  else if (!a.startsWith('-') && opts.id === undefined) opts.id = a
43
43
  else return { error: { key: 'updateUnknownFlag', params: { arg: a } } }
44
44
  }
45
- if (opts.id === undefined) return { error: { key: 'repairIdRequired' } }
46
45
  if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) return { error: { key: 'updateToInvalid' } }
47
46
  return { opts }
48
47
  }
@@ -67,15 +66,32 @@ export async function cmdRepair(args, { spawn = spawnSync, log = err, write = ou
67
66
  return 2
68
67
  }
69
68
 
70
- // 台账定位:省略 --profile 时跨 profile 搜索
69
+ // 台账定位:省略 --profile 时跨 profile 搜索;省略 id 时——
70
+ // 恰好一条隔离记录 → 直接修它(最常见情况);
71
+ // 多条 → 列出清单让用户挑(标记哪些可自动修复)。
71
72
  const ledger = loadLedger()
72
- const matches = []
73
+ let matches = []
73
74
  for (const [profile, entries] of Object.entries(ledger.profiles)) {
74
75
  if (opts.profile && profile !== opts.profile) continue
75
76
  for (const entry of entries ?? []) {
76
- if (entry.id === opts.id) matches.push({ profile, entry })
77
+ if (opts.id === undefined || entry.id === opts.id) matches.push({ profile, entry })
77
78
  }
78
79
  }
80
+ if (opts.id === undefined) {
81
+ if (!matches.length) {
82
+ log(t('noRecords'))
83
+ return 0
84
+ }
85
+ if (matches.length > 1) {
86
+ log(t('repairPickOne', { count: matches.length }))
87
+ for (const m of matches) {
88
+ const repairable = RESOLVE_FAIL_RE.test(m.entry.reason ?? '')
89
+ log(` - ${m.entry.id}(${m.entry.name ?? '-'})${repairable ? '' : t('repairNotSupportedTag')}`)
90
+ }
91
+ return 0
92
+ }
93
+ opts.id = matches[0].entry.id
94
+ }
79
95
  if (!matches.length) {
80
96
  log(t('repairEntryMissing', { id: opts.id }))
81
97
  return 1
package/lib/wrap.js CHANGED
@@ -11,9 +11,11 @@
11
11
  * 再启动(见 dshpaths.js),其余平台原样 spawn。
12
12
  */
13
13
  import { spawn } from 'node:child_process'
14
+ import { mkdirSync, writeFileSync } from 'node:fs'
15
+ import { dirname } from 'node:path'
14
16
  import { summarizeLine, parseFailureReport } from './failures.js'
15
17
  import { collectKnownRows, matchFailures } from './knownrows.js'
16
- import { detectInvocation, resolveDshSpawnTarget } from './dshpaths.js'
18
+ import { detectInvocation, lastFailureFile, resolveDshSpawnTarget } from './dshpaths.js'
17
19
  import { writeQuarantine } from './quarantine.js'
18
20
  import { loadConfig } from './config.js'
19
21
  import { aiEnabled, detectFailureWithAI } from './ai.js'
@@ -24,6 +26,16 @@ const FIRST_PARTY_PREFIX = '@deepseek-ai/'
24
26
 
25
27
  const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
26
28
 
29
+ /** 启动失败时把捕获的 stderr 存到 last-failure-<profile>.log(供 explain 默认解读与人工翻阅)。 */
30
+ function persistFailure(profile, stderr) {
31
+ try {
32
+ if (!stderr?.trim()) return
33
+ const file = lastFailureFile(profile)
34
+ mkdirSync(dirname(file), { recursive: true })
35
+ writeFileSync(file, stderr)
36
+ } catch {}
37
+ }
38
+
27
39
  const aiRecoverEnabled = () => process.env.DSH_SAFE_AI_RECOVER === '1' && aiEnabled()
28
40
 
29
41
  /**
@@ -104,6 +116,7 @@ export async function runWrapped(options) {
104
116
  log(t('noProfile'))
105
117
  return code
106
118
  }
119
+ if (!dryRun) persistFailure(invocation.profile, stderr)
107
120
  const known = collectKnownRows(invocation.profile)
108
121
  const report = parseFailureReport(stderr)
109
122
  let hits = matchFailures(report, known)
@@ -128,7 +141,10 @@ export async function runWrapped(options) {
128
141
  log(t('skipFirstParty', { name: hit.name, id: hit.id }))
129
142
  }
130
143
  if (!quarantinable.length) {
131
- if (!firstParty.length) log(t('nothingFound'))
144
+ if (!firstParty.length) {
145
+ log(t('nothingFound'))
146
+ log(t('explainHint'))
147
+ }
132
148
  return code
133
149
  }
134
150
  if (attempt >= maxRetries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",