@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 +84 -0
- package/README.md +3 -13
- package/lib/cli.js +15 -38
- package/lib/i18n.js +171 -0
- package/lib/knownrows.js +20 -16
- package/lib/wrap.js +12 -11
- package/package.json +4 -2
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](
|
|
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(
|
|
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(
|
|
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}
|
|
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('
|
|
86
|
+
process.stderr.write(`${t('restoreNeedsProfile')}\n`)
|
|
110
87
|
return 2
|
|
111
88
|
}
|
|
112
89
|
if (!all && ids0.length === 0) {
|
|
113
|
-
process.stderr.write('
|
|
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 ? '
|
|
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('
|
|
120
|
-
else if (kept.length) process.stdout.write(
|
|
121
|
-
else process.stdout.write('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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(
|
|
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('
|
|
67
|
+
log(t('pluginPassthrough'))
|
|
67
68
|
return code
|
|
68
69
|
}
|
|
69
70
|
if (!invocation.profile) {
|
|
70
|
-
log('
|
|
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(
|
|
85
|
+
log(t('skipFirstParty', { name: hit.name, id: hit.id }))
|
|
85
86
|
}
|
|
86
87
|
if (!quarantinable.length) {
|
|
87
|
-
if (!firstParty.length) log('
|
|
88
|
+
if (!firstParty.length) log(t('nothingFound'))
|
|
88
89
|
return code
|
|
89
90
|
}
|
|
90
91
|
if (attempt >= maxRetries) {
|
|
91
|
-
log(
|
|
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
|
|
102
|
-
const verb = dryRun ? '
|
|
103
|
-
log(`[dsh-safe] ${verb} ${
|
|
104
|
-
log(
|
|
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('
|
|
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.
|
|
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",
|