dsh-plugin 2.0.6 → 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 (38) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +421 -326
  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/registry-fallback.js +89 -0
  10. package/lib/services/install/task-queue.js +69 -19
  11. package/lib/services/settings.js +1 -0
  12. package/lib/types/services/install/decode-output.d.ts +4 -0
  13. package/lib/types/services/install/install-types.d.ts +4 -0
  14. package/lib/types/services/install/operation-log.d.ts +10 -4
  15. package/lib/types/services/install/registry-fallback.d.ts +18 -0
  16. package/lib/types/services/install/task-queue.d.ts +18 -0
  17. package/lib/types/services/settings.d.ts +3 -1
  18. package/package.json +1 -1
  19. package/src/client/components/PluginHubSection.tsx +11 -2
  20. package/src/client/components/modals/NotificationsModal.tsx +8 -1
  21. package/src/client/components/modals/modals.tsx +9 -1
  22. package/src/client/components/views/SettingsView.tsx +10 -0
  23. package/src/client/hooks/useCatalog.ts +10 -8
  24. package/src/client/hooks/useSettings.ts +4 -1
  25. package/src/client/hooks/useTaskQueue.ts +7 -1
  26. package/src/client/locales.ts +12 -0
  27. package/src/client/logic/failures.ts +17 -1
  28. package/src/client/logic/release-age.ts +40 -0
  29. package/src/client/logic/urls.ts +4 -1
  30. package/src/client/types.ts +13 -0
  31. package/src/server/http/install.ts +46 -11
  32. package/src/server/http/settings.ts +1 -1
  33. package/src/server/services/install/decode-output.ts +22 -0
  34. package/src/server/services/install/install-types.ts +4 -0
  35. package/src/server/services/install/operation-log.ts +58 -11
  36. package/src/server/services/install/registry-fallback.ts +89 -0
  37. package/src/server/services/install/task-queue.ts +78 -19
  38. package/src/server/services/settings.ts +4 -1
@@ -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,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,7 +15,7 @@ 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'
@@ -23,6 +23,8 @@ import { addPendingRestart, clearPendingRestart } from '../profile/pending-resta
23
23
  import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
24
24
  import { appendLog } from '../log.ts'
25
25
  import { commandOnPath, gitMissingDetail } from './preflight.ts'
26
+ import { isTransportFailure, registryHost } from './registry-fallback.ts'
27
+ import { OutputDecoder } from './decode-output.ts'
26
28
 
27
29
  /** 内存任务注册表,按任务 id 索引。 */
28
30
  const tasks = new Map<number, InstallTask>()
@@ -213,6 +215,16 @@ export function desktopCliBootstrap(entry: string): string | null {
213
215
  return null
214
216
  }
215
217
 
218
+ /**
219
+ * 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
220
+ * 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
221
+ */
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
226
+ }
227
+
216
228
  function stopChild(child: ReturnType<typeof spawn>): void {
217
229
  if (process.platform === 'win32' && child.pid !== undefined) {
218
230
  const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
@@ -272,6 +284,11 @@ export function startPluginMutation(options: {
272
284
  /** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
273
285
  * 目录插件安装不传 */
274
286
  installChannel?: 'npm'
287
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
288
+ registries?: string[]
289
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
290
+ * 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
291
+ installSpec?: string
275
292
  }): InstallTask {
276
293
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
277
294
  // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
@@ -397,8 +414,12 @@ function spawnMutation(options: {
397
414
  globalNpm?: string[]
398
415
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
399
416
  updateNpm?: boolean
417
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
418
+ registries?: string[]
419
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
420
+ installSpec?: string
400
421
  }): Promise<InstallResult> {
401
- 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
402
423
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
403
424
  const isGlobalNpm = (globalNpm ?? []).length > 0
404
425
  const invocation = isGlobalNpm
@@ -411,9 +432,8 @@ function spawnMutation(options: {
411
432
  // CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
412
433
  const isUpdate = action === 'update' || updateNpm === true
413
434
  const cliAction = action === 'update' ? 'add' : action
414
- const npmUpdateTarget = isUpdate && !isGlobalNpm && githubRepoOf(target) === null
415
- ? `${target}@latest`
416
- : target
435
+ // 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
436
+ const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec })
417
437
  const args = isGlobalNpm
418
438
  ? ['install', '-g', ...(globalNpm ?? [])]
419
439
  : [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
@@ -427,9 +447,12 @@ function spawnMutation(options: {
427
447
  // 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
428
448
  // 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
429
449
  // 这样全局 npm 首次失败后可以干净地换参数重试一次。
430
- const spawnOnce = (attemptArgs: string[]): Promise<InstallResult> => new Promise((resolveOnce) => {
450
+ const spawnOnce = (attemptArgs: string[], registry?: string): Promise<InstallResult> => new Promise((resolveOnce) => {
431
451
  let stdout = ''
432
452
  let stderr = ''
453
+ // 每次 spawn 独立解码器:中文 Windows 下 dsh/pnpm 可能输出 GBK,需按流判定编码
454
+ const stdoutDecoder = new OutputDecoder()
455
+ const stderrDecoder = new OutputDecoder()
433
456
  let timedOut = false
434
457
  let settled = false
435
458
  let child: ReturnType<typeof spawn>
@@ -463,11 +486,17 @@ function spawnMutation(options: {
463
486
  return
464
487
  }
465
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)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
@@ -15,8 +15,10 @@ export interface HubSettings {
15
15
  checkUpdatesOnStart: boolean
16
16
  /** HTTP(S) 代理地址,用于 npm / git / 目录请求(空串 = 直连) */
17
17
  proxy: string
18
- /** npm 镜像源 registry 地址(空串 = 未配置,跟随用户本机 npm 配置,本插件不注入任何 registry) */
18
+ /** npm 镜像源 registry 地址(空串 = 未配置,跟随用户本机 npm 配置) */
19
19
  npmRegistry: string
20
+ /** 公共源传输失败时自动换下一个镜像重试(自定义/内网源不切换) */
21
+ registryFallback: boolean
20
22
  /** 启用命令行安装 NPM 包(默认开;关掉后输入 npm 包名安装被拦截) */
21
23
  enableNpmInstall: boolean
22
24
  /** 日志存放位置覆盖(空串 = 默认 ~/.dsh/profiles/<profile>/hub.log;可填目录或 .log 文件路径) */
@@ -27,6 +29,7 @@ export const DEFAULT_SETTINGS: HubSettings = {
27
29
  checkUpdatesOnStart: true,
28
30
  proxy: '',
29
31
  npmRegistry: '',
32
+ registryFallback: true,
30
33
  enableNpmInstall: true,
31
34
  logPath: '',
32
35
  }