dsh-plugin 2.0.5 → 2.1.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.
Files changed (42) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +418 -317
  4. package/client/client.js.map +1 -1
  5. package/lib/http/install.js +42 -11
  6. package/lib/http/settings.js +1 -1
  7. package/lib/services/install/decode-output.js +23 -0
  8. package/lib/services/install/operation-log.js +61 -11
  9. package/lib/services/install/preflight.js +36 -0
  10. package/lib/services/install/registry-fallback.js +89 -0
  11. package/lib/services/install/task-queue.js +84 -34
  12. package/lib/services/settings.js +1 -0
  13. package/lib/types/services/install/decode-output.d.ts +4 -0
  14. package/lib/types/services/install/install-types.d.ts +4 -0
  15. package/lib/types/services/install/operation-log.d.ts +10 -4
  16. package/lib/types/services/install/preflight.d.ts +11 -0
  17. package/lib/types/services/install/registry-fallback.d.ts +18 -0
  18. package/lib/types/services/install/task-queue.d.ts +18 -0
  19. package/lib/types/services/settings.d.ts +3 -1
  20. package/package.json +1 -1
  21. package/src/client/components/PluginHubSection.tsx +11 -2
  22. package/src/client/components/modals/NotificationsModal.tsx +8 -1
  23. package/src/client/components/modals/modals.tsx +9 -1
  24. package/src/client/components/views/SettingsView.tsx +10 -0
  25. package/src/client/hooks/useCatalog.ts +10 -8
  26. package/src/client/hooks/useSettings.ts +4 -1
  27. package/src/client/hooks/useTaskQueue.ts +7 -1
  28. package/src/client/locales.ts +12 -0
  29. package/src/client/logic/failures.ts +17 -1
  30. package/src/client/logic/installed.ts +10 -6
  31. package/src/client/logic/release-age.ts +40 -0
  32. package/src/client/logic/urls.ts +4 -1
  33. package/src/client/types.ts +13 -0
  34. package/src/server/http/install.ts +46 -11
  35. package/src/server/http/settings.ts +1 -1
  36. package/src/server/services/install/decode-output.ts +22 -0
  37. package/src/server/services/install/install-types.ts +4 -0
  38. package/src/server/services/install/operation-log.ts +58 -11
  39. package/src/server/services/install/preflight.ts +37 -0
  40. package/src/server/services/install/registry-fallback.ts +89 -0
  41. package/src/server/services/install/task-queue.ts +91 -32
  42. package/src/server/services/settings.ts +4 -1
@@ -11,11 +11,13 @@ import { recordResolvedNpmPackage } from '../services/profile/installed-versions
11
11
  import { isDshNpmPackageForRepo } from '../services/install/npm-resolve.ts'
12
12
  import { probeUrl, systemProxy } from '../services/probe.ts'
13
13
  import { loadSettings, type HubSettings } from '../services/settings.ts'
14
+ import { registryCandidates, registryHost, userNpmRegistry } from '../services/install/registry-fallback.ts'
15
+ import { profileDirectory } from '../services/profile/profile.ts'
14
16
  import { readInstalled } from './catalog.ts'
15
17
  import { COMMAND_TIMEOUT_MS, readJsonBody, requireTrustedPost, sendJson, type WebRoute, type WebServerService } from './shared.ts'
16
18
 
17
19
  /** 安装/卸载 CLI 的子进程环境:透传宿主环境并注入 HTTP(S) 代理,
18
- * 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);不注入 npm registry,沿用用户本机 npm 配置。 */
20
+ * 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);registry 由任务队列按候选源逐次注入。 */
19
21
  function mutationEnv(settings: HubSettings): NodeJS.ProcessEnv {
20
22
  const env: NodeJS.ProcessEnv = { ...process.env, CI: 'true' }
21
23
  const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '')
@@ -64,6 +66,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
64
66
  const lang = typeof body === 'object' && body !== null && (body as { lang?: unknown }).lang === 'en'
65
67
  ? 'en'
66
68
  : 'zh'
69
+ // 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
70
+ const effectiveProxy = settings.proxy !== ''
71
+ ? settings.proxy
72
+ : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
73
+ // 安装用的 npm 源:设置里显式选择优先;未配置时读本机 npm 配置(与诊断同口径)。
74
+ // 只有公共源允许失败后换源;自定义/内网源保持单一候选,避免私有包名泄露给公共源。
75
+ const registries = registryCandidates(
76
+ settings.npmRegistry,
77
+ settings.npmRegistry === '' ? userNpmRegistry(profileDirectory(profile)) : '',
78
+ settings.registryFallback,
79
+ )
67
80
  // 全局 npm 安装(`npm install -g <pkgs>`):不进任何 profile、无需宿主重启。
