@hyzyn/dsh-safe 0.3.1 → 0.4.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 CHANGED
@@ -67,6 +67,7 @@ Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`,
67
67
  | `--to <version>` | Target dsh version, also how you roll back (explicit downgrades allowed); dsh-safe itself always upgrades to the latest |
68
68
  | `--self` | Update dsh-safe itself only; dsh and quarantine state untouched |
69
69
  | `--no-restore` | Do not auto-restore quarantined plugins after upgrading dsh |
70
+ | `--no-verify` | Skip the post-upgrade parser self-check (boots the new dsh with a throwaway profile to confirm error recognition still works) |
70
71
  | `--pm <npm\|pnpm>` | Force the package manager (auto-detected by default) |
71
72
 
72
73
  ### Environment variables
@@ -98,8 +99,8 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
98
99
  - If the patch file itself fails YAML parsing (e.g. broken by hand-editing), plugins cannot be identified and the failure is passed through.
99
100
  - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned).
100
101
  - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected.
101
- - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser.
102
- - 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.
102
+ - Match patterns target the dsh 0.1.x error formats; a major dsh upgrade that changes them requires updating the parser. Mitigation: after update/-u upgrades dsh it runs a parser self-check — boots the new dsh with a throwaway profile and confirms failures are still recognized, warning right away on mismatch (`--no-verify` skips it).
103
+ - 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.
103
104
 
104
105
  ## Development
105
106
 
package/README.md CHANGED
@@ -67,6 +67,7 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
67
67
  | `--to <版本>` | 指定 dsh 的目标版本,也是回滚方式(显式允许降级);dsh-safe 自身始终升到最新 |
68
68
  | `--self` | 只更新 dsh-safe 自身,不动 dsh 与隔离状态 |
69
69
  | `--no-restore` | 升级 dsh 后不自动恢复被隔离的插件 |
70
+ | `--no-verify` | 跳过升级后的解析器自校验(临时 profile 试启新版 dsh,验证报错识别仍有效) |
70
71
  | `--pm <npm\|pnpm>` | 强制指定包管理器(缺省自动探测) |
71
72
 
72
73
  ### 环境变量
@@ -98,13 +99,13 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
98
99
  - patch 文件本身 YAML 解析错误(如手改坏了)时无法识别插件,只会透传。
99
100
  - `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
100
101
  - 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
101
- - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
102
- - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm),包装启动 dsh 的子进程方式未在 Windows 验证。
102
+ - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。缓解:update/-u 升级 dsh 后会自动做解析器自校验——临时 profile 试启新版 dsh 并确认报错仍可识别,失配当场告警(`--no-verify` 跳过)。
103
+ - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm);包装启动会把 PATH 上 dsh .cmd/.ps1 shim 解析出内嵌的 node 入口、改为 `node <入口>` 直接启动(.exe 直接运行,shim 解析失败退回 shell 方式),绕开 Node 禁止 spawn .cmd 的限制。尚未在真实 Windows 上端到端验证,欢迎反馈。
103
104
 
104
105
  ## 开发
105
106
 
106
107
  ```bash
107
- npm test # node:test 单元测试 + dsh 集成测试
108
+ npm test # node:test 单元测试 + fake dsh 集成测试
108
109
  ```
109
110
 
110
111
  ## 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/i18n.js CHANGED
