@hyzyn/dsh-safe 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,84 @@
1
+ # dsh-safe · Startup Fuse for dsh
2
+
3
+ [中文](./README.md) | English
4
+
5
+ When a community plugin of DeepSeek Harness (DSH) is incompatible with the dsh runtime, `dsh web` **fails to boot entirely** — the loader flattens all patch layers into a single load tree, so if any plugin fails to import, throws inside `apply`, or times out waiting for an injected service, the boot audit rejects the whole tree and the process exits. The only remedy was manually editing `cordis.patch.yml` to disable the broken plugin.
6
+
7
+ **dsh-safe automates that manual step**: it wraps `dsh`, identifies the offending plugin from the startup error, sets the matching row to `disabled: true` in the profile patch (recording it in a quarantine ledger), and retries automatically. A broken plugin only breaks itself; dsh boots as usual.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install -g @hyzyn/dsh-safe
13
+ ```
14
+
15
+ Requires Node >= 20 and a local `dsh` command. Zero runtime dependencies.
16
+
17
+ ## Quick Start
18
+
19
+ Just replace `dsh` with `dsh-safe`:
20
+
21
+ ```bash
22
+ dsh-safe web # same as dsh web, with auto-quarantine
23
+ dsh-safe --profile tui --patch ./extra.yml
24
+ ```
25
+
26
+ Sample output (shown with a zh locale: a broken plugin is quarantined, then startup retries):
27
+
28
+ ```
29
+ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broken (@smoke/broken-impl): Cannot find package '@smoke/broken-impl' ...
30
+ [dsh-safe] 已禁用 @smoke/broken-impl (id: smoke-broken) → /Users/me/.dsh/profiles/web/cordis.patch.yml
31
+ 原因: Error: failed to import loader entry smoke-broken (@smoke/broken-impl): Cannot find package …
32
+ [dsh-safe] 重试启动…
33
+ ```
34
+
35
+ ## Commands
36
+
37
+ ```
38
+ dsh-safe <dsh args…> wrap and run dsh
39
+ dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
40
+ dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
41
+ re-enable auto-disabled plugins (after a fixed plugin upgrade)
42
+ dsh-safe help
43
+ ```
44
+
45
+ Wrapper-mode options (must come before the profile / subcommand):
46
+
47
+ | Option | Description |
48
+ | --- | --- |
49
+ | `--dry-run` | Parse and report only; no files are modified |
50
+ | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
51
+ | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
52
+
53
+ Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE` (`zh*` → Chinese, otherwise English); force with `DSH_SAFE_LANG=zh|en`.
54
+
55
+ ## How It Works
56
+
57
+ 1. **Failure identification**: when dsh fails to start, stderr carries four kinds of signatures (`plugin(s) failed to load: …`, `N entries did not activate` with per-row failures, `failed to apply/import loader entry <id> (<name>)`, and outer stack frames `…#<entryId>`). dsh-safe extracts the broken plugin's package name and row id from them.
58
+ 2. **Match against real rows**: it scans the profile patch, `$DSH_HOME/cordis.patch.yml` (home layer) and each bundle's patch to build a "row id ↔ plugin package" mapping; only rows that actually exist are disabled, avoiding collateral damage.
59
+ 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.
60
+ 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`).
61
+
62
+ ## Safety Boundaries
63
+
64
+ - **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.
65
+ - **Startup-phase failures only**: module resolution failures / `apply` throws / timed-out service injection. Uncaught runtime exceptions are still handled by dsh's own fail-loud policy and are out of scope for boot quarantine.
66
+ - **Auditable**: every write records the reason and a timestamp; `--dry-run` previews which plugins would be disabled.
67
+ - **Faithful pass-through**: when no broken plugin can be identified, the retry limit is exceeded, or for `dsh plugin` (pnpm forwarding), the exit code is passed through untouched and no files are modified.
68
+
69
+ ## Known Limitations
70
+
71
+ - If the patch file itself fails YAML parsing (e.g. broken by hand-editing), plugins cannot be identified and the failure is passed through.
72
+ - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned).
73
+ - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected.
74
+ - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ npm test # node:test unit tests + fake-dsh integration tests
80
+ ```
81
+
82
+ ## License
83
+
84
+ [MIT](./LICENSE)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-safe · dsh 启动保险丝
2
2
 