68
81
  // 包列表优先取 body.globalNpm,兜底从 rawRepo 解析,保证直接调 API 传完整命令也能识别。
69
82
  const bodyGlobalNpm = Array.isArray((body as { globalNpm?: unknown }).globalNpm)
@@ -95,6 +108,8 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
95
108
  globalNpm,
96
109
  timeoutMs: COMMAND_TIMEOUT_MS,
97
110
  env: mutationEnv(settings),
111
+ // 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
112
+ registries,
98
113
  // 全局 npm 安装由 NPM 包卡片/官方命令发起:日志体现入口渠道
99
114
  installChannel: installChannel ?? 'npm',
100
115
  })
@@ -107,12 +122,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
107
122
  return
108
123
  }
109
124
  const target = rawRepo
125
+ // 钉住精确版本(Hub 自我更新):最新版可能仍在 pnpm 供应链门禁内,
126
+ // 客户端提示的是「已过门禁」的版本,服务端必须按该版本安装而不是 @latest。
127
+ const pinVersion = typeof (body as { version?: unknown }).version === 'string'
128
+ ? (body as { version: string }).version.trim()
129
+ : ''
130
+ if (pinVersion !== '' && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(pinVersion)) {
131
+ sendJson(response, 400, { error: 'invalid version' })
132
+ return
133
+ }
110
134
  // 已尝试的安装方式(供失败提 Issue 预填):保持为空
111
135
  const attempts: string[] = []
112
- // 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
113
- const effectiveProxy = settings.proxy !== ''
114
- ? settings.proxy
115
- : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
116
136
  // 目录插件安装:目录数据可能滞后于打包拆分,确认目录下发的 npm 目标确为该仓库的 DSH 包,
117
137
  // 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
118
138
  const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null
@@ -136,18 +156,30 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
136
156
  return
137
157
  }
138
158
  // 安装前网络探测:探 registry 根而非具体包(404 是「包不存在」而非网络不通);仅新安装执行,更新跳过。
139
- // 不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
159
+ // 首选源不通时按候选顺序继续探下一个,能通的那个提到首位供实际安装使用。
160
+ // 全部不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
161
+ let ordered = registries
140
162
  if (already === null) {
141
- const probeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
142
- const net = await probeUrl(probeTarget, effectiveProxy, 6000)
143
- if (!net.ok) {
163
+ const tried: string[] = []
164
+ let chosen: string | null = null
165
+ for (const candidate of registries) {
166
+ tried.push(candidate)
167
+ const net = await probeUrl(`${candidate}/`, effectiveProxy, 6000)
168
+ if (net.ok) {
169
+ chosen = candidate
170
+ break
171
+ }
172
+ }
173
+ if (chosen === null) {
144
174
  // 错误消息按界面语言输出,让用户看清是网络问题而非插件问题
175
+ const hosts = tried.map(registryHost).join(' → ')
145
176
  const error = lang === 'zh'
146
- ? `[network] 安装已中止:安装前无法连接到 npm 源(${probeTarget})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
147
- : `[network] install aborted: cannot reach the npm registry (${probeTarget}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`
177
+ ? `[network] 安装已中止:安装前无法连接到 npm 源(${hosts})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
178
+ : `[network] install aborted: cannot reach the npm registry (${hosts}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`
148
179
  sendJson(response, 400, { error, attempts })
149
180
  return
150
181
  }
182
+ ordered = [chosen, ...registries.filter((r) => r !== chosen)]
151
183
  }
152
184
  // 入队前复检:网络探测的 await 期间并发请求可能已通过上方检查,而此处到 startPluginMutation
153
185
  // 之间无 await,JS 单线程保证同一目标至多一个任务,杜绝双击/并发双任务。
@@ -170,6 +202,9 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
170
202
  loader,
171
203
  timeoutMs: COMMAND_TIMEOUT_MS,
172
204
  env: mutationEnv(settings),
205
+ // 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
206
+ registries: ordered,
207
+ installSpec: pinVersion !== '' ? `${target}@${pinVersion}` : undefined,
173
208
  // 自定义安装入口渠道:日志/报错据此溯源
174
209
  installChannel,
175
210
  })
