@hyzyn/dsh-safe 0.1.0 → 0.3.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,94 @@
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 -u [dsh args…] upgrade dsh first (skip if latest), then boot
40
+ dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
41
+ dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
42
+ re-enable auto-disabled plugins (after a fixed plugin upgrade)
43
+ dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--pm npm|pnpm]
44
+ upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
45
+ dsh-safe help
46
+ ```
47
+
48
+ Wrapper-mode options (must come before the profile / subcommand):
49
+
50
+ | Option | Description |
51
+ | --- | --- |
52
+ | `--dry-run` | Parse and report only; no files are modified |
53
+ | `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
54
+ | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
55
+
56
+ Upgrading dsh: `dsh-safe update` auto-detects the dsh package name and install method (npm / pnpm global installs), compares against the latest version and runs the upgrade for you, then automatically restores all quarantined plugins — any still incompatible under the new dsh will be auto-quarantined again on the next start. `--to <version>` pins a target version (also how you roll back), `--no-restore` skips the restore, `-y` skips the confirmation.
57
+
58
+ update / -u also checks dsh-safe's own version and upgrades whichever is outdated (single install command); `--self` updates dsh-safe only. Additionally, every wrapped boot checks for a new dsh-safe version at most once a day and prints a one-line notice (fully silent on check failure); disable with `DSH_SAFE_NO_UPDATE_CHECK=1`.
59
+
60
+ For daily use, just make `dsh-safe -u web` your start command: boots immediately when dsh is already latest (one version check), upgrades + restores first when an update is available, and only warns (still boots) if the update check itself fails. `-u` accepts update options (e.g. `-u -y web`) and wrapper flags (e.g. `-u --max-retries 0 web`).
61
+
62
+ Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE` (`zh*` → Chinese, otherwise English); force with `DSH_SAFE_LANG=zh|en`.
63
+
64
+ ## How It Works
65
+
66
+ 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.
67
+ 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.
68
+ 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.
69
+ 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`).
70
+
71
+ ## Safety Boundaries
72
+
73
+ - **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.
74
+ - **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.
75
+ - **Auditable**: every write records the reason and a timestamp; `--dry-run` previews which plugins would be disabled.
76
+ - **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.
77
+
78
+ ## Known Limitations
79
+
80
+ - If the patch file itself fails YAML parsing (e.g. broken by hand-editing), plugins cannot be identified and the failure is passed through.
81
+ - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned).
82
+ - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected.
83
+ - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser.
84
+ - Windows is best-effort: update / --self / list / restore are adapted (.cmd shim parsing, shelled npm/pnpm invocations), but the wrapped dsh boot's child-process spawn is unverified on Windows.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ npm test # node:test unit tests + fake-dsh integration tests
90
+ ```
91
+
92
+ ## License
93
+
94
+ [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
 
@@ -36,9 +36,12 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
36
36
 
37
37
  ```
38
38
  dsh-safe <dsh 参数…> 包装运行 dsh
39
+ dsh-safe -u [dsh 参数…] 先升级 dsh(已最新则跳过),再启动
39
40
  dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
40
41
  dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
41
42
  恢复被自动禁用的插件(升级修复后使用)
43
+ dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--pm npm|pnpm]
44
+ 升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
42
45
  dsh-safe help
43
46
  ```
44
47
 
@@ -50,6 +53,14 @@ dsh-safe help
50
53
  | `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
51
54
  | `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
52
55
 
56
+ 升级 dsh:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。`--to <版本>` 指定目标版本(也是回滚方式),`--no-restore` 跳过恢复,`-y` 跳过确认。
57
+
58
+ update / -u 会同时检查 dsh-safe 自身的版本,谁旧升谁(一条安装命令);`--self` 只更新 dsh-safe。另外每次包装启动时最多每天一次检查 dsh-safe 新版并提示一行(检查失败完全静默),`DSH_SAFE_NO_UPDATE_CHECK=1` 关闭。
59
+
60
+ 日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
61
+
62
+ 提示信息语言跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`(`zh*` 为中文,其余英文),也可用环境变量 `DSH_SAFE_LANG=zh|en` 强制指定。
63
+
53
64
  ## 工作原理
54
65
 
55
66
  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。
@@ -70,6 +81,7 @@ dsh-safe help
70
81
  - `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
71
82
  - 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
72
83
  - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
84
+ - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm),包装启动 dsh 的子进程方式未在 Windows 验证。
73
85
 
74
86
  ## 开发
75
87
 
@@ -77,18 +89,6 @@ dsh-safe help
77
89
  npm test # node:test 单元测试 + 假 dsh 集成测试
78
90
  ```
79
91
 
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
92
  ## License
93
93
 
94
94
  [MIT](./LICENSE)
package/lib/cli.js CHANGED
@@ -10,38 +10,14 @@
10
10
  import { createRequire } from 'node:module'
11
11
  import { loadLedger, restoreQuarantine } from './quarantine.js'
12
12
  import { runWrapped } from './wrap.js'
13
+ import { cmdUpdate, cmdUpdateAndBoot, maybeNotifySelfUpdate } from './update.js'
14
+ import { t } from './i18n.js'
13
15
 
14
16
  const require = createRequire(import.meta.url)
15
17
  const { version } = require('../package.json')
16
18
 
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
19
  function printHelp() {
44
- process.stdout.write(HELP)
20
+ process.stdout.write(t('helpText', { version }))
45
21
  }
46
22
 
