@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 +94 -0
- package/README.md +13 -13
- package/lib/cli.js +24 -38
- package/lib/dshpaths.js +3 -0
- package/lib/i18n.js +234 -0
- package/lib/knownrows.js +20 -16
- package/lib/update.js +423 -0
- package/lib/wrap.js +12 -11
- package/package.json +4 -2
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](
|
|
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(
|
|
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(
|
|
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}
|
|
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('
|
|
87
|
+
process.stderr.write(`${t('restoreNeedsProfile')}\n`)
|
|
110
88
|
return 2
|
|
111
89
|
}
|
|
112
90
|
if (!all && ids0.length === 0) {
|
|
113
|
-
process.stderr.write('
|
|
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 ? '
|
|
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('
|
|
120
|
-
else if (kept.length) process.stdout.write(
|
|
121
|
-
else process.stdout.write('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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/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(
|
|
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.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",
|