@@ -28,7 +28,7 @@ export function registerSettingsRoutes(webServer: WebServerService, profile: str
28
28
  // 白名单字段:只接受已知设置项,防客户端把任意键写进文件
29
29
  const patch: Record<string, unknown> = {}
30
30
  const source = typeof body === 'object' && body !== null ? body as Record<string, unknown> : {}
31
- for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'proxy', 'logPath'] as const) {
31
+ for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'registryFallback', 'proxy', 'logPath'] as const) {
32
32
  if (source[key] !== undefined) patch[key] = source[key]
33
33
  }
34
34
  // 日志位置:非空时预建目录验证可写,失败直接 400 拦截(默认位置永远合法,空串 = 回默认)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 子进程输出解码:默认 UTF-8;出现替换字符(中文 Windows 的 GBK 输出)时改用 GBK 重解。
3
+ * 用 StringDecoder 跨 chunk 缓冲,避免 UTF-8 多字节字符被 chunk 边界切断后误判为 GBK。
4
+ */
5
+ import { StringDecoder } from 'node:string_decoder'
6
+
7
+ export class OutputDecoder {
8
+ private readonly utf8 = new StringDecoder('utf8')
9
+
10
+ push(chunk: Buffer): string {
11
+ const text = this.utf8.write(chunk)
12
+ if (!text.includes('\uFFFD')) return text
13
+ try {
14
+ const gbk = new TextDecoder('gbk').decode(chunk)
15
+ // GBK 能完整解出且含中文才采用:纯 ASCII 或仍有替换字符时保持 UTF-8 结果
16
+ if (!gbk.includes('\uFFFD') && /[\u4e00-\u9fff]/.test(gbk)) return gbk
17
+ } catch {
18
+ // 无完整 ICU(TextDecoder 不支持 gbk)时退回 UTF-8 结果
19
+ }
20
+ return text
21
+ }
22
+ }
@@ -72,6 +72,10 @@ export interface QueueItem {
72
72
  env?: NodeJS.ProcessEnv
73
73
  globalNpm?: string[]
74
74
  installChannel?: 'npm'
75
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
76
+ registries?: string[]
77
+ /** 精确安装 spec(Hub 自我更新钉版本) */
78
+ installSpec?: string
75
79
  }
76
80
  }
77
81
 
@@ -6,36 +6,71 @@
6
6
  * 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
7
7
  * 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
8
8
  * 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
9
- * 只允许读 DSH 自己的 operation 日志(路径含 .plugin-manager 且以 pnpm.log 结尾),
9
+ * 只允许读 profile 目录下的 pnpm.log(dsh 自己的 operation 日志),
10
10
  * 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
11
11
  * 不改变原有失败处理。
12
12
  */
13
13
  import { readFileSync, readdirSync, statSync } from 'node:fs'
14
- import { join } from 'node:path'
14
+ import { basename, join, resolve, sep } from 'node:path'
15
15
  import { CAPTURE_LIMIT_BYTES } from './install-types.ts'
16
16
 
17
+ /** 去掉 ANSI 颜色码:宿主的摘要行可能带终端着色,直接匹配路径会失败。 */
18
+ function stripAnsi(text: string): string {
19
+ return text.replace(/\x1b\[[0-9;]*m/g, '')
20
+ }
21
+
17
22
  /**
18
23
  * 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
19
- * 只认 DSH 的 operation 日志:路径含 `.plugin-manager` 且以 `pnpm.log` 结尾;
20
- * 不是这种路径(或压根没有该行)返回 null,调用方据此跳过回捞。
24
+ * 去掉首尾引号与句末标点(不同版本/语言的摘要行尾部可能带 `。`、`.`、`)`);
25
+ * 相对路径原样返回,由读取方按 profile 目录解析。
21
26
  */
22
27
  export function operationLogPathOf(output: string): string | null {
23
28
  for (const raw of output.split(/\r?\n/)) {
24
- const m = /diagnostics:\s*"?(.+?)"?\s*$/i.exec(raw.trim())
29
+ // 兼容纯文本与 JSON 包裹两种形态:`diagnostics: <path>` / `"diagnostics": "<path>"}`
30
+ const m = /"?diagnostics"?\s*:\s*(.+)$/i.exec(stripAnsi(raw).trim())
25
31
  if (m === null) continue
26
- const path = m[1].trim()
27
- if (!path.includes('.plugin-manager') || !path.toLowerCase().endsWith('pnpm.log')) continue
28
- return path
32
+ let path = m[1]
33
+ .trim()
34
+ .replace(/[.,;:,。;、)\]}]+$/, '')
35
+ .trim()
36
+ // JSON 形态:引号包裹且反斜杠被转义过,按字符串字面量还原;普通文本里的引号直接剥掉
37
+ if (path.startsWith('"') && path.endsWith('"')) {
38
+ try {
39
+ const parsed: unknown = JSON.parse(path)
40
+ if (typeof parsed === 'string') path = parsed
41
+ } catch {
42
+ path = path.slice(1, -1)
43
+ }
44
+ }
45
+ path = path.trim()
46
+ // URL 不是本地日志:避免把网络地址当文件读
47
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) continue
48
+ if (path.toLowerCase().endsWith('pnpm.log')) return path
29
49
  }