3
- 中文 | [English](#english)
3
+ 中文 | [English](./README.en.md)
4
4
 
5
5
  DeepSeek Harness(DSH)的社区插件与 dsh 运行时不兼容时,`dsh web` 会**整体启动失败**——加载器把所有 patch 层拉平成同一棵加载树,任何一个插件 import 失败、`apply` 抛错、或等不到注入的服务,启动审计就会拒绝整棵树,进程退出。此时只能手动编辑 `cordis.patch.yml` 把坏插件禁用。
6
6
 
@@ -50,6 +50,8 @@ dsh-safe help
50
50
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
51
51
  | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
52
52
 
53
+ 提示信息语言跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`(`zh*` 为中文,其余英文),也可用环境变量 `DSH_SAFE_LANG=zh|en` 强制指定。
54
+
53
55
  ## 工作原理
54
56
 
55
57
  1. **识别失败**:dsh 启动失败时,stderr 里有四类特征(`plugin(s) failed to load: …`、`N entries did not activate` 逐行失败、`failed to apply/import loader entry <id> (<name>)`、外层栈 `…#<entryId>`)。dsh-safe 从中提取坏插件的包名与行 id。
@@ -77,18 +79,6 @@ dsh-safe help
77
79
  npm test # node:test 单元测试 + 假 dsh 集成测试
78
80
  ```
79
81
 
80
- <a name="english"></a>
81
- ## English
82
-
83
- **dsh-safe** is a fuse for DeepSeek Harness (DSH) startup: when a community plugin is incompatible with the running dsh version, `dsh web` normally fails to boot entirely — one broken plugin rejects the whole loader tree. dsh-safe wraps any `dsh` invocation, parses the startup-failure diagnostics on stderr, marks the offending patch rows as `disabled: true` inside a managed block in your profile patch file (keeping all user content and comments), records the action in a quarantine ledger, and retries. First-party `@deepseek-ai/*` plugins are protected by default; `dsh-safe restore --profile <name> --all` re-enables quarantined rows after you upgrade the plugin.
84
-
85
- ```bash
86
- npm install -g @hyzyn/dsh-safe
87
- dsh-safe web # run dsh web with auto-quarantine
88
- dsh-safe list # show quarantined plugins
89
- dsh-safe restore --profile web --all
90
- ```
91
-
92
82
  ## License
93
83
 
94
84
  [MIT](./LICENSE)
package/lib/cli.js CHANGED
@@ -10,38 +10,13 @@
10
10
  import { createRequire } from 'node:module'
11
11
  import { loadLedger, restoreQuarantine } from './quarantine.js'
12
12
  import { runWrapped } from './wrap.js'
13
+ import { t } from './i18n.js'
13
14
 
14
15
  const require = createRequire(import.meta.url)
15
16
  const { version } = require('../package.json')
16
17
 
17
- const HELP = `dsh-safe ${version} — dsh 启动保险丝
18
-
19
- 社区插件与 dsh 运行时不兼容会让 dsh 整体启动失败。dsh-safe 包装运行 dsh:
20
- 启动失败时从报错里识别坏插件,在 profile patch 里把对应行置为 disabled
21
- (记录进隔离台账),然后自动重试。
22
-
23
- 用法:
24
- dsh-safe <dsh 参数…> 包装运行 dsh,例: dsh-safe web
25
- dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
26
- dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
27
- 恢复被自动禁用的插件(升级修复后使用)
28
- dsh-safe help 显示本帮助
29
- dsh-safe --version 显示版本
30
-
31
- 包装模式选项(必须写在 profile / 子命令之前):
32
- --dry-run 只解析与报告,不修改任何文件
33
- --max-retries <n> 自动隔离后最多重试启动的次数(默认 2)
34
- --allow-first-party 允许自动禁用 @deepseek-ai/* 第一方插件(默认跳过)
35
-
36
- 说明:
37
- - 行的禁用以 patch 文件末尾的托管区块写入(带标记注释),不改动用户内容;
38
- 恢复用 dsh-safe restore,或手动删除区块。
39
- - 只隔离"启动期"失败(模块解析失败 / apply 抛错 / 等不到注入服务);
40
- 运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理。
41
- `
42
-
43
18
  function printHelp() {
44
- process.stdout.write(HELP)
19
+ process.stdout.write(t('helpText', { version }))
45
20
  }
46
21
 
47
22
  function printVersion() {
@@ -92,10 +67,12 @@ function cmdList(args) {
92
67
  process.stdout.write(`${p}:\n`)
93
68
  for (const e of entries) {
94
69
  found++
95
- process.stdout.write(` - ${e.name ?? '(未知包名)'} (id: ${e.id})\n 隔离于 ${e.quarantinedAt}\n 原因: ${e.reason}\n 位置: ${e.file}\n`)
70
+ process.stdout.write(
71
+ ` - ${e.name ?? t('unknownName')} (id: ${e.id})\n${t('quarantinedAt', { time: e.quarantinedAt })}\n${t('reasonLine', { reason: e.reason })}\n${t('locationLine', { file: e.file })}\n`,
72
+ )
96
73
  }
97
74
  }
98
- if (!found) process.stdout.write(profile ? `${profile}: 没有隔离记录\n` : '没有隔离记录\n')
75
+ if (!found) process.stdout.write(profile ? `${t('noRecordsProfile', { profile })}\n` : `${t('noRecords')}\n`)
99
76
  return 0
100
77
  }
101
78
 
@@ -106,19 +83,19 @@ function cmdRestore(args) {
106
83
  const all = rest1.includes('--all')
107
84
  const profile = profile0
108
85
  if (!profile) {
109
- process.stderr.write('[dsh-safe] restore 需要 --profile <名>\n')
86
+ process.stderr.write(`${t('restoreNeedsProfile')}\n`)
110
87
  return 2
111
88
  }
112
89
  if (!all && ids0.length === 0) {
113
- process.stderr.write('[dsh-safe] restore 需要 --id <id>(可重复)或 --all\n')
90
+ process.stderr.write(`${t('restoreNeedsId')}\n`)
114
91
  return 2
115
92
  }
116
93
  const { restored, kept } = restoreQuarantine(profile, all ? 'all' : ids0, dryRun)
117
- const verb = dryRun ? '(dry-run)将恢复' : '已恢复'
94
+ const verb = t(dryRun ? 'restoredDry' : 'restored')
118
95
  for (const e of restored) process.stdout.write(`[dsh-safe] ${verb} ${e.name ?? e.id} (id: ${e.id})\n`)
119
- if (!restored.length) process.stdout.write('[dsh-safe] 没有匹配的隔离记录\n')
120
- else if (kept.length) process.stdout.write(`[dsh-safe] ${profile} 仍隔离 ${kept.length} 行;重启 dsh 生效。\n`)
121
- else process.stdout.write('[dsh-safe] 该 profile 的隔离名单已清空;重启 dsh 生效。\n')
96
+ if (!restored.length) process.stdout.write(`${t('noMatching')}\n`)
97
+ else if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile, count: kept.length })}\n`)
98
+ else process.stdout.write(`${t('ledgerCleared')}\n`)
122
99
  return 0
123
100
  }
124
101
 
@@ -158,7 +135,7 @@ export async function main(argv) {
158
135
  const raw = argv[++i]
159
136
  const v = raw === undefined || raw === '' ? NaN : Number(raw)
160
137
  if (!Number.isFinite(v) || v < 0) {
161
- process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
138
+ process.stderr.write(`${t('maxRetriesInvalid')}\n`)
162
139
  return 2
163
140
  }
164
141
  maxRetries = v
@@ -168,7 +145,7 @@ export async function main(argv) {
168
145
  const raw = a.slice('--max-retries='.length)
169
146
  const v = raw === '' ? NaN : Number(raw)
170
147
  if (!Number.isFinite(v) || v < 0) {
171
- process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
148
+ process.stderr.write(`${t('maxRetriesInvalid')}\n`)
172
149
  return 2
173
150
  }
174
151
  maxRetries = v
@@ -181,6 +158,6 @@ export async function main(argv) {
181
158
  printHelp()
182
159
  return 0
183
160
  }
184
- if (dryRun) process.stderr.write('[dsh-safe] dry-run:只报告,不修改文件。\n')
161
+ if (dryRun) process.stderr.write(`${t('dryRunNotice')}\n`)
185
162
  return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty })
186
163
  }
package/lib/i18n.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — 输出语言自适应。
3
+ *
4
+ * 语言判定优先级:DSH_SAFE_LANG > LC_ALL > LC_MESSAGES > LANG > LANGUAGE。
5
+ * 值为 zh* 时用中文,否则英文;全部未设置(或为 C/POSIX)时退回 Node 的
6
+ * Intl 默认 locale,仍非 zh 则英文。零依赖,不引 i18n 库。
7
+ */
8
+
9
+ const ZH = {
10
+ // ---- cli.js ----
11
+ helpText: `dsh-safe {version} — dsh 启动保险丝
12
+
13
+ 社区插件与 dsh 运行时不兼容会让 dsh 整体启动失败。dsh-safe 包装运行 dsh:
14
+ 启动失败时从报错里识别坏插件,在 profile patch 里把对应行置为 disabled
15
+ (记录进隔离台账),然后自动重试。
16
+
17
+ 用法:
18
+ dsh-safe <dsh 参数…> 包装运行 dsh,例: dsh-safe web
19
+ dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
20
+ dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
21
+ 恢复被自动禁用的插件(升级修复后使用)
22
+ dsh-safe help 显示本帮助
23
+ dsh-safe --version 显示版本
24
+
25
+ 包装模式选项(必须写在 profile / 子命令之前):
26
+ --dry-run 只解析与报告,不修改任何文件
27
+ --max-retries <n> 自动隔离后最多重试启动的次数(默认 2)
28
+ --allow-first-party 允许自动禁用 @deepseek-ai/* 第一方插件(默认跳过)
29
+
30
+ 说明:
31
+ - 行的禁用以 patch 文件末尾的托管区块写入(带标记注释),不改动用户内容;
32
+ 恢复用 dsh-safe restore,或手动删除区块。
33
+ - 只隔离"启动期"失败(模块解析失败 / apply 抛错 / 等不到注入服务);
34
+ 运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理。
35
+ - 输出语言跟随 LC_ALL / LC_MESSAGES / LANG / LANGUAGE(zh* 中文,其余英文);
36
+ 可用环境变量 DSH_SAFE_LANG=zh|en 强制指定。
37
+ `,
38
+ unknownName: '(未知包名)',
39
+ quarantinedAt: ' 隔离于 {time}',
40
+ reasonLine: ' 原因: {reason}',
41
+ locationLine: ' 位置: {file}',
42
+ noRecordsProfile: '{profile}: 没有隔离记录',
43
+ noRecords: '没有隔离记录',
44
+ restoreNeedsProfile: '[dsh-safe] restore 需要 --profile <名>',
45
+ restoreNeedsId: '[dsh-safe] restore 需要 --id <id>(可重复)或 --all',
46
+ restored: '已恢复',
47
+ restoredDry: '(dry-run)将恢复',
48
+ noMatching: '[dsh-safe] 没有匹配的隔离记录',
49
+ stillQuarantined: '[dsh-safe] {profile} 仍隔离 {count} 行;重启 dsh 生效。',
50
+ ledgerCleared: '[dsh-safe] 该 profile 的隔离名单已清空;重启 dsh 生效。',
51
+ maxRetriesInvalid: '[dsh-safe] --max-retries 需要一个非负整数',
52
+ dryRunNotice: '[dsh-safe] dry-run:只报告,不修改文件。',
53
+
54
+ // ---- wrap.js ----
55
+ spawnFailed: '[dsh-safe] 无法启动 {command}: {message}',
56
+ pluginPassthrough: '[dsh-safe] `dsh plugin` 为 pnpm 转发,不做隔离,原样透传。',
57
+ noProfile:
58
+ '[dsh-safe] 未能从参数确定 profile(launcher 旗标需在最前,或用 --profile <name>),无法自动隔离,原样透传。',
59
+ skipFirstParty:
60
+ '[dsh-safe] 跳过第一方插件 {name} (id: {id}) —— 默认保护 @deepseek-ai/*,需要手动处理;确认要禁用可加 --allow-first-party。',
61
+ nothingFound: '[dsh-safe] 报错里没有识别出可自动隔离的已挂载插件,原样透传。',
62
+ maxRetriesReached: '[dsh-safe] 已达最大重试次数(--max-retries {count}),不再重试。',
63
+ disabled: '已禁用',
64
+ willDisable: '(dry-run)将禁用',
65
+ reasonIndent: ' 原因: {reason}',
66
+ retrying: '[dsh-safe] 重试启动…',
67
+ }
68
+
69
+ const EN = {
70
+ // ---- cli.js ----
71
+ helpText: `dsh-safe {version} — startup fuse for dsh
72
+
73
+ When a community plugin is incompatible with the dsh runtime, dsh fails to boot as a
74
+ whole. dsh-safe wraps dsh: on startup failure it identifies the broken plugin from
75
+ the error, marks its row disabled in the profile patch (recording it in a quarantine
76
+ ledger), and retries automatically.
77
+
78
+ Usage:
79
+ dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
80
+ dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
81
+ dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
82
+ re-enable auto-disabled plugins (after a fixed plugin upgrade)
83
+ dsh-safe help show this help
84
+ dsh-safe --version show version
85
+
86
+ Wrapper-mode options (must come before the profile / subcommand):
87
+ --dry-run parse and report only; no files are modified
88
+ --max-retries <n> max startup retries after an auto-quarantine (default 2)
89
+ --allow-first-party allow auto-disabling @deepseek-ai/* first-party plugins (skipped by default)
90
+
91
+ Notes:
92
+ - Rows are disabled via a marker-commented managed block appended to the patch file;
93
+ user content is untouched. Restore with dsh-safe restore, or delete the block manually.
94
+ - Only startup-phase failures are quarantined (module resolution / apply throw /
95
+ missing injected service); runtime uncaught exceptions stay under dsh's own
96
+ fail-loud policy.
97
+ - Output language follows LC_ALL / LC_MESSAGES / LANG / LANGUAGE (zh* → Chinese,
98
+ otherwise English); force it with DSH_SAFE_LANG=zh|en.
99
+ `,
100
+ unknownName: '(unknown package)',
101
+ quarantinedAt: ' quarantined at {time}',
102
+ reasonLine: ' reason: {reason}',
103
+ locationLine: ' location: {file}',
104
+ noRecordsProfile: '{profile}: no quarantine records',
105
+ noRecords: 'no quarantine records',
106
+ restoreNeedsProfile: '[dsh-safe] restore requires --profile <name>',
107
+ restoreNeedsId: '[dsh-safe] restore requires --id <id> (repeatable) or --all',
108
+ restored: 'restored',
109
+ restoredDry: '(dry-run) would restore',
110
+ noMatching: '[dsh-safe] no matching quarantine records',
111
+ stillQuarantined: '[dsh-safe] {profile} still has {count} quarantined row(s); restart dsh to apply.',
112
+ ledgerCleared: '[dsh-safe] quarantine list for this profile is now empty; restart dsh to apply.',
113
+ maxRetriesInvalid: '[dsh-safe] --max-retries requires a non-negative integer',
114
+ dryRunNotice: '[dsh-safe] dry-run: report only, no files will be modified.',
115
+
116
+ // ---- wrap.js ----
117
+ spawnFailed: '[dsh-safe] failed to start {command}: {message}',
118
+ pluginPassthrough: '[dsh-safe] `dsh plugin` is a pnpm forwarder; no quarantine, exit code passed through.',
119
+ noProfile:
120
+ '[dsh-safe] could not determine the profile from args (launcher flags must come first, or use --profile <name>); no auto-quarantine, exit code passed through.',
121
+ skipFirstParty:
122
+ '[dsh-safe] skipping first-party plugin {name} (id: {id}) — @deepseek-ai/* is protected by default; handle manually, or pass --allow-first-party to allow disabling.',
123
+ nothingFound:
124
+ '[dsh-safe] no mounted plugin could be identified as auto-quarantinable from the error; exit code passed through.',
125
+ maxRetriesReached: '[dsh-safe] max retries reached (--max-retries {count}); giving up.',
126
+ disabled: 'disabled',
127
+ willDisable: '(dry-run) would disable',
128
+ reasonIndent: ' reason: {reason}',
129
+ retrying: '[dsh-safe] retrying…',
130
+ }
131
+
132
+ const CATALOG = { zh: ZH, en: EN }
133
+
134
+ /** 把 locale 值归一成 'zh' | 'en';空值 / C / POSIX 返回 null(交给下一优先级)。 */
135
+ function parseLocale(raw) {
136
+ if (!raw) return null
137
+ const base = raw.split(':')[0].split('.')[0].split('@')[0].trim().toLowerCase()
138
+ if (!base || base === 'c' || base === 'posix') return null
139
+ return base.startsWith('zh') ? 'zh' : 'en'
140
+ }
141
+
142
+ /**
143
+ * 纯函数,方便单测。默认参数取真实环境。
144
+ * @param {Record<string, string | undefined>} env
145
+ * @param {string} [intlLocale] 环境变量全部缺失时的兜底(生产为 Node 的 Intl 默认 locale)
146
+ */
147
+ export function detectLocale(env = process.env, intlLocale = Intl.DateTimeFormat().resolvedOptions().locale) {
148
+ for (const key of ['DSH_SAFE_LANG', 'LC_ALL', 'LC_MESSAGES', 'LANG', 'LANGUAGE']) {
149
+ const lang = parseLocale(env[key])
150
+ if (lang) return lang
151
+ }
152
+ return typeof intlLocale === 'string' && intlLocale.toLowerCase().startsWith('zh') ? 'zh' : 'en'
153
+ }
154
+
155
+ let cached
156
+
157
+ /** 当前进程的输出语言(首次调用时检测并缓存)。 */
158
+ export function getLocale() {
159
+ return (cached ??= detectLocale())
160
+ }
161
+
162
+ /** 指定语言取词条,主要用于单测。 */
163
+ export function translate(locale, key, params = {}) {
164
+ const text = CATALOG[locale]?.[key] ?? CATALOG.en[key] ?? key
165
+ return text.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? String(params[name]) : match))
166
+ }
167
+
168
+ /** 按当前语言取词条并做 {name} 插值。 */
169
+ export function t(key, params = {}) {
170
+ return translate(getLocale(), key, params)
171
+ }
package/lib/knownrows.js CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * @hyzyn/dsh-safe — 收集一个 profile 下"行 id ↔ 插件包名"的对照表。
3
3
  *
