dsh-plugin 2.0.6 → 2.2.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 (46) hide show
  1. package/README.en.md +11 -11
  2. package/README.md +11 -11
  3. package/client/client.js +585 -328
  4. package/client/client.js.map +1 -1
  5. package/lib/http/diagnostics.js +2 -63
  6. package/lib/http/install.js +42 -11
  7. package/lib/http/settings.js +1 -1
  8. package/lib/services/host-version.js +72 -0
  9. package/lib/services/install/decode-output.js +23 -0
  10. package/lib/services/install/dsh-compat.js +105 -0
  11. package/lib/services/install/operation-log.js +61 -11
  12. package/lib/services/install/registry-fallback.js +89 -0
  13. package/lib/services/install/task-queue.js +95 -19
  14. package/lib/services/settings.js +1 -0
  15. package/lib/types/services/host-version.d.ts +10 -0
  16. package/lib/types/services/install/decode-output.d.ts +4 -0
  17. package/lib/types/services/install/dsh-compat.d.ts +19 -0
  18. package/lib/types/services/install/install-types.d.ts +4 -0
  19. package/lib/types/services/install/operation-log.d.ts +10 -4
  20. package/lib/types/services/install/registry-fallback.d.ts +18 -0
  21. package/lib/types/services/install/task-queue.d.ts +18 -0
  22. package/lib/types/services/settings.d.ts +3 -1
  23. package/package.json +1 -1
  24. package/src/client/components/PluginHubSection.tsx +11 -2
  25. package/src/client/components/modals/NotificationsModal.tsx +62 -5
  26. package/src/client/components/modals/modals.tsx +68 -5
  27. package/src/client/components/views/SettingsView.tsx +10 -0
  28. package/src/client/hooks/useCatalog.ts +10 -8
  29. package/src/client/hooks/useSettings.ts +4 -1
  30. package/src/client/hooks/useTaskQueue.ts +7 -1
  31. package/src/client/locales.ts +42 -0
  32. package/src/client/logic/failures.ts +86 -5
  33. package/src/client/logic/release-age.ts +40 -0
  34. package/src/client/logic/urls.ts +19 -4
  35. package/src/client/types.ts +13 -0
  36. package/src/server/http/diagnostics.ts +2 -59
  37. package/src/server/http/install.ts +46 -11
  38. package/src/server/http/settings.ts +1 -1
  39. package/src/server/services/host-version.ts +69 -0
  40. package/src/server/services/install/decode-output.ts +22 -0
  41. package/src/server/services/install/dsh-compat.ts +99 -0
  42. package/src/server/services/install/install-types.ts +4 -0
  43. package/src/server/services/install/operation-log.ts +58 -11
  44. package/src/server/services/install/registry-fallback.ts +89 -0
  45. package/src/server/services/install/task-queue.ts +107 -19
  46. package/src/server/services/settings.ts +4 -1
@@ -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,9 @@ 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 { dshTooOldMarker } from './dsh-compat.ts'
28
+ import { OutputDecoder } from './decode-output.ts'
26
29
 
27
30
  /** 内存任务注册表,按任务 id 索引。 */
28
31
  const tasks = new Map<number, InstallTask>()
@@ -213,6 +216,16 @@ export function desktopCliBootstrap(entry: string): string | null {
213
216
  return null
214
217
  }
215
218
 
