@noob-stupid/dsh-plugin-console 0.5.47 → 0.5.48

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.
@@ -0,0 +1,47 @@
1
+ // L1 · domain —— 已落地仓库复用(批次 C-⑩ 2026-09-27 加法;0.5.48 从 repoland.js 抽出)
2
+ //
3
+ // 为什么单开一个模块:repoland.js 贴着 tests/test-architecture-guard.mjs 的 **单文件 ≤ 600 行**硬顶,
4
+ // 而"判断某个仓库本机是否已经落地、能不能直接复用"是自洽的一块只读逻辑(0.5.48 改错时新增了判据,
5
+ // 抽出来是**搬移 + 那一条判据**,导出名与行为在 repoland.js 侧保持不变)。
6
+ //
7
+ // 为什么要有这条复用:用户在「仓库落地」里已经把仓库拉到本地(reposDir,默认 ~/.dsh/repos),
8
+ // 安装/套装装配时再去网上拉一遍纯属浪费 —— 本机直连不通时甚至是"白等一场空"。
9
+ //
10
+ // ★ 2026-10-13(0.5.48)改错:判据从"**仓库根**必须有 package.json"改为
11
+ // 「根有清单 **或** 目标子目录有清单」。
12
+ // 为什么原来那条对**子目录插件结构性失效**:像 DeepSeek-Bot 这类仓库,根目录**故意**没有
13
+ // package.json(真正的插件在 `<repo>/<subdir>/`)。旧判据要求根有清单 ⇒ 这类仓库**永远 hit:false**
14
+ // ⇒ 明明本地已经落地,还是去网上重拉一遍 —— 恰好把"已落地优先 ⇒ 零网络"这条特性,
15
+ // 对它最该服务的场景(用户特地为子目录插件落的仓库)废掉了。
16
+ // `subdir` 由调用方按本次安装目标传入(形如 `web-search` 或 `packages/web-search`);
17
+ // 不传 ⇒ 只认根清单 ⇒ **行为与旧版逐项相同**(不改变既有调用点的语义、不制造新的命中面)。
18
+
19
+ import { existsSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ /** 已落地仓库复用(纯判断,只读)。返回 `{ hit, path, subdir? }`;deps 只为单测注入。 */
23
+ function findLandedRepoImpl(repo, deps = {}) {
24
+ const exists = deps.exists ?? existsSync
25
+ const list = deps.listLanded
26
+ const full = String(repo ?? '').trim().replace(/\.git$/u, '')
27
+ if (full === '' || !full.includes('/')) return { hit: false, path: null }
28
+ const subdir = typeof deps.subdir === 'string' && deps.subdir.trim() !== ''
29
+ ? deps.subdir.trim().replace(/^[./\\]+/u, '').replace(/\\/gu, '/')
30
+ : null
31
+ let landed = []
32
+ try { landed = list() ?? [] } catch { landed = [] }
33
+ const match = landed.find((r) => String(r.repo).toLowerCase() === full.toLowerCase())
34
+ if (match === undefined) return { hit: false, path: null }
35
+ try {
36
+ if (exists(join(match.path, 'package.json'))) return { hit: true, path: match.path }
37
+ // 根没有清单:只有调用方明确给了子目录时才继续看子目录(否则不扩大命中面)
38
+ if (subdir !== null && exists(join(match.path, ...subdir.split('/'), 'package.json'))) {
39
+ return { hit: true, path: match.path, subdir }
40
+ }
41
+ return { hit: false, path: match.path }
42
+ } catch {
43
+ return { hit: false, path: match.path }
44
+ }
45
+ }
46
+
47
+ export { findLandedRepoImpl }
@@ -9,6 +9,7 @@ import { gitCloneCandidates } from './sources.js'
9
9
  import { archiveRepo } from './archive-source.js'
10
10
  import { execFileAsync, gitEnv, killProcessTree } from '../infra/exec.js'
11
11
  import { copyTree, disposeDir, disposeNote } from '../infra/fsx.js'
12
+ import { findLandedRepoImpl } from './landed-repo.js'
12
13
  import { dshHome, repoLandConfFile } from '../infra/paths.js'
13
14
 
14
15
  /** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
@@ -174,27 +175,11 @@ function waitChildExit(child, timeoutMs, pollMs = 60) {
174
175
  * 效果:一个"只连不传"的源从"每个源白等 60/180 秒"变成"≈20 秒判死 → 立刻换下一个源"。 */
175
176
  const GIT_STALL_ARGS = ['-c', 'http.lowSpeedLimit=1', '-c', 'http.lowSpeedTime=20']
176
177
 
177
- /** 已落地仓库复用(批次 C-⑩,2026-09-27 加法):克隆前先看 `<reposDir>/<owner>/<repo>`。
178
- * 为什么:用户在「仓库落地」里已经把仓库拉到本地(reposDir,默认 ~/.dsh/repos),
179
- * 安装/套装装配时再去网上拉一遍纯属浪费——本机直连不通时甚至是"白等一场空"。
180
- * 命中条件:目录存在、看起来是仓库(有 .git 或 .gitmodules)、且**含 package.json**
181
- * (没有 package.json 的多半是半成品/技能仓库,交给正常通道更稳)。
182
- * 返回 `{ hit, path }`;deps 只为单测注入。 */
178
+ /** 已落地仓库复用:判据与实现见 `domain/landed-repo.js`(2026-10-13 抽出 —— 本文件贴着
179
+ * tests/test-architecture-guard.mjs 的"单文件 ≤ 600 行"硬顶;只搬移,导出名与行为一字未变)。
180
+ * 这里保留同名包装,既有调用点与单测不用改。 */
183
181
  function findLandedRepo(repo, deps = {}) {
184
- const exists = deps.exists ?? existsSync
185
- const list = deps.listLanded ?? listLandedRepos
186
- const full = String(repo ?? '').trim().replace(/\.git$/u, '')
187
- if (full === '' || !full.includes('/')) return { hit: false, path: null }
188
- let landed = []
189
- try { landed = list() ?? [] } catch { landed = [] }
190
- const match = landed.find((r) => String(r.repo).toLowerCase() === full.toLowerCase())
191
- if (match === undefined) return { hit: false, path: null }
192
- try {
193
- if (!exists(join(match.path, 'package.json'))) return { hit: false, path: match.path }
194
- } catch {
195
- return { hit: false, path: match.path }
196
- }
197
- return { hit: true, path: match.path }
182
+ return findLandedRepoImpl(repo, { ...deps, listLanded: deps.listLanded ?? listLandedRepos })
198
183
  }
199
184
 
200
185
  /** git 克隆的默认首轮超时(2026-09-27 下调 180 秒 → 60 秒)。
@@ -114,13 +114,20 @@ async function verifySubdirManifest({ repo, branch, dir, readManifest }) {
114
114
  }
115
115
 
116
116
  /** 取子目录源码:先复用**已落地**仓库(零网络),否则稀疏取源码只拉这一个目录。
117
- * 返回 `{ ok, dir, reused, notes }` / `{ ok:false, notes, error }`。clone 可注入(单测零进程/零网络)。 */
117
+ * 返回 `{ ok, dir, reused, notes }` / `{ ok:false, notes, error }`。clone 可注入(单测零进程/零网络)。
118
+ *
119
+ * ★ 2026-10-13(0.5.48)接线改错:把本次目标子目录 `dir` **显式传给 findLandedRepo**。
120
+ * 原来这里调 `findLanded(repo)` 不传子目录,而 findLandedRepo 的旧判据要求**仓库根**有
121
+ * package.json ⇒ 像 DeepSeek-Bot 这种"根目录故意没有清单、插件在子目录"的仓库**永远不命中**
122
+ * ⇒ 本地明明已经落地,还是往下走去网上取源码 —— 正是"已落地优先 ⇒ 零网络"对子目录插件的结构性失效。
123
+ * (下面那段"再看 candidate/package.json"的兜底是从**未命中**里救回来的补丁;判据修好后
124
+ * 正常路径在 findLandedRepo 内就命中,这段兜底保留作为行为不变的安全网。) */
118
125
  async function acquireSubdirSource({ repo, dir, source = 'github', deps = {} }) {
119
126
  const notes = []
120
127
  const findLanded = typeof deps.findLandedRepo === 'function' ? deps.findLandedRepo : findLandedRepo
121
128
  let landed = { hit: false, path: null }
122
129
  try {
123
- landed = findLanded(repo) ?? landed
130
+ landed = findLanded(repo, { subdir: dir }) ?? landed
124
131
  } catch {
125
132
  landed = { hit: false, path: null }
126
133
  }
@@ -0,0 +1,63 @@
1
+ // L0 · infra —— cmd.exe 命令行拼装(0.5.48 从 exec.js 抽出:本文件贴着
2
+ // tests/test-architecture-guard.mjs 的"单文件 ≤ 600 行"硬顶;**只搬移,不改逻辑**)。
3
+ //
4
+ // 为什么 Windows 上要这么绕(2026-09-27 改错,桌面端实例"所有 pnpm 操作都跑不了"的两处根因之一):
5
+ // 旧代码是 `['corepack','pnpm',...args].map((a) => JSON.stringify(a)).join(' ')` —— JSON.stringify
6
+ // 加的是**真引号**,而这一整串又是作为一个 argv 元素交给 Node 的 spawn;Windows 上 Node/libuv 按
7
+ // MSVCRT 规则把参数里的 `"` 转义成 `\"`,cmd 实际收到 `\"corepack\" \"pnpm\"`
8
+ // → `'\"corepack\"' 不是内部或外部命令`。
9
+ // 本机对着一个只回显参数的 .cmd 实测三种形态:
10
+ // · `"a" "b"` + 默认转义 → 收到 `\"a\" \"b\"` → 失败(旧缺陷)
11
+ // · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
12
+ // · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓
13
+ // 所以本模块产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**),
14
+ // 最外层那对引号由 cmdShellArgv 负责加。
15
+ //
16
+ // 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
17
+ // **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
18
+ // 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。
19
+ //
20
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
21
+
22
+ /** cmd.exe 的元字符:`^` 是 cmd 自己的转义符(**引号内照样生效** —— 本机实测 `"a^b"` 到程序手里变成 `ab`),
23
+ * `& | < > ( )` 在引号内**仍然是 cmd 的操作符**(实测 `"a&b"` 会在 `&` 处断开,把 `b` 当命令执行)。
24
+ * 所以段内这些字符必须逐个加 `^` 转义。 */
25
+ const CMD_META_RE = /[\^&|<>()]/gu
26
+
27
+ /** 把一串命令段拼成**一条 cmd.exe 命令字符串**(纯函数,导出给单测)。
28
+ * 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
29
+ * install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
30
+ * `corepack` 在 Windows 上常是 **cmd 批处理 shim**(`<node 安装目录>\corepack.cmd` →
31
+ * `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
32
+ * 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
33
+ * (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。真 cmd.exe 实测:
34
+ * · `""corepack" "pnpm" "--version""` → status=1、stdout=""(旧形态,坏)
35
+ * · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
36
+ * 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符。
37
+ * 只有「干净 token」(无空白、无 cmd 元字符)能去引号:含 `& | < > ( ) ^` 的段,实测
38
+ * **裸串 + `^` 转义会失效**(真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),
39
+ * 必须沿用「引号 + 段内 ^」的既有形态。 */
40
+ function cmdCommandLine(parts) {
41
+ return parts
42
+ .map((part) => String(part))
43
+ .map((text) => {
44
+ const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
45
+ if (clean) return text
46
+ return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
47
+ })
48
+ .join(' ')
49
+ }
50
+
51
+ /** corepack 形态的命令字符串(纯函数,导出给单测断言"不含 `\"` 转义")。 */
52
+ function cmdCorepackCommand(args) {
53
+ return cmdCommandLine(['corepack', 'pnpm', ...args])
54
+ }
55
+
56
+ /** 交给 cmd.exe 的完整 argv(`/d /s /c` + **整条再包一层引号**的内层命令字符串)。
57
+ * 必须与 `verbatim: true`(windowsVerbatimArguments)配套:Node 不再做任何引号转义,
58
+ * cmd 的 `/s` 剥掉最外层那一对,剩下的正好是我们要的命令行。 */
59
+ function cmdShellArgv(commandLine) {
60
+ return ['/d', '/s', '/c', `"${commandLine}"`]
61
+ }
62
+
63
+ export { CMD_META_RE, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }
@@ -7,6 +7,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'
7
7
  import { dirname, join } from 'node:path'
8
8
  import { homedir } from 'node:os'
9
9
  import { selectPnpmRunners } from './pnpm-runner-pick.js'
10
+ import { releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError } from './pnpm-release-gate.js'
11
+ import { RUNTIME_PNPM_BIN_RE, RUNTIME_BIN_RE, desktopRuntimeCandidates, dshRuntimeCandidates } from './pnpm-runtime-discovery.js'
12
+ import { CMD_META_RE, cmdCommandLine, cmdCorepackCommand, cmdShellArgv } from './cmd-line.js'
10
13
 
11
14
  /** git 可执行名(跨平台)。事故(2026-09-20,另一位用户:Android + proot Ubuntu):
12
15
  * 「仓库落地」硬编码 `git.exe` → 非 Windows 环境 spawn git.exe ENOENT,克隆必然失败。 */
@@ -14,70 +17,6 @@ function gitBin() {
14
17
  return process.platform === 'win32' ? 'git.exe' : 'git'
15
18
  }
16
19
 
17
- /** PATH 条目是不是桌面端运行时的 `<…>\runtime\pnpm\bin` / `<…>\runtime\bin`(两种分隔符都认)。 */
18
- const RUNTIME_PNPM_BIN_RE = /[\\/]runtime[\\/]pnpm[\\/]bin[\\/]?$/u
19
- const RUNTIME_BIN_RE = /[\\/]runtime[\\/]bin[\\/]?$/u
20
-
21
- /** cmd.exe 的元字符:`^` 是 cmd 自己的转义符(**引号内照样生效** —— 本机实测 `"a^b"` 到程序手里变成 `ab`),
22
- * `& | < > ( )` 在引号内**仍然是 cmd 的操作符**(实测 `"a&b"` 会在 `&` 处断开,把 `b` 当命令执行)。
23
- * 所以段内这些字符必须逐个加 `^` 转义。 */
24
- const CMD_META_RE = /[\^&|<>()]/gu
25
-
26
- /** 把一串命令段拼成**一条 cmd.exe 命令字符串**(纯函数,导出给单测)。
27
- *
28
- * 2026-09-27 改错(桌面端实例"所有 pnpm 操作都跑不了"的两处根因之一):
29
- * 旧代码是 `['corepack','pnpm',...args].map((a) => JSON.stringify(a)).join(' ')` —— JSON.stringify 加的是
30
- * **真引号**,而这一整串又是作为一个 argv 元素交给 Node 的 spawn;Windows 上 Node/libuv 按 MSVCRT 规则
31
- * 把参数里的 `"` 转义成 `\"`,cmd 实际收到 `\"corepack\" \"pnpm\"` → `'\"corepack\"' 不是内部或外部命令`。
32
- * 本机对着一个只回显参数的 .cmd 实测三种形态:
33
- * · `"a" "b"` + 默认转义 → 收到 `\"a\" \"b\"` → 失败(旧缺陷)
34
- * · `"a" "b"` + windowsVerbatimArguments → `/s` 会把首尾两个引号一起剥掉 → 也失败
35
- * · `""a" "b""` + windowsVerbatimArguments → 正确解析成两个参数 ✓(含空格 / `&` / `|` / `>` 的参数
36
- * 在段内加 `^` 转义后也原样到达)
37
- * 所以本函数产出的是"**内层**命令字符串"(形如 `corepack pnpm add x`,**永不含 `\"`**;只有含空白的段才带引号 —— 2026-09-28 改错,见函数内注释),
38
- * 最外层那对引号由 cmdShellArgv 负责加。
39
- * 已知边界(如实记,不假装覆盖):`%VAR%` 与开启延迟展开时的 `!VAR!` 是 cmd 自己的展开,
40
- * **任何引号/`^` 都挡不住**;我们生成的 argv(包名、registry、布尔选项、绝对路径)不含这两个字符,
41
- * 真出现时该通道会失败并如实报错(不会静默跑成别的命令)。 */
42
- function cmdCommandLine(parts) {
43
- return parts
44
- .map((part) => String(part))
45
- .map((text) => {
46
- // 2026-09-28 改错(真机:`cmd-corepack` 一条都跑不动 → pnpm-runners / suite-fallback /
47
- // install-smoke 三套红,报 `cmd-corepack :: status=1 out=""`):
48
- // `corepack` 在 Windows 上常是 **cmd 批处理 shim**(`<node 安装目录>\corepack.cmd` →
49
- // `node …\corepack\dist\corepack.js %*`)。对批处理,cmd **不会剥掉参数外层的引号**,
50
- // 引号会随 `%*` 一路进到 node 的 argv —— 于是 corepack 收到的是 `"pnpm"` / `"--version"`
51
- // (带引号当成了包管理器名)→ 直接崩:status=1、stdout 空。
52
- // 真 cmd.exe + windowsVerbatimArguments 实测(同一台机):
53
- // · `""corepack" "pnpm" "--version""` → status=1、stdout=""(现行形态,坏)
54
- // · `"corepack pnpm --version"` → status=0、stdout="11.21.0"(按需引号,好)
55
- // 所以**只为含空白的段加引号**;其余裸串 + `^` 转义 cmd 元字符(裸串里 `^` 是标准且可靠的转义)。
56
- // 含空白的段仍走「引号 + 段内 ^ 转义」,与既有行为一致,不改语义。
57
- // 已知边界(如实记):段内含 `"` 时无法在 cmd 里安全表达 —— 我们生成的 argv(包名/registry/布尔
58
- // 选项/绝对路径)不含裸引号;真出现时该通道会失败并如实报错,不会静默跑成别的命令。
59
- // 「干净 token」(无空白、无 cmd 元字符)→ 裸串。**只有这种情况**能去引号:
60
- // 含空白的段必须带引号;含 `& | < > ( ) ^` 的段,实测**裸串 + `^` 转义会失效**
61
- // (真机 ⑥:裸的 `a^&b` 被 cmd 当分隔符断开,收到 `[a`),必须沿用「引号 + 段内 ^」的既有形态。
62
- const clean = !/[\s]/u.test(text) && !/[\^&|<>()]/u.test(text)
63
- if (clean) return text
64
- return `"${text.replace(CMD_META_RE, (ch) => `^${ch}`)}"`
65
- })
66
- .join(' ')
67
- }
68
-
69
- /** corepack 形态的命令字符串(纯函数,导出给单测断言"不含 `\"` 转义")。 */
70
- function cmdCorepackCommand(args) {
71
- return cmdCommandLine(['corepack', 'pnpm', ...args])
72
- }
73
-
74
- /** 交给 cmd.exe 的完整 argv(`/d /s /c` + **整条再包一层引号**的内层命令字符串)。
75
- * 必须与 `verbatim: true`(windowsVerbatimArguments)配套:Node 不再做任何引号转义,
76
- * cmd 的 `/s` 剥掉最外层那一对,剩下的正好是我们要的命令行。 */
77
- function cmdShellArgv(commandLine) {
78
- return ['/d', '/s', '/c', `"${commandLine}"`]
79
- }
80
-
81
20
  /** pnpm 执行方式定位(跨平台,纯函数便于单测 → 按优先级返回列表,逐个尝试)。
82
21
  * 事故(2026-09-20,同一位用户,node v24 + Linux):只按 Windows 布局找 corepack.js
83
22
  * (`<node bin>/node_modules/corepack/dist/corepack.js`),而 Linux 的 npm 全局布局在
@@ -98,7 +37,7 @@ function cmdShellArgv(commandLine) {
98
37
  * 作为**新候选插在 node-corepack 之后、cmd-corepack 之前**:既有候选与顺序一个都没动。
99
38
  * 兜底语义保持不变:只有"执行方式本身不可用"(ENOENT / Cannot find module)才换下一个,
100
39
  * 真正的失败立即抛出(见 runPnpmWithFallback)。
101
- * 全部候选都走注入的 `exists`;`env/resourcesPath/delimiter/nodeBin` 四个注入点只为离线单测,
40
+ * 全部候选都走注入的 `exists`;`env/resourcesPath/delimiter/nodeBin/home` 五个注入点只为离线单测,
102
41
  * 生产调用方一个字都不用改(默认值就是当前进程的事实)。 */
103
42
  function resolvePnpmRunners({
104
43
  platform = process.platform,
@@ -109,6 +48,7 @@ function resolvePnpmRunners({
109
48
  resourcesPath = process.resourcesPath,
110
49
  delimiter = platform === 'win32' ? ';' : ':',
111
50
  nodeBin = 'node',
51
+ home = homedir(),
112
52
  } = {}) {
113
53
  const binDir = dirname(execPath)
114
54
  const corepackCandidates = [
@@ -127,29 +67,26 @@ function resolvePnpmRunners({
127
67
  }
128
68
  }
129
69
 
130
- // ── 新候选 ①/③:桌面端(Electron)自带运行时 ──────────────────────────────────
131
- // ① Electron 的 `process.resourcesPath`(桌面端 = `<安装目录>\resources`);
132
- // ③ 环境线索:PATH 条目里的 `<…>\runtime\pnpm\bin` 与 `<…>\runtime\bin`(桌面端 host 就是把
133
- // `resources\runtime\bin` 拼进 profile 的 PATH 的,其同级就是 `runtime\pnpm\bin\pnpm.mjs`)。
70
+ // ── 候选发现:桌面端自带运行时 与 DSH 运行时装(细节见 infra/pnpm-runtime-discovery.js)────
134
71
  const desktopNode = typeof env?.DSH_DESKTOP_NODE_EXECUTABLE === 'string' && env.DSH_DESKTOP_NODE_EXECUTABLE !== ''
135
72
  ? env.DSH_DESKTOP_NODE_EXECUTABLE
136
73
  : execPath
137
- const runtimeMjs = []
138
- if (typeof resourcesPath === 'string' && resourcesPath !== '') runtimeMjs.push(join(resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
139
74
  const pathValue = typeof env?.PATH === 'string' && env.PATH !== '' ? env.PATH : (typeof env?.Path === 'string' ? env.Path : '')
140
75
  const pathDirs = String(pathValue).split(delimiter).map((d) => d.trim()).filter((d) => d !== '')
141
- for (const dir of pathDirs) {
142
- if (RUNTIME_PNPM_BIN_RE.test(dir)) runtimeMjs.push(join(dir, 'pnpm.mjs'))
143
- else if (RUNTIME_BIN_RE.test(dir)) runtimeMjs.push(join(dirname(dir), 'pnpm', 'bin', 'pnpm.mjs'))
76
+
77
+ // ① DSH 运行时装那份(**普通 node 进程也找得到** ⇒ 缺陷②的正解;见模块头注释)
78
+ for (const js of dshRuntimeCandidates({ env, home })) {
79
+ if (!exists(js)) continue
80
+ // 用普通 **node** 跑,**不带** ELECTRON_RUN_AS_NODE(那不是 Electron 二进制,带了反而污染语义)
81
+ add({ kind: 'runtime-pnpm-mjs', note: `${nodeBin} ${js} pnpm`, run: (args) => ({ bin: nodeBin, argv: [js, ...args] }) })
144
82
  }
145
- for (const js of runtimeMjs) {
83
+ // ② 桌面端(Electron)自带运行时那份。ELECTRON_RUN_AS_NODE 必须显式带上:不带它,
84
+ // Electron 二进制会当 **GUI 应用**启动 —— 等于弹一个新窗口,退出码还可能是 0("假成功"更糟)。
85
+ for (const js of desktopRuntimeCandidates({ resourcesPath, pathDirs, delimiter })) {
146
86
  if (!exists(js)) continue
147
87
  add({
148
88
  kind: 'desktop-pnpm-mjs',
149
89
  note: `${desktopNode} --expose-internals ${js} pnpm`,
150
- // 照抄桌面端 host 的形态(command=execPath,args=['--expose-internals', <pnpm.mjs>])。
151
- // ELECTRON_RUN_AS_NODE 必须显式带上:不带它,Electron 二进制会当 **GUI 应用**启动 ——
152
- // 等于弹一个新窗口,而且退出码可能是 0("假成功",比失败更糟)。
153
90
  run: (args) => ({ bin: desktopNode, argv: ['--expose-internals', js, ...args], env: { ELECTRON_RUN_AS_NODE: '1' } }),
154
91
  })
155
92
  }
@@ -506,12 +443,19 @@ function execFileWithKillTree(bin, argv, opts = {}, deps = {}) {
506
443
  * 所以不是 env 前缀的问题,是这两个键不在 pnpm 的 env 白名单里);
507
444
  * 真正生效的是 `pnpm add --fetch-timeout / --fetch-retries` 两个 CLI 选项(见 pnpmFetchArgs)。
508
445
  * 仍然照需求注入这三个 env(CI 与非交互确实有用、registry 镜像照旧、fetch_* 是 npm 系标准写法,
509
- * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。 */
446
+ * 对 npm/后续 pnpm 版本仍可能生效),但**不把健壮性只押在 env 上**。
447
+ *
448
+ * ★ 2026-10-13(0.5.48,实测"装 ds-bot 时无 TTY 永久挂住"):补 `npm_config_ci=true` ——
449
+ * pnpm 判"能不能弹交互"用的是**它自己的 ci 配置项**(`opts3.ci`,env 形态即 `npm_config_ci`),
450
+ * **不是** `process.env.CI`;只给 `CI=true` 时 `canPrompt` 可能仍为真 ⇒ 弹提问 ⇒ 读 stdin 到 EOF
451
+ * ⇒ 桌面壳的管道永不关闭 ⇒ 永久挂住。取证与完整链条见 infra/pnpm-release-gate.js 头注释。 */
510
452
  function pnpmEnvOverrides(registry) {
511
453
  const overrides = {
512
454
  npm_config_fetch_timeout: '60000',
513
455
  npm_config_fetch_retries: '1',
514
456
  CI: 'true',
457
+ // pnpm 自己的 ci 位(非交互的权威开关);`CI=true` 单独不够,理由见上。
458
+ npm_config_ci: 'true',
515
459
  }
516
460
  if (typeof registry === 'string' && registry !== '') overrides.COREPACK_NPM_REGISTRY = registry
517
461
  return overrides
@@ -542,11 +486,17 @@ function pnpmFetchArgs(timeoutMs = 60000, retries = 1) {
542
486
 
543
487
  /** pnpm add 的完整参数(纯函数,单测覆盖)。registry 只在是**非空字符串**时才拼 ——
544
488
  * 旧代码无条件拼 `--registry ${registry}`,`git:` 规格那条路传的是 undefined,命令行长成
545
- * `--registry undefined`(今天恰好还能装,但那是"垃圾参数被忽略",不是设计)。 */
489
+ * `--registry undefined`(今天恰好还能装,但那是"垃圾参数被忽略",不是设计)。
490
+ *
491
+ * ★ 2026-10-13(0.5.48):追加 `--config.ci=true` —— pnpm **官方支持的非交互开关**
492
+ * (`--config.<key>=<value>` 是 pnpm 设任意配置项的通用语法,**不是**绕过闸门的手段)。
493
+ * 理由与取证见 infra/pnpm-release-gate.js 头注释:命中供应链闸门时它会弹提问等人回答,
494
+ * 而无 TTY 的子进程 stdin 是永不关闭的管道 ⇒ **永久挂住**;钉死 ci 位 ⇒ 改成快速如实失败。 */
546
495
  function pnpmAddArgs(spec, registry, { fetchFlags = true, fetchTimeoutMs = 60000, fetchRetries = 1 } = {}) {
547
496
  const args = ['add', spec]
548
497
  if (typeof registry === 'string' && registry !== '') args.push('--registry', registry)
549
498
  if (fetchFlags) args.push(...pnpmFetchArgs(fetchTimeoutMs, fetchRetries))
499
+ args.push('--config.ci=true')
550
500
  return args
551
501
  }
552
502
 
@@ -563,11 +513,18 @@ function unknownPnpmOption(message) {
563
513
 
564
514
  /** 跑一次 `pnpm add`(本控制台所有安装通道的唯一入口)—— 2026-09-26 新增。
565
515
  * 相对旧的 `runPnpmWithFallback(['add', spec, '--registry', registry], { execOpts })`,只加三样:
566
- * ① env 走 buildPnpmEnv(追加 fetch 超时/重试 + CI=true;registry 镜像与禁交互照旧);
516
+ * ① env 走 buildPnpmEnv(追加 fetch 超时/重试 + CI/ci=true;registry 镜像与禁交互照旧);
567
517
  * ② 参数走 pnpmAddArgs(追加 `--fetch-timeout/--fetch-retries`:实测 pnpm 11 不读同名 env,只认这两个选项);
568
518
  * ③ 遇到 `Unknown options` 就去掉加固选项重试一次 —— 加固不允许变成"装不上"。
569
519
  * 失败语义不变:真实失败照旧抛出(含 pnpm 原始 stderr),成功不返回值。
570
- * `deps.run` 只是单测注入口(默认 runPnpmWithFallback),生产调用方不传。 */
520
+ * `deps.run` 只是单测注入口(默认 runPnpmWithFallback),生产调用方不传。
521
+ *
522
+ * ★ 2026-10-13(0.5.48,两个真缺陷一起修):
523
+ * · **供应链闸命中 ⇒ 快速如实失败**(判据/错误文案见 infra/pnpm-release-gate.js):闸门命中即
524
+ * 翻译成带解法的错误立刻抛,**不重试、更不绕过**(不忽略锁文件校验、不强制装)。
525
+ * · **显式传 profileDir**:以前只靠 `execOpts.cwd` 兜出 profileDir(**隐式**耦合)⇒ 一旦调用方
526
+ * 换了 cwd 形态,就会落回 corepack 那版 pnpm(本机 11.21.0)而不是 profile 声明的那版(11.7.0)
527
+ * ⇒ 版本错配 ⇒ Windows 上 EPERM。现在两处调用都显式传,判据不依赖 cwd 这个副作用。 */
571
528
  async function runPnpmAdd({ profileDir, spec, registry, timeout = 90000, signal = null, deps = {} } = {}) {
572
529
  const run = typeof deps.run === 'function' ? deps.run : runPnpmWithFallback
573
530
  const execOpts = (ms) => ({
@@ -578,11 +535,20 @@ async function runPnpmAdd({ profileDir, spec, registry, timeout = 90000, signal
578
535
  env: buildPnpmEnv(registry),
579
536
  ...(signal ? { signal } : {}),
580
537
  })
538
+ // profileDir 显式下传(不再依赖 execOpts.cwd 的隐式兜底):见上面 ★ 第二条。
539
+ const runOpts = (ms) => ({ execOpts: execOpts(ms), profileDir })
581
540
  try {
582
- await run(pnpmAddArgs(spec, registry), { execOpts: execOpts(timeout) })
541
+ await run(pnpmAddArgs(spec, registry), runOpts(timeout))
583
542
  } catch (error) {
543
+ // 闸门命中:立刻抛出带解法的错误 —— 不重试(重试也是同样的结局,只会白白多挂一轮)。
544
+ if (releaseAgeGateHit(error?.message)) throw releaseAgeGateError({ spec, profileDir, message: error.message })
584
545
  if (!unknownPnpmOption(error?.message)) throw error
585
- await run(pnpmAddArgs(spec, registry, { fetchFlags: false }), { execOpts: execOpts(timeout) })
546
+ try {
547
+ await run(pnpmAddArgs(spec, registry, { fetchFlags: false }), runOpts(timeout))
548
+ } catch (retryError) {
549
+ if (releaseAgeGateHit(retryError?.message)) throw releaseAgeGateError({ spec, profileDir, message: retryError.message })
550
+ throw retryError
551
+ }
586
552
  }
587
553
  }
588
554
 
@@ -595,4 +561,4 @@ const GH_BIN_CANDIDATES = [
595
561
  join(homedir(), 'scoop', 'shims', 'gh.exe'),
596
562
  ]
597
563
 
598
- export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, pnpmRunnersFor, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }
564
+ export { GH_BIN_CANDIDATES, gitEnv, gitBin, processAlive, execFileAsync, resolvePnpmRunners, pnpmRunnersFor, runPnpmWithFallback, killProcessTree, posixDescendants, execFileWithKillTree, KILL_WAIT_MS, KILL_WAIT_POLL_MS, GIT_LOW_SPEED_ENV, pnpmEnvOverrides, buildPnpmEnv, pnpmFetchArgs, pnpmAddArgs, unknownPnpmOption, runPnpmAdd, releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError, cmdCommandLine, cmdCorepackCommand, cmdShellArgv }
@@ -52,9 +52,65 @@ function baseDirOf(baseUrl) {
52
52
  * require.resolve('pkg/package.json') 对声明了 exports 且不含 './package.json' 的包会抛错
53
53
  * (2026-09-04 教训:@openviking/dsh-memory-plugin 因此被 heal 误判模块缺失并自动禁用)——
54
54
  * fallback 直接查 node_modules 物理路径。
55
+ *
56
+ * ★ 2026-10-13(0.5.48)改错:**对 exports 子路径的假阳性**。
57
+ * 现场:装 ds-bot 时 `verifyInstalledEntry` 拿 `ds-bot/web-search` 来核实,旧代码把引用名拼成
58
+ * `ds-bot/web-search/package.json` ⇒ ds-bot 的 exports 里没有这个子路径 ⇒
59
+ * `ERR_PACKAGE_PATH_NOT_EXPORTED` ⇒ 退物理路径 `node_modules/ds-bot/web-search/package.json`
60
+ * ⇒ **也不存在**(包的清单在 `node_modules/ds-bot/package.json`,不在子目录里)⇒ 返回 null
61
+ * ⇒ 误报"缺模块会崩/会被自动禁用"。
62
+ * 但按**真实解析语义**这个引用是好的:本机实测 `require.resolve('ds-bot/web-search')` **成功**
63
+ * (`包/子路径` 是生态既有约定,官方 patch 里也有这种 entry),`import.meta.resolve` 同理,
64
+ * 模块也能真 `import()`。
65
+ * ⇒ 现在先用**真实解析语义**解析「引用名本身」(不再拿字符串拼 `/package.json`),
66
+ * 成功后从解析结果**向上找最近的 package.json** 作为该包的清单。
67
+ * ⇒ 负控不变:真不存在的子路径,三条路都失败 ⇒ 仍然返回 null(**绝不放松成"一律放行"**)。
55
68
  */
69
+ function nearestPackageJson(fromPath) {
70
+ try {
71
+ let dir = dirname(fromPath)
72
+ for (let depth = 0; depth < 40; depth += 1) {
73
+ const candidate = join(dir, 'package.json')
74
+ if (existsSync(candidate)) return candidate
75
+ const parent = dirname(dir)
76
+ if (parent === dir) break
77
+ dir = parent
78
+ }
79
+ } catch {}
80
+ return null
81
+ }
82
+
83
+ /** 该引用是不是"包 + 子路径"形态(`pkg/sub`、`@scope/pkg/sub`)。
84
+ * 判据与 packageNameOf 同源:剥掉包名后还剩东西 ⇒ 是子路径引用。 */
85
+ function isSubpathRef(refName) {
86
+ if (typeof refName !== 'string' || refName === '') return false
87
+ return refName !== packageNameOf(refName)
88
+ }
89
+
90
+ /** 用真实解析语义解析「包/子路径」引用的清单路径:先解析引用本身,再向上找最近的清单。 */
91
+ function resolveViaRealSemantics(refName, base) {
92
+ if (typeof base !== 'string' || base === '') return null
93
+ try {
94
+ const entry = createRequire(join(base, 'package.json')).resolve(refName)
95
+ return nearestPackageJson(entry)
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
56
101
  function resolvePackageJson(pkgName, baseDir, fallbackBase) {
57
102
  const name = packageNameOf(pkgName)
103
+ // ① 真实解析语义(认 exports 子路径)—— 优先,且**不**拼 `/package.json`
104
+ const real = resolveViaRealSemantics(pkgName, baseDir)
105
+ if (real !== null) return real
106
+ // ★ 子路径引用(`包/子路径`)到此为止:**不许**再退到"按包名找清单"的那两条 fallback。
107
+ // 为什么:那两条是按**包名**(packageNameOf 剥掉子路径后的 name)找 `node_modules/<包名>/package.json`,
108
+ // 对不存在的子路径也会命中**包根**那份清单 ⇒ 把一个坏引用判成好的
109
+ // —— 正是"校验被放松成一律放行",任务红线明令不许。
110
+ // (本机实测:`@scope/tool/nope` 在 @scope/tool 存在时会从 fallback 拿到包根清单。)
111
+ // 而真实解析语义对这一类已经给出权威答案:能解析就是好,不能解析就是坏 ⇒ 直接返回 null。
112
+ if (isSubpathRef(pkgName)) return null
113
+ // ② 既有两条(顺序与行为不变):`<name>/package.json` 解析 → node_modules 物理路径
58
114
  try {
59
115
  return createRequire(join(baseDir, 'package.json')).resolve(`${name}/package.json`)
60
116
  } catch {}
@@ -65,6 +121,8 @@ function resolvePackageJson(pkgName, baseDir, fallbackBase) {
65
121
  // 官方 @deepseek-ai/* 恰好可见、第三方插件全部解析失败 → 详情/版本/仓库全空。
66
122
  // 回退到 profile 目录(fallbackBase)再试一次。
67
123
  if (typeof fallbackBase === 'string' && fallbackBase !== '' && fallbackBase !== baseDir) {
124
+ const realFallback = resolveViaRealSemantics(pkgName, fallbackBase)
125
+ if (realFallback !== null) return realFallback
68
126
  try {
69
127
  return createRequire(join(fallbackBase, 'package.json')).resolve(`${name}/package.json`)
70
128
  } catch {}
@@ -0,0 +1,74 @@
1
+ // L0 · infra —— pnpm 供应链闸门(minimumReleaseAge)的**判据与错误翻译**(0.5.48 加法)
2
+ //
3
+ // 为什么单开一个模块:这两个函数的归属是自洽的("把 pnpm 的闸门原文翻译成用户能照着做的错误"),
4
+ // 而 infra/exec.js 贴着 tests/test-architecture-guard.mjs 的 **单文件 ≤ 600 行**硬顶(0.5.48 实测
5
+ // 723 行被棘轮拦下)⇒ 抽出来是**只搬移与新增**,exec.js 只留调用点。
6
+ //
7
+ // 现场(用户实测,装 ds-bot 时撞出来):
8
+ // pnpm 的 minimumReleaseAge 闸门拦下了太新的版本,而它在**可交互**时会弹提问等人回答;
9
+ // 桌面壳/服务进程给的是**永不关闭的 stdin 管道** ⇒ 不抛错、不超时,**永久挂住**
10
+ // (实测两次分别挂到 90s / 180s 被杀)。
11
+ //
12
+ // 闸门实现(本机 pnpm 11.7.0 `dist/pnpm.mjs`,只读反编译取证):
13
+ // L194277 const inCi = opts3.ci ?? isCI
14
+ // L194278 const canPrompt = !inCi && Boolean(process.stdin.isTTY)
15
+ // L194305 if (canPrompt) { await promptForApproval(immature) }
16
+ // L194307 else { throw failOnImmature(immature) } ← 我们要走的分支
17
+ // L194330 confirmed = await dist_default5({ message, default: false }) ← prompt 库读 stdin 到 EOF
18
+ // L63849 MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION'
19
+ //
20
+ // ★ 红线:本模块只做"识别 + 翻译成可操作错误",**绝不**提供任何绕过闸门的手段
21
+ // (不使用忽略锁文件校验 / 强制安装一类开关)。闸门该拦还是拦,只是把"等人回答"换成"立刻报错并给解法"。
22
+ //
23
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
24
+
25
+ /** 命中 pnpm 供应链闸门的**判据**(纯函数,单测覆盖)。
26
+ * 两代真实文案都要认(照 exec.js#unknownPnpmOption 的教训:缺一条就等于"闸门命中被当成普通失败",
27
+ * 用户拿不到"怎么加白名单"的解法):
28
+ * · pnpm 11.7.0 的违规码 `MINIMUM_RELEASE_AGE_VIOLATION` / `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`;
29
+ * · 严格模式不可交互时 pnpm 抛 `NO_MATURE_MATCHING_VERSION`(同文件 194308 的 failOnImmature);
30
+ * · 用户在提问处拒绝时是 `MINIMUM_RELEASE_AGE_DENIED`(同文件 194340)。 */
31
+ function releaseAgeGateHit(message) {
32
+ const text = String(message ?? '')
33
+ return /MINIMUM_RELEASE_AGE_VIOLATION|MINIMUM_RELEASE_AGE_DENIED|NO_MATURE_MATCHING_VERSION|ERR_PNPM_MINIMUM_RELEASE_AGE/iu.test(text)
34
+ }
35
+
36
+ /** 从 pnpm 原始输出里抠出"被闸门拦下的 `包@版本`"清单(尽力而为,抠不到就返回 [])。
37
+ * 两种真实排版:` <name>@<version> <reason>`(failOnImmature)与裸 `<name>@<version>`。 */
38
+ function releaseAgeBlockedSpecs(message) {
39
+ const found = new Set()
40
+ for (const line of String(message ?? '').split(/\r?\n/u)) {
41
+ const m = line.match(/(@?[A-Za-z0-9][\w.@/-]*?)@(\d+\.\d+\.\d+(?:-[\w.]+)?)/u)
42
+ if (m) found.add(`${m[1]}@${m[2]}`)
43
+ }
44
+ return [...found]
45
+ }
46
+
47
+ /** 把"命中供应链闸门"翻译成**用户能照着做**的错误(纯函数,单测覆盖)。
48
+ * 需求红线:**必须快速如实失败并给出解法**。所以这里只说三件事:
49
+ * ① 哪个包被拦下 ② 命中哪条闸 ③ 怎么加白名单。 */
50
+ function releaseAgeGateError({ spec, profileDir, message } = {}) {
51
+ const blocked = releaseAgeBlockedSpecs(message)
52
+ const who = blocked.length > 0 ? blocked.join('、') : String(spec ?? '(未能从 pnpm 输出解析出包名)')
53
+ const lines = [
54
+ `pnpm 供应链闸门(minimumReleaseAge)拦下了 ${who}:该版本发布时间还没满 pnpm 配置的最短"冷静期"。`,
55
+ '这不是网络故障。闸门是安全特性,本控制台**不会绕过它**(既不忽略锁文件校验,也不强制装),',
56
+ '所以这里选择**立刻如实失败**,并把解法交给你:',
57
+ '解法(任选其一):',
58
+ ` ① 加白名单:在 profile 的 pnpm-workspace.yaml 里把该版本追加进 minimumReleaseAgeExclude,例如`,
59
+ ` minimumReleaseAgeExclude:\n - '${blocked[0] ?? '<包名>@<版本>'}'`,
60
+ ' ★ 该清单是**逐版本**匹配的(pnpm 只认 `包名@精确版本`,不支持范围)⇒ 每发一个新版本都要加一行。',
61
+ ' ② 等:过了冷静期后重试同一操作即可,无需改配置。',
62
+ ' ③ 调低/关闭该策略:不推荐,等于放弃这道供应链保护。',
63
+ ]
64
+ if (typeof profileDir === 'string' && profileDir !== '') lines.push(`目标 profile:${profileDir}`)
65
+ const error = new Error(lines.join('\n'))
66
+ error.name = 'PnpmSupplyChainGateError'
67
+ error.code = 'DSH_PNPM_RELEASE_AGE_GATE'
68
+ error.gate = 'minimumReleaseAge'
69
+ error.blocked = blocked
70
+ error.pnpmMessage = String(message ?? '')
71
+ return error
72
+ }
73
+
74
+ export { releaseAgeGateHit, releaseAgeBlockedSpecs, releaseAgeGateError }
@@ -0,0 +1,64 @@
1
+ // L0 · infra —— 「本机自带那份 pnpm 在哪」的候选发现(0.5.48 加法;从 exec.js 抽出)
2
+ //
3
+ // 为什么单开一个模块:exec.js 贴着 tests/test-architecture-guard.mjs 的 **单文件 ≤ 600 行**硬顶,
4
+ // 而"从桌面端运行时 / DSH 运行时装 / PATH 里找出那份 pnpm"是自洽的一块只读探测逻辑。
5
+ //
6
+ // 两类被发现的 pnpm(形态不同,**绝不能混**):
7
+ // · `desktop-pnpm-mjs`:桌面端(Electron)自带运行时那份。用 **Electron 二进制**跑,
8
+ // 必须显式带 `ELECTRON_RUN_AS_NODE=1` —— 不带它,Electron 会当 **GUI 应用**启动
9
+ // (等于弹一个新窗口,而且退出码可能是 0,"假成功"比失败更糟)。
10
+ // · `runtime-pnpm-mjs`:DSH **运行时装**那份(0.5.48 新增)。用普通 **node** 跑,**不带**那个 env。
11
+ //
12
+ // ★ 为什么必须有 `runtime-pnpm-mjs`(这条就是缺陷②的正解):
13
+ // 原来只有两条线索能找到本机那份 pnpm —— ① Electron 的 `process.resourcesPath`;
14
+ // ② PATH 里的 `<…>\runtime\pnpm\bin` / `<…>\runtime\bin`。**两条都只在桌面端宿主成立**。
15
+ // 而控制台的安装动作跑在**普通 node 服务进程**里 ⇒ 两条都没有 ⇒ 落到候选表最前面的
16
+ // `node-corepack` ⇒ 跑 corepack 自己解析出的那版(本机实测 11.21.0),
17
+ // 而 profile 的 node_modules 是**声明的那版**建的(`.modules.yaml` 写着 pnpm@11.7.0)
18
+ // ⇒ 版本错配 ⇒ Windows 上 rename 覆盖已存在目录 ⇒ ERR_PNPM_EPERM。
19
+ // 本机实据(两份都在,且都是 profile 声明的那版 11.7.0):
20
+ // · ~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/pnpm
21
+ // · <桌面端安装目录>/resources/runtime/pnpm
22
+ //
23
+ // 全部探测只走注入的 `exists`(生产默认值 = 进程事实);本模块**不 spawn 任何东西**。
24
+ //
25
+ // 分层:L0 · infra(不得 import domain/routes/index —— tests/test-architecture-guard.mjs 断言②)。
26
+
27
+ import { dirname, join } from 'node:path'
28
+ import { homedir } from 'node:os'
29
+
30
+ /** PATH 条目里的 `<…>\runtime\pnpm\bin` 形态(桌面端 host 就是把它拼进 profile 的 PATH 的)。 */
31
+ const RUNTIME_PNPM_BIN_RE = /[\\/]runtime[\\/]pnpm[\\/]bin[\\/]?$/u
32
+ /** PATH 条目里的 `<…>\runtime\bin` 形态(其同级就是 `runtime\pnpm\bin\pnpm.mjs`)。 */
33
+ const RUNTIME_BIN_RE = /[\\/]runtime[\\/]bin[\\/]?$/u
34
+
35
+ /** 桌面端(Electron)自带运行时的 pnpm 入口候选(resourcesPath + PATH 两条线索)。 */
36
+ function desktopRuntimeCandidates({ resourcesPath, pathDirs, delimiter }) {
37
+ const out = []
38
+ if (typeof resourcesPath === 'string' && resourcesPath !== '') {
39
+ out.push(join(resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs'))
40
+ }
41
+ void delimiter
42
+ for (const dir of pathDirs) {
43
+ if (RUNTIME_PNPM_BIN_RE.test(dir)) out.push(join(dir, 'pnpm.mjs'))
44
+ else if (RUNTIME_BIN_RE.test(dir)) out.push(join(dirname(dir), 'pnpm', 'bin', 'pnpm.mjs'))
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** DSH 运行时装那份 pnpm 的入口候选(**普通 node 进程也找得到**,0.5.48 新增)。
50
+ * 位置:`<dshHome>/dsh-runtimes/<runtime>/dependencies/pnpm/bin/pnpm.mjs`。
51
+ * `env.DSH_PNPM_HINT` 是显式注入点(单测用,也允许用户指定)。 */
52
+ function dshRuntimeCandidates({ env, home = homedir() }) {
53
+ const dshHomeDir = typeof env?.DSH_HOME === 'string' && env.DSH_HOME !== ''
54
+ ? env.DSH_HOME
55
+ : join(home, '.dsh')
56
+ const out = []
57
+ if (typeof env?.DSH_PNPM_HINT === 'string' && env.DSH_PNPM_HINT !== '') out.push(env.DSH_PNPM_HINT)
58
+ for (const rt of ['dsh-primary-runtime', 'dsh-runtime']) {
59
+ out.push(join(dshHomeDir, 'dsh-runtimes', rt, 'dependencies', 'pnpm', 'bin', 'pnpm.mjs'))
60
+ }
61
+ return out
62
+ }
63
+
64
+ export { RUNTIME_PNPM_BIN_RE, RUNTIME_BIN_RE, desktopRuntimeCandidates, dshRuntimeCandidates }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.47",
3
+ "version": "0.5.48",
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",