@@ -20,7 +20,7 @@ const ZH = {
20
20
  dsh-safe list [--profile <名>] 查看隔离名单(缺省列出全部 profile)
21
21
  dsh-safe restore --profile <名> (--id <id> | --all) [--dry-run]
22
22
  恢复被自动禁用的插件(升级修复后使用)
23
- dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--pm npm|pnpm]
23
+ dsh-safe update [-y] [--to <版本>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
24
24
  升级 dsh 与 dsh-safe 自身,并自动恢复被隔离的插件
25
25
  dsh-safe help 显示本帮助
26
26
  dsh-safe --version 显示版本
@@ -89,6 +89,11 @@ const ZH = {
89
89
  updateRestoreSkipped: '[dsh-safe] 已按 --no-restore 跳过恢复;可稍后用 dsh-safe restore --profile <名> --all 恢复。',
90
90
  updateVerifyHint: '[dsh-safe] 请启动 dsh 验证;仍不兼容的插件会自动再次隔离。',
91
91
  updateRollbackHint: '[dsh-safe] 如需回滚: dsh-safe update --to {version}',
92
+ verifyRunning: '[dsh-safe] 正在自校验解析器:用临时 profile 试启新版 dsh(预期失败)…',
93
+ verifyPassed: '[dsh-safe] 解析器自校验通过:新版 dsh 的报错能被识别,自动隔离可用。',
94
+ verifyFailNoHit: '[dsh-safe] 警告:解析器自校验未通过——无法从新版 dsh 的报错里识别坏插件,自动隔离可能失效;建议回滚(dsh-safe update --to {version})并反馈给 dsh-safe。',
95
+ verifyFailBoot: '[dsh-safe] 警告:解析器自校验未通过——坏插件试启意外成功,未触发报错,自动隔离未验证。',
96
+ verifyFailTimeout: '[dsh-safe] 警告:解析器自校验未完成(试启超时或无法启动),自动隔离未验证。',
92
97
  selfUpdateLagHint: '[dsh-safe] dsh-safe 已更新: {old} → {new}(本次运行仍是旧版,下次启动生效)',
93
98
  updateNotify: '[dsh-safe] 提示: dsh-safe 有新版本 {new}(当前 {old})',
94
99
  updateNotifyHow: ' 升级: npm i -g {name} 或 dsh-safe update --self',
@@ -112,7 +117,7 @@ Usage:
112
117
  dsh-safe list [--profile <name>] show quarantined plugins (defaults to all profiles)
113
118
  dsh-safe restore --profile <name> (--id <id> | --all) [--dry-run]
114
119
  re-enable auto-disabled plugins (after a fixed plugin upgrade)
115
- dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--pm npm|pnpm]
120
+ dsh-safe update [-y] [--to <ver>] [--self] [--no-restore] [--no-verify] [--pm npm|pnpm]
116
121
  upgrade dsh and dsh-safe itself, auto-restore quarantined plugins
117
122
  dsh-safe help show this help
118
123
  dsh-safe --version show version
@@ -184,6 +189,11 @@ Notes:
184
189
  updateRestoreSkipped: '[dsh-safe] restore skipped due to --no-restore; restore later with dsh-safe restore --profile <name> --all.',
185
190
  updateVerifyHint: '[dsh-safe] start dsh to verify; plugins still incompatible will be auto-quarantined again.',
186
191
  updateRollbackHint: '[dsh-safe] to roll back: dsh-safe update --to {version}',
192
+ verifyRunning: '[dsh-safe] verifying the parser: booting the new dsh with a throwaway profile (expected to fail)…',
193
+ verifyPassed: '[dsh-safe] parser self-check passed: failures from the new dsh are recognized, auto-quarantine is available.',
194
+ verifyFailNoHit: '[dsh-safe] warning: parser self-check failed — the broken plugin could not be recognized in the new dsh errors; auto-quarantine may be broken. Consider rolling back (dsh-safe update --to {version}) and reporting it to dsh-safe.',
195
+ verifyFailBoot: '[dsh-safe] warning: parser self-check failed — the deliberately broken profile booted unexpectedly, no error was triggered; auto-quarantine unverified.',
196
+ verifyFailTimeout: '[dsh-safe] warning: parser self-check did not finish (boot timed out or could not start); auto-quarantine unverified.',
187
197
  selfUpdateLagHint: '[dsh-safe] dsh-safe updated: {old} → {new} (this run still uses the old version; takes effect on the next run)',
188
198
  updateNotify: '[dsh-safe] notice: a new dsh-safe version is available: {new} (current {old})',
189
199
  updateNotifyHow: ' to upgrade: npm i -g {name} or dsh-safe update --self',
package/lib/update.js CHANGED
@@ -13,6 +13,7 @@ import { createInterface } from 'node:readline/promises'
13
13
  import { delimiter, dirname, join } from 'node:path'
14
14
  import { loadLedger, restoreQuarantine } from './quarantine.js'
15
15
  import { readIfExists, updateCheckFile } from './dshpaths.js'
16
+ import { verifyParser } from './verify.js'
16
17
  import { t } from './i18n.js'
17
18
 
18
19
  const err = (line) => process.stderr.write(`${line}\n`)
@@ -235,11 +236,12 @@ export async function cmdUpdateAndBoot(args, { boot } = {}) {
235
236
  /**
236
237
  * 更新主体;bootArgs 非空时更新成功(或已最新/检查失败)后继续启动。
237
238
  * 默认同时检查 dsh 与 dsh-safe 自身,谁旧升谁(一条安装命令);--self 只更新自身。
238
- * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean }} opts
239
+ * dsh 更新成功后默认跑解析器自校验(--no-verify 跳过)。
240
+ * @param {{ to?: string, yes?: boolean, restore?: boolean, pm?: string, selfOnly?: boolean, noVerify?: boolean }} opts
239
241
  * @param {string[] | null} bootArgs
240
- * @param {{ boot?: (args: string[]) => Promise<number> }} hooks
242
+ * @param {{ boot?: (args: string[]) => Promise<number>, verify?: typeof verifyParser }} hooks
241
243
  */
242
- async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
244
+ async function updateAndMaybeBoot(opts, bootArgs, { boot, verify = verifyParser } = {}) {
243
245
  const dshPkg = opts.selfOnly ? null : resolveDshPackage()
244
246
  const selfPkg = resolveSelfPackage()
245
247
  if (!dshPkg && !selfPkg) {
@@ -317,6 +319,9 @@ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
317
319
  err(t('selfUpdateLagHint', { old: p.pkg.version, new: p.target }))
318
320
  }
319
321
  }
322
+ if (dshUpdated && !opts.noVerify) {
323
+ await verify({ rollbackVersion: plans.find((p) => p.pkg === dshPkg)?.pkg.version, log: err })
324
+ }
320
325
 
321
326
  if (opts.restore && dshUpdated) {
322
327
  const ledger = loadLedger()
@@ -343,12 +348,13 @@ async function updateAndMaybeBoot(opts, bootArgs, { boot } = {}) {
343
348
 
344
349
  /** 解析 update 子命令参数;出错返回 { error: { key, params? } } 供 i18n。 */
345
350
  function parseUpdateArgs(args) {
346
- const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
351
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false, noVerify: false }
347
352
  for (let i = 0; i < args.length; i++) {
348
353
  const a = args[i]
349
354
  if (a === '-y' || a === '--yes') opts.yes = true
350
355
  else if (a === '--no-restore') opts.restore = false
351
356
  else if (a === '--self') opts.selfOnly = true
357
+ else if (a === '--no-verify') opts.noVerify = true
352
358
  else if (a === '--to') {
353
359
  const v = args[++i]
354
360
  if (v === undefined) return { error: { key: 'updateUnknownFlag', params: { arg: '--to' } } }
@@ -375,13 +381,14 @@ function parseUpdateArgs(args) {
375
381
  * dsh 启动参数(宽松处理——不像子命令那样对未知参数报错)。
376
382
  */
377
383
  function parseLeadingUpdateArgs(args) {
378
- const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false }
384
+ const opts = { to: undefined, yes: false, restore: true, pm: undefined, selfOnly: false, noVerify: false }
379
385
  let i = 0
380
386
  for (; i < args.length; i++) {
381
387
  const a = args[i]
382
388
  if (a === '-y' || a === '--yes') opts.yes = true
383
389
  else if (a === '--no-restore') opts.restore = false
384
390
  else if (a === '--self') opts.selfOnly = true
391
+ else if (a === '--no-verify') opts.noVerify = true
385
392
  else if (a === '--to' && args[i + 1] !== undefined) opts.to = args[++i]
386
393
  else if (a.startsWith('--to=')) opts.to = a.slice('--to='.length)
387
394
  else if (a === '--pm' && args[i + 1] !== undefined) opts.pm = args[++i]
package/lib/verify.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @hyzyn/dsh-safe — 升级 dsh 后的解析器自校验。
3
+ *
4
+ * 风险背景:报错解析器与 dsh 的 stderr 格式耦合,dsh 升级可能让特征失配,
5
+ * 保险丝静默失效。自校验在临时 DSH_HOME 里搭一次性 profile(引用现场生成
6
+ * 的坏插件),用 PATH 上新装的 dsh 试启——预期启动失败,再确认解析器能从
7
+ * 报错里识别出坏插件。识别失败只告警(附回滚命令),不阻断后续启动。
8
+ */
9
+ import { spawnSync } from 'node:child_process'
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { parseFailureReport } from './failures.js'
14
+ import { matchFailures } from './knownrows.js'
15
+ import { resolveDshSpawnTarget } from './dshpaths.js'
16
+ import { t } from './i18n.js'
17
+
18
+ const BAD_ID = 'dsh-safe-verify'
19
+ const BAD_NAME = '@dsh-safe-verify/broken'
20
+
21
+ /**
22
+ * @param {{
23
+ * rollbackVersion?: string,
24
+ * log?: (line: string) => void,
25
+ * timeoutMs?: number,
26
+ * spawn?: typeof spawnSync,
27
+ * }} [options]
28
+ * @returns {Promise<boolean>} 自校验是否通过
29
+ */
30
+ export async function verifyParser({ rollbackVersion, log = (line) => process.stderr.write(`${line}\n`), timeoutMs = 60_000, spawn = spawnSync } = {}) {
31
+ log(t('verifyRunning'))
32
+ const home = mkdtempSync(join(tmpdir(), 'dsh-safe-verify-'))
33
+ try {
34
+ // 一次性 profile:patch 行引用现场生成的坏插件(模块级 throw → import 失败)
35
+ const profileDir = join(home, 'profiles', 'verify')
36
+ const modDir = join(profileDir, 'node_modules', BAD_NAME)
37
+ mkdirSync(join(modDir, 'lib'), { recursive: true })
38
+ writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-verify', private: true }))
39
+ const patchPath = join(profileDir, 'cordis.patch.yml')
40
+ writeFileSync(patchPath, `- id: ${BAD_ID}\n name: '${BAD_NAME}'\n`)
41
+ writeFileSync(
42
+ join(modDir, 'package.json'),
43
+ JSON.stringify({ name: BAD_NAME, version: '0.0.1', type: 'module', main: 'lib/index.js' }),
44
+ )
45
+ writeFileSync(join(modDir, 'lib', 'index.js'), `throw new Error('dsh-safe parser self-check')\n`)
46
+
47
+ const target = resolveDshSpawnTarget('dsh')
48
+ const { status, stderr } = spawn(target.file, [...target.prefix, '--profile', 'verify'], {
49
+ env: { ...process.env, DSH_HOME: home },
50
+ stdio: ['ignore', 'ignore', 'pipe'],
51
+ timeout: timeoutMs,
52
+ shell: target.shell,
53
+ encoding: 'utf8',
54
+ })
55
+
56
+ if (status === 0) {
57
+ log(t('verifyFailBoot'))
58
+ return false
59
+ }
60
+ if (status === null) {
61
+ // 超时被杀或无法启动试启进程
62
+ log(t('verifyFailTimeout'))
63
+ return false
64
+ }
65
+ const report = parseFailureReport(stderr ?? '')
66
+ const hits = matchFailures(report, {
67
+ rows: [{ id: BAD_ID, name: BAD_NAME, disabled: false, file: patchPath }],
68
+ })
69
+ if (hits.length) {
70
+ log(t('verifyPassed'))
71
+ return true
72
+ }
73
+ log(t('verifyFailNoHit', { version: rollbackVersion ?? '' }))
74
+ return false
75
+ } finally {
76
+ rmSync(home, { recursive: true, force: true })
77
+ }
78
+ }
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(command, args, {
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-safe",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",