@hyzyn/dsh-safe 0.7.0 → 0.9.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,7 +47,7 @@ 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`) |
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
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 |
@@ -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,7 +47,7 @@ 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`) |
50
+ | `dsh-safe explain [--profile <名> \| --file <路径>]` | 用 AI 解读启动失败:默认解读最近一次失败记录,`--profile` 现场试启并解读,`--file`/stdin 读任意日志(需 `DSH_SAFE_AI_KEY`) |
51
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`) | 显示版本 |
@@ -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 <路径>] [-- <dsh 参数…>]`**:默认解读最近一次启动失败(失败时 stderr 自动持久化到 `$DSH_HOME/dsh-safe/last-failure-<profile>.log`,人也可直接翻阅);`--profile` 现场试启该 profile 并解读(60 秒超时,`--` 之后可透传 dsh 启动参数,如 `-- --port 3084`);`--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,123 @@ export async function detectFailureWithAI(stderr, knownRows) {
104
108
  return []
105
109
  }
106
110
  }
111
+
112
+ /** 试启一个 profile 并捕获 stderr(60s 超时;超时留下的部分 stderr 也可解读)。 */
113
+ function bootProfileForExplain(profile, extraArgs, { spawn }) {
114
+ const target = resolveDshSpawnTarget('dsh')
115
+ const { status, stderr } = spawn(target.file, [...target.prefix, '--profile', profile, ...extraArgs], {
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
+ const bootArgs = []
169
+ let passthrough = false
170
+ for (let i = 0; i < args.length; i++) {
171
+ const a = args[i]
172
+ if (passthrough) { bootArgs.push(a); continue }
173
+ if (a === '--') { passthrough = true; continue }
174
+ if (a === '--file') file = args[++i]
175
+ else if (a.startsWith('--file=')) file = args[i].slice('--file='.length)
176
+ else if (a === '--profile') profile = args[++i]
177
+ else if (a.startsWith('--profile=')) profile = args[i].slice('--profile='.length)
178
+ else {
179
+ // 严格解析:未知参数绝不静默吞(否则用户传给 dsh 的意图会被丢弃)
180
+ log(t('explainUnknownArg', { arg: a }))
181
+ return 2
182
+ }
183
+ }
184
+ if (bootArgs.length && profile === undefined) {
185
+ log(t('explainArgsNeedProfile'))
186
+ return 2
187
+ }
188
+ if (!aiEnabled()) {
189
+ log(t('aiDisabled'))
190
+ return 1
191
+ }
192
+ let input = ''
193
+ if (file !== undefined) {
194
+ try {
195
+ input = readFileSync(file, 'utf8')
196
+ } catch {
197
+ log(t('aiFileUnreadable', { file }))
198
+ return 2
199
+ }
200
+ } else if (profile !== undefined) {
201
+ log(t('explainBooting', { profile }))
202
+ const { code, stderr } = bootProfileForExplain(profile, bootArgs, { spawn })
203
+ if (code === 0 || !stderr?.trim()) {
204
+ log(t('explainBootOk', { profile }))
205
+ return 0
206
+ }
207
+ input = stderr
208
+ } else if (!isTTY) {
209
+ input = readStdin()
210
+ } else {
211
+ const latest = findLatestFailureFile()
212
+ if (!latest) {
213
+ log(t('explainNoSource'))
214
+ return 2
215
+ }
216
+ log(t('explainUsingLast', { file: latest.path }))
217
+ input = readFileSync(latest.path, 'utf8')
218
+ }
219
+ if (!input.trim()) {
220
+ log(t('aiNoInput'))
221
+ return 2
222
+ }
223
+ const answer = await explainFailure(input)
224
+ if (!answer) {
225
+ log(t('aiExplainFailed'))
226
+ return 1
227
+ }
228
+ write(answer)
229
+ return 0
230
+ }
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,7 +24,7 @@ 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)
27
+ dsh-safe explain [--profile <名> | --file <路径>] 用 AI 解读启动失败(默认解读最近一次失败)
28
28
  dsh-safe repair [id] [--profile <名>] [--to <版本>] [-y] [--dry-run]
29
29
  重装/升级被隔离的插件并自动恢复(限模块解析失败类;省略 id 时只有一条记录则直接修)
30
30
  dsh-safe help 显示本帮助
@@ -108,8 +108,14 @@ 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
+ explainArgsNeedProfile: '[dsh-safe] 透传的启动参数需要与 --profile 一起使用。',
115
+ explainBootOk: '[dsh-safe] profile {profile} 当前能正常启动,没有失败可解读。',
116
+ explainUsingLast: '[dsh-safe] 解读最近一次失败记录: {file}',
117
+ explainNoSource: '[dsh-safe] 没有可解读的内容。用法:dsh-safe explain [--profile <名> | --file <路径>],或从 stdin 管道输入。\n 包装启动失败时 stderr 会自动存到 $DSH_HOME/dsh-safe/last-failure-<profile>.log。',
118
+ explainUnknownArg: '[dsh-safe] explain 无法识别的参数: {arg}(透传给 dsh 的启动参数请放在 -- 之后,如 -- --port 3084)',
113
119
  excludedByList: '[dsh-safe] 按豁免名单跳过 {label}(不自动禁用)',
114
120
  doctorSelf: 'dsh-safe {version}',
115
121
  doctorDsh: 'dsh {name} {version}',
@@ -153,7 +159,7 @@ Usage:
153
159
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
154
160
  dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
155
161
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
156
- dsh-safe explain [--file <path>] interpret a failed-boot stderr with AI (needs DSH_SAFE_AI_KEY)
162
+ dsh-safe explain [--profile <name> | --file <path>] interpret the last boot failure with AI
157
163
  dsh-safe repair [id] [--profile <name>] [--to <ver>] [-y] [--dry-run]
158
164
  reinstall/upgrade a quarantined plugin and auto-restore it (module-resolution failures only; omit id when there is exactly one record)
159
165
  dsh-safe help show this help
@@ -240,8 +246,14 @@ Notes:
240
246
  aiNoInput: '[dsh-safe] no stderr content was read: use --file <path>, or paste to stdin and press Ctrl-D.',
241
247
  aiExplainFailed: '[dsh-safe] AI interpretation failed (network or API error); no files were modified.',
242
248
  aiFileUnreadable: '[dsh-safe] cannot read file: {file}',
243
- explainStdinHint: '[dsh-safe] reading from stdin: paste the stderr and press Ctrl-D (EOF) to finish, or use --file <path>.',
244
249
  aiRecovered: '[dsh-safe] AI fallback identified {count} suspected broken plugin(s); the same quarantine pipeline applies',
250
+ explainHint: '[dsh-safe] run dsh-safe explain to interpret this failure',
251
+ explainBooting: '[dsh-safe] booting profile {profile} (60s timeout; no files are modified)…',
252
+ explainArgsNeedProfile: '[dsh-safe] passthrough boot args require --profile.',
253
+ explainBootOk: '[dsh-safe] profile {profile} boots fine right now — nothing failed to interpret.',
254
+ explainUsingLast: '[dsh-safe] interpreting the last failure record: {file}',
255
+ 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.',
256
+ explainUnknownArg: '[dsh-safe] unrecognized argument for explain: {arg} (put dsh boot args after --, e.g. -- --port 3084)',
245
257
  excludedByList: '[dsh-safe] skipped {label} per the exclusion list (never auto-disabled)',
246
258
  doctorSelf: 'dsh-safe {version}',
247
259
  doctorDsh: 'dsh {name} {version}',
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.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",