47
23
  function printVersion() {
@@ -92,10 +68,12 @@ function cmdList(args) {
92
68
  process.stdout.write(`${p}:\n`)
93
69
  for (const e of entries) {
94
70
  found++
95
- process.stdout.write(` - ${e.name ?? '(未知包名)'} (id: ${e.id})\n 隔离于 ${e.quarantinedAt}\n 原因: ${e.reason}\n 位置: ${e.file}\n`)
71
+ process.stdout.write(
72
+ ` - ${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`,
73
+ )
96
74
  }
97
75
  }
98
- if (!found) process.stdout.write(profile ? `${profile}: 没有隔离记录\n` : '没有隔离记录\n')
76
+ if (!found) process.stdout.write(profile ? `${t('noRecordsProfile', { profile })}\n` : `${t('noRecords')}\n`)
99
77
  return 0
100
78
  }
101
79
 
@@ -106,19 +84,19 @@ function cmdRestore(args) {
106
84
  const all = rest1.includes('--all')
107
85
  const profile = profile0
108
86
  if (!profile) {
109
- process.stderr.write('[dsh-safe] restore 需要 --profile <名>\n')
87
+ process.stderr.write(`${t('restoreNeedsProfile')}\n`)
110
88
  return 2
111
89
  }
112
90
  if (!all && ids0.length === 0) {
113
- process.stderr.write('[dsh-safe] restore 需要 --id <id>(可重复)或 --all\n')
91
+ process.stderr.write(`${t('restoreNeedsId')}\n`)
114
92
  return 2
115
93
  }
116
94
  const { restored, kept } = restoreQuarantine(profile, all ? 'all' : ids0, dryRun)
117
- const verb = dryRun ? '(dry-run)将恢复' : '已恢复'
95
+ const verb = t(dryRun ? 'restoredDry' : 'restored')
118
96
  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')
97
+ if (!restored.length) process.stdout.write(`${t('noMatching')}\n`)
98
+ else if (kept.length) process.stdout.write(`${t('stillQuarantined', { profile, count: kept.length })}\n`)
99
+ else process.stdout.write(`${t('ledgerCleared')}\n`)
122
100
  return 0
123
101
  }
124
102
 
@@ -142,9 +120,17 @@ export async function main(argv) {
142
120
  }
143
121
  if (cmd === 'list') return cmdList(argv.slice(1))
144
122
  if (cmd === 'restore') return cmdRestore(argv.slice(1))
123
+ if (cmd === 'update') return cmdUpdate(argv.slice(1))
124
+ if (cmd === '-u' || cmd === '--update') return cmdUpdateAndBoot(argv.slice(1), { boot: runWrapperMode })
145
125
 
146
126
  // 包装模式:剥掉 dsh-safe 自己的旗标(必须出现在第一个位置参数之前),
147
127
  // 其余原样转发给 dsh。
