@hyzyn/dsh-safe 0.3.0 → 0.3.2
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 +37 -19
- package/README.md +38 -20
- package/lib/dshpaths.js +73 -1
- package/lib/wrap.js +10 -2
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -16,13 +16,16 @@ Requires Node >= 20 and a local `dsh` command. Zero runtime dependencies.
|
|
|
16
16
|
|
|
17
17
|
## Quick Start
|
|
18
18
|
|
|
19
|
-
Just
|
|
19
|
+
Just swap `dsh` for `dsh-safe` — `-u` (update-and-boot) is recommended: when dsh has a new version it upgrades and restores quarantined plugins first; when dsh is already latest it behaves exactly like a plain start:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
dsh-safe web
|
|
22
|
+
dsh-safe -u web # recommended: update then boot (with auto-quarantine)
|
|
23
|
+
dsh-safe web # no update check, boot with auto-quarantine
|
|
23
24
|
dsh-safe --profile tui --patch ./extra.yml
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
`-u` adds one version check per boot (needs network; on check failure it just warns and boots anyway) — offline or scripted environments can use the second line.
|
|
28
|
+
|
|
26
29
|
Sample output (shown with a zh locale: a broken plugin is quarantined, then startup retries):
|
|
27
30
|
|
|
28
31
|
```
|
|
@@ -32,20 +35,23 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
32
35
|
[dsh-safe] 重试启动…
|
|
33
36
|
```
|
|
34
37
|
|
|
35
|
-
## Commands
|
|
38
|
+
## Commands & Options
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
dsh-safe
|
|
42
|
-
|
|
43
|
-
dsh-safe update [
|
|
44
|
-
|
|
45
|
-
dsh-safe
|
|
46
|
-
|
|
40
|
+
### Subcommands
|
|
41
|
+
|
|
42
|
+
| Command | Description |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `dsh-safe <dsh args…>` | wrap and run dsh (swap `dsh` for `dsh-safe`) |
|
|
45
|
+
| `dsh-safe -u [update options] [dsh args…]` | upgrade dsh and dsh-safe itself first (skip if latest), then boot in wrap mode; `--update` is an alias |
|
|
46
|
+
| `dsh-safe update [options]` | upgrade only, no boot — options below |
|
|
47
|
+
| `dsh-safe list [--profile <name>]` | show quarantined plugins (defaults to all profiles) |
|
|
48
|
+
| `dsh-safe restore --profile <name> (--id <id> \| --all) [--dry-run]` | re-enable auto-disabled plugins (after a fixed plugin upgrade) |
|
|
49
|
+
| `dsh-safe help` (`-h` / `--help`) | show help |
|
|
50
|
+
| `dsh-safe --version` (`-V`) | show version |
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`, `-h` = `--help`, `-V` = `--version`); single letters use `-`, words use `--`.
|
|
53
|
+
|
|
54
|
+
### Wrapper-mode options (must come before the first positional argument)
|
|
49
55
|
|
|
50
56
|
| Option | Description |
|
|
51
57
|
| --- | --- |
|
|
@@ -53,13 +59,25 @@ Wrapper-mode options (must come before the profile / subcommand):
|
|
|
53
59
|
| `--max-retries <n>` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) |
|
|
54
60
|
| `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins (skipped by default; handle manually) |
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply)
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
| Option | Description |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `-y` / `--yes` | Skip the upgrade confirmation (required in non-interactive terminals) |
|
|
67
|
+
| `--to <version>` | Target dsh version, also how you roll back (explicit downgrades allowed); dsh-safe itself always upgrades to the latest |
|
|
68
|
+
| `--self` | Update dsh-safe itself only; dsh and quarantine state untouched |
|
|
69
|
+
| `--no-restore` | Do not auto-restore quarantined plugins after upgrading dsh |
|
|
70
|
+
| `--pm <npm\|pnpm>` | Force the package manager (auto-detected by default) |
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
### Environment variables
|
|
73
|
+
|
|
74
|
+
| Variable | Description |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
|
|
77
|
+
| `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the at-most-daily dsh-safe new-version notice on boot |
|
|
78
|
+
| `DSH_HOME` | dsh home directory (dsh's own variable; the quarantine ledger and patch paths follow it) |
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
How upgrading works: `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. 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`).
|
|
63
81
|
|
|
64
82
|
## How It Works
|
|
65
83
|
|
|
@@ -81,7 +99,7 @@ Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE` (`zh*` → Chines
|
|
|
81
99
|
- 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
100
|
- 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
101
|
- 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)
|
|
102
|
+
- Windows is best-effort: update / --self / list / restore are adapted (.cmd shim parsing, shelled npm/pnpm invocations); the wrapped boot resolves the node entry embedded in dsh's .cmd/.ps1 shim on PATH and spawns `node <entry>` directly (.exe runs as-is, unparseable shims fall back to a shelled spawn), sidestepping Node's ban on spawning .cmd files. Not yet verified end-to-end on a real Windows machine — feedback welcome.
|
|
85
103
|
|
|
86
104
|
## Development
|
|
87
105
|
|
package/README.md
CHANGED
|
@@ -16,13 +16,16 @@ npm install -g @hyzyn/dsh-safe
|
|
|
16
16
|
|
|
17
17
|
## 快速开始
|
|
18
18
|
|
|
19
|
-
把平时的 `dsh` 换成 `dsh-safe`
|
|
19
|
+
把平时的 `dsh` 换成 `dsh-safe` 即可,推荐直接用 `-u`(更新并启动):dsh 有新版本时先升级并恢复被隔离的插件再启动,已最新时和普通启动完全一样:
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
dsh-safe web
|
|
22
|
+
dsh-safe -u web # 推荐:更新并启动(含自动隔离)
|
|
23
|
+
dsh-safe web # 不检查更新,直接带自动隔离启动
|
|
23
24
|
dsh-safe --profile tui --patch ./extra.yml
|
|
24
25
|
```
|
|
25
26
|
|
|
27
|
+
`-u` 每次启动多做一次版本检查(需要联网,检查失败只告警、照常启动);离线或脚本环境用第二行即可。
|
|
28
|
+
|
|
26
29
|
输出示例(坏插件被自动隔离后重试):
|
|
27
30
|
|
|
28
31
|
```
|
|
@@ -32,20 +35,23 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
|
|
|
32
35
|
[dsh-safe] 重试启动…
|
|
33
36
|
```
|
|
34
37
|
|
|
35
|
-
##
|
|
38
|
+
## 命令与参数
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
dsh-safe
|
|
42
|
-
|
|
43
|
-
dsh-safe update [
|
|
44
|
-
|
|
45
|
-
dsh-safe
|
|
46
|
-
|
|
40
|
+
### 子命令
|
|
41
|
+
|
|
42
|
+
| 命令 | 说明 |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `dsh-safe <dsh 参数…>` | 包装运行 dsh(把平时的 `dsh` 换成 `dsh-safe`) |
|
|
45
|
+
| `dsh-safe -u [update 选项] [dsh 参数…]` | 先升级 dsh 与 dsh-safe 自身(已最新则跳过),再按包装模式启动;`--update` 等价 |
|
|
46
|
+
| `dsh-safe update [选项]` | 只升级不启动,选项见下 |
|
|
47
|
+
| `dsh-safe list [--profile <名>]` | 查看隔离名单(缺省列出全部 profile) |
|
|
48
|
+
| `dsh-safe restore --profile <名> (--id <id> \| --all) [--dry-run]` | 恢复被自动禁用的插件(升级修复后使用) |
|
|
49
|
+
| `dsh-safe help`(`-h` / `--help`) | 显示帮助 |
|
|
50
|
+
| `dsh-safe --version`(`-V`) | 显示版本 |
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
短选项都有等价的长形式(`-u` = `--update`、`-y` = `--yes`、`-h` = `--help`、`-V` = `--version`);单字母用 `-`,多字母用 `--`。
|
|
53
|
+
|
|
54
|
+
### 包装模式选项(必须写在第一个位置参数之前)
|
|
49
55
|
|
|
50
56
|
| 选项 | 说明 |
|
|
51
57
|
| --- | --- |
|
|
@@ -53,13 +59,25 @@ dsh-safe help
|
|
|
53
59
|
| `--max-retries <n>` | 自动隔离后最多重试启动的次数(默认 2;`0` 表示不隔离只透传) |
|
|
54
60
|
| `--allow-first-party` | 允许自动禁用 `@deepseek-ai/*` 第一方插件(默认跳过,需手动处理) |
|
|
55
61
|
|
|
56
|
-
|
|
62
|
+
### update / -u 选项(写在 `-u` 或 `update` 之后;其前的包装旗标照常生效)
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
| 选项 | 说明 |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `-y` / `--yes` | 跳过升级确认(非交互终端必须显式加 `-y`) |
|
|
67
|
+
| `--to <版本>` | 指定 dsh 的目标版本,也是回滚方式(显式允许降级);dsh-safe 自身始终升到最新 |
|
|
68
|
+
| `--self` | 只更新 dsh-safe 自身,不动 dsh 与隔离状态 |
|
|
69
|
+
| `--no-restore` | 升级 dsh 后不自动恢复被隔离的插件 |
|
|
70
|
+
| `--pm <npm\|pnpm>` | 强制指定包管理器(缺省自动探测) |
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
### 环境变量
|
|
73
|
+
|
|
74
|
+
| 变量 | 说明 |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| `DSH_SAFE_LANG=zh\|en` | 强制提示信息语言(缺省跟随 `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) |
|
|
77
|
+
| `DSH_SAFE_NO_UPDATE_CHECK=1` | 关闭启动时每天最多一次的 dsh-safe 新版提示 |
|
|
78
|
+
| `DSH_HOME` | dsh 的 home 目录(dsh 自己的环境变量;隔离台账与各 patch 路径随之) |
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
升级行为:`dsh-safe update` 自动探测 dsh 的包名与安装方式(npm / pnpm 全局安装)、对比最新版本后代跑升级,完成后自动恢复所有被隔离的插件——新 dsh 下仍不兼容的会在下次启动时再次被自动隔离。日常把 `dsh-safe -u web` 当启动命令即可:dsh 已是最新时直接启动(仅一次版本检查),有更新时先升级并恢复隔离再启动,更新检查失败只告警、照常启动。`-u` 后可接 update 的选项(如 `-u -y web`)与包装旗标(如 `-u --max-retries 0 web`)。
|
|
63
81
|
|
|
64
82
|
## 工作原理
|
|
65
83
|
|
|
@@ -81,12 +99,12 @@ update / -u 会同时检查 dsh-safe 自身的版本,谁旧升谁(一条安
|
|
|
81
99
|
- `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
|
|
82
100
|
- 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
|
|
83
101
|
- 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
|
|
84
|
-
- Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm
|
|
102
|
+
- Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm);包装启动会把 PATH 上 dsh 的 .cmd/.ps1 shim 解析出内嵌的 node 入口、改为 `node <入口>` 直接启动(.exe 直接运行,shim 解析失败退回 shell 方式),绕开 Node 禁止 spawn .cmd 的限制。尚未在真实 Windows 上端到端验证,欢迎反馈。
|
|
85
103
|
|
|
86
104
|
## 开发
|
|
87
105
|
|
|
88
106
|
```bash
|
|
89
|
-
npm test # node:test 单元测试 +
|
|
107
|
+
npm test # node:test 单元测试 + fake dsh 集成测试
|
|
90
108
|
```
|
|
91
109
|
|
|
92
110
|
## License
|
package/lib/dshpaths.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
|
-
import { resolve, join } from 'node:path'
|
|
9
|
+
import { dirname, resolve, join } from 'node:path'
|
|
10
10
|
|
|
11
11
|
/** dsh home 目录:`DSH_HOME` 优先,默认 `~/.dsh`。 */
|
|
12
12
|
export function dshHome() {
|
|
@@ -79,3 +79,75 @@ export function detectInvocation(args) {
|
|
|
79
79
|
}
|
|
80
80
|
return { mode: 'boot', profile: profile || null }
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析把 dsh 跑起来的 spawn 目标(Windows 兼容的关键)。
|
|
85
|
+
*
|
|
86
|
+
* 非 Windows 的全局 bin 是 symlink / 带执行位脚本,`spawn('dsh')` 原样可用。
|
|
87
|
+
* Windows 的全局 bin 是 .cmd/.ps1 批处理:Node 出于安全禁止无 shell 地 spawn
|
|
88
|
+
* 它们(EINVAL),裸名也不参与 PATHEXT 解析(ENOENT)。因此按 PATH 目录顺序
|
|
89
|
+
* 找 `dsh.exe` / `dsh.cmd` / `dsh.ps1`:
|
|
90
|
+
* - .exe → 直接 spawn;
|
|
91
|
+
* - .cmd/.ps1 → 解析 shim 内嵌的 node_modules 入口脚本,改 spawn
|
|
92
|
+
* `node <entry>`(无 shell、无参数转义问题,stderr 管道行为与 unix 一致);
|
|
93
|
+
* - shim 里解析不出入口 → 退回 shell 方式运行 shim 本身;
|
|
94
|
+
* - PATH 上什么都没有 → 原样返回(报错行为与从前一致)。
|
|
95
|
+
*
|
|
96
|
+
* @param {string} command dsh 可执行名(默认 'dsh')
|
|
97
|
+
* @param {{
|
|
98
|
+
* platform?: string,
|
|
99
|
+
* pathEnv?: string,
|
|
100
|
+
* execPath?: string,
|
|
101
|
+
* exists?: (path: string) => boolean,
|
|
102
|
+
* readFile?: (path: string) => string | undefined,
|
|
103
|
+
* }} [inject] 测试注入
|
|
104
|
+
* @returns {{ file: string, prefix: string[], shell: boolean }}
|
|
105
|
+
* spawn(file, [...prefix, ...args], { shell })
|
|
106
|
+
*/
|
|
107
|
+
export function resolveDshSpawnTarget(command, {
|
|
108
|
+
platform = process.platform,
|
|
109
|
+
pathEnv = process.env.PATH ?? '',
|
|
110
|
+
execPath = process.execPath,
|
|
111
|
+
exists = existsSync,
|
|
112
|
+
readFile = (p) => { try { return readFileSync(p, 'utf8') } catch { return undefined } },
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (platform !== 'win32') return { file: command, prefix: [], shell: false }
|
|
115
|
+
for (const dir of pathEnv.split(';')) {
|
|
116
|
+
if (!dir) continue
|
|
117
|
+
for (const ext of ['.exe', '.cmd', '.ps1']) { // 同目录内按 PATHEXT 惯例 .exe 优先
|
|
118
|
+
const p = join(dir, command + ext)
|
|
119
|
+
if (!exists(p)) continue
|
|
120
|
+
if (ext === '.exe') return { file: p, prefix: [], shell: false }
|
|
121
|
+
return resolveShimEntry(p, { execPath, exists, readFile })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { file: command, prefix: [], shell: false }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 从 .cmd/.ps1 shim 解析内嵌入口;失败退回 shell 方式运行 shim(路径含空白时加引号)。 */
|
|
128
|
+
function resolveShimEntry(shimPath, { execPath, exists, readFile }) {
|
|
129
|
+
const content = readFile(shimPath)
|
|
130
|
+
const entry = content ? findShimEntry(content, dirname(shimPath), exists) : null
|
|
131
|
+
if (entry) return { file: execPath, prefix: [entry], shell: false }
|
|
132
|
+
return { file: /\s/.test(shimPath) ? `"${shimPath}"` : shimPath, prefix: [], shell: true }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* npm/pnpm 的 cmd/ps1 shim 内容里都内嵌 node_modules 下的入口脚本路径:
|
|
137
|
+
* cmd 形如 `"%~dp0\node_modules\@scope\pkg\bin\x.js"`(也可能先 SET 进变量再
|
|
138
|
+
* `node "%_prog%" %*`),ps1 形如 `$basedir/node_modules/...`。%~dp0 与
|
|
139
|
+
* $basedir 都展开为 shim 所在目录;取第一个真实存在的候选。
|
|
140
|
+
*/
|
|
141
|
+
function findShimEntry(content, shimDir, exists) {
|
|
142
|
+
const candidates = []
|
|
143
|
+
for (const m of content.matchAll(/"([^"\r\n]*node_modules[^"\r\n]*\.(?:js|cjs|mjs))"/gi)) candidates.push(m[1])
|
|
144
|
+
for (const m of content.matchAll(/[\w@.$%~\-\\\/]*node_modules[\w@.$%~\-\\\/]*\.(?:js|cjs|mjs)/gi)) candidates.push(m[0])
|
|
145
|
+
for (let raw of candidates) {
|
|
146
|
+
// `SET "_prog=%~dp0\..."` 会把变量名一起捕进来,切掉 `node_modules` 之前的 `xxx=`
|
|
147
|
+
const eq = raw.indexOf('=')
|
|
148
|
+
if (eq >= 0 && eq < raw.toLowerCase().indexOf('node_modules')) raw = raw.slice(eq + 1)
|
|
149
|
+
const p = resolve(raw.replace(/%~dp0|\$basedir/gi, `${shimDir}/`).replace(/\\/g, '/'))
|
|
150
|
+
if (exists(p)) return p
|
|
151
|
+
}
|
|
152
|
+
return null
|
|
153
|
+
}
|
package/lib/wrap.js
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* - 失败退出 → 从 stderr 解析坏插件,对照 patch 行后把对应行置为 disabled
|
|
7
7
|
* (写入托管区块 + 台账),然后重试;识别不出、超过重试上限、或命中
|
|
8
8
|
* 第一方插件(@deepseek-ai/*,默认保护)时原样透传退出码。
|
|
9
|
+
*
|
|
10
|
+
* Windows 上经 resolveDshSpawnTarget 把 dsh 的 .cmd shim 解析成 `node <入口>`
|
|
11
|
+
* 再启动(见 dshpaths.js),其余平台原样 spawn。
|
|
9
12
|
*/
|
|
10
13
|
import { spawn } from 'node:child_process'
|
|
11
14
|
import { summarizeLine, parseFailureReport } from './failures.js'
|
|
12
15
|
import { collectKnownRows, matchFailures } from './knownrows.js'
|
|
13
|
-
import { detectInvocation } from './dshpaths.js'
|
|
16
|
+
import { detectInvocation, resolveDshSpawnTarget } from './dshpaths.js'
|
|
14
17
|
import { writeQuarantine } from './quarantine.js'
|
|
15
18
|
import { t } from './i18n.js'
|
|
16
19
|
|
|
@@ -19,12 +22,17 @@ const FIRST_PARTY_PREFIX = '@deepseek-ai/'
|
|
|
19
22
|
|
|
20
23
|
const isFirstParty = (name) => typeof name === 'string' && name.startsWith(FIRST_PARTY_PREFIX)
|
|
21
24
|
|
|
25
|
+
/** shell 方式兜底时给含空白的参数补引号(正常路径不走 shell,不受影响)。 */
|
|
26
|
+
const quoteShellArg = (a) => (/\s/.test(a) && !/^".*"$/.test(a) ? `"${a}"` : a)
|
|
27
|
+
|
|
22
28
|
/** 运行 dsh:stdin/stdout 直通,stderr 回显并捕获(上限内)。 */
|
|
23
29
|
export function spawnDsh(args, { command = 'dsh' } = {}) {
|
|
30
|
+
const target = resolveDshSpawnTarget(command)
|
|
24
31
|
return new Promise((resolve) => {
|
|
25
|
-
const child = spawn(
|
|
32
|
+
const child = spawn(target.file, [...target.prefix, ...(target.shell ? args.map(quoteShellArg) : args)], {
|
|
26
33
|
stdio: ['inherit', 'inherit', 'pipe'],
|
|
27
34
|
env: process.env,
|
|
35
|
+
shell: target.shell,
|
|
28
36
|
})
|
|
29
37
|
let captured = ''
|
|
30
38
|
child.stderr?.on('data', (chunk) => {
|