30
50
  return null
31
51
  }
32
52
 
53
+ /**
54
+ * 只允许读 profile 目录下的 pnpm.log(dsh 自己的操作日志):
55
+ * 绝对路径必须落在该目录内,相对路径按该目录解析。
56
+ * 未提供 profileDir 时退回旧口径(路径须含 .plugin-manager),防止插件打印任意路径造成越权读取。
57
+ */
58
+ function allowedLogPath(raw: string, profileDir?: string): string | null {
59
+ const path = resolve(profileDir ?? '', raw)
60
+ if (basename(path).toLowerCase() !== 'pnpm.log') return null
61
+ if (profileDir === undefined) return path.includes('.plugin-manager') ? path : null
62
+ const root = resolve(profileDir)
63
+ return path.startsWith(`${root}${sep}`) ? path : null
64
+ }
65
+
33
66
  /**
34
67
  * 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
35
- * 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
68
+ * 内容为空 / 路径越界)返回 null。尾部截到与子进程输出相同的上限。
36
69
  */
37
- export function backfillOperationLog(output: string): string | null {
38
- const path = operationLogPathOf(output)
70
+ export function backfillOperationLog(output: string, profileDir?: string): string | null {
71
+ const raw = operationLogPathOf(output)
72
+ if (raw === null) return null
73
+ const path = allowedLogPath(raw, profileDir)
39
74
  if (path === null) return null
40
75
  try {
41
76
  const text = readFileSync(path, 'utf8').trim()
@@ -46,6 +81,18 @@ export function backfillOperationLog(output: string): string | null {
46
81
  }
47
82
  }
48
83
 
84
+ /**
85
+ * 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
86
+ * 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
87
+ * 否则上报正文只剩退出码,无从排查。
88
+ */
89
+ export function hasSubstantiveOutput(output: string): boolean {
90
+ return output.split(/\r?\n/).some((raw) => {
91
+ const line = raw.trim()
92
+ return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\])/.test(line)
93
+ })
94
+ }
95
+
49
96
  /**
50
97
  * 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
51
98
  * 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 安装前置探测:命令是否存在、安装目标是否需要 git。
3
+ *
4
+ * 命令缺失时各平台报错形态不同(Windows 只回 GBK 乱码、POSIX 才抛 spawn ENOENT),
5
+ * 统一在 spawn 前用 which/where 探测,缺失则打 ASCII 标记,前端归类不受编码影响。
6
+ */
7
+ import { spawnSync } from 'node:child_process'
8
+
9
+ /** 缺 git 的失败标记:前端按 `[git-missing]` 归类并给「安装 git」指引(乱码免疫)。 */
10
+ export const GIT_MISSING_DETAIL = '[git-missing] the git CLI was not found on this machine (install git and add it to the PATH, then retry)'
11
+
12
+ /** 命令存在性探测:which/where 找 PATH 里的命令。
13
+ * 探测自身异常(which/where 不可用等)返回 true,不拦截安装,交给装后校验兜底。 */
14
+ export function commandOnPath(cmd: string, env?: NodeJS.ProcessEnv): boolean {
15
+ try {
16
+ const probe = process.platform === 'win32' ? 'where' : 'which'
17
+ const res = spawnSync(probe, [cmd], { env, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] })
18
+ return res.status === 0 && (res.stdout ?? '').trim().length > 0
19
+ } catch {
20
+ return true
21
+ }
22
+ }
23
+
24
+ /** 该安装目标是否需要 git:github: / git+ / git@ 与 owner/repo 简写都要先 clone;
25
+ * release 直链(https://…releases/download/…tgz)由 pnpm 直接下载,不需要 git。
26
+ * owner/repo 段首必须是字母数字:`./x`、`../x` 这类相对路径是本地目录,不能误判。 */
27
+ export function needsGit(target: string): boolean {
28
+ const v = target.trim()
29
+ if (/^(github:|git\+|git@)/i.test(v)) return true
30
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9._-]+$/.test(v)
31
+ }
32
+
33
+ /** git 源安装前置检查:需要 git 但本机 PATH 里没有 → 返回 [git-missing] 文本;否则 null。 */
34
+ export function gitMissingDetail(target: string, env?: NodeJS.ProcessEnv): string | null {
35
+ if (!needsGit(target)) return null
36
+ return commandOnPath('git', env) ? null : GIT_MISSING_DETAIL
37
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * npm 源降级:当前源是公共镜像时,传输层失败后按「当前源 → 官方 → npmmirror」
3
+ * 自动换源重试;自定义/内网源不降级,避免私有包名泄露给公共源。
4
+ */
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ /** 允许自动切换的公共源:只放官方与大厂/高校镜像。 */
8
+ const PUBLIC_REGISTRIES = [
9
+ 'https://registry.npmjs.org',
10
+ 'https://registry.npmmirror.com',
11
+ 'https://mirrors.cloud.tencent.com/npm',
12
+ 'https://mirrors.tuna.tsinghua.edu.cn/npm',
13
+ ]
14
+
15
+ const PUBLIC_HOSTS = new Set([
16
+ 'registry.npmjs.org',
17
+ 'registry.npmmirror.com',
18
+ 'mirrors.cloud.tencent.com',
19
+ 'mirrors.tuna.tsinghua.edu.cn',
20
+ ])
21
+
22
+ /** 去掉尾部斜杠,避免同一源因写法不同被当成两个候选。 */
23
+ export function normalizeRegistry(value: string): string {
24
+ return value.trim().replace(/\/+$/, '')
25
+ }
26
+
27
+ /** 是否属于允许自动切换的公共源白名单。 */
28
+ export function isPublicRegistry(value: string): boolean {
29
+ const v = normalizeRegistry(value)
30
+ if (!/^https?:\/\//i.test(v)) return false
31
+ try {
32
+ return PUBLIC_HOSTS.has(new URL(v).hostname.toLowerCase())
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 依次尝试的源:显式配置 → 本机 npm 配置 → 官方源。
40
+ * 只有公共源才追加后备(最多 3 个);关闭降级或源不在白名单时只返回它自己。
41
+ */
42
+ export function registryCandidates(configured: string, userRegistry: string, enabled: boolean): string[] {
43
+ const base = normalizeRegistry(configured) || normalizeRegistry(userRegistry) || PUBLIC_REGISTRIES[0]!
44
+ if (!enabled || !isPublicRegistry(base)) return [base]
45
+ const out = [base]
46
+ for (const candidate of PUBLIC_REGISTRIES) {
47
+ if (out.length >= 3) break
48
+ if (!out.includes(candidate)) out.push(candidate)
49
+ }
50
+ return out
51
+ }
52
+
53
+ /** 本机 npm 实际生效的源(含 profile 目录的 .npmrc);读不到返回空串。 */
54
+ export function userNpmRegistry(cwd: string): string {
55
+ try {
56
+ const res = spawnSync('npm', ['config', 'get', 'registry'], {
57
+ cwd,
58
+ encoding: 'utf8',
59
+ timeout: 3000,
60
+ shell: process.platform === 'win32',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ })
63
+ if (res.status !== 0) return ''
64
+ const value = (res.stdout ?? '').trim()
65
+ return /^https?:\/\//i.test(value) ? normalizeRegistry(value) : ''
66
+ } catch {
67
+ return ''
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 传输层失败才值得换源:超时、连接重置、DNS、5xx。
73
+ * 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
74
+ */
75
+ export function isTransportFailure(text: string): boolean {
76
+ if (/\b(?:ERR_PNPM_FETCH_404|E404|ERR_PNPM_NO_MATCHING_VERSION|ETARGET|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|E401|E403)\b/i.test(text)) {
77
+ return false
78
+ }
79
+ return /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EPIPE)\b|socket hang up|getaddrinfo|network request failed|connect timeout|request to .+ failed|FetchError|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL/i.test(text)
80
+ }
81
+
82
+ /** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
83
+ export function registryHost(value: string): string {
84
+ try {
85
+ return new URL(value).host
86
+ } catch {
87
+ return value
88
+ }
89
+ }
@@ -15,13 +15,16 @@ import type { InstallResult, InstallTask, Invocation, QueueItem } from './instal
15
15
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
16
16
  import { npmTooLowMarker } from './npm-check.ts'
17
17
  import { registryMissingMarker } from './registry-check.ts'
18
- import { backfillOperationLog, createOperationLogTap, operationLogPathOf } from './operation-log.ts'
18
+ import { backfillOperationLog, createOperationLogTap, hasSubstantiveOutput, operationLogPathOf } from './operation-log.ts'
19
19
  import { cleanLine, estimateProgress } from '../profile/progress.ts'
20
20
  import type { LoaderHandle } from '../loader.ts'
21
21
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
22
22
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.ts'
23
23
  import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
24
24
  import { appendLog } from '../log.ts'
25
+ import { commandOnPath, gitMissingDetail } from './preflight.ts'
26
+ import { isTransportFailure, registryHost } from './registry-fallback.ts'
27
+ import { OutputDecoder } from './decode-output.ts'
25
28
 
26
29
  /** 内存任务注册表,按任务 id 索引。 */
27
30
  const tasks = new Map<number, InstallTask>()
@@ -213,19 +216,13 @@ export function desktopCliBootstrap(entry: string): string | null {
213
216
  }
214
217
 
215
218
  /**
216
- * dsh 命令存在性探测:which/where 找 PATH 里的 dsh。
217
- * 命令缺失时各平台报错形态不同(Windows shell 只回 GBK 乱码、POSIX 才抛 spawn ENOENT),
218
- * 统一在 spawn 前用 which/where 探测,命令不存在则直接失败并打 ASCII 标记 [dsh-missing](乱码免疫)。
219
- * 探测自身异常(which/where 不可用等)不拦截安装,交给装后校验兜底。
219
+ * 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
220
+ * 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
220
221
  */
221
- function dshOnPath(env?: NodeJS.ProcessEnv): boolean {
222
- try {
223
- const probe = process.platform === 'win32' ? 'where' : 'which'
224
- const res = spawnSync(probe, ['dsh'], { env, encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] })
225
- return res.status === 0 && (res.stdout ?? '').trim().length > 0
226
- } catch {
227
- return true
228
- }
222
+ export function npmInstallTargetOf(target: string, opts: { update: boolean; global: boolean; pinned?: string }): string {
223
+ if (opts.global || githubRepoOf(target) !== null) return target
224
+ if (opts.pinned !== undefined && opts.pinned !== '') return opts.pinned
225
+ return opts.update ? `${target}@latest` : target
229
226
  }
230
227
 
231
228
  function stopChild(child: ReturnType<typeof spawn>): void {
@@ -287,6 +284,11 @@ export function startPluginMutation(options: {
287
284
  /** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
288
285
  * 目录插件安装不传 */
289
286
  installChannel?: 'npm'
287
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
288
+ registries?: string[]
289
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
290
+ * 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
291
+ installSpec?: string
290
292
  }): InstallTask {
291
293
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
292
294
  // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
@@ -412,8 +414,12 @@ function spawnMutation(options: {
412
414
  globalNpm?: string[]
413
415
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
414
416
  updateNpm?: boolean
417
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
418
+ registries?: string[]
419
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
420
+ installSpec?: string
415
421
  }): Promise<InstallResult> {
416
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options
422
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm, registries, installSpec } = options
417
423
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
418
424
  const isGlobalNpm = (globalNpm ?? []).length > 0
419
425
  const invocation = isGlobalNpm
@@ -426,9 +432,8 @@ function spawnMutation(options: {
426
432
  // CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
427
433
  const isUpdate = action === 'update' || updateNpm === true
428
434
  const cliAction = action === 'update' ? 'add' : action
429
- const npmUpdateTarget = isUpdate && !isGlobalNpm && githubRepoOf(target) === null
430
- ? `${target}@latest`
431
- : target
435
+ // 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
436
+ const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec })
432
437
  const args = isGlobalNpm
433
438
  ? ['install', '-g', ...(globalNpm ?? [])]
434
439
  : [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
@@ -442,9 +447,12 @@ function spawnMutation(options: {
442
447
  // 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
443
448
  // 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
444
449
  // 这样全局 npm 首次失败后可以干净地换参数重试一次。
445
- const spawnOnce = (attemptArgs: string[]): Promise<InstallResult> => new Promise((resolveOnce) => {
450
+ const spawnOnce = (attemptArgs: string[], registry?: string): Promise<InstallResult> => new Promise((resolveOnce) => {
446
451
  let stdout = ''
447
452
  let stderr = ''
453
+ // 每次 spawn 独立解码器:中文 Windows 下 dsh/pnpm 可能输出 GBK,需按流判定编码
454
+ const stdoutDecoder = new OutputDecoder()
455
+ const stderrDecoder = new OutputDecoder()
448
456
  let timedOut = false
449
457
  let settled = false
450
458
  let child: ReturnType<typeof spawn>
@@ -452,7 +460,7 @@ function spawnMutation(options: {
452
460
  // 独立 CLI 场景(file: 'dsh',宿主加载器走 node bin 不经过这里):先探测 dsh 是否在 PATH。
453
461
  // 命令缺失时各平台报错形态不同(GBK 乱码 / spawn ENOENT),统一前置拦截并打 ASCII 标记
454
462
  // [dsh-missing],前端据此精准提示「dsh 未加入 PATH」而不是笼统的插件失败。
455
- if (invocation.file === 'dsh' && !dshOnPath(env)) {
463
+ if (invocation.file === 'dsh' && !commandOnPath('dsh', env)) {
456
464
  const detail = '[dsh-missing] the dsh CLI was not found on this machine (DeepSeek Harness not installed, or dsh not on the PATH)'
457
465
  if (task) {
458
466
  task.status = 'failed'
@@ -463,11 +471,32 @@ function spawnMutation(options: {
463
471
  resolveOnce({ exitCode: 1, timedOut: false, error: detail, stdout: '', stderr: detail })
464
472
  return
465
473
  }
474
+ // git 源安装:pnpm 需要调用 git 解析仓库;Windows 缺 git 只回 GBK 乱码,前端认不出来。
475
+ // 前置探测并打 ASCII 标记 [git-missing],给出装 git 的指引而不是笼统的安装失败。
476
+ if (!isGlobalNpm && action !== 'remove') {
477
+ const gitMissing = gitMissingDetail(target, env)
478
+ if (gitMissing !== null) {
479
+ if (task) {
480
+ task.status = 'failed'
481
+ task.exitCode = null
482
+ task.progress = 0
483
+ pushLine(task, gitMissing)
484
+ }
485
+ resolveOnce({ exitCode: 1, timedOut: false, error: gitMissing, stdout: '', stderr: gitMissing })
486
+ return
487
+ }
488
+ }
489
+ // invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
490
+ // 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
491
+ // registry 最后写入:换源重试只改这一层,pnpm/npm 都认 npm_config_registry。
492
+ const childEnv: NodeJS.ProcessEnv = { ...(env ?? process.env), ...(invocation.env ?? {}) }
493
+ if (registry !== undefined) {
494
+ childEnv.npm_config_registry = registry
495
+ childEnv.NPM_CONFIG_REGISTRY = registry
496
+ }
466
497
  child = spawn(invocation.file, attemptArgs, {
467
498
  cwd: invocation.cwd,
468
- // invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
469
- // 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
470
- env: invocation.env === undefined ? env : { ...(env ?? process.env), ...invocation.env },
499
+ env: childEnv,
471
500
  shell: invocation.useShell,
472
501
  // detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
473
502
  // 且宿主进程崩溃后安装任务不会被连带误杀
@@ -522,7 +551,7 @@ function spawnMutation(options: {
522
551
  if (task && task.status === 'running' && task.progress < 85) task.progress += 1
523
552
  }, 500)
524
553
  const collect = (kind: 'stdout' | 'stderr', chunk: Buffer): void => {
525
- const text = chunk.toString()
554
+ const text = kind === 'stdout' ? stdoutDecoder.push(chunk) : stderrDecoder.push(chunk)
526
555
  if (kind === 'stdout') stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES)
527
556
  else stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES)
528
557
  if (task) {
@@ -578,7 +607,21 @@ function spawnMutation(options: {
578
607
  // 超时复核用基线:本次任务开始前目标是否已在 profile 里。已安装的话,超时后核对无法区分
579
608
  // 「旧版本还在」与「刚装上」,那种情形(更新超时)不冒认成功。
580
609
  const wasInstalled = action !== 'remove' && !isGlobalNpm && verifyInstalledEntry(profile, target).name !== null
581
- let result = await spawnOnce(args)
610
+ // 候选源:卸载不拉包、git 源装的是仓库快照,都不受 npm 镜像影响,不参与换源
611
+ const candidates = action === 'remove' || githubRepoOf(target) !== null ? [] : (registries ?? [])
612
+ let activeRegistry = candidates[0]
613
+ let result = await spawnOnce(args, activeRegistry)
614
+ // 传输层失败(超时 / 连接重置 / DNS / 5xx)才换下一个公共镜像;404、版本不存在、鉴权错误
615
+ // 保持原样,否则会掩盖作者侧问题或装到滞后版本。整段超时也不再换源,避免耗时翻倍。
616
+ for (const candidate of candidates.slice(1)) {
617
+ if (task?.status === 'cancelled' || result.exitCode === 0 || result.timedOut) break
618
+ if (!isTransportFailure(`${result.stderr}\n${result.stdout}`)) break
619
+ if (task) {
620
+ pushLine(task, `[registry-fallback] ${registryHost(activeRegistry ?? '')} unreachable — retrying with ${registryHost(candidate)}`)
621
+ }
622
+ activeRegistry = candidate
623
+ result = await spawnOnce(args, candidate)
624
+ }
582
625
  // npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
583
626
  // 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
584
627
  // 跳过 peer 自动解析即可绕过该 bug;二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
@@ -587,7 +630,7 @@ function spawnMutation(options: {
587
630
  const retryExecuted = `npm install -g --legacy-peer-deps ${(globalNpm ?? []).join(' ')}`
588
631
  if (task && !task.attempts.includes(retryExecuted)) task.attempts.push(retryExecuted)
589
632
  if (task) pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps')
590
- result = await spawnOnce(retryArgs)
633
+ result = await spawnOnce(retryArgs, activeRegistry)
591
634
  }
592
635
  // 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
593
636
  // 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
@@ -644,7 +687,9 @@ function spawnMutation(options: {
644
687
  addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install')
645
688
  }
646
689
  }
647
- pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]`)
690
+ // 子进程一个字节都没吐时在退出行上标注:日志面板与失败上报都能看出「没有输出可排查」
691
+ const printedNothing = `${result.stdout}${result.stderr}`.trim() === ''
692
+ pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`)
648
693
  }
649
694
  }
650
695
  return result
@@ -771,9 +816,14 @@ export async function runPluginMutation(options: {
771
816
  loader?: LoaderHandle
772
817
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
773
818
  globalNpm?: string[]
819
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
820
+ registries?: string[]
821
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
822
+ installSpec?: string
774
823
  }): Promise<InstallResult> {
775
824
  // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
776
- const logTap = createOperationLogTap(profileDirectory(options.profile))
825
+ const profileDir = profileDirectory(options.profile)
826
+ const logTap = createOperationLogTap(profileDir)
777
827
  let result = await spawnMutation(options)
778
828
  // allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
779
829
  // 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
@@ -834,18 +884,27 @@ export async function runPluginMutation(options: {
834
884
  // 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
835
885
  // 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
836
886
  // 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
837
- if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
887
+ // 超时同样回捞:被强杀前 pnpm 往往已经把真实原因写进 operation 日志。
888
+ if ((result.exitCode !== 0 || result.timedOut) && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
838
889
  const output = `${result.stderr}\n${result.stdout}`
839
- const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output)
890
+ const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output, profileDir)
840
891
  if (backfilled !== null) {
841
892
  if (options.task && options.task.status !== 'cancelled') {
842
893
  pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
843
894
  for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
844
895
  }
845
896
  result = { ...result, stderr: `${result.stderr}\n${backfilled}` }
846
- } else if (options.task && options.task.status !== 'cancelled' && operationLogPathOf(output) !== null) {
847
- // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
848
- pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
897
+ } else if (options.task && options.task.status !== 'cancelled') {
898
+ if (operationLogPathOf(output) !== null) {
899
+ // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
900
+ pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
901
+ } else if (!hasSubstantiveOutput(output)) {
902
+ // 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
903
+ const what = result.timedOut
904
+ ? 'printed nothing before the timeout'
905
+ : `exited with code ${result.exitCode ?? '?'} without printing anything`
906
+ pushLine(options.task, `[no-output] the dsh CLI ${what}, and no pnpm log was written under .plugin-manager/logs — attach ~/.dsh/profiles/${options.profile}/hub.log when reporting this`)
907
+ }
849
908
  }
850
909
  }
851
910
  // 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」