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
@@ -15,7 +15,7 @@ import { CloseIcon, ConfirmIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
15
15
  import { ProgressView } from './ProgressView.tsx'
16
16
  import { installCommandOf } from '../../logic/install-command.ts'
17
17
  import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl, hubIssueUrl } from '../../logic/urls.ts'
18
- import { classifyFailure, formatFailureMessage, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, registryMissingHintOf, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
18
+ import { classifyFailure, formatFailureMessage, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, registryMissingHintOf, unreachableTargetOf, registryHostOf, authRegistryHostOf, exclusiveProfileOf, desktopCliFallbackOf, dshTooOldInfoOf, missingLocalDirOf } from '../../logic/failures.ts'
19
19
  import type { FailureAction } from '../../logic/failures.ts'
20
20
 
21
21
  /** 完成结果视图:绿色对勾 + 标题/描述;
@@ -445,6 +445,10 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
445
445
  // 调 pnpm add 时未声明在根操作,任何插件都会失败,不是插件问题 ——
446
446
  // 给 .npmrc 豁免指引,不引导提 Issue
447
447
  ? h('div', { className: styles.failPrepareHint }, t('failPnpmWorkspaceHint'))
448
+ : failureKind === 'pnpmVirtualStore'
449
+ // profile 的 modules 目录与当前 pnpm 的 virtual-store-dir-max-length 不一致:
450
+ // 该 profile 内的安装/卸载都会失败 —— 重建 modules 目录或统一配置即可,不引导提 Issue
451
+ ? h('div', { className: styles.failPrepareHint }, t('failPnpmVirtualStoreHint'))
448
452
  : failureKind === 'pnpmStore'
449
453
  // pnpm 存在但 store 大版本不匹配(ERR_PNPM_UNEXPECTED_STORE):profile 目录的依赖是
450
454
  // 另一大版本 pnpm 装的,当前 pnpm 不认,任何插件装进该 profile 都会失败 ——
@@ -513,10 +517,16 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
513
517
  // 给「对齐源 / 那一次显式走官方源 / 用离线包」的指引,不引导提 Issue
514
518
  ? h('div', { className: styles.failPrepareHint }, t('failRegistryVersionStaleHint'))
515
519
  : failureKind === 'pnpmLinkedDirMissing'
516
- // profile 里留着指向不存在目录的本地依赖(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND):常见于
517
- // link:/本地路径装过开发中的插件、之后目录被删;pnpm 每次解析 profile 依赖都先卡在它上面,
518
- // 与本次要装的插件无关 —— 给删除悬空条目的指引,不引导提 Issue
519
- ? h('div', { className: styles.failPrepareHint }, t('failPnpmLinkedDirMissingHint'))
520
+ // profile 里留着指向不存在目录的本地依赖(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND / pnpm ENOENT lstat):
521
+ // 常见于 link:/本地路径装过开发中的插件、之后目录被删;pnpm 每次解析 profile 依赖都先卡在它上面,
522
+ // 与本次要装的插件无关 —— 点名缺失目录 + 删除悬空条目的指引,不引导提 Issue
523
+ ? h('div', null, [
524
+ (() => {
525
+ const dir = missingLocalDirOf(message)
526
+ return dir ? h('div', { className: styles.failNetworkTarget }, t('failLinkedDirTarget', { path: dir })) : null
527
+ })(),
528
+ h('div', { className: styles.failPrepareHint }, t('failPnpmLinkedDirMissingHint')),
529
+ ])
520
530
  : failureKind === 'fileLocked'
521
531
  // profile 文件被其他进程占用
522
532
  ? h('div', { className: styles.failPrepareHint }, t('failFileLockedHint'))
@@ -530,6 +540,59 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
530
540
  // 请求来源没通过本地 hub 服务的校验(服务端 403 untrusted origin):请求根本没进
531
541
  // 安装流程,与插件无关 —— 引导从本机地址重新打开市场,不引导提 Issue
532
542
  ? h('div', { className: styles.failPrepareHint }, t('failOriginRejectedHint', { profile: env?.profile ?? '<profile>' }))
543
+ : failureKind === 'profileExclusive'
544
+ // desktop profile 只能由桌面应用管理:外部 dsh CLI 被宿主拒绝,属环境约束。
545
+ // 但日志里有 [desktop-cli-fallback] 时是我们没找到应用内 CLI 引导脚本才退回 PATH
546
+ // —— 那是安装器自身的问题,必须留一条回到插件中心的通道
547
+ ? (() => {
548
+ const profile = exclusiveProfileOf(message)
549
+ const ourBug = desktopCliFallbackOf(message)
550
+ return h('div', null, [
551
+ h('div', { className: styles.failPrepareHint },
552
+ t(profile ? 'failProfileExclusiveHint' : 'failProfileExclusiveHintNoProfile', profile ? { profile } : undefined)),
553
+ ourBug ? h('div', { className: styles.failPrepareHint }, t('failDesktopCliFallbackHint')) : null,
554
+ ourBug ? h('a', {
555
+ className: styles.failBigIssue,
556
+ href: hubIssueUrl(repo, message, env, command, attempts, 'triage', action),
557
+ target: '_blank',
558
+ rel: 'noopener noreferrer',
559
+ title: t('failHubIssueHint'),
560
+ }, t('failIssueBig')) : null,
561
+ ])
562
+ })()
563
+ : failureKind === 'registryAuth'
564
+ // npm 源返回 401/403:私有源需要凭据,或当前网络/IP 不被允许 —— 本机环境问题,
565
+ // 给「配认证 / 换回官方源 / 切公共镜像」指引,不引导提 Issue
566
+ ? (() => {
567
+ const host = authRegistryHostOf(message)
568
+ return h('div', { className: styles.failPrepareHint },
569
+ t(host ? 'failRegistryAuthHint' : 'failRegistryAuthHintNoHost', host ? { host } : undefined))
570
+ })()
571
+ : failureKind === 'dshTooOld'
572
+ // 安装器读插件 npm 元数据得出的结论:插件要求的 DSH 下限高于本机宿主。
573
+ // 用户升级 DSH 即可解决,不属插件作者、也不属插件中心 —— 不引导提 Issue
574
+ ? (() => {
575
+ const info = dshTooOldInfoOf(message)
576
+ return h('div', { className: styles.failPrepareHint },
577
+ t('failDshTooOldHint', info ?? { pkg: repo ?? 'plugin', floor: '?', current: '?' }))
578
+ })()
579
+ : failureKind === 'localEnv'
580
+ // 未命中具体成因、但特征指向本机环境:给通用自查清单 + 直达系统诊断,不推给作者,
581
+ // 也默认不推给我们 —— 只留小字反馈入口,防环境问题堆进插件中心的 issue 列表
582
+ ? h('div', null, [
583
+ h('div', { className: styles.failPrepareHint }, t('failLocalEnvHint')),
584
+ onRunDiagnostics ? h('button', { className: styles.failDiagBtn, onClick: onRunDiagnostics }, t('failNetworkRunDiag')) : null,
585
+ h('div', null, [
586
+ h('div', { className: styles.failPrepareHint }, t('failMisjudgeHint')),
587
+ h('a', {
588
+ className: styles.failMisjudgeLink,
589
+ href: hubIssueUrl(repo, message, env, command, attempts, 'triage', action),
590
+ target: '_blank',
591
+ rel: 'noopener noreferrer',
592
+ title: t('failHubIssueHint'),
593
+ }, t('failMisjudgeLink')),
594
+ ]),
595
+ ])
533
596
  : failureKind === 'network'
534
597
  // 安装前网络探测 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
535
598
  // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
@@ -176,6 +176,16 @@ export function SettingsView({ t, settings, update, reset, env, onCopy, openSect
176
176
  className: `${styles.controlDropdown} ${dropdownStyles.dropdownFill}`,
177
177
  }),
178
178
  }),
179
+ // 自动换源:只在公共源之间切换;自定义/内网源不降级,避免私有包名泄露
180
+ h(SettingRow, {
181
+ title: t('settingsRegistryFallback'),
182
+ desc: t('settingsRegistryFallbackDesc'),
183
+ children: h(Toggle, {
184
+ checked: settings.registryFallback,
185
+ onChange: (v) => update({ registryFallback: v }),
186
+ title: t('settingsRegistryFallback'),
187
+ }),
188
+ }),
179
189
  // 代理地址:文本框独占一行,整行输入,便于粘贴长代理地址;停笔后实时探测连通性,
180
190
  // 不通仅提示(可保存)—— 用户可能先填地址后开代理
181
191
  h(SettingRow, {
@@ -16,7 +16,7 @@ import type { SortKey } from '../logic/constants.ts'
16
16
  import { fetchCatalog, fetchStats } from '../data/catalog.ts'
17
17
  import { fetchHubAbout, fetchHubUpdate } from '../data/hub.ts'
18
18
  import { fetchInstalled, fetchNpmReleaseAge } from '../data/host.ts'
19
- import { isReleaseTooFresh } from '../logic/release-age.ts'
19
+ import { isReleaseTooFresh, pickHubUpdate } from '../logic/release-age.ts'
20
20
  import {
21
21
  hasUpdateOf, installedItemsOf, installedNameOf, installedVersionOf, npmPackageOf,
22
22
  } from '../logic/installed.ts'
@@ -272,13 +272,14 @@ export function useCatalog(lang: LocaleId) {
272
272
  * 落回「无更新」——回落会让弹窗在本机版本落后时谎称「已是最新」。
273
273
  */
274
274
  const hubCurrentVersion = PLUGIN_VERSION || versions[HUB_REPO]?.version || ''
275
- const hubUpdateState: HubUpdateState = (() => {
276
- if (!hubUpdateInfo) return 'none'
277
- if (hubCurrentVersion === '' || hubUpdateInfo.version === hubCurrentVersion) return 'latest'
278
- // 供应链门禁(24h + 30 分钟缓冲)与插件更新同一口径:发布未满门槛的版本暂缓提示
279
- if (isReleaseTooFresh(hubUpdateInfo.publishedAt, Date.now())) return 'pending'
280
- return 'available'
281
- })()
275
+ // 候选含 recentVersions:最新版还在门禁内时回退到「更早、但已过门禁」的版本,
276
+ // 避免连续快速发版把所有提示窗口都吞掉(见 pickHubUpdate)。
277
+ const hubUpdateResult = hubUpdateInfo === null
278
+ ? { state: 'none' as HubUpdateState, target: null }
279
+ : pickHubUpdate(hubUpdateInfo, hubCurrentVersion, Date.now())
280
+ const hubUpdateState: HubUpdateState = hubUpdateResult.state
281
+ /** 本次提示的目标版本(available 时有值;pending 时是暂缓中的最高版本) */
282
+ const hubUpdateTarget = hubUpdateResult.target
282
283
  const hubHasUpdate = hubUpdateState === 'available'
283
284
 
284
285
  /** 当前分类下的插件(「全部」时为整个目录)。 */
@@ -334,6 +335,7 @@ export function useCatalog(lang: LocaleId) {
334
335
  hasUpdate,
335
336
  hubHasUpdate,
336
337
  hubUpdateState,
338
+ hubUpdateTarget,
337
339
  releaseAgeReady,
338
340
  hubCurrentVersion,
339
341
  hubUpdateInfo,
@@ -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 包(默认开) */
21
23
  enableNpmInstall: boolean
22
24
  /** 日志存放位置覆盖(空串 = 默认 ~/.dsh/profiles/<profile>/hub.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
  }
@@ -433,6 +433,9 @@ export function useTaskQueue(opts: TaskQueueOptions) {
433
433
  source: 'catalog' | 'custom'
434
434
  /** 更新已安装目标(放行 add 覆盖重装) */
435
435
  update?: boolean
436
+ /** 钉住精确版本(Hub 自我更新用):最新版可能仍在 pnpm 供应链门禁内,
437
+ * 必须安装「已过门禁」的那个版本,不能再用 @latest。 */
438
+ pinVersion?: string
436
439
  /** 全局 npm 安装包列表(`npm install -g <pkgs>`):非空时服务端执行全局安装,不进任何 profile */
437
440
  globalNpm?: string[]
438
441
  /** 自定义安装入口渠道(NPM 包卡片):随请求上报服务端落日志,
@@ -473,6 +476,8 @@ export function useTaskQueue(opts: TaskQueueOptions) {
473
476
  display: input.repo,
474
477
  source: input.source,
475
478
  mode: input.update ? 'update' : undefined,
479
+ // 钉住版本(Hub 自我更新):服务端据此执行 `add <pkg>@<version>` 而不是 @latest
480
+ ...(input.pinVersion ? { version: input.pinVersion } : {}),
476
481
  // 界面语言带给服务端:安装前网络探测等错误消息按用户当前语言返回(中文界面给中文,英文界面给英文)
477
482
  ...(langKey ? { lang: langKey } : {}),
478
483
  // 安装入口渠道(只可能是 npm):服务端日志/报错提示据此溯源
@@ -523,7 +528,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
523
528
  }
524
529
 
525
530
  /** 目录插件安装:安装目标取目录数据的 npm 包名,走 catalog 白名单。 */
526
- const installNow = async (p: HubPlugin, opts?: { update?: boolean }) => {
531
+ const installNow = async (p: HubPlugin, opts?: { update?: boolean; pinVersion?: string }) => {
527
532
  const repo = p.source?.repo ?? ''
528
533
  if (!repo) return
529
534
  // 目录条目必带 npm 包名:直接用包名安装(用户无感知)。
@@ -538,6 +543,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
538
543
  command: installCommandOf(p, true),
539
544
  source: 'catalog',
540
545
  update: opts?.update,
546
+ pinVersion: opts?.pinVersion,
541
547
  })
542
548
  }
543
549
 
@@ -117,6 +117,8 @@ export const zh = {
117
117
  proxyCheckRevocation: '证书吊销检查受阻(Windows Schannel 取不到 CRL/OCSP),握手被中断;属本机 TLS 环境问题,非代理地址不通',
118
118
  settingsMirror: 'npm 镜像源',
119
119
  settingsMirrorDesc: '默认跟随本机 npm 配置;官方源慢可换国内镜像',
120
+ settingsRegistryFallback: '连不上时自动换源',
121
+ settingsRegistryFallbackDesc: '公共源超时或被拦截时,自动改用下一个镜像重试(最多 3 个);自定义/内网源不会切换,避免私有包名外泄',
120
122
  mirrorNone: '未配置(跟随本机 npm 配置)',
121
123
  mirrorOfficial: '官方源 registry.npmjs.org',
122
124
  mirrorNpmmirror: '阿里云 npmmirror',
@@ -372,6 +374,9 @@ export const zh = {
372
374
  failReason_buildToolchainMissing: '这个插件依赖需要在本机编译的原生模块,但找不到可用的编译器。',
373
375
  failKind_pnpmStore: 'pnpm store 版本不匹配',
374
376
  failReason_pnpmStore: 'profile 里的依赖由另一大版本的 pnpm 生成,当前 pnpm 不认。',
377
+ failKind_pnpmVirtualStore: 'profile 的依赖目录与当前 pnpm 配置不一致',
378
+ failReason_pnpmVirtualStore: 'node_modules 是用另一套 virtual-store-dir-max-length 配置创建的,pnpm 拒绝复用。',
379
+ failPnpmVirtualStoreHint: '安装器调用 pnpm 时报「modules 目录由另一套 virtual-store-dir-max-length 配置创建」(ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF):profile 里的 node_modules 是在别的 pnpm 配置下生成的(常见于在 .npmrc 里改过 virtual-store-dir-max-length,或换过 pnpm 大版本),当前 pnpm 出于安全拒绝在它上面继续操作——所以这个 profile 里的安装/卸载都会失败。修复(任选其一,在 profile 目录 ~/.dsh/profiles/<profile> 下操作):① 先执行一次 pnpm install,让它按当前配置重建 modules 目录;② 删掉该目录下的 node_modules,下次安装/卸载会自动重建;③ 如果你在 .npmrc 里设过 virtual-store-dir-max-length,把它统一成同一个值(或删掉自定义、回到默认)。这不是插件本身的问题。',
375
380
  failKind_pnpmWorkspace: 'workspace 根校验阻止了安装',
376
381
  failReason_pnpmWorkspace: 'profile 被当作 workspace 根,但没有声明根操作,pnpm 中止了安装。',
377
382
  failKind_originRejected: '本地服务的来源校验未通过',
@@ -386,6 +391,10 @@ export const zh = {
386
391
  failReason_pluginMissingDep: '插件声明依赖的包在官方 npm 源上不存在(已删除或从未发布)。',
387
392
  failKind_registryVersionStale: '该版本在所用源上不存在',
388
393
  failReason_registryVersionStale: '实际安装用的源还没同步到这个版本,或版本号写错。',
394
+ failKind_registryAuth: 'npm 源拒绝了访问',
395
+ failReason_registryAuth: 'registry 返回 401/403:这个源需要登录凭据,或当前网络不被允许。',
396
+ failKind_profileExclusive: 'profile 由桌面应用独占管理',
397
+ failReason_profileExclusive: '命令行 dsh 无权管理桌面应用的 profile,安装前就被宿主拒绝。',
389
398
  failKind_pnpmLinkedDirMissing: 'profile 里有失效的本地依赖',
390
399
  failReason_pnpmLinkedDirMissing: '有一条指向本机不存在目录的 link/file 依赖,pnpm 每次解析都卡住。',
391
400
  failKind_fileLocked: '文件被其他程序占用',
@@ -412,8 +421,20 @@ export const zh = {
412
421
  failReason_profileLock: '另一个 dsh 进程正在使用这个 profile。',
413
422
  failKind_hub: '出现未知错误',
414
423
  failReason_hub: '我们还没能识别这条报错,你可以一键反馈给我们。',
424
+ failKind_localEnv: '本机环境异常',
425
+ failReason_localEnv: '报错特征指向你的本机环境(系统错误码 / 证书代理 / 本地配置),没有发现插件侧原因。',
426
+ failKind_dshTooOld: 'DSH 版本低于插件要求',
427
+ failReason_dshTooOld: '插件声明的最低 DSH 版本高于你当前运行的宿主版本。',
428
+ failDshTooOldHint: '插件「{pkg}」声明需要 DSH {floor} 或更高,而你当前运行的是 {current} —— 版本不够时装不上(或装上后无法正常加载),这不是插件本身的问题,也不是插件市场的误判。请先把 DeepSeek Harness 升级到 {floor} 以上,再回到插件中心重试。如果升级后仍然失败,把新的报错发给我们。',
429
+ failLocalEnvHint: '从日志能确认的只有一点:没有插件侧的失败证据;报错特征落在本机环境(系统调用错误码、证书/代理校验、本地配置文件损坏等),但还不足以精确到某一类。这类问题装别的插件同样会失败,不是插件本身的问题。请按顺序自查:① 点下面的「运行系统诊断」看本机检查项;② 确认 profile 目录(~/.dsh/profiles/<profile>)可写、磁盘有空间、杀毒软件没拦截;③ 若在用代理或公司镜像源,先临时切回官方源 https://registry.npmjs.org 重试;④ 完全退出 DeepSeek Harness 后重试。排查后仍然失败、且你确信环境没问题,再用下面的链接反馈给我们,并附上通知中心里复制的完整日志。',
415
430
  failRegistryVersionStaleHint: '安装器调用 pnpm 时报「找不到匹配的版本」(ERR_PNPM_NO_MATCHING_VERSION):要装的这个版本在实际执行安装的 npm 源里查不到。常见原因是你设置的 npmRegistry 与本机 ~/.npmrc 用的不是同一个源——官方源刚发布、镜像还没同步上来(快则几分钟、慢则几小时),也可能只是版本号写错了。这不是插件本身的问题。可在插件市场设置里把 npmRegistry 改成与本机 ~/.npmrc 一致(或反过来),或那一次显式走官方源(https://registry.npmjs.org)、改用离线包后重试;若该版本刚发布不久,等镜像同步后重试即可。',
431
+ failRegistryAuthHint: '安装器访问 npm 源时被拒绝(HTTP 401/403):这个源需要登录凭据,或不允许当前网络/IP 访问。如果你在用公司或私有源,请先配好认证(npm login,或在 ~/.npmrc 写入 //{host}/:_authToken=…);如果不需要私有源,把 registry 换回官方源 https://registry.npmjs.org,或在插件市场设置里切换到公共镜像后重试。这不是插件本身的问题。',
432
+ failRegistryAuthHintNoHost: '安装器访问 npm 源时被拒绝(HTTP 401/403):这个源需要登录凭据,或不允许当前网络/IP 访问。如果你在用公司或私有源,请先配好认证(npm login,或在 ~/.npmrc 写入 //<registry>/:_authToken=…);如果不需要私有源,把 registry 换回官方源 https://registry.npmjs.org,或在插件市场设置里切换到公共镜像后重试。这不是插件本身的问题。',
433
+ failProfileExclusiveHint: 'profile「{profile}」由桌面应用独占管理,命令行 dsh 无权安装 —— 这不是插件本身的问题。请在 DeepSeek Harness 桌面应用的「设置 → 插件市场」里安装;或改用非桌面独占的 profile(例如 web)后重试。如果你就是在桌面应用内看到这条错误,请到插件市场的「通知」里打开这条失败记录点「复制」,把完整日志发给我们。',
434
+ failProfileExclusiveHintNoProfile: '当前 profile 由桌面应用独占管理,命令行 dsh 无权安装 —— 这不是插件本身的问题。请在 DeepSeek Harness 桌面应用的「设置 → 插件市场」里安装;或改用非桌面独占的 profile(例如 web)后重试。如果你就是在桌面应用内看到这条错误,请到插件市场的「通知」里打开这条失败记录点「复制」,把完整日志发给我们。',
435
+ failDesktopCliFallbackHint: '这次是安装器没找到桌面应用内置的 CLI 引导脚本、退回命令行 dsh 才被拒绝 —— 属于插件中心自身的问题,不是你的环境问题。点下面的按钮把完整日志发给我们,我们会补上路径推导。',
416
436
  failPnpmLinkedDirMissingHint: '安装器调用 pnpm 时报「要安装的目录不存在」(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND):你的 profile 里留着一条指向本机不存在目录的本地依赖(link: / file: 形式,例如用 dsh plugin add <本地路径> 装过开发中的插件,之后那个目录被删除或移动了)。pnpm 每次解析 profile 依赖都会先卡在它上面,因此任何插件都装不进来。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)的 package.json 里删除该本地依赖条目(同时删掉对应的 pnpm-lock.yaml 条目或整个锁文件),或把该目录恢复回原路径后重试。',
437
+ failLinkedDirTarget: '缺失的本地目录:{path}',
417
438
  failFileLockedHint: '安装过程中 pnpm 无法替换 profile 里的文件:有程序正占用着它(Windows 常见报错 os error 32「另一个程序正在使用此文件」)。通常是 DeepSeek Harness 自身或杀毒软件的实时扫描持有文件句柄。这不是插件本身的问题——请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试。',
418
439
  failAccessDeniedHint: '安装过程中 pnpm 写入 profile 里的文件被系统拒绝访问(Windows 常见报错 os error 5「拒绝访问」,英文为 Access is denied;Node 下的 EPERM / EACCES 同理)。常见原因是该文件或目录正被其他进程占用、文件带只读属性、目录权限受限,或杀毒软件实时防护拦截了写入——这与「文件正被占用」(os error 32)同属本机文件系统问题,任何插件装进该目录都会一样失败,不是插件本身的问题。请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试;若仍然失败,请检查 profile 目录及其文件是否被设为只读,并以管理员身份重新运行。',
419
440
  failFsUnavailableHint: '安装过程中 pnpm 无法把文件写进本机磁盘(这类报错与装哪个插件无关,任何插件装在同一目录都会失败,不是插件本身的问题)。请按报错对号入座:① 磁盘空间不足(ENOSPC / no space left on device,Windows 为 os error 112)——请清理 profile 所在磁盘的剩余空间后重试;② 磁盘或挂载点只读、带写保护(EROFS / read-only file system,Windows 为 os error 19)——请确认 profile 目录所在磁盘可写(外接盘/U 盘、带写保护的移动盘或只读挂载的分区都会触发),必要时把插件目录换到系统盘的可写位置;③ 进程可用的文件句柄被耗尽(EMFILE / ENFILE / too many open files)——请关闭占用大量文件的程序并重启 DeepSeek Harness 后重试。',
@@ -583,6 +604,8 @@ export const en = {
583
604
  proxyCheckRevocation: 'Certificate revocation check blocked (Windows Schannel could not fetch CRL/OCSP), so the handshake was aborted — a local TLS issue, not a bad proxy address',
584
605
  settingsMirror: 'npm mirror',
585
606
  settingsMirrorDesc: 'Unset by default — follows local npm config; pick a domestic mirror if slow',
607
+ settingsRegistryFallback: 'Switch mirrors on failure',
608
+ settingsRegistryFallbackDesc: 'When a public registry times out or is blocked, retry with the next mirror (up to 3). Custom/intranet registries are never switched, so private package names do not leak.',
586
609
  mirrorNone: 'Unset (follow local npm config)',
587
610
  mirrorOfficial: 'Official registry.npmjs.org',
588
611
  mirrorNpmmirror: 'Alibaba npmmirror',
@@ -836,6 +859,9 @@ export const en = {
836
859
  failReason_buildToolchainMissing: 'A dependency of this plugin must be compiled locally, but no usable compiler was found.',
837
860
  failKind_pnpmStore: 'pnpm store version mismatch',
838
861
  failReason_pnpmStore: 'The profile dependencies were written by a different pnpm major, which the current pnpm does not accept.',
862
+ failKind_pnpmVirtualStore: 'profile dependency directory does not match the current pnpm config',
863
+ failReason_pnpmVirtualStore: 'node_modules was created with a different virtual-store-dir-max-length, so pnpm refuses to reuse it.',
864
+ failPnpmVirtualStoreHint: 'pnpm reported that the modules directory was created with a different virtual-store-dir-max-length value (ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF): node_modules inside the profile was built under a different pnpm config (typically after editing virtual-store-dir-max-length in .npmrc, or switching pnpm majors), so the current pnpm refuses to operate on it — installs and removals in this profile will keep failing. Fix it (any one of these, run inside the profile directory ~/.dsh/profiles/<profile>): ① run pnpm install once so it recreates the modules directory with the current config; ② delete node_modules there — the next install/removal recreates it; ③ if you set virtual-store-dir-max-length in .npmrc, make it the same value everywhere (or drop the override and go back to the default). This is not a problem with the plugin itself.',
839
865
  failKind_pnpmWorkspace: 'workspace-root check blocked the install',
840
866
  failReason_pnpmWorkspace: 'The profile is treated as a workspace root without a root operation declared, so pnpm aborted.',
841
867
  failKind_originRejected: 'request origin rejected by the local service',
@@ -850,6 +876,10 @@ export const en = {
850
876
  failReason_pluginMissingDep: 'A package the plugin depends on does not exist on the official npm registry.',
851
877
  failKind_registryVersionStale: 'this version is not available on the registry in use',
852
878
  failReason_registryVersionStale: 'The registry that actually performs the install has not synced this version yet, or the version is mistyped.',
879
+ failKind_registryAuth: 'the npm registry rejected access',
880
+ failReason_registryAuth: 'The registry returned 401/403: it needs credentials, or the current network is not allowed.',
881
+ failKind_profileExclusive: 'the profile is managed only by the desktop app',
882
+ failReason_profileExclusive: 'The dsh CLI may not manage the desktop profile, so the host refused before installing.',
853
883
  failKind_pnpmLinkedDirMissing: 'dangling local dependency in the profile',
854
884
  failReason_pnpmLinkedDirMissing: 'A link/file dependency points at a directory that no longer exists, so pnpm stalls on it.',
855
885
  failKind_fileLocked: 'a file is held by another program',
@@ -876,8 +906,20 @@ export const en = {
876
906
  failReason_profileLock: 'Another dsh process is using this profile right now.',
877
907
  failKind_hub: 'unknown error',
878
908
  failReason_hub: 'We could not identify this error yet — you can report it to us in one click.',
909
+ failKind_localEnv: 'local environment failure',
910
+ failReason_localEnv: 'The error points at your local environment (system error codes, certificates/proxy, or local config), and no plugin-side cause was found.',
911
+ failKind_dshTooOld: 'DSH is older than the plugin requires',
912
+ failReason_dshTooOld: 'The plugin declares a minimum DSH version that is newer than the host you are running.',
913
+ failDshTooOldHint: 'The plugin "{pkg}" requires DSH {floor} or newer, but this host runs {current} — the install cannot succeed on an older host (or the plugin will not load properly). This is not a problem with the plugin itself, and not a mistake by the marketplace. Upgrade DeepSeek Harness to {floor} or newer, then retry from the Plugin Hub. If it still fails after upgrading, send us the new error.',
914
+ failLocalEnvHint: 'The log only tells us one thing for sure: there is no evidence of a plugin-side failure — the signals point at your local environment (system call error codes, certificate/proxy validation, a broken local config file), but not precisely enough to name one. Any plugin would fail the same way on this machine, so it is not a problem with the plugin itself. Check in order: ① press "Run diagnostics" below; ② make sure the profile directory (~/.dsh/profiles/<profile>) is writable, the disk has free space, and antivirus is not blocking it; ③ if you use a proxy or a company mirror, switch back to the official registry (https://registry.npmjs.org) for one retry; ④ fully quit DeepSeek Harness and retry. If it still fails and you are sure your environment is fine, use the link below to report it with the full log copied from the Hub notifications.',
879
915
  failRegistryVersionStaleHint: 'pnpm reported that no matching version was found (ERR_PNPM_NO_MATCHING_VERSION): the version being installed does not exist in the npm registry that actually performs the install. This usually means the npmRegistry configured in the hub differs from the source in your local ~/.npmrc — the official registry has published it but the mirror has not synced yet (it can take minutes to hours) — or the version number was simply mistyped. This is not a problem with the plugin itself. Align the hub\'s npmRegistry with your local ~/.npmrc (or the other way round), or explicitly use the official registry (https://registry.npmjs.org) for that one install, or use an offline package, then retry; if the version was published only recently, waiting for the mirror to sync is enough.',
916
+ failRegistryAuthHint: 'The installer was denied access to the npm registry (HTTP 401/403): that registry requires credentials, or it does not allow the current network/IP. If you are using a company or private registry, configure authentication first (npm login, or write //{host}/:_authToken=… into ~/.npmrc). If you do not need a private registry, point the registry back to the official one (https://registry.npmjs.org), or switch to a public mirror in the hub settings, then retry. This is not a problem with the plugin itself.',
917
+ failRegistryAuthHintNoHost: 'The installer was denied access to the npm registry (HTTP 401/403): that registry requires credentials, or it does not allow the current network/IP. If you are using a company or private registry, configure authentication first (npm login, or write //<registry>/:_authToken=… into ~/.npmrc). If you do not need a private registry, point the registry back to the official one (https://registry.npmjs.org), or switch to a public mirror in the hub settings, then retry. This is not a problem with the plugin itself.',
918
+ failProfileExclusiveHint: 'Profile "{profile}" is managed exclusively by the desktop app, and the command-line dsh is not allowed to install into it — this is not a problem with the plugin itself. Install from "Settings → Plugin Hub" inside DeepSeek Harness Desktop, or retry with a profile that is not desktop-managed (for example web). If you hit this inside the desktop app, open the Hub notifications, select the failed record and press "Copy" so you can send us the full log.',
919
+ failProfileExclusiveHintNoProfile: 'The current profile is managed exclusively by the desktop app, and the command-line dsh is not allowed to install into it — this is not a problem with the plugin itself. Install from "Settings → Plugin Hub" inside DeepSeek Harness Desktop, or retry with a profile that is not desktop-managed (for example web). If you hit this inside the desktop app, open the Hub notifications, select the failed record and press "Copy" so you can send us the full log.',
920
+ failDesktopCliFallbackHint: 'This failed because the installer could not find the desktop app\'s bundled CLI bootstrap and fell back to the command-line dsh, which is then refused — that is a bug in the plugin hub, not your environment. Use the button below to send us the full log and we will fix the path detection.',
880
921
  failPnpmLinkedDirMissingHint: 'pnpm reported that a directory it needs to install from does not exist (ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND): your profile keeps a local dependency (a link: / file: entry, e.g. from `dsh plugin add <local path>`) whose target directory has since been deleted or moved. pnpm hits it every time it resolves profile dependencies, so no plugin can be installed into that profile. This is not a problem with the plugin itself. Remove that local dependency from package.json inside the profile directory (~/.dsh/profiles/<profile>) — along with its entry in pnpm-lock.yaml, or the lockfile itself — or restore the directory to its original path, then retry.',
922
+ failLinkedDirTarget: 'Missing local directory: {path}',
881
923
  failFileLockedHint: 'During install, pnpm could not replace a file inside the profile because another program is holding it (on Windows this shows as os error 32, "the process cannot access the file because it is being used by another process"). Usually DeepSeek Harness itself or a real-time antivirus scan holds the handle. This is not a problem with the plugin itself — fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry.',
882
924
  failAccessDeniedHint: 'During install, Windows denied pnpm access to a file inside the profile (this shows as os error 5, "Access is denied"; on Node the equivalent codes are EPERM / EACCES). Typical causes: the file or directory is held by another process, the file is marked read-only, the directory permissions are restricted, or a real-time antivirus scan blocked the write. This is the same class of local file-system problem as a locked file (os error 32) — no plugin can be installed into that directory, so it is not a problem with the plugin itself. Fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry. If it still fails, check whether the profile directory or its files are read-only and run as administrator.',
883
925
  failFsUnavailableHint: 'During install, pnpm could not write files to the local disk — this has nothing to do with which plugin you are installing: any plugin installed into the same directory would fail the same way, so it is not a problem with the plugin itself. Match the error you see: ① Out of space (ENOSPC / no space left on device, os error 112 on Windows) — free up space on the drive that holds the profile and retry. ② Read-only or write-protected volume (EROFS / read-only file system, os error 19 on Windows) — make sure the drive holding the profile directory is writable (external or USB drives, write-protected media, and read-only mounts all trigger this); move the plugin directory to a writable location on the system drive if needed. ③ File handles exhausted (EMFILE / ENFILE / too many open files) — close programs that hold many files open, restart DeepSeek Harness, then retry.',
@@ -148,7 +148,7 @@ export function removeNotification(id: number): NotificationRecord[] {
148
148
  return next
149
149
  }
150
150
 
151
- export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'network' | 'hostCliSpawn' | 'hubState' | 'timeout' | 'profileLock' | 'hub'
151
+ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmVirtualStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'registryAuth' | 'profileExclusive' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'network' | 'hostCliSpawn' | 'hubState' | 'timeout' | 'profileLock' | 'dshTooOld' | 'localEnv' | 'hub'
152
152
 
153
153
  /** 失败动作口径:标题、上报正文与提示文案按安装/更新/卸载取词,不写死「安装」。 */
154
154
  export type FailureAction = 'install' | 'update' | 'uninstall'
@@ -220,6 +220,9 @@ export function classifyFailure(message: string): FailureKind {
220
220
  // 宿主版本门禁:插件声明的兼容范围不含当前 dsh 版本,宿主在安装前直接拒绝。
221
221
  // 必须最先判 —— 这是宿主自己给的结论,不是 pnpm 的安装失败
222
222
  if (/installation rejected:.*incompatible with dsh/i.test(message)) return 'hostIncompatible'
223
+ // desktop profile 由桌面应用独占:外部 dsh CLI 无权管理,宿主在安装前直接拒绝。
224
+ // 属宿主/环境约束,不是插件问题;必须在所有 pnpm 分支之前判(日志里没有 pnpm 报错)。
225
+ if (/is managed exclusively by the Electron application|managed exclusively by the desktop app/i.test(message)) return 'profileExclusive'
223
226
  // npm 自身崩溃(edgesOut)或服务端标记的本机 npm 版本过低。必须最先判,否则会被外层
224
227
  // ERR_PNPM_PREPARE_PACKAGE 吞成「插件打包问题」
225
228
  if (/\[npm-too-low\]|edgesOut/i.test(message)) return 'npmTooOld'
@@ -231,6 +234,9 @@ export function classifyFailure(message: string): FailureKind {
231
234
  if (/\[npm-missing\]|spawn npm ENOENT|'npm' 不是内部或外部命令|"npm" 不是内部或外部命令|npm['"]?\s*is not recognized|npm: command not found/i.test(message)) return 'npmMissing'
232
235
  // 本机缺 dsh。服务端 [dsh-missing] 标记乱码免疫(Windows GBK 解码后无法匹配原文)
233
236
  if (/\[dsh-missing\]|不是内部或外部命令|is not recognized as an internal or external command|command not found|spawn dsh ENOENT/i.test(message)) return 'dshMissing'
237
+ // profile 的 modules 目录由另一套 virtual-store-dir-max-length 配置生成,当前 pnpm 拒绝复用。
238
+ // 必须在 pnpmStore 之前:两者的处置方式不同(这里只需重建 modules 目录 / 统一配置)
239
+ if (/ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF|virtual-store-dir-max-length/i.test(message)) return 'pnpmVirtualStore'
234
240
  // store 版本不匹配:profile 依赖由另一大版本 pnpm 生成,当前 pnpm 不认。
235
241
  // 必须在 pnpmMissing 之后(pnpm 能跑起来报错,不是找不到命令)
236
242
  if (/ERR_PNPM_UNEXPECTED_(VIRTUAL_)?STORE|Unexpected (virtual )?store location/i.test(message)) return 'pnpmStore'
@@ -253,6 +259,12 @@ export function classifyFailure(message: string): FailureKind {
253
259
  if (/os error 112\b|os error 19\b|ERROR_DISK_FULL|ERROR_WRITE_PROTECT|\bENOSPC\b|\bEROFS\b|\bEMFILE\b|\bENFILE\b|no space left on device|read-only file system|too many open files/i.test(message)) return 'fsUnavailable'
254
260
  // 构建脚本被 allowBuilds 拦截。必须在 network 之前判:日志尾部混重试残留的连接失败特征
255
261
  if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/i.test(message)) return 'pnpmIgnoredBuild'
262
+ // npm 源拒绝了访问(401/403):私有源要登录凭据,或当前网络/IP 不被源允许。
263
+ // 必须在 network 之前判:pnpm 的 401/403 日志也常带 "fetch failed" 字样,先判网络会误报成「网络不通」
264
+ if (/ERR_PNPM_FETCH_40[13]\b|\bE40[13]\b|(?:Unauthorized|Forbidden)\s*-\s*40[13]\b/i.test(message)) return 'registryAuth'
265
+ // profile 里指向已删除目录的本地依赖(file:/link:):pnpm 直接 ENOENT(lstat),任何安装都会失败。
266
+ // 必须在 network 之前判 —— 同一份日志常混着 pnpm 重试产生的 UND_ERR_* 警告,先判网络会把根因盖掉。
267
+ if (/ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND|Could not install from .+ as it does not exist|pnpm:\s*ENOENT: no such file or directory,\s*(?:lstat|scandir|readlink)/i.test(message)) return 'pnpmLinkedDirMissing'
256
268
  // 网络/代理/源配置问题。必须在 prepare 之前判:git fetch 失败常被外层包成 "Command failed"
257
269
  // tarball 下载失败的括号里可能是数字码(pnpm 自己的)或符号码(undici 的 UND_ERR_*),两种都要认
258
270
  if (/\[network\]|ERR_PNPM_GIT_FETCH_FAILED|ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH|UND_ERR_[A-Z0-9_]+|getaddrinfo|Could not connect|Could not resolve host|Network unreachable|Failed to connect|socket hang up|CERT_HAS_EXPIRED|SSL certificate problem|\bTLS\b|\bSSL\b|fetch failed|failed to fetch|\bGET https?:\/\/\S+\.tgz\s+error \([A-Za-z0-9_]+\)/i.test(message)) return 'network'
@@ -266,8 +278,6 @@ export function classifyFailure(message: string): FailureKind {
266
278
  if (/ERR_PNPM_FETCH_404|404 Not Found|Not Found - 404/i.test(message)) return 'pnpmMissingDep'
267
279
  // 要装的版本在实际安装源里没有(镜像滞后或版本写错)。必须在 pluginPrepare 之前判
268
280
  if (/\bERR_PNPM_NO_MATCHING_VERSION\b|\bETARGET\b|No matching version found/i.test(message)) return 'registryVersionStale'
269
- // profile 残留指向不存在目录的本地依赖(link:/file:)。必须在 pluginPrepare 之前判
270
- if (/\bERR_PNPM_LINKED_PKG_DIR_NOT_FOUND\b|Could not install from .+ as it does not exist/i.test(message)) return 'pnpmLinkedDirMissing'
271
281
  // 原生模块需要本机编译,缺编译器/构建工具属于用户环境,不是插件的 prepare 失败。
272
282
  // 必须排在 pluginPrepare 之前:这类日志同样带 ELIFECYCLE,否则会被推给作者。
273
283
  if (/Unable to detect compiler type|gyp ERR!|Could not find any Visual Studio|No Xcode or CLT version detected|xcode-select|python(?:3)? is (?:not set|not found)|Could not find Python|\bmake: not found\b|\bgcc: not found\b|\bg\+\+: not found\b|\bMSB\d{4}\b/i.test(message)) return 'buildToolchainMissing'
@@ -293,10 +303,26 @@ export function classifyFailure(message: string): FailureKind {
293
303
  // 宿主自身入口起不来(服务端 [host-spawn] 标记):环境问题,给提示、不引导提 Issue。
294
304
  // 不匹配 `spawn … ENOENT` 文本 —— 那个形态任何 spawn(curl/git/taskkill…)都会产出
295
305
  if (/\[host-spawn\]/i.test(message)) return 'hostCliSpawn'
296
- // 其余未知失败:引导到插件中心仓库排查
306
+ // 安装器读插件 npm 元数据得出的客观结论:插件声明的 DSH 下限高于本机宿主。
307
+ // 放在所有具体成因之后 —— 日志里有真实报错时仍以真实报错为准;但在「未分类」之前,
308
+ // 否则这类本来能说清的失败又会变成未知。
309
+ if (/\[dsh-too-old\]/i.test(message)) return 'dshTooOld'
310
+ // 裸 `spawn <cmd> ENOENT`(无 [host-spawn] 标记):分不清是用户机器上缺这个可执行文件,
311
+ // 还是安装器解析错了路径 —— 不冒充环境问题,留给插件中心分诊
312
+ if (/spawn .+ ENOENT/i.test(message)) return 'hub'
313
+ // 未命中任何具体成因,但特征指向本机环境(系统调用错误码 / 证书代理 / 本地配置损坏):
314
+ // 归「本机环境」给自助排查,而不是丢进未分类让用户等着我们分析 —— 装任何插件都会同样失败,
315
+ // 且这类误判代价低(用户自查重试即可)。位置必须在所有具体分支之后:能精确归因的优先精确。
316
+ if (LOCAL_ENV_RE.test(message)) return 'localEnv'
317
+ // 其余未知失败:没有任何环境/插件侧特征,可能是安装器或宿主的 bug —— 引导到插件中心仓库排查
297
318
  return 'hub'
298
319
  }
299
320
 
321
+ /** 「特征指向本机环境但说不清具体哪一类」的兜底判据:系统调用错误码(权限/路径过长/设备异常)、
322
+ * 证书与代理校验失败、本地配置文件(~/.npmrc、profile 的 package.json)损坏。
323
+ * 只做排除用:命中它表示「不是插件侧问题」,不代表我们已经定位到原因。 */
324
+ const LOCAL_ENV_RE = /os error \d{1,3}\b|ERROR_[A-Z_]{4,}|\bE(?:ACCES|PERM|NOENT|NAMETOOLONG|INVAL|PROTO|ISDIR|NOTDIR|EXIST|BADF|NOTEMPTY|JSONPARSE)\b|Unexpected token .{0,60}in JSON|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|ERR_TLS_CERT_ALTNAME_INVALID|unable to verify the first certificate|self[- ]signed certificate|ERR_INVALID_URL|invalid registry|proxy authentication|407 Proxy/i
325
+
300
326
  /** 从服务端 `[npm-too-low]` 标记行提取本机 npm 版本(如 `[npm-too-low] npm@11.3.0` → "11.3.0");
301
327
  * 历史记录无标记时返回 null,前端据此决定提示文案是否带具体版本。 */
302
328
  export function npmTooLowVersion(message: string): string | null {
@@ -312,6 +338,10 @@ const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed
312
338
  * 无关 —— 抓核心错误时必须跳过,否则会被 CORE_LINE_RE 的 `missing` 分支误抓、淹没真正的
313
339
  * 错误码(如 git 源的 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED),让 issue 正文只剩一堆 peer WARN。 */
314
340
  const PEER_WARN_RE = /missing peer|issues with peer dependencies|peer dependencies that should be installed/i
341
+ /** 安装器自己打的诊断标记行:它们本身就是「为什么没有日志 / 卡在哪一步」的证据,
342
+ * 不含 CORE_LINE_RE 的关键词,必须无条件保留 —— 否则上报正文会退回成只剩宿主那句摘要,
343
+ * 我们又拿不到任何可排查的信息(#382/#393 就是这样)。 */
344
+ const MARKER_LINE_RE = /^\[(?:pnpm log|no-output|desktop-cli-fallback|dsh-too-old|host-spawn|npm-too-low|git-missing|npm-missing|pnpm-missing|dsh-missing|network|packaging|registry-missing|registry-mirror-lag|internal|exit\b|timed out)/i
315
345
  /** 提交 issue 时正文里错误摘要的上限字符数。GitHub 请求行上限 8192 字节,
316
346
  * 固定模板与 URL 编码开销约 1~2K,核心错误(以 ASCII 日志为主)可安全带到 ~5K;
317
347
  * 仍超长时 pluginIssueUrl 会逐档缩小核心预算,最终 URL 不会超限。 */
@@ -344,7 +374,8 @@ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHAR
344
374
  const core: string[] = []
345
375
  for (const raw of message.split(/\r?\n/)) {
346
376
  const line = raw.trim()
347
- if (!line || PEER_WARN_RE.test(line) || !CORE_LINE_RE.test(line)) continue
377
+ if (!line || PEER_WARN_RE.test(line)) continue
378
+ if (!MARKER_LINE_RE.test(line) && !CORE_LINE_RE.test(line)) continue
348
379
  const short = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line
349
380
  if (!seen.has(short)) {
350
381
  seen.add(short)
@@ -353,6 +384,9 @@ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHAR
353
384
  }
354
385
  let out: string
355
386
  if (core.length === 0) {
387
+ // 整段本来就是短消息时直接给全量:head+tail 兜底会把同一段重复两遍,正文反而更难读
388
+ const whole = message.trim()
389
+ if (whole.length <= maxChars) return whole
356
390
  // 无关键行时退化为「头部 + 尾部」快照:优先保尾部完整,头部按预算压缩,总长不超上限
357
391
  const tail = message.trimEnd().slice(-1000)
358
392
  const sep = '\n…\n'
@@ -431,3 +465,50 @@ export function registryHostOf(message: string): string | null {
431
465
  const host = m[1].replace(/:\d+$/, '')
432
466
  return PUBLIC_REGISTRY_HOSTS.has(host) ? null : host
433
467
  }
468
+
469
+ /**
470
+ * 「本地依赖目录不存在」失败里缺失的路径:
471
+ * `pnpm: ENOENT: no such file or directory, lstat '<path>'`
472
+ * 或 `Could not install from "<path>" as it does not exist`。取不到返回 null。
473
+ */
474
+ export function missingLocalDirOf(message: string): string | null {
475
+ const lstat = /ENOENT: no such file or directory,\s*(?:lstat|scandir|readlink)\s+'([^']+)'/i.exec(message)
476
+ if (lstat !== null) return lstat[1]
477
+ const quoted = /Could not install from\s+"([^"]+)"\s+as it does not exist/i.exec(message)
478
+ ?? /Could not install from\s+'([^']+)'\s+as it does not exist/i.exec(message)
479
+ return quoted === null ? null : quoted[1]
480
+ }
481
+
482
+ /**
483
+ * 「profile 由桌面应用独占」错误里的 profile 名:
484
+ * `error: profile "desktop" is managed exclusively by the Electron application`。
485
+ * 取不到返回 null(提示文案退回通用说法)。
486
+ */
487
+ export function exclusiveProfileOf(message: string): string | null {
488
+ const m = /profile\s+"?([A-Za-z0-9_-]+)"?\s+is managed exclusively/i.exec(message)
489
+ return m === null ? null : m[1]
490
+ }
491
+
492
+ /**
493
+ * 日志里有安装器的 `[desktop-cli-fallback]` 标记:没找到桌面应用内置的 CLI 引导脚本,
494
+ * 退回 PATH 上的 dsh 才被「profile 独占」拒绝 —— 是我们的路径推导问题,不是用户环境问题。
495
+ */
496
+ export function desktopCliFallbackOf(message: string): boolean {
497
+ return /\[desktop-cli-fallback\]/i.test(message)
498
+ }
499
+
500
+ /** `[dsh-too-old]` 标记里的三方信息(包名 / 插件要求的 DSH 下限 / 当前宿主版本);取不到返回 null。 */
501
+ export function dshTooOldInfoOf(message: string): { pkg: string; floor: string; current: string } | null {
502
+ const m = /\[dsh-too-old\]\s*(\S+)\s+requires DSH >= (\S+).*?runs (\S+)/i.exec(message)
503
+ return m === null ? null : { pkg: m[1]!, floor: m[2]!, current: m[3]! }
504
+ }
505
+
506
+ /**
507
+ * 鉴权失败(401/403)时被拒绝的源主机名:从 `ERR_PNPM_FETCH_401 GET https://host/pkg: Unauthorized - 401`
508
+ * 里取,取不到返回 null(提示文案退回通用说法,不拼出残缺句子)。
509
+ */
510
+ export function authRegistryHostOf(message: string): string | null {
511
+ const m = /ERR_PNPM_FETCH_40[13]\s+GET\s+https?:\/\/([^\s/:]+)/i.exec(message)
512
+ ?? /GET\s+https?:\/\/([^\s/:]+)\S*\s*:?\s*(?:Unauthorized|Forbidden)\s*-\s*40[13]/i.exec(message)
513
+ return m === null ? null : m[1]
514
+ }
@@ -6,6 +6,7 @@
6
6
  * 供应链门禁的时间判定(纯函数):pnpm 会静默回退发布未满门槛的版本,
7
7
  * 故发布太新的版本不该提示更新。Hub 自身更新与插件更新共用同一门槛。
8
8
  */
9
+ import type { HubUpdateInfo, HubUpdateState, HubUpdateVersion } from '../types.ts'
9
10
 
10
11
  /** pnpm 供应链门禁:dsh profile 默认 minimumReleaseAge=1440(分钟);+30 分钟缓冲抵消网络/CDN 边缘时间偏差。 */
11
12
  export const MIN_RELEASE_AGE_MS = 24 * 60 * 60 * 1000 + 30 * 60 * 1000
@@ -17,3 +18,42 @@ export function isReleaseTooFresh(publishedAt: string | null | undefined, now: n
17
18
  if (Number.isNaN(ms)) return false
18
19
  return now - ms < MIN_RELEASE_AGE_MS
19
20
  }
21
+
22
+ /** 版本号比较(逐段数字;缺失段按 0)。返回负数/0/正数,非法输入按相等处理。 */
23
+ export function compareVersions(a: string, b: string): number {
24
+ const parse = (v: string): number[] => v.split(/[.+-]/).map((part) => Number.parseInt(part, 10)).filter((n) => !Number.isNaN(n))
25
+ const va = parse(a)
26
+ const vb = parse(b)
27
+ const len = Math.max(va.length, vb.length)
28
+ for (let i = 0; i < len; i++) {
29
+ const d = (va[i] ?? 0) - (vb[i] ?? 0)
30
+ if (d !== 0) return d
31
+ }
32
+ return 0
33
+ }
34
+
35
+ /**
36
+ * 选出本次真正该提示的更新版本。
37
+ *
38
+ * 只看最新版会漏更新:连续发版时间隔小于供应链门禁时,最新版永远「太新」,
39
+ * 提示被整体跳过。这里把最新版与 recentVersions 一起当候选,取「高于当前版本且已过门禁」
40
+ * 的最高版本;一个都没有但仍存在更高版本 → pending(暂缓,不谎称已是最新)。
41
+ */
42
+ export function pickHubUpdate(info: HubUpdateInfo, currentVersion: string, now: number): { state: HubUpdateState; target: HubUpdateVersion | null } {
43
+ if (currentVersion === '') return { state: 'latest', target: null }
44
+ const seen = new Set<string>()
45
+ const candidates: HubUpdateVersion[] = []
46
+ for (const entry of [{ version: info.version, publishedAt: info.publishedAt, notes: info.notes }, ...(info.recentVersions ?? [])]) {
47
+ if (typeof entry.version !== 'string' || entry.version === '' || seen.has(entry.version)) continue
48
+ seen.add(entry.version)
49
+ candidates.push(entry)
50
+ }
51
+ const higher = candidates
52
+ .filter((c) => compareVersions(c.version, currentVersion) > 0)
53
+ .sort((a, b) => compareVersions(b.version, a.version))
54
+ if (higher.length === 0) return { state: 'latest', target: null }
55
+ const ready = higher.find((c) => !isReleaseTooFresh(c.publishedAt, now))
56
+ return ready !== undefined
57
+ ? { state: 'available', target: ready }
58
+ : { state: 'pending', target: higher[0]! }
59
+ }