@noob-stupid/dsh-plugin-console 0.5.21 → 0.5.23

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.
@@ -27,7 +27,7 @@ import { NPM_NAME_RE } from './dep-source.js'
27
27
  import { runLockfileRepair } from './lockfile-health.js'
28
28
  import { pinProfileDependency, specOf } from './manifest.js'
29
29
  import { lockVersion } from './selfupdate.js'
30
- import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback } from '../infra/exec.js'
30
+ import { buildPnpmEnv, pnpmAddArgs, runPnpmWithFallback, unknownPnpmOption } from '../infra/exec.js'
31
31
 
32
32
  const DEFAULT_REGISTRY = 'https://registry.npmmirror.com'
33
33
  /** 动作白名单(客户端只能给这些 kind;执行分支与 argv 全在服务端)。 */
@@ -91,14 +91,18 @@ function isReleaseAgeBlock(message) {
91
91
  }
92
92
 
93
93
  /** 跑一次 pnpm(原样),必要时**只对这一条命令**放宽供应链闸并如实标记。
94
- * 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。 */
94
+ * 返回 { ok, exitCode, stdout, stderr, relaxedReleaseAge, error }。
95
+ *
96
+ * ★ 2026-09-27 CI 实测踩到(本函数的第一个版本):这里直接拼 `pnpmAddArgs` 却没有 `runPnpmAdd` 的
97
+ * **未知选项降级** —— 而 CI 的 corepack 解析到 pnpm 12,`--fetch-timeout/--fetch-retries` 在 pnpm 12
98
+ * 上是 `error: unexpected argument`(直接退出),于是"加固"变成了"动作跑不成"(真机 CI 红)。
99
+ * 现在与 `infra/exec.js#runPnpmAdd` 同一条规矩:认不出这两个选项就去掉加固选项重试一次。 */
95
100
  async function runAddWithReleaseAgeRetry({ profileDir, spec, registry, runAdd, timeoutMs = 180000 }) {
96
- const argv = pnpmAddArgs(spec, registry)
97
101
  const execOpts = { cwd: profileDir, timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: buildPnpmEnv(registry) }
98
- const attempt = async (args) => {
102
+ const once = async (args) => {
99
103
  try {
100
104
  const out = await runAdd(args, { execOpts })
101
- return { ok: true, exitCode: 0, stdout: tail(out?.stdout), stderr: tail(out?.stderr), error: null }
105
+ return { ok: true, exitCode: 0, stdout: tail(out?.stdout), stderr: tail(out?.stderr), error: null, argv: args }
102
106
  } catch (error) {
103
107
  return {
104
108
  ok: false,
@@ -106,14 +110,21 @@ async function runAddWithReleaseAgeRetry({ profileDir, spec, registry, runAdd, t
106
110
  stdout: tail(error?.stdout),
107
111
  stderr: tail(error?.stderr ?? error?.message ?? String(error)),
108
112
  error: error instanceof Error ? error : new Error(String(error)),
113
+ argv: args,
109
114
  }
110
115
  }
111
116
  }
112
- const first = await attempt(argv)
117
+ const attempt = async (extra) => {
118
+ const first = await once([...pnpmAddArgs(spec, registry), ...extra])
119
+ if (first.ok || !unknownPnpmOption(String(first.error?.message ?? ''))) return first
120
+ // 加固选项不被该版本 pnpm 认识 → 去掉它们重试一次(绝不让加固变成"跑不成")
121
+ return once([...pnpmAddArgs(spec, registry, { fetchFlags: false }), ...extra])
122
+ }
123
+ const first = await attempt([])
113
124
  // 放宽条件**必须同时**成立:第一次真的失败了,且失败原因确定是供应链闸(文案/错误码认得出)
114
125
  const blocked = !first.ok && (isReleaseAgeBlock(first.stderr) || isReleaseAgeBlock(String(first.error?.message ?? '')))
115
126
  if (!blocked) return { ...first, relaxedReleaseAge: false }
116
- const retried = await attempt([...argv, '--config.minimumReleaseAge=0'])
127
+ const retried = await attempt(['--config.minimumReleaseAge=0'])
117
128
  return { ...retried, relaxedReleaseAge: true }
118
129
  }
119
130
 
@@ -13,13 +13,81 @@ function gitBin() {
13
13
  return process.platform === 'win32' ? 'git.exe' : 'git'
14
14
  }
15
15
 
16
+ /** PATH 条目是不是桌面端运行时的 `<…>\runtime\pnpm\bin` / `<…>\runtime\bin`(两种分隔符都认)。 */
17
+ const RUNTIME_PNPM_BIN_RE = /[\\/]runtime[\\/]pnpm[\\/]bin[\\/]?$/u
18
+ const RUNTIME_BIN_RE = /[\\/]runtime[\\/]bin[\\/]?$/u
19
+
20
+ /** cmd.exe 的元字符:`^` 是 cmd 自己的转义符(**引号内照样生效** —— 本机实测 `"a^b"` 到程序手里变成 `ab`),
21
+ * `& | < > ( )` 在引号内**仍然是 cmd 的操作符**(实测 `"a&b"` 会在 `&` 处断开,把 `b` 当命令执行)。
22
+ * 所以段内这些字符必须逐个加 `^` 转义。 */
23
+ const CMD_META_RE = /[\^&|<>()]/gu
24
+
25
+ /** 把一串命令段拼成**一条 cmd.exe 命令字符串**(纯函数,导出给单测)。
26
+ *
27
+ * 2026-09-27 改错(桌面端实例"所有 pnpm 操作都跑不了"的两处根因之一):
28
+ * 旧代码是 `['corepack','pnpm',...args].map((a) => JSON.stringify(a)).join(' ')` —— JSON.stringify 加的是
29
+ * **真引号**,而这一整串又是作为一个 argv 元素交给 Node 的 spawn;Windows 上 Node/libuv 按 MSVCRT 规则
30
+ * 把参数里的 `"` 转义成 `\"`,cmd 实际收到 `\"corepack\" \"pnpm\"` → `'\"corepack\"' 不是内部或外部命令`。
31
+ * 本机对着一个只回显参数的 .cmd 实测三种形态:
32
+ * · `"a" "b"` + 默认转义 → 收到 `\"a\" \"b\"` → 失败(旧缺陷)
33
+ * · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
34
+ * · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓(含空格 / `&` / `|` / `>` 的参数
35
+ * 在段内加 `^` 转义后也原样到达)
36
+ * 所以本函数产出的是"**内层**命令字符串"(形如 `"corepack" "pnpm" "add" "x"`,**永不含 `\"`**),
37
+ * 最外层那对引号由 cmdShellArgv 负责加。
38
+ * 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
39
+ * **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
40
+ * 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。 */
41
+ function cmdCommandLine(parts) {
42
+ return parts
43
+ .map((part) => `"${String(part).replace(CMD_META_RE, (ch) => `^${ch}`)}"`)
44
+ .join(' ')
45
+ }
46
+
47
+ /** corepack 形态的命令字符串(纯函数,导出给单测断言"不含 `\"` 转义")。 */
48
+ function cmdCorepackCommand(args) {
49
+ return cmdCommandLine(['corepack', 'pnpm', ...args])
50
+ }
51
+
52
+ /** 交给 cmd.exe 的完整 argv(`/d /s /c` + **整条再包一层引号**的内层命令字符串)。
53
+ * 必须与 `verbatim: true`(windowsVerbatimArguments)配套:Node 不再做任何引号转义,
54
+ * cmd 的 `/s` 剥掉最外层那一对,剩下的正好是我们要的命令行。 */
55
+ function cmdShellArgv(commandLine) {
56
+ return ['/d', '/s', '/c', `"${commandLine}"`]
57
+ }
58
+
16
59
  /** pnpm 执行方式定位(跨平台,纯函数便于单测 → 按优先级返回列表,逐个尝试)。
17
60
  * 事故(2026-09-20,同一位用户,node v24 + Linux):只按 Windows 布局找 corepack.js
18
61
  * (`<node bin>/node_modules/corepack/dist/corepack.js`),而 Linux 的 npm 全局布局在
19
62
  * `<prefix>/lib/node_modules/corepack/...` → AI 赋能的 install-npm 生成
20
63
  * `node /usr/local/bin/node_modules/corepack/dist/corepack.js pnpm add …` →
21
- * `Error: Cannot find module …`(MODULE_NOT_FOUND)。这里把三种布局 + PATH 兜底都列出来。 */
22
- function resolvePnpmRunners({ platform = process.platform, execPath = process.execPath, comspec = process.env.ComSpec ?? 'cmd.exe', exists = existsSync } = {}) {
64
+ * `Error: Cannot find module …`(MODULE_NOT_FOUND)。这里把三种布局 + PATH 兜底都列出来。
65
+ *
66
+ * 事故(2026-09-27,官方桌面端实例:**所有** pnpm 操作都跑不了)—— 本次两处修:
67
+ * ① 形态缺陷(先于本轮存在):cmd-corepack 分支原来把 argv 数组交给 cmd.exe,Node 在 Windows 上
68
+ * 按 MSVCRT 规则把段内引号转义成 `\"` → cmd 收到 `\"corepack\" \"pnpm\"` → 一条都跑不起来。
69
+ * 修法与实测证据见 cmdCommandLine / cmdShellArgv。
70
+ * ② 认不出桌面端自带运行时(加法):桌面端 host 自己就是用
71
+ * `command: process.execPath`(Electron 二进制)+ `args: ['--expose-internals', <resources>\runtime\pnpm\bin\pnpm.mjs]`
72
+ * + `env.ELECTRON_RUN_AS_NODE=1` 跑 pnpm 的(dsh-desktop-host 的 packageManager),
73
+ * 而我们的候选只有"node 旁边的三种 corepack.js 布局 + cmd corepack + PATH 上的 pnpm" ——
74
+ * 桌面端的 host 二进制旁边没有 corepack.js,PATH 里也只有 `runtime\bin`(没有 pnpm.cmd),
75
+ * 于是三条候选全落空。现在把桌面端运行时(resourcesPath ① / PATH+env 线索 ③ / PATH 扫描 ②)
76
+ * 作为**新候选插在 node-corepack 之后、cmd-corepack 之前**:既有候选与顺序一个都没动。
77
+ * 兜底语义保持不变:只有"执行方式本身不可用"(ENOENT / Cannot find module)才换下一个,
78
+ * 真正的失败立即抛出(见 runPnpmWithFallback)。
79
+ * 全部候选都走注入的 `exists`;`env/resourcesPath/delimiter/nodeBin` 四个注入点只为离线单测,
80
+ * 生产调用方一个字都不用改(默认值就是当前进程的事实)。 */
81
+ function resolvePnpmRunners({
82
+ platform = process.platform,
83
+ execPath = process.execPath,
84
+ comspec = process.env.ComSpec ?? 'cmd.exe',
85
+ exists = existsSync,
86
+ env = process.env,
87
+ resourcesPath = process.resourcesPath,
88
+ delimiter = platform === 'win32' ? ';' : ':',
89
+ nodeBin = 'node',
90
+ } = {}) {
23
91
  const binDir = dirname(execPath)
24
92
  const corepackCandidates = [
25
93
  join(binDir, 'node_modules', 'corepack', 'dist', 'corepack.js'), // Windows 官方安装器 / nvm-windows
@@ -27,17 +95,63 @@ function resolvePnpmRunners({ platform = process.platform, execPath = process.ex
27
95
  join(binDir, '..', 'libexec', 'lib', 'node_modules', 'corepack', 'dist', 'corepack.js'), // brew / 自编译布局
28
96
  ]
29
97
  const runners = []
98
+ const add = (runner) => {
99
+ if (runners.some((r) => r.note === runner.note)) return // 同一路径可能被多条线索命中(resourcesPath + PATH)
100
+ runners.push(runner)
101
+ }
30
102
  for (const js of corepackCandidates) {
31
103
  if (exists(js)) {
32
104
  runners.push({ kind: 'node-corepack', note: `node ${js} pnpm`, run: (args) => ({ bin: execPath, argv: [js, 'pnpm', ...args] }) })
33
105
  }
34
106
  }
107
+
108
+ // ── 新候选 ①/③:桌面端(Electron)自带运行时 ──────────────────────────────────
109
+ // ① Electron 的 `process.resourcesPath`(桌面端 = `<安装目录>\resources`);
110
+ // ③ 环境线索:PATH 条目里的 `<…>\runtime\pnpm\bin` 与 `<…>\runtime\bin`(桌面端 host 就是把
111
+ // `resources\runtime\bin` 拼进 profile 的 PATH 的,其同级就是 `runtime\pnpm\bin\pnpm.mjs`)。
112
+ const desktopNode = typeof env?.DSH_DESKTOP_NODE_EXECUTABLE === 'string' && env.DSH_DESKTOP_NODE_EXECUTABLE !== ''
113
+ ? env.DSH_DESKTOP_NODE_EXECUTABLE
114
+ : execPath
115
+ const runtimeMjs = []
116
+ if (typeof resourcesPath === 'string' && resourcesPath !== '') runtimeMjs.push(join(resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
117
+ const pathValue = typeof env?.PATH === 'string' && env.PATH !== '' ? env.PATH : (typeof env?.Path === 'string' ? env.Path : '')
118
+ const pathDirs = String(pathValue).split(delimiter).map((d) => d.trim()).filter((d) => d !== '')
119
+ for (const dir of pathDirs) {
120
+ if (RUNTIME_PNPM_BIN_RE.test(dir)) runtimeMjs.push(join(dir, 'pnpm.mjs'))
121
+ else if (RUNTIME_BIN_RE.test(dir)) runtimeMjs.push(join(dirname(dir), 'pnpm', 'bin', 'pnpm.mjs'))
122
+ }
123
+ for (const js of runtimeMjs) {
124
+ if (!exists(js)) continue
125
+ add({
126
+ kind: 'desktop-pnpm-mjs',
127
+ note: `${desktopNode} --expose-internals ${js} pnpm`,
128
+ // 照抄桌面端 host 的形态(command=execPath,args=['--expose-internals', <pnpm.mjs>])。
129
+ // ELECTRON_RUN_AS_NODE 必须显式带上:不带它,Electron 二进制会当 **GUI 应用**启动 ——
130
+ // 等于弹一个新窗口,而且退出码可能是 0("假成功",比失败更糟)。
131
+ run: (args) => ({ bin: desktopNode, argv: ['--expose-internals', js, ...args], env: { ELECTRON_RUN_AS_NODE: '1' } }),
132
+ })
133
+ }
134
+
135
+ // ── 新候选 ②:PATH 扫描(pnpm.cmd(win) / pnpm(posix) / <dir>\pnpm\bin\pnpm.mjs)──────
136
+ for (const dir of pathDirs) {
137
+ const mjs = join(dir, 'pnpm', 'bin', 'pnpm.mjs')
138
+ if (exists(mjs)) add({ kind: 'path-pnpm-mjs', note: `${nodeBin} ${mjs} pnpm`, run: (args) => ({ bin: nodeBin, argv: [mjs, ...args] }) })
139
+ if (platform === 'win32') {
140
+ // .cmd 批处理不能直接 execFile(本机实测 EINVAL)→ 唯一通路是 cmd.exe(修好的单字符串形态)
141
+ if (exists(join(dir, 'pnpm.cmd'))) {
142
+ add({ kind: 'cmd-pnpm', note: `cmd /c pnpm(PATH: ${dir})`, run: (args) => ({ bin: comspec, argv: cmdShellArgv(cmdCommandLine(['pnpm', ...args])), verbatim: true }) })
143
+ }
144
+ } else if (exists(join(dir, 'pnpm'))) {
145
+ add({ kind: 'path-pnpm', note: join(dir, 'pnpm'), run: (args) => ({ bin: join(dir, 'pnpm'), argv: args }) })
146
+ }
147
+ }
148
+
35
149
  if (platform === 'win32') {
36
- // .cmd 批处理不能直接 execFile(EINVAL)→ 经 cmd.exe 调用(整条命令作为一个参数)
150
+ // 修好的形态:单个命令字符串(段内各自加引号 + 转义 cmd 元字符)+ 整条外包裹 + verbatim
37
151
  runners.push({
38
152
  kind: 'cmd-corepack',
39
153
  note: 'cmd /c corepack pnpm',
40
- run: (args) => ({ bin: comspec, argv: ['/d', '/s', '/c', ['corepack', 'pnpm', ...args].map((a) => JSON.stringify(a)).join(' ')] }),
154
+ run: (args) => ({ bin: comspec, argv: cmdShellArgv(cmdCorepackCommand(args)), verbatim: true }),
41
155
  })
42
156
  } else {
43
157
  runners.push({ kind: 'corepack', note: 'corepack pnpm', run: (args) => ({ bin: 'corepack', argv: ['pnpm', ...args] }) })
@@ -73,10 +187,21 @@ async function runPnpmWithFallback(args, { execOpts = {}, runners = resolvePnpmR
73
187
  return wrapped
74
188
  }
75
189
  for (const runner of runners) {
76
- const { bin, argv } = runner.run(args)
190
+ // 执行方式可以带自己的 spawn 附加项(2026-09-27 加法):
191
+ // · `verbatim`:cmd.exe 形态必须原样透传命令行(否则 Node 会把段内引号转义成 `\"`,见 cmdCommandLine);
192
+ // · `env`:桌面端形态必须带 ELECTRON_RUN_AS_NODE=1(否则 Electron 二进制会当 GUI 应用启动)。
193
+ // 两者都只在 runner 明确给出时生效,既有的 node-corepack / corepack / pnpm 候选一个字不变。
194
+ const { bin, argv, verbatim = false, env: runnerEnv = null } = runner.run(args)
195
+ const opts = verbatim || runnerEnv !== null
196
+ ? {
197
+ ...execOpts,
198
+ ...(verbatim ? { windowsVerbatimArguments: true } : {}),
199
+ ...(runnerEnv === null ? {} : { env: { ...(typeof execOpts.env === 'object' && execOpts.env !== null ? execOpts.env : process.env), ...runnerEnv } }),
200
+ }
201
+ : execOpts
77
202
  try {
78
203
  // eslint-disable-next-line no-await-in-loop
79
- await exec(bin, argv, execOpts)
204
+ await exec(bin, argv, opts)
80
205
  return { runner }
81
206
  } catch (error) {
82
207
  lastError = withStderr(error)
@@ -300,6 +425,9 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
300
425
  cwd: opts.cwd,
301
426
  env: opts.env,
302
427
  windowsHide: opts.windowsHide !== false,
428
+ // 2026-09-27(加法):cmd.exe 形态要求"命令行原样透传"(见 infra/exec.js#cmdCommandLine)。
429
+ // 只有 runner 显式标了 verbatim 才会是 true;其余调用点行为完全不变(默认 false)。
430
+ windowsVerbatimArguments: opts.windowsVerbatimArguments === true,
303
431
  detached: platform !== 'win32', // POSIX:自成进程组,-pid 才杀得掉整棵树(platform 可注入,见单测)
304
432
  })
305
433
  } catch (error) { finish(error, null); return }
@@ -434,4 +562,4 @@ const GH_BIN_CANDIDATES = [
434
562
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
435
563
  ]
436
564
 
437
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd }
565
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",