128
+ return runWrapperMode(argv)
129
+ }
130
+
131
+ /** 包装模式:剥离 dsh-safe 的旗标后运行 dsh;-u 升级完成后的启动也走这里。 */
132
+ async function runWrapperMode(argv) {
133
+ maybeNotifySelfUpdate()
148
134
  const forwardArgs = []
149
135
  let dryRun = false
150
136
  let maxRetries = 2
@@ -158,7 +144,7 @@ export async function main(argv) {
158
144
  const raw = argv[++i]
159
145
  const v = raw === undefined || raw === '' ? NaN : Number(raw)
160
146
  if (!Number.isFinite(v) || v < 0) {
161
- process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
147
+ process.stderr.write(`${t('maxRetriesInvalid')}\n`)
162
148
  return 2
163
149
  }
164
150
  maxRetries = v
@@ -168,7 +154,7 @@ export async function main(argv) {
168
154
  const raw = a.slice('--max-retries='.length)
169
155
  const v = raw === '' ? NaN : Number(raw)
170
156
  if (!Number.isFinite(v) || v < 0) {
171
- process.stderr.write('[dsh-safe] --max-retries 需要一个非负整数\n')
157
+ process.stderr.write(`${t('maxRetriesInvalid')}\n`)
172
158
  return 2
173
159
  }
174
160
  maxRetries = v
@@ -181,6 +167,6 @@ export async function main(argv) {
181
167
  printHelp()
182
168
  return 0
183
169
  }
184
- if (dryRun) process.stderr.write('[dsh-safe] dry-run:只报告,不修改文件。\n')
170
+ if (dryRun) process.stderr.write(`${t('dryRunNotice')}\n`)
185
171
  return runWrapped({ forwardArgs, dryRun, maxRetries, allowFirstParty })
186
172
  }
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
+ /** dsh-safe 的更新检查时间戳缓存(每天最多提示一次新版)。 */
32
+ export const updateCheckFile = () => join(dshHome(), 'dsh-safe', 'update-check.json')
33
+
31
34
  /** 读文件,不存在或读不了返回 undefined。 */
32
35
  export const readIfExists = (path) => {
33
36
  try {
package/lib/i18n.js ADDED
@@ -0,0 +1,234 @@
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 -u [<dsh 参数…>] 先升级 dsh(已最新则跳过),再启动
20
+ dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
21
+ dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
22
+ 恢复被自动禁用的插件(升级修复后使用)
23
+ dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--pm npm|pnpm]
24
+ 升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
25
+ dsh-safe help 显示本帮助
26
+ dsh-safe --version 显示版本
27
+
28
+ 包装模式选项(必须写在 profile / 子命令之前):
29
+ --dry-run 只解析与报告,不修改任何文件
30
+ --max-retries <n> 自动隔离后最多重试启动的次数(默认 2)
31
+ --allow-first-party 允许自动禁用 @deepseek-ai/* 第一方插件(默认跳过)
32
+
33
+ 说明:
34
+ - 行的禁用以 patch 文件末尾的托管区块写入(带标记注释),不改动用户内容;
35
+ 恢复用 dsh-safe restore,或手动删除区块。
36
+ - 只隔离"启动期"失败(模块解析失败 / apply 抛错 / 等不到注入服务);
37
+ 运行期的未捕获异常仍由 dsh 自身的 fail-loud 策略处理。
38
+ - update/-u 会同时检查 dsh 与 dsh-safe 自身;--self 只更新 dsh-safe。
39
+ - 启动时每天最多提示一次 dsh-safe 新版;DSH_SAFE_NO_UPDATE_CHECK=1 关闭。
40
+ - 输出语言跟随 LC_ALL / LC_MESSAGES / LANG / LANGUAGE(zh* 中文,其余英文);
41
+ 可用环境变量 DSH_SAFE_LANG=zh|en 强制指定。
42
+ `,
43
+ unknownName: '(未知包名)',
44
+ quarantinedAt: ' 隔离于 {time}',
45
+ reasonLine: ' 原因: {reason}',
46
+ locationLine: ' 位置: {file}',
47
+ noRecordsProfile: '{profile}: 没有隔离记录',
48
+ noRecords: '没有隔离记录',
49
+ restoreNeedsProfile: '[dsh-safe] restore 需要 --profile <名>',
50
+ restoreNeedsId: '[dsh-safe] restore 需要 --id <id>(可重复)或 --all',
51
+ restored: '已恢复',
52
+ restoredDry: '(dry-run)将恢复',
53
+ noMatching: '[dsh-safe] 没有匹配的隔离记录',
54
+ stillQuarantined: '[dsh-safe] {profile} 仍隔离 {count} 行;重启 dsh 生效。',
55
+ ledgerCleared: '[dsh-safe] 该 profile 的隔离名单已清空;重启 dsh 生效。',
56
+ maxRetriesInvalid: '[dsh-safe] --max-retries 需要一个非负整数',
57
+ dryRunNotice: '[dsh-safe] dry-run:只报告,不修改文件。',
58
+
59
+ // ---- wrap.js ----
60
+ spawnFailed: '[dsh-safe] 无法启动 {command}: {message}',
61
+ pluginPassthrough: '[dsh-safe] `dsh plugin` 为 pnpm 转发,不做隔离,原样透传。',
62
+ noProfile:
63
+ '[dsh-safe] 未能从参数确定 profile(launcher 旗标需在最前,或用 --profile <name>),无法自动隔离,原样透传。',
64
+ skipFirstParty:
65
+ '[dsh-safe] 跳过第一方插件 {name} (id: {id}) —— 默认保护 @deepseek-ai/*,需要手动处理;确认要禁用可加 --allow-first-party。',
66
+ nothingFound: '[dsh-safe] 报错里没有识别出可自动隔离的已挂载插件,原样透传。',
67
+ maxRetriesReached: '[dsh-safe] 已达最大重试次数(--max-retries {count}),不再重试。',
68
+ disabled: '已禁用',
69
+ willDisable: '(dry-run)将禁用',
70
+ reasonIndent: ' 原因: {reason}',
71
+ retrying: '[dsh-safe] 重试启动…',
72
+
73
+ // ---- update.js ----
74
+ updateDshNotFound: '[dsh-safe] 在 PATH 上找不到 dsh 命令,无法升级。',
75
+ updatePlan: '[dsh-safe] {label} {name} {old} → {target} ({pm})',
76
+ updateInstallCmd: '[dsh-safe] 即将执行: {command}',
77
+ updateConfirm: '继续? [y/N] ',
78
+ updateNonInteractive: '[dsh-safe] 当前不是交互终端,无法确认;加 -y 跳过确认后重试。',
79
+ updateAborted: '[dsh-safe] 已取消。',
80
+ updateInstalling: '[dsh-safe] 正在升级 dsh…',
81
+ updateInstallFail: '[dsh-safe] 升级失败(退出码 {code}),未恢复任何隔离插件。',
82
+ updateLatestFetchFail: '[dsh-safe] 无法获取 {name} 的最新版本({pm} view 失败);可用 --to <版本> 指定目标版本。',
83
+ updateSkipCheckWarn: '[dsh-safe] 无法检查 dsh 更新({pm} view 失败),跳过升级直接启动。',
84
+ updateAlreadyLatest: '[dsh-safe] 已是最新,无需更新(dsh {dsh} / dsh-safe {self})。',
85
+ updateDone: '[dsh-safe] dsh 已更新: {old} → {new}',
86
+ updateVerifyWarn: '[dsh-safe] 警告:升级后无法重新解析 dsh 版本,请自行确认。',
87
+ updateNothingToRestore: '[dsh-safe] 没有需要恢复的隔离记录。',
88
+ updateRestoredProfile: '[dsh-safe] 已恢复 {count} 个被隔离的插件 (profile: {profile})',
89
+ updateRestoreSkipped: '[dsh-safe] 已按 --no-restore 跳过恢复;可稍后用 dsh-safe restore --profile <名> --all 恢复。',
90
+ updateVerifyHint: '[dsh-safe] 请启动 dsh 验证;仍不兼容的插件会自动再次隔离。',
91
+ updateRollbackHint: '[dsh-safe] 如需回滚: dsh-safe update --to {version}',
92
+ selfUpdateLagHint: '[dsh-safe] dsh-safe 已更新: {old} → {new}(本次运行仍是旧版,下次启动生效)',
93
+ updateNotify: '[dsh-safe] 提示: dsh-safe 有新版本 {new}(当前 {old})',
94
+ updateNotifyHow: ' 升级: npm i -g {name} 或 dsh-safe update --self',
95
+ updateUnknownFlag: '[dsh-safe] update 无法识别的参数: {arg}',
96
+ updatePmInvalid: '[dsh-safe] --pm 只支持 npm 或 pnpm。',
97
+ updateToInvalid: '[dsh-safe] --to 需要合法的版本号(如 1.2.3 或 1.2.3-rc.1)。',
98
+ }
99
+
100
+ const EN = {
101
+ // ---- cli.js ----
102
+ helpText: `dsh-safe {version} — startup fuse for dsh
103
+
104
+ When a community plugin is incompatible with the dsh runtime, dsh fails to boot as a
105
+ whole. dsh-safe wraps dsh: on startup failure it identifies the broken plugin from
106
+ the error, marks its row disabled in the profile patch (recording it in a quarantine
107
+ ledger), and retries automatically.
108
+
109
+ Usage:
110
+ dsh-safe <dsh args…> wrap and run dsh, e.g. dsh-safe web
111
+ dsh-safe -u [<dsh args…>] upgrade dsh first (skip if latest), then boot
112
+ dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
113
+ dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
114
+ re-enable auto-disabled plugins (after a fixed plugin upgrade)
115
+ dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--pm npm|pnpm]
116
+ upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
117
+ dsh-safe help show this help
118
+ dsh-safe --version show version
119
+
120
+ Wrapper-mode options (must come before the profile / subcommand):
121
+ --dry-run parse and report only; no files are modified
122
+ --max-retries <n> max startup retries after an auto-quarantine (default 2)
123
+ --allow-first-party allow auto-disabling @deepseek-ai/* first-party plugins (skipped by default)
124
+
125
+ Notes:
126
+ - Rows are disabled via a marker-commented managed block appended to the patch file;
127
+ user content is untouched. Restore with dsh-safe restore, or delete the block manually.
128
+ - Only startup-phase failures are quarantined (module resolution / apply throw /
129
+ missing injected service); runtime uncaught exceptions stay under dsh's own
130
+ fail-loud policy.
131
+ - update/-u checks both dsh and dsh-safe itself; --self updates dsh-safe only.
132
+ - A new-version notice for dsh-safe is shown at most once a day on boot;
133
+ disable it with DSH_SAFE_NO_UPDATE_CHECK=1.
134
+ - Output language follows LC_ALL / LC_MESSAGES / LANG / LANGUAGE (zh* → Chinese,
135
+ otherwise English); force it with DSH_SAFE_LANG=zh|en.
136
+ `,
137
+ unknownName: '(unknown package)',
138
+ quarantinedAt: ' quarantined at {time}',
139
+ reasonLine: ' reason: {reason}',
140
+ locationLine: ' location: {file}',
141
+ noRecordsProfile: '{profile}: no quarantine records',
142
+ noRecords: 'no quarantine records',
143
+ restoreNeedsProfile: '[dsh-safe] restore requires --profile <name>',
144
+ restoreNeedsId: '[dsh-safe] restore requires --id <id> (repeatable) or --all',
145
+ restored: 'restored',
146
+ restoredDry: '(dry-run) would restore',
147
+ noMatching: '[dsh-safe] no matching quarantine records',
148
+ stillQuarantined: '[dsh-safe] {profile} still has {count} quarantined row(s); restart dsh to apply.',
149
+ ledgerCleared: '[dsh-safe] quarantine list for this profile is now empty; restart dsh to apply.',
150
+ maxRetriesInvalid: '[dsh-safe] --max-retries requires a non-negative integer',
151
+ dryRunNotice: '[dsh-safe] dry-run: report only, no files will be modified.',
152
+
153
+ // ---- wrap.js ----
154
+ spawnFailed: '[dsh-safe] failed to start {command}: {message}',
155
+ pluginPassthrough: '[dsh-safe] `dsh plugin` is a pnpm forwarder; no quarantine, exit code passed through.',
156
+ noProfile:
157
+ '[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.',
158
+ skipFirstParty:
159
+ '[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.',
160
+ nothingFound:
161
+ '[dsh-safe] no mounted plugin could be identified as auto-quarantinable from the error; exit code passed through.',
162
+ maxRetriesReached: '[dsh-safe] max retries reached (--max-retries {count}); giving up.',
163
+ disabled: 'disabled',
164
+ willDisable: '(dry-run) would disable',
165
+ reasonIndent: ' reason: {reason}',
166
+ retrying: '[dsh-safe] retrying…',
167
+
168
+ // ---- update.js ----
169
+ updateDshNotFound: '[dsh-safe] could not find the `dsh` command on PATH; nothing to upgrade.',
170
+ updatePlan: '[dsh-safe] {label} {name} {old} → {target} ({pm})',
171
+ updateInstallCmd: '[dsh-safe] about to run: {command}',
172
+ updateConfirm: 'continue? [y/N] ',
173
+ updateNonInteractive: '[dsh-safe] not an interactive terminal; re-run with -y to skip the confirmation.',
174
+ updateAborted: '[dsh-safe] cancelled.',
175
+ updateInstalling: '[dsh-safe] upgrading dsh…',
176
+ updateInstallFail: '[dsh-safe] upgrade failed (exit code {code}); no quarantined plugins were restored.',
177
+ updateLatestFetchFail: '[dsh-safe] could not fetch the latest version of {name} ({pm} view failed); specify a target with --to <version>.',
178
+ updateSkipCheckWarn: '[dsh-safe] could not check for dsh updates ({pm} view failed); starting without upgrading.',
179
+ updateAlreadyLatest: '[dsh-safe] already up to date (dsh {dsh} / dsh-safe {self}); nothing to update.',
180
+ updateDone: '[dsh-safe] dsh updated: {old} → {new}',
181
+ updateVerifyWarn: '[dsh-safe] warning: could not re-resolve the dsh version after the upgrade; please verify manually.',
182
+ updateNothingToRestore: '[dsh-safe] no quarantine records to restore.',
183
+ updateRestoredProfile: '[dsh-safe] restored {count} quarantined plugin(s) (profile: {profile})',
184
+ updateRestoreSkipped: '[dsh-safe] restore skipped due to --no-restore; restore later with dsh-safe restore --profile <name> --all.',
185
+ updateVerifyHint: '[dsh-safe] start dsh to verify; plugins still incompatible will be auto-quarantined again.',
186
+ updateRollbackHint: '[dsh-safe] to roll back: dsh-safe update --to {version}',
187
+ selfUpdateLagHint: '[dsh-safe] dsh-safe updated: {old} → {new} (this run still uses the old version; takes effect on the next run)',
188
+ updateNotify: '[dsh-safe] notice: a new dsh-safe version is available: {new} (current {old})',
189
+ updateNotifyHow: ' to upgrade: npm i -g {name} or dsh-safe update --self',
190
+ updateUnknownFlag: '[dsh-safe] unrecognized argument for update: {arg}',
191
+ updatePmInvalid: '[dsh-safe] --pm only accepts npm or pnpm.',
192
+ updateToInvalid: '[dsh-safe] --to requires a valid version (e.g. 1.2.3 or 1.2.3-rc.1).',
193
+ }
194
+
195
+ const CATALOG = { zh: ZH, en: EN }
196
+
197
+ /** 把 locale 值归一成 'zh' | 'en';空值 / C / POSIX 返回 null(交给下一优先级)。 */
198
+ function parseLocale(raw) {
199
+ if (!raw) return null
200
+ const base = raw.split(':')[0].split('.')[0].split('@')[0].trim().toLowerCase()
201
+ if (!base || base === 'c' || base === 'posix') return null
202
+ return base.startsWith('zh') ? 'zh' : 'en'
203
+ }
204
+
205
+ /**
206
+ * 纯函数,方便单测。默认参数取真实环境。
207
+ * @param {Record<string, string | undefined>} env
208
+ * @param {string} [intlLocale] 环境变量全部缺失时的兜底(生产为 Node 的 Intl 默认 locale)
209
+ */
210
+ export function detectLocale(env = process.env, intlLocale = Intl.DateTimeFormat().resolvedOptions().locale) {
211
+ for (const key of ['DSH_SAFE_LANG', 'LC_ALL', 'LC_MESSAGES', 'LANG', 'LANGUAGE']) {
212
+ const lang = parseLocale(env[key])
213
+ if (lang) return lang
214
+ }
215
+ return typeof intlLocale === 'string' && intlLocale.toLowerCase().startsWith('zh') ? 'zh' : 'en'
216
+ }
217
+
218
+ let cached
219
+
220
+ /** 当前进程的输出语言(首次调用时检测并缓存)。 */
221
+ export function getLocale() {
222
+ return (cached ??= detectLocale())
223
+ }
224
+
225
+ /** 指定语言取词条,主要用于单测。 */
226
+ export function translate(locale, key, params = {}) {
227
+ const text = CATALOG[locale]?.[key] ?? CATALOG.en[key] ?? key
228
+ return text.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? String(params[name]) : match))
229
+ }
230
+
231
+ /** 按当前语言取词条并做 {name} 插值。 */
232
+ export function t(key, params = {}) {
233
+ return translate(getLocale(), key, params)
234
+ }
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/update.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — `dsh-safe update`:升级 dsh 并自动恢复被隔离的插件。
3
+ *
4
+ * 自动探测:PATH 上的 dsh 可执行文件 realpath → 向上找最近的 package.json
5
+ * 得到包名与当前版本;包管理器按 realpath 是否落在 pnpm 全局根下判定
6
+ * (PATH 上有 pnpm 时探测,否则默认 npm),--pm 可强制指定。
7
+ * 升级完成后遍历台账恢复全部被隔离的插件(新 dsh 下仍不兼容的会在
8
+ * 下次启动时再次被自动隔离),并提示回滚方式。
9
+ */
10
+ import { accessSync, constants, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { createInterface } from 'node:readline/promises'
13
+ import { delimiter, dirname, join } from 'node:path'
14
+ import { loadLedger, restoreQuarantine } from './quarantine.js'
15
+ import { readIfExists, updateCheckFile } from './dshpaths.js'
16
+ import { t } from './i18n.js'
17
+
18
+ const err = (line) => process.stderr.write(`${line}\n`)
19
+ const out = (line) => process.stdout.write(`${line}\n`)
20
+
21
+ const WIN32 = process.platform === 'win32'
22
+
23
+ /**
24
+ * 运行包管理器命令并捕获输出(view / pnpm root -g)。
25
+ * Windows 上 npm/pnpm 是 .cmd 批处理,Node 禁止无 shell 地 spawn(EINVAL),
26
+ * 必须走 cmd.exe;传参只含已校验的包名与版本号(--to 有 ^[\w.+-]+$ 校验)。
27
+ */
28
+ function spawnPmCapture(bin, args) {
29
+ return WIN32
30
+ ? spawnSync(bin, args, { encoding: 'utf8', shell: true })
31
+ : spawnSync(bin, args, { encoding: 'utf8' })
32
+ }
33
+
34
+ /** 运行包管理器命令并透传 stdio(install)。Windows 处理同上。 */
35
+ function spawnPmInherit(bin, args) {
36
+ return WIN32
37
+ ? spawnSync(bin, args, { shell: true, stdio: 'inherit' })
38
+ : spawnSync(bin, args, { stdio: 'inherit' })
39
+ }
40
+
41
+ /** Windows 上全局 bin 的可执行是 .cmd/.exe 拷贝而非 symlink,候选名不同。 */
42
+ function binCandidates(name) {
43
+ return WIN32 ? [`${name}.cmd`, `${name}.exe`, `${name}.ps1`, name] : [name]
44
+ }
45
+
46
+ const readJsonIfExists = (path) => {
47
+ const raw = readIfExists(path)
48
+ if (raw === undefined) return undefined
49
+ try {
50
+ return JSON.parse(raw)
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** 在 PATH 上找可执行文件(Windows 展开为 .cmd/.exe/.ps1/裸名多候选),返回绝对路径或 null。 */
57
+ function whichCmd(name) {
58
+ for (const dir of (process.env.PATH ?? '').split(delimiter)) {
59
+ if (!dir) continue
60
+ for (const candidate of binCandidates(name).map((n) => join(dir, n))) {
61
+ try {
62
+ accessSync(candidate, constants.X_OK)
63
+ return candidate
64
+ } catch {}
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /** 从某个目录向上找最近的 package.json,返回 { name, version, pkgDir }。 */
71
+ function resolvePackageFromDir(dir) {
72
+ for (;;) {
73
+ const pkg = readJsonIfExists(join(dir, 'package.json'))
74
+ if (pkg && typeof pkg.name === 'string' && pkg.name) {
75
+ return { name: pkg.name, version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', pkgDir: dir }
76
+ }
77
+ const parent = dirname(dir)
78
+ if (parent === dir) return null
79
+ dir = parent
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 从可执行文件解析所属包:symlink → realpath 后向上找 package.json(macOS/Linux
85
+ * 的 npm/pnpm 全局 bin 都是链接);独立 shim 文件(Windows 的 .cmd/sh 拷贝)→
86
+ * 解析其内容里内嵌的 node_modules 入口路径。
87
+ */
88
+ function resolvePackageFromBinFile(file) {
89
+ let real
90
+ try {
91
+ real = realpathSync(file)
92
+ } catch {
93
+ return null
94
+ }
95
+ const viaWalkUp = resolvePackageFromDir(dirname(real))
96
+ if (viaWalkUp) return viaWalkUp
97
+ return resolvePackageFromShim(file)
98
+ }
99
+
100
+ /** 解析 npm/pnpm 的 cmd/ps1/sh shim:内容里都内嵌 node_modules/<pkg> 的入口路径。 */
101
+ export function resolvePackageFromShim(shimPath) {
102
+ const content = readIfExists(shimPath)
103
+ if (!content) return null
104
+ const m = /node_modules[\\\/]((?:@[^\\\/"'`\s]+[\\\/])?[^\\\/"'`\s]+)/.exec(content)
105
+ if (!m) return null
106
+ const name = m[1].replace(/[\\\/]/g, '/')
107
+ // 候选 pkgDir:npm 全局布局(shim 同级的 node_modules)与 pnpm 全局根
108
+ const candidates = [join(dirname(shimPath), 'node_modules', ...name.split('/'))]
109
+ const pnpmBin = whichCmd('pnpm')
110
+ if (pnpmBin) {
111
+ const { status, stdout } = spawnPmCapture(pnpmBin, ['root', '-g'])
112
+ const root = status === 0 ? (stdout ?? '').trim() : ''
113
+ if (root) candidates.push(join(root, ...name.split('/')))
114
+ }
115
+ for (const dir of candidates) {
116
+ const pkg = readJsonIfExists(join(dir, 'package.json'))
117
+ if (pkg?.name === name) {
118
+ return { name, version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', pkgDir: dir }
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * 解析 PATH 上的 dsh。
126
+ * @returns {{ name: string, version: string, pkgDir: string } | null}
127
+ */
128
+ export function resolveDshPackage() {
129
+ const bin = whichCmd('dsh')
130
+ if (!bin) return null
131
+ return resolvePackageFromBinFile(bin)
132
+ }
133
+
134
+ /**
135
+ * 解析正在运行的 dsh-safe 自身(argv[1],npm/pnpm 全局 bin symlink 或仓库内直跑均适用)。
136
+ * @returns {{ name: string, version: string, pkgDir: string } | null}
137
+ */
138
+ export function resolveSelfPackage() {
139
+ const entry = process.argv[1]
140
+ if (!entry) return null
141
+ return resolvePackageFromBinFile(entry)
142
+ }
143
+
144
+ /** 判断 dsh 的安装方式:落在 pnpm 全局根下则为 pnpm,否则 npm。 */
145
+ export function detectPm(pkgDir) {
146
+ const pnpmBin = whichCmd('pnpm')
147
+ if (!pnpmBin) return 'npm'
148
+ const { status, stdout } = spawnPmCapture(pnpmBin, ['root', '-g'])
149
+ const root = status === 0 ? (stdout ?? '').trim() : ''
150
+ if (root && (pkgDir === root || pkgDir.startsWith(`${root}/`) || pkgDir.startsWith(`${root}\\`))) return 'pnpm'
151
+ return 'npm'
152
+ }
153
+
154
+ /** npm view 查最新版本(只查 registry,与安装方式无关),失败返回 null。 */
155
+ function fetchLatestVersion(name) {
156
+ const { status, stdout } = spawnPmCapture(whichCmd('npm') ?? 'npm', ['view', name, 'version'])
157
+ const version = status === 0 ? (stdout ?? '').trim() : ''
158
+ return /^[\w.+-]+$/.test(version) ? version : null
159
+ }
160
+
161
+ /**
162
+ * 比较 semver 风格版本:a > b 返回 true(零依赖的够用版)。
163
+ * 核心段按数字逐段比较;核心相同时正式版 > 预发布,预发布逐段比较(数字段按数值)。
164
+ * 自动检查用它避免"registry 落后于本地开发版"时的自降级;--to 显式指定不走这里,允许降级回滚。
165
+ */
166
+ export function isNewerVersion(a, b) {
167
+ if (a === b) return false
168
+ const [aCore, aPre] = String(a).split('-', 2)
169
+ const [bCore, bPre] = String(b).split('-', 2)
170
+ const pa = aCore.split('.').map((n) => parseInt(n, 10) || 0)
171
+ const pb = bCore.split('.').map((n) => parseInt(n, 10) || 0)
172
+ for (let i = 0; i < 3; i++) {
173
+ const x = pa[i] ?? 0
174
+ const y = pb[i] ?? 0
175
+ if (x !== y) return x > y
176
+ }
177
+ if (aPre && !bPre) return false
178
+ if (!aPre && bPre) return true
179
+ const qa = (aPre ?? '').split('.')
180
+ const qb = (bPre ?? '').split('.')
181
+ for (let i = 0; i < Math.max(qa.length, qb.length); i++) {
182
+ const x = qa[i]
183
+ const y = qb[i]
184
+ if (x === undefined) return false
185
+ if (y === undefined) return true
186
+ const numericX = /^\d+$/.test(x)
187
+ const numericY = /^\d+$/.test(y)
188
+ if (numericX && numericY) {
189
+ const d = Number(x) - Number(y)
190
+ if (d) return d > 0
191
+ } else if (x !== y) {
192
+ return x > y
193
+ }
194
+ }
195
+ return false
196
+ }
197
+
198
+ /**
199
+ * @param {string[]} args update 子命令参数
200
+ * @returns {Promise<number>} 退出码
201
+ */
202
+ export async function cmdUpdate(args) {
203
+ if (args.includes('-h') || args.includes('--help')) {
204
+ out(t('helpText', {}))
205
+ return 0
206
+ }
207
+ const { opts, error } = parseUpdateArgs(args)
208
+ if (error) {
209
+ err(t(error.key, error.params))
210
+ return 2
211
+ }
212
+ return updateAndMaybeBoot(opts, null, {})
213
+ }
214
+
215
+ /**
216
+ * `-u`/`--update` 模式:前缀解析 update 选项(-y/--to/--pm/--no-restore),
217
+ * 第一个不属于 update 的参数起就是 dsh 启动参数(可再带包装旗标)。
218
+ * 启动优先:更新检查失败只告警并照常启动;升级失败/用户取消则不启动。
219
+ * @param {string[]} args
220
+ * @param {{ boot?: (args: string[]) => Promise<number> }} hooks cli 传入的包装启动
221
+ */
222
+ export async function cmdUpdateAndBoot(args, { boot } = {}) {
223
+ if (args.includes('-h') || args.includes('--help')) {
224
+ out(t('helpText', {}))
225
+ return 0
226
+ }
227
+ const { opts, rest, error } = parseLeadingUpdateArgs(args)
228
+ if (error) {
229
+ err(t(error.key, error.params))
230
+ return 2
231
+ }
232
+ return updateAndMaybeBoot(opts, rest.length ? rest : null, { boot })
233
+ }
234
+
235
+ /**
236
+ * 更新主体;bootArgs 非空时更新成功(或已最新/检查失败)后继续启动。
237
+ * 默认同时检查 dsh 与 dsh-safe 自身,谁旧升谁(一条安装命令);--self 只更新自身。
238
+ * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean }} opts
239
+ * @param {string[] | null} bootArgs
240
+ * @param {{ boot?: (args: string[]) => Promise<number> }} hooks
241
+ */
242
+ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
243
+ const dshPkg = opts.selfOnly ? null : resolveDshPackage()
244
+ const selfPkg = resolveSelfPackage()
245
+ if (!dshPkg && !selfPkg) {
246
+ err(t('updateDshNotFound'))
247
+ return 1
248
+ }
249
+ const pm = opts.pm ?? (dshPkg ? detectPm(dshPkg.pkgDir) : detectPm(selfPkg.pkgDir))
250
+
251
+ // 组装更新计划:[{ pkg, target }]
252
+ const plans = []
253
+ if (dshPkg) {
254
+ if (opts.to) {
255
+ if (opts.to !== dshPkg.version) plans.push({ pkg: dshPkg, target: opts.to })
256
+ } else {
257
+ const latest = fetchLatestVersion(dshPkg.name)
258
+ if (!latest) {
259
+ if (bootArgs) {
260
+ err(t('updateSkipCheckWarn', { pm }))
261
+ return boot(bootArgs)
262
+ }
263
+ err(t('updateLatestFetchFail', { name: dshPkg.name, pm }))
264
+ return 1
265
+ }
266
+ if (isNewerVersion(latest, dshPkg.version)) plans.push({ pkg: dshPkg, target: latest })
267
+ }
268
+ }
269
+ if (selfPkg && !opts.to) {
270
+ const selfLatest = fetchLatestVersion(selfPkg.name)
271
+ if (selfLatest && isNewerVersion(selfLatest, selfPkg.version)) plans.push({ pkg: selfPkg, target: selfLatest })
272
+ }
273
+
274
+ if (!plans.length) {
275
+ if (bootArgs) return boot(bootArgs)
276
+ err(t('updateAlreadyLatest', { dsh: dshPkg?.version ?? '未安装', self: selfPkg?.version ?? '未知' }))
277
+ return 0
278
+ }
279
+
280
+ const installCmd = pm === 'pnpm' ? 'pnpm' : 'npm'
281
+ const specs = plans.map((p) => `${p.pkg.name}@${p.target}`)
282
+ const installArgs =
283
+ pm === 'pnpm' ? ['add', '-g', ...specs] : ['install', '-g', ...specs]
284
+ for (const p of plans) {
285
+ err(t('updatePlan', { label: p.pkg === dshPkg ? 'dsh' : 'dsh-safe', name: p.pkg.name, old: p.pkg.version, target: p.target, pm }))
286
+ }
287
+ err(t('updateInstallCmd', { command: `${installCmd} ${installArgs.join(' ')}` }))
288
+ if (!opts.yes) {
289
+ if (!process.stdin.isTTY) {
290
+ err(t('updateNonInteractive'))
291
+ return 1
292
+ }
293
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
294
+ const answer = (await rl.question(t('updateConfirm'))).trim().toLowerCase()
295
+ rl.close()
296
+ if (answer !== 'y' && answer !== 'yes') {
297
+ err(t('updateAborted'))
298
+ return 0
299
+ }
300
+ }
301
+
302
+ err(t('updateInstalling'))
303
+ const { status } = spawnPmInherit(whichCmd(installCmd) ?? installCmd, installArgs)
304
+ if (status !== 0) {
305
+ err(t('updateInstallFail', { code: status ?? '?' }))
306
+ return status ?? 1
307
+ }
308
+
309
+ let dshUpdated = false
310
+ for (const p of plans) {
311
+ if (p.pkg === dshPkg) {
312
+ dshUpdated = true
313
+ const fresh = resolveDshPackage()
314
+ if (!fresh?.version) err(t('updateVerifyWarn'))
315
+ else err(t('updateDone', { old: p.pkg.version, new: fresh.version }))
316
+ } else {
317
+ err(t('selfUpdateLagHint', { old: p.pkg.version, new: p.target }))
318
+ }
319
+ }
320
+
321
+ if (opts.restore && dshUpdated) {
322
+ const ledger = loadLedger()
323
+ const profiles = Object.keys(ledger.profiles).filter((p) => (ledger.profiles[p] ?? []).length)
324
+ if (!profiles.length) {
325
+ out(t('updateNothingToRestore'))
326
+ } else {
327
+ for (const profile of profiles) {
328
+ const { restored } = restoreQuarantine(profile, 'all', false)
329
+ if (restored.length) out(t('updateRestoredProfile', { profile, count: restored.length }))
330
+ }
331
+ err(t('updateVerifyHint'))
332
+ }
333
+ } else if (dshUpdated && !opts.restore) {
334
+ err(t('updateRestoreSkipped'))
335
+ }
336
+ if (dshUpdated) {
337
+ const oldDsh = plans.find((p) => p.pkg === dshPkg)?.pkg.version
338
+ if (oldDsh) err(t('updateRollbackHint', { version: oldDsh }))
339
+ }
340
+ if (bootArgs) return boot(bootArgs)
341
+ return 0
342
+ }
343
+
344
+ /** 解析 update 子命令参数;出错返回 { error: { key, params? } } 供 i18n。 */
345
+ function parseUpdateArgs(args) {
346
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
347
+ for (let i = 0; i < args.length; i++) {
348
+ const a = args[i]
349
+ if (a === '-y' || a === '--yes') opts.yes = true
350
+ else if (a === '--no-restore') opts.restore = false
351
+ else if (a === '--self') opts.selfOnly = true
352
+ else if (a === '--to') {
353
+ const v = args[++i]
354
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--to' } } }
355
+ opts.to = v
356
+ } else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
357
+ else if (a === '--pm') {
358
+ const v = args[++i]
359
+ if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--pm' } } }
360
+ opts.pm = v
361
+ } else if (a.startsWith('--pm=')) opts.pm = a.slice('--pm='.length)
362
+ else return { error: { key: 'updateUnknownFlag', params: { arg: a } } }
363
+ }
364
+ if (opts.pm !== undefined && opts.pm !== 'npm' && opts.pm !== 'pnpm') {
365
+ return { error: { key: 'updatePmInvalid' } }
366
+ }
367
+ if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
368
+ return { error: { key: 'updateToInvalid' } }
369
+ }
370
+ return { opts }
371
+ }
372
+
373
+ /**
374
+ * `-u` 模式的前缀解析:从头吃掉 update 选项,第一个不认识的参数起就是
375
+ * dsh 启动参数(宽松处理——不像子命令那样对未知参数报错)。
376
+ */
377
+ function parseLeadingUpdateArgs(args) {
378
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
379
+ let i = 0
380
+ for (; i < args.length; i++) {
381
+ const a = args[i]
382
+ if (a === '-y' || a === '--yes') opts.yes = true
383
+ else if (a === '--no-restore') opts.restore = false
384
+ else if (a === '--self') opts.selfOnly = true
385
+ else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
386
+ else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
387
+ else if (a === '--pm' && args[i + 1] !== undefined) opts.pm = args[++i]
388
+ else if (a.startsWith('--pm=')) opts.pm = a.slice('--pm='.length)
389
+ else break
390
+ }
391
+ const rest = args.slice(i)
392
+ if (opts.pm !== undefined && opts.pm !== 'npm' && opts.pm !== 'pnpm') {
393
+ return { error: { key: 'updatePmInvalid' } }
394
+ }
395
+ if (opts.to !== undefined && !/^[\w.+-]+$/.test(opts.to)) {
396
+ return { error: { key: 'updateToInvalid' } }
397
+ }
398
+ return { opts, rest }
399
+ }
400
+
401
+ /**
402
+ * 每日一次的 dsh-safe 新版提示(只在包装启动路径调用;update/-u 有自己的检查)。
403
+ * 时间戳缓存于 $DSH_HOME/dsh-safe/update-check.json;任何失败都完全静默。
404
+ */
405
+ export function maybeNotifySelfUpdate() {
406
+ try {
407
+ if (process.env.DSH_SAFE_NO_UPDATE_CHECK === '1') return
408
+ const selfPkg = resolveSelfPackage()
409
+ if (!selfPkg) return
410
+ const file = updateCheckFile()
411
+ const last = readJsonIfExists(file)
412
+ if (last?.lastCheckAt && Date.now() - Date.parse(last.lastCheckAt) < 24 * 60 * 60 * 1000) return
413
+ try {
414
+ mkdirSync(dirname(file), { recursive: true })
415
+ writeFileSync(file, `${JSON.stringify({ lastCheckAt: new Date().toISOString() }, null, 2)}\n`)
416
+ } catch {}
417
+ const latest = fetchLatestVersion(selfPkg.name)
418
+ if (latest && isNewerVersion(latest, selfPkg.version)) {
419
+ err(t('updateNotify', { new: latest, old: selfPkg.version }))
420
+ err(t('updateNotifyHow', { name: selfPkg.name }))
421
+ }
422
+ } catch {}
423
+ }
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.3.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",