4
- * 来源(后写的层优先级更高):
5
- * 1. profile 自己的 cordis.patch.yml(写层)
6
- * 2. `$DSH_HOME/cordis.patch.yml` home 层(写层:发现的行写回 home patch)
7
- * 3. profile package.json `dsh.profile.bundles` 里每个 bundle 的 patch
8
- * (只读对照;禁用行写入 profile 层,它在 bundle 层之后合成,同 id 后写覆盖)
4
+ * 收集顺序 = dsh 的 patch 合成顺序(同 id 后写覆盖):
5
+ * 1. profile package.json `dsh.profile.bundles` 里每个 bundle 的 patch
6
+ * (只读对照;禁用行写入 profile 层——它在 bundle 层之后合成,同 id 覆盖 bundle 行)
7
+ * 2. `$DSH_HOME/cordis.patch.yml` home
8
+ * 3. profile 自己的 cordis.patch.yml(写层,dsh-safe 托管区块也在其中)
9
+ *
10
+ * 因此对照表数组里越靠后的行优先级越高:profile 层(含托管区块的 disabled 行)
11
+ * 覆盖 home / bundle 层的同 id 行。
9
12
  */
10
13
  import { join } from 'node:path'
11
14
  import { scanPatchRows } from './patchfile.js'
@@ -32,16 +35,6 @@ export function collectKnownRows(profile) {
32
35
  const profilePatch = profilePatchPath(profile)
33
36
  const homePatch = homePatchPath()
34
37
 
35
- const profileText = readIfExists(profilePatch)
36
- if (profileText !== undefined) {
37
- for (const row of scanPatchRows(profileText)) rows.push({ ...row, source: 'profile', file: profilePatch })
38
- }
39
-
40
- const homeText = readIfExists(homePatch)
41
- if (homeText !== undefined) {
42
- for (const row of scanPatchRows(homeText)) rows.push({ ...row, source: 'home', file: homePatch })
43
- }
44
-
45
38
  const manifest = readJsonIfExists(profileManifestPath(profile))
46
39
  const bundles = manifest?.dsh?.profile?.bundles ?? []
47
40
  for (const bundle of bundles) {
@@ -54,12 +47,23 @@ export function collectKnownRows(profile) {
54
47
  for (const row of scanPatchRows(patchText)) rows.push({ ...row, source: bundle, file: profilePatch })
55
48
  }
56
49
 
50
+ const homeText = readIfExists(homePatch)
51
+ if (homeText !== undefined) {
52
+ for (const row of scanPatchRows(homeText)) rows.push({ ...row, source: 'home', file: homePatch })
53
+ }
54
+
55
+ const profileText = readIfExists(profilePatch)
56
+ if (profileText !== undefined) {
57
+ for (const row of scanPatchRows(profileText)) rows.push({ ...row, source: 'profile', file: profilePatch })
58
+ }
59
+
57
60
  return { rows, profilePatch, homePatch }
58
61
  }
59
62
 
60
63
  /**
61
64
  * 把失败报告对照到真实存在的 patch 行。
62
65
  * 包名命中该包的全部行(一个包可能挂多个 id);行 id 命中对应行。
66
+ * 同一 id 在多层重复出现时,取合成顺序里最后出现的行(profile/托管区块覆盖 home/bundle)。
63
67
  * @param {{ names: Array<[string, string]>, entryIds: Array<[string, string]> }} report
64
68
  * @param {{ rows: Array<{ id: string, name: string|null, disabled: boolean, file: string }> }} known
65
69
  * @returns {Array<{ id: string, name: string|null, disabled: boolean, file: string, line: string }>}
@@ -68,7 +72,7 @@ export function matchFailures(report, known) {
68
72
  const byId = new Map()
69
73
  const byName = new Map()
70
74
  for (const row of known.rows) {
71
- if (!byId.has(row.id)) byId.set(row.id, row)
75
+ byId.set(row.id, row) // 同 id 后写覆盖
72
76
  if (row.name) {
73
77
  if (!byName.has(row.name)) byName.set(row.name, [])
74
78
  byName.get(row.name).push(row)
package/lib/wrap.js CHANGED
@@ -12,6 +12,7 @@ import { summarizeLine, parseFailureReport } from './failures.js'
12
12
  import { collectKnownRows, matchFailures } from './knownrows.js'
13
13
  import { detectInvocation } from './dshpaths.js'
14
14
  import { writeQuarantine } from './quarantine.js'
15
+ import { t } from './i18n.js'
15
16
 
16
17
  const CAPTURE_LIMIT = 512 * 1024
17
18
  const FIRST_PARTY_PREFIX = '@deepseek-ai/'
@@ -31,7 +32,7 @@ export function spawnDsh(args, { command = 'dsh' } = {}) {
31
32
  if (captured.length < CAPTURE_LIMIT) captured += chunk
32
33
  })
33
34
  child.on('error', (error) => {
34
- process.stderr.write(`[dsh-safe] 无法启动 ${command}: ${error.message}\n`)
35
+ process.stderr.write(`${t('spawnFailed', { command, message: error.message })}\n`)
35
36
  resolve({ code: 127, stderr: captured })
36
37
  })
37
38
  child.on('close', (code) => resolve({ code: code ?? 1, stderr: captured }))
@@ -63,11 +64,11 @@ export async function runWrapped(options) {
63
64
  const { code, stderr } = await spawnFn(forwardArgs)
64
65
  if (code === 0) return 0
65
66
  if (invocation.mode === 'plugin') {
66
- log('[dsh-safe] `dsh plugin` 为 pnpm 转发,不做隔离,原样透传。')
67
+ log(t('pluginPassthrough'))
67
68
  return code
68
69
  }
69
70
  if (!invocation.profile) {
70
- log('[dsh-safe] 未能从参数确定 profile(launcher 旗标需在最前,或用 --profile <name>),无法自动隔离,原样透传。')
71
+ log(t('noProfile'))
71
72
  return code
72
73
  }
73
74
  const known = collectKnownRows(invocation.profile)
@@ -81,14 +82,14 @@ export async function runWrapped(options) {
81
82
  else quarantinable.push(hit)
82
83
  }
83
84
  for (const hit of firstParty) {
84
- log(`[dsh-safe] 跳过第一方插件 ${hit.name} (id: ${hit.id}) —— 默认保护 @deepseek-ai/*,需要手动处理;确认要禁用可加 --allow-first-party。`)
85
+ log(t('skipFirstParty', { name: hit.name, id: hit.id }))
85
86
  }
86
87
  if (!quarantinable.length) {
87
- if (!firstParty.length) log('[dsh-safe] 报错里没有识别出可自动隔离的已挂载插件,原样透传。')
88
+ if (!firstParty.length) log(t('nothingFound'))
88
89
  return code
89
90
  }
90
91
  if (attempt >= maxRetries) {
91
- log(`[dsh-safe] 已达最大重试次数(--max-retries ${maxRetries}),不再重试。`)
92
+ log(t('maxRetriesReached', { count: maxRetries }))
92
93
  return code
93
94
  }
94
95
  const targets = quarantinable.map((hit) => ({
@@ -98,12 +99,12 @@ export async function runWrapped(options) {
98
99
  file: hit.file,
99
100
  }))
100
101
  writeQuarantine(invocation.profile, targets, dryRun)
101
- for (const t of targets) {
102
- const verb = dryRun ? '(dry-run)将禁用' : '已禁用'
103
- log(`[dsh-safe] ${verb} ${t.name ?? t.id} (id: ${t.id}) → ${t.file}`)
104
- log(` 原因: ${t.reason}`)
102
+ for (const target of targets) {
103
+ const verb = t(dryRun ? 'willDisable' : 'disabled')
104
+ log(`[dsh-safe] ${verb} ${target.name ?? target.id} (id: ${target.id}) → ${target.file}`)
105
+ log(t('reasonIndent', { reason: target.reason }))
105
106
  }
106
107
  if (dryRun) return code
107
- log('[dsh-safe] 重试启动…')
108
+ log(t('retrying'))
108
109
  }
109
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "bin",
12
12
  "lib",
13
- "README.md"
13
+ "README.md",
14
+ "README.en.md"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=20"
@@ -24,6 +25,7 @@
24
25
  },
25
26
  "keywords": [
26
27
  "dsh",
28
+ "dsh-plugin",
27
29
  "deepseek-harness",
28
30
  "plugin",
29
31
  "quarantine",