219
+ /**
220
+ * 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
221
+ * 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
222
+ */
223
+ export function npmInstallTargetOf(target: string, opts: { update: boolean; global: boolean; pinned?: string }): string {
224
+ if (opts.global || githubRepoOf(target) !== null) return target
225
+ if (opts.pinned !== undefined && opts.pinned !== '') return opts.pinned
226
+ return opts.update ? `${target}@latest` : target
227
+ }
228
+
216
229
  function stopChild(child: ReturnType<typeof spawn>): void {
217
230
  if (process.platform === 'win32' && child.pid !== undefined) {
218
231
  const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
@@ -272,6 +285,11 @@ export function startPluginMutation(options: {
272
285
  /** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
273
286
  * 目录插件安装不传 */
274
287
  installChannel?: 'npm'
288
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
289
+ registries?: string[]
290
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
291
+ * 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
292
+ installSpec?: string
275
293
  }): InstallTask {
276
294
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
277
295
  // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
@@ -397,13 +415,23 @@ function spawnMutation(options: {
397
415
  globalNpm?: string[]
398
416
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
399
417
  updateNpm?: boolean
418
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
419
+ registries?: string[]
420
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
421
+ installSpec?: string
400
422
  }): Promise<InstallResult> {
401
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options
423
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm, registries, installSpec } = options
402
424
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
403
425
  const isGlobalNpm = (globalNpm ?? []).length > 0
404
426
  const invocation = isGlobalNpm
405
427
  ? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
406
428
  : cliInvocation(profile)
429
+ // 打包桌面端却回退到 PATH 上的 dsh:说明 desktop CLI 引导路径没命中,desktop profile 必被宿主拒绝。
430
+ // 留一行 ASCII 标记,后续同类上报可直接区分「用户自起 CLI」与「我们没走对引导路径」。
431
+ if (task && task.status !== 'cancelled' && invocation.file === 'dsh'
432
+ && (process.versions as Record<string, string | undefined>).electron !== undefined) {
433
+ pushLine(task, '[desktop-cli-fallback] running inside Electron but no desktop CLI bootstrap was found — falling back to dsh on PATH (a desktop-managed profile will be rejected)')
434
+ }
407
435
  // 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
408
436
  // (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
409
437
  // 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
@@ -411,9 +439,8 @@ function spawnMutation(options: {
411
439
  // CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
412
440
  const isUpdate = action === 'update' || updateNpm === true
413
441
  const cliAction = action === 'update' ? 'add' : action
414
- const npmUpdateTarget = isUpdate && !isGlobalNpm && githubRepoOf(target) === null
415
- ? `${target}@latest`
416
- : target
442
+ // 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
443
+ const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec })
417
444
  const args = isGlobalNpm
418
445
  ? ['install', '-g', ...(globalNpm ?? [])]
419
446
  : [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
@@ -427,9 +454,12 @@ function spawnMutation(options: {
427
454
  // 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
428
455
  // 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
429
456
  // 这样全局 npm 首次失败后可以干净地换参数重试一次。
430
- const spawnOnce = (attemptArgs: string[]): Promise<InstallResult> => new Promise((resolveOnce) => {
457
+ const spawnOnce = (attemptArgs: string[], registry?: string): Promise<InstallResult> => new Promise((resolveOnce) => {
431
458
  let stdout = ''
432
459
  let stderr = ''
460
+ // 每次 spawn 独立解码器:中文 Windows 下 dsh/pnpm 可能输出 GBK,需按流判定编码
461
+ const stdoutDecoder = new OutputDecoder()
462
+ const stderrDecoder = new OutputDecoder()
433
463
  let timedOut = false
434
464
  let settled = false
435
465
  let child: ReturnType<typeof spawn>
@@ -463,11 +493,17 @@ function spawnMutation(options: {
463
493
  return
464
494
  }
465
495
  }
496
+ // invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
497
+ // 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
498
+ // registry 最后写入:换源重试只改这一层,pnpm/npm 都认 npm_config_registry。
499
+ const childEnv: NodeJS.ProcessEnv = { ...(env ?? process.env), ...(invocation.env ?? {}) }
500
+ if (registry !== undefined) {
501
+ childEnv.npm_config_registry = registry
502
+ childEnv.NPM_CONFIG_REGISTRY = registry
503
+ }
466
504
  child = spawn(invocation.file, attemptArgs, {
467
505
  cwd: invocation.cwd,
468
- // invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
469
- // 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
470
- env: invocation.env === undefined ? env : { ...(env ?? process.env), ...invocation.env },
506
+ env: childEnv,
471
507
  shell: invocation.useShell,
472
508
  // detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
473
509
  // 且宿主进程崩溃后安装任务不会被连带误杀
@@ -522,7 +558,7 @@ function spawnMutation(options: {
522
558
  if (task && task.status === 'running' && task.progress < 85) task.progress += 1
523
559
  }, 500)
524
560
  const collect = (kind: 'stdout' | 'stderr', chunk: Buffer): void => {
525
- const text = chunk.toString()
561
+ const text = kind === 'stdout' ? stdoutDecoder.push(chunk) : stderrDecoder.push(chunk)
526
562
  if (kind === 'stdout') stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES)
527
563
  else stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES)
528
564
  if (task) {
@@ -578,7 +614,21 @@ function spawnMutation(options: {
578
614
  // 超时复核用基线:本次任务开始前目标是否已在 profile 里。已安装的话,超时后核对无法区分
579
615
  // 「旧版本还在」与「刚装上」,那种情形(更新超时)不冒认成功。
580
616
  const wasInstalled = action !== 'remove' && !isGlobalNpm && verifyInstalledEntry(profile, target).name !== null
581
- let result = await spawnOnce(args)
617
+ // 候选源:卸载不拉包、git 源装的是仓库快照,都不受 npm 镜像影响,不参与换源
618
+ const candidates = action === 'remove' || githubRepoOf(target) !== null ? [] : (registries ?? [])
619
+ let activeRegistry = candidates[0]
620
+ let result = await spawnOnce(args, activeRegistry)
621
+ // 传输层失败(超时 / 连接重置 / DNS / 5xx)才换下一个公共镜像;404、版本不存在、鉴权错误
622
+ // 保持原样,否则会掩盖作者侧问题或装到滞后版本。整段超时也不再换源,避免耗时翻倍。
623
+ for (const candidate of candidates.slice(1)) {
624
+ if (task?.status === 'cancelled' || result.exitCode === 0 || result.timedOut) break
625
+ if (!isTransportFailure(`${result.stderr}\n${result.stdout}`)) break
626
+ if (task) {
627
+ pushLine(task, `[registry-fallback] ${registryHost(activeRegistry ?? '')} unreachable — retrying with ${registryHost(candidate)}`)
628
+ }
629
+ activeRegistry = candidate
630
+ result = await spawnOnce(args, candidate)
631
+ }
582
632
  // npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
583
633
  // 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
584
634
  // 跳过 peer 自动解析即可绕过该 bug;二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
@@ -587,7 +637,7 @@ function spawnMutation(options: {
587
637
  const retryExecuted = `npm install -g --legacy-peer-deps ${(globalNpm ?? []).join(' ')}`
588
638
  if (task && !task.attempts.includes(retryExecuted)) task.attempts.push(retryExecuted)
589
639
  if (task) pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps')
590
- result = await spawnOnce(retryArgs)
640
+ result = await spawnOnce(retryArgs, activeRegistry)
591
641
  }
592
642
  // 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
593
643
  // 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
@@ -644,7 +694,9 @@ function spawnMutation(options: {
644
694
  addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install')
645
695
  }
646
696
  }
647
- pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]`)
697
+ // 子进程一个字节都没吐时在退出行上标注:日志面板与失败上报都能看出「没有输出可排查」
698
+ const printedNothing = `${result.stdout}${result.stderr}`.trim() === ''
699
+ pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`)
648
700
  }
649
701
  }
650
702
  return result
@@ -740,6 +792,26 @@ async function annotateRegistryMissing(result: InstallResult, options: {
740
792
  return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line }
741
793
  }
742
794
 
795
+ /** 安装失败且插件声明的最低 DSH 版本高于当前宿主 → 注入 [dsh-too-old] 标记。
796
+ * 只在失败路径上发生:peer 不满足本身通常只是警告,装成功时不该冒这个提示。
797
+ * 超时也算失败 —— 旧宿主上插件可能卡在半兼容状态,这个信息同样有用。 */
798
+ async function annotateDshTooOld(result: InstallResult, options: {
799
+ action: 'add' | 'remove' | 'update'
800
+ target: string
801
+ task?: InstallTask
802
+ globalNpm?: string[]
803
+ registries?: string[]
804
+ }): Promise<InstallResult> {
805
+ if (options.action === 'remove' || result.exitCode === 0) return result
806
+ // 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
807
+ if ((options.globalNpm ?? []).length > 0) return result
808
+ // 用安装实际使用的源读元数据:私有源/镜像里也能读到同一个包的声明
809
+ const line = await dshTooOldMarker(options.target, { registry: options.registries?.[0] })
810
+ if (line === null) return result
811
+ if (options.task && options.task.status !== 'cancelled') pushLine(options.task, line)
812
+ return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line }
813
+ }
814
+
743
815
  /**
744
816
  * 宿主自身 CLI 调不起来时的 ASCII 标记行:spawn 我们解析出的宿主入口报 ENOENT 时返回
745
817
  * `[host-spawn]`,供前端单独归因、不引导提 Issue。PATH 上的 dsh/npm 各有自己的标记,返回 null。
@@ -771,9 +843,14 @@ export async function runPluginMutation(options: {
771
843
  loader?: LoaderHandle
772
844
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
773
845
  globalNpm?: string[]
846
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
847
+ registries?: string[]
848
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
849
+ installSpec?: string
774
850
  }): Promise<InstallResult> {
775
851
  // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
776
- const logTap = createOperationLogTap(profileDirectory(options.profile))
852
+ const profileDir = profileDirectory(options.profile)
853
+ const logTap = createOperationLogTap(profileDir)
777
854
  let result = await spawnMutation(options)
778
855
  // allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
779
856
  // 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
@@ -834,22 +911,33 @@ export async function runPluginMutation(options: {
834
911
  // 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
835
912
  // 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
836
913
  // 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
837
- if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
914
+ // 超时同样回捞:被强杀前 pnpm 往往已经把真实原因写进 operation 日志。
915
+ if ((result.exitCode !== 0 || result.timedOut) && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
838
916
  const output = `${result.stderr}\n${result.stdout}`
839
- const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output)
917
+ const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output, profileDir)
840
918
  if (backfilled !== null) {
841
919
  if (options.task && options.task.status !== 'cancelled') {
842
920
  pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
843
921
  for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
844
922
  }
845
923
  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)')
924
+ } else if (options.task && options.task.status !== 'cancelled') {
925
+ if (operationLogPathOf(output) !== null) {
926
+ // 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
927
+ pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
928
+ } else if (!hasSubstantiveOutput(output)) {
929
+ // 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
930
+ const what = result.timedOut
931
+ ? 'printed nothing before the timeout'
932
+ : `exited with code ${result.exitCode ?? '?'} without printing anything`
933
+ 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`)
934
+ }
849
935
  }
850
936
  }
851
937
  // 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
852
938
  result = await annotateRegistryMissing(result, options)
939
+ // 插件声明的最低 DSH 版本高于当前宿主 → 追加 [dsh-too-old] 标记,前端提示先升级 DSH
940
+ result = await annotateDshTooOld(result, options)
853
941
  // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
854
942
  return annotateNpmTooLow(result, options)
855
943
  }
@@ -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
  }