@hyzyn/dsh-safe 0.3.1 → 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 CHANGED
@@ -99,7 +99,7 @@ How upgrading works: `dsh-safe update` auto-detects the dsh package name and ins
99
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).
100
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.
101
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
+ - 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
103
 
104
104
  ## Development
105
105
 
package/README.md CHANGED
@@ -99,12 +99,12 @@ Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broke
99
99
  - `--patch` 覆盖层里插入的行不参与对照表(对照表只扫 profile patch、home patch 与 bundle patch)。
100
100
  - 为了捕获 stderr,包装器把 dsh 的 stderr 接到管道(内容仍实时回显到终端);stdout/stdin 直通不受影响。
101
101
  - 本项目针对 dsh 0.1.x 的报错格式做匹配;dsh 大版本升级后格式变化时需要同步更新解析器。
102
- - Windows 为尽力支持:update / --self / list / restore 已适配(.cmd shim 解析、shell 方式调用 npm/pnpm),包装启动 dsh 的子进程方式未在 Windows 验证。
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 上端到端验证,欢迎反馈。
103
103
 
104
104
  ## 开发
105
105
 
106
106
  ```bash
107
- npm test # node:test 单元测试 + dsh 集成测试
107
+ npm test # node:test 单元测试 + fake dsh 集成测试
108
108
  ```
109
109
 
110
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(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.3.2",
4
4
  "description": "dsh 启动保险丝:社区插件不兼容导致 dsh 启动失败时,自动禁用坏插件并重试",
5
5
  "type": "module",
6
6
  "license": "MIT",