dsh-plugin 2.1.0 → 2.2.1

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 (35) hide show
  1. package/client/client.js +665 -352
  2. package/client/client.js.map +1 -1
  3. package/lib/http/diagnostics.js +2 -63
  4. package/lib/services/host-version.js +72 -0
  5. package/lib/services/install/dsh-compat.js +105 -0
  6. package/lib/services/install/operation-log.js +1 -1
  7. package/lib/services/install/registry-fallback.js +5 -0
  8. package/lib/services/install/task-queue.js +64 -1
  9. package/lib/services/npm-release-age.js +10 -6
  10. package/lib/services/profile/installed-versions.js +6 -2
  11. package/lib/types/services/host-version.d.ts +10 -0
  12. package/lib/types/services/install/dsh-compat.d.ts +19 -0
  13. package/lib/types/services/install/install-types.d.ts +5 -0
  14. package/lib/types/services/install/registry-fallback.d.ts +2 -0
  15. package/lib/types/services/install/task-queue.d.ts +5 -0
  16. package/lib/types/services/npm-release-age.d.ts +1 -1
  17. package/package.json +1 -1
  18. package/src/client/components/modals/NotificationsModal.tsx +80 -5
  19. package/src/client/components/modals/modals.tsx +84 -5
  20. package/src/client/hooks/useCatalog.ts +45 -25
  21. package/src/client/hooks/useTaskQueue.ts +30 -8
  22. package/src/client/locales.ts +56 -2
  23. package/src/client/logic/failures.ts +98 -7
  24. package/src/client/logic/installed.ts +4 -1
  25. package/src/client/logic/release-age.ts +52 -4
  26. package/src/client/logic/urls.ts +23 -3
  27. package/src/server/http/diagnostics.ts +2 -59
  28. package/src/server/services/host-version.ts +69 -0
  29. package/src/server/services/install/dsh-compat.ts +99 -0
  30. package/src/server/services/install/install-types.ts +5 -0
  31. package/src/server/services/install/operation-log.ts +1 -1
  32. package/src/server/services/install/registry-fallback.ts +5 -0
  33. package/src/server/services/install/task-queue.ts +64 -1
  34. package/src/server/services/npm-release-age.ts +10 -6
  35. package/src/server/services/profile/installed-versions.ts +6 -2
@@ -14,7 +14,7 @@ import type { MouseEvent } from 'react'
14
14
  import styles from '../../styles/Modal.module.css'
15
15
  import type { EnvInfo, Translate } from '../../types.ts'
16
16
  import type { NotificationRecord } from '../../logic/failures.ts'
17
- import { actionOfRecord, classifyFailure, formatFailureMessage, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, registryMissingHintOf, unreachableTargetOf, registryHostOf, authRegistryHostOf } from '../../logic/failures.ts'
17
+ import { actionOfRecord, classifyFailure, formatFailureMessage, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, registryMissingHintOf, unreachableTargetOf, registryHostOf, authRegistryHostOf, exclusiveProfileOf, desktopCliFallbackOf, dshTooOldInfoOf, unresolvedBundleOf, missingLocalDirOf } from '../../logic/failures.ts'
18
18
  import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
19
19
  import { pluginIssueUrl, pluginSiteUrl, hubIssueUrl } from '../../logic/urls.ts'
20
20
  import { CloseIcon } from '../ui/icons.tsx'
@@ -306,6 +306,11 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
306
306
  // 给 .npmrc 豁免指引,不引导提 Issue
307
307
  return h('div', { className: styles.failPrepareHint }, t('failPnpmWorkspaceHint'))
308
308
  }
309
+ if (kind === 'pnpmModulesMismatch') {
310
+ // profile 的 modules 目录与当前 pnpm 配置不一致(hoist / 虚拟目录长度 / node-linker 等):
311
+ // 该 profile 内的安装/卸载都会失败 —— 重建 modules 目录或统一配置即可,不引导提 Issue
312
+ return h('div', { className: styles.failPrepareHint }, t('failPnpmModulesMismatchHint'))
313
+ }
309
314
  if (kind === 'pnpmStore') {
310
315
  // pnpm 存在但 store 大版本不匹配(ERR_PNPM_UNEXPECTED_STORE):profile 目录的依赖是
311
316
  // 另一大版本 pnpm 装的,当前 pnpm 不认,任何插件装进该 profile 都会失败 ——
@@ -322,6 +327,16 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
322
327
  // 发现补丁没被用上就中止安装,任何插件都装不进来 —— 不引导提 Issue
323
328
  return h('div', { className: styles.failPrepareHint }, t('failPnpmUnusedPatchHint'))
324
329
  }
330
+ if (kind === 'profileState') {
331
+ // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
332
+ // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
333
+ // 给重建指引,不引导提 Issue
334
+ return h('div', { className: styles.failPrepareHint }, t('failProfileStateHint'))
335
+ }
336
+ if (kind === 'nodeEngine') {
337
+ // 本机 Node / pnpm 版本低于包声明的下限:升级即可解决 —— 不引导提 Issue
338
+ return h('div', { className: styles.failPrepareHint }, t('failNodeEngineHint'))
339
+ }
325
340
  if (kind === 'pnpmMissingDep') {
326
341
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
327
342
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
@@ -372,10 +387,13 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
372
387
  return h('div', { className: styles.failPrepareHint }, t('failRegistryVersionStaleHint'))
373
388
  }
374
389
  if (kind === 'pnpmLinkedDirMissing') {
375
- // profile 里留着指向不存在目录的本地依赖(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND):
376
- // 常见于 link:/本地路径装过开发中的插件、之后目录被删;与本次要装的插件无关
377
- // —— 给删除悬空条目的指引,不引导提 Issue
378
- return h('div', { className: styles.failPrepareHint }, t('failPnpmLinkedDirMissingHint'))
390
+ // profile 里留着指向不存在目录的本地依赖(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND /
391
+ // pnpm ENOENT lstat):点名缺失目录 + 删除悬空条目,不引导提 Issue
392
+ const dir = missingLocalDirOf(r.message)
393
+ return h('div', null, [
394
+ dir ? h('div', { className: styles.failNetworkTarget }, t('failLinkedDirTarget', { path: dir })) : null,
395
+ h('div', { className: styles.failPrepareHint }, t('failPnpmLinkedDirMissingHint')),
396
+ ])
379
397
  }
380
398
  if (kind === 'fileLocked') {
381
399
  // profile 里的文件被其他进程占用(Windows os error 32):通常是宿主或杀毒软件
@@ -397,6 +415,24 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
397
415
  // 安装流程,与插件无关 —— 引导从本机地址重新打开市场,不引导提 Issue
398
416
  return h('div', { className: styles.failPrepareHint }, t('failOriginRejectedHint', { profile: env?.profile ?? '<profile>' }))
399
417
  }
418
+ if (kind === 'profileExclusive') {
419
+ // desktop profile 只能由桌面应用管理:外部 dsh CLI 被宿主拒绝,属环境约束。
420
+ // 带 [desktop-cli-fallback] 则是安装器没找到应用内 CLI 引导脚本 —— 我们的问题,给反馈入口
421
+ const profile = exclusiveProfileOf(r.message)
422
+ const ourBug = desktopCliFallbackOf(r.message)
423
+ return h('div', null, [
424
+ h('div', { className: styles.failPrepareHint },
425
+ t(profile ? 'failProfileExclusiveHint' : 'failProfileExclusiveHintNoProfile', profile ? { profile } : undefined)),
426
+ ourBug ? h('div', { className: styles.failPrepareHint }, t('failDesktopCliFallbackHint')) : null,
427
+ ourBug ? h('a', {
428
+ className: styles.failBigIssue,
429
+ href: hubIssueUrl(shownRepo || r.repo, r.message, env, r.command, r.attempts, 'triage', action),
430
+ target: '_blank',
431
+ rel: 'noopener noreferrer',
432
+ title: t('failHubIssueHint'),
433
+ }, t('failIssueBig')) : null,
434
+ ])
435
+ }
400
436
  if (kind === 'registryAuth') {
401
437
  // npm 源返回 401/403:私有源需要凭据,或当前网络/IP 不被允许 —— 本机环境问题,
402
438
  // 给「配认证 / 换回官方源 / 切公共镜像」指引,不引导提 Issue
@@ -404,6 +440,38 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
404
440
  return h('div', { className: styles.failPrepareHint },
405
441
  t(host ? 'failRegistryAuthHint' : 'failRegistryAuthHintNoHost', host ? { host } : undefined))
406
442
  }
443
+ if (kind === 'dshTooOld') {
444
+ // 插件要求的 DSH 下限高于本机宿主:升级 DSH 即可,不属作者、也不属插件中心
445
+ const info = dshTooOldInfoOf(r.message)
446
+ return h('div', { className: styles.failPrepareHint },
447
+ t('failDshTooOldHint', info ?? { pkg: shownRepo || r.repo || 'plugin', floor: '?', current: '?' }))
448
+ }
449
+ if (kind === 'localEnv') {
450
+ // 未命中具体成因、但特征指向本机环境:给通用自查清单 + 系统诊断,默认不推给我们,
451
+ // 只留小字反馈入口(判据是我们自己的启发式,可能误判)
452
+ return h('div', null, [
453
+ h('div', { className: styles.failPrepareHint }, t('failLocalEnvHint')),
454
+ onRunDiagnostics ? h('button', { className: styles.failDiagBtn, onClick: onRunDiagnostics }, t('failNetworkRunDiag')) : null,
455
+ h('div', null, [
456
+ h('div', { className: styles.failPrepareHint }, t('failMisjudgeHint')),
457
+ h('a', {
458
+ className: styles.failMisjudgeLink,
459
+ href: hubIssueUrl(shownRepo || r.repo, r.message, env, r.command, r.attempts, 'triage', action),
460
+ target: '_blank',
461
+ rel: 'noopener noreferrer',
462
+ title: t('failHubIssueHint'),
463
+ }, t('failMisjudgeLink')),
464
+ ]),
465
+ ])
466
+ }
467
+ if (kind === 'tlsTrust') {
468
+ // 本机证书链不被信任(公司 SSL 审计 / 杀软 / 抓包代理):给 CA 与镜像的具体处置,
469
+ // 不引导提 Issue —— 换任何插件都会一样失败
470
+ return h('div', null, [
471
+ h('div', { className: styles.failPrepareHint }, t('failTlsTrustHint')),
472
+ onRunDiagnostics ? h('button', { className: styles.failDiagBtn, onClick: onRunDiagnostics }, t('failNetworkRunDiag')) : null,
473
+ ])
474
+ }
407
475
  if (kind === 'network') {
408
476
  // 安装前网络探测 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
409
477
  // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
@@ -447,6 +515,13 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
447
515
  ]) : null,
448
516
  ])
449
517
  }
518
+ if (kind === 'profileBundleMissing') {
519
+ // profile 声明的某个 bundle 没装进 node_modules:宿主在安装前就中止,
520
+ // 与本次插件无关 —— 给「让宿主补装 profile 依赖 / 重装应用」指引,不引导提 Issue
521
+ const info = unresolvedBundleOf(r.message)
522
+ return h('div', { className: styles.failPrepareHint },
523
+ t('failProfileBundleMissingHint', { pkg: info?.pkg ?? 'unknown', profile: env?.profile ?? '<profile>' }))
524
+ }
450
525
  if (kind === 'hubState') {
451
526
  // 插件中心的列表状态已过期(安装器自身守卫消息):非插件问题、也不是 bug,只给可行动提示,不引导提 Issue
452
527
  return h('div', null, [
@@ -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, authRegistryHostOf } from '../../logic/failures.ts'
18
+ import { classifyFailure, formatFailureMessage, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, registryMissingHintOf, unreachableTargetOf, registryHostOf, authRegistryHostOf, exclusiveProfileOf, desktopCliFallbackOf, dshTooOldInfoOf, unresolvedBundleOf, 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 === 'pnpmModulesMismatch'
449
+ // profile 的 modules 目录与当前 pnpm 配置不一致(hoist / 虚拟目录长度 / node-linker 等):
450
+ // 该 profile 内的安装/卸载都会失败 —— 重建 modules 目录或统一配置即可,不引导提 Issue
451
+ ? h('div', { className: styles.failPrepareHint }, t('failPnpmModulesMismatchHint'))
448
452
  : failureKind === 'pnpmStore'
449
453
  // pnpm 存在但 store 大版本不匹配(ERR_PNPM_UNEXPECTED_STORE):profile 目录的依赖是
450
454
  // 另一大版本 pnpm 装的,当前 pnpm 不认,任何插件装进该 profile 都会失败 ——
@@ -458,6 +462,15 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
458
462
  // profile 里留着指向旧版本 dsh-plugin 的补丁声明(ERR_PNPM_UNUSED_PATCH):pnpm 发现
459
463
  // 补丁没被用上就中止整次安装,任何插件都装不进来 —— 给删除条目的指引,不引导提 Issue
460
464
  ? h('div', { className: styles.failPrepareHint }, t('failPnpmUnusedPatchHint'))
465
+ : failureKind === 'profileState'
466
+ // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
467
+ // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
468
+ // 给重建指引,不引导提 Issue
469
+ ? h('div', { className: styles.failPrepareHint }, t('failProfileStateHint'))
470
+ : failureKind === 'nodeEngine'
471
+ // 本机 Node / pnpm 版本低于包声明的下限:升级即可解决,装任何同类插件都会失败 ——
472
+ // 不引导提 Issue
473
+ ? h('div', { className: styles.failPrepareHint }, t('failNodeEngineHint'))
461
474
  : failureKind === 'pnpmMissingDep'
462
475
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
463
476
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
@@ -513,10 +526,16 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
513
526
  // 给「对齐源 / 那一次显式走官方源 / 用离线包」的指引,不引导提 Issue
514
527
  ? h('div', { className: styles.failPrepareHint }, t('failRegistryVersionStaleHint'))
515
528
  : 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'))
529
+ // profile 里留着指向不存在目录的本地依赖(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND / pnpm ENOENT lstat):
530
+ // 常见于 link:/本地路径装过开发中的插件、之后目录被删;pnpm 每次解析 profile 依赖都先卡在它上面,
531
+ // 与本次要装的插件无关 —— 点名缺失目录 + 删除悬空条目的指引,不引导提 Issue
532
+ ? h('div', null, [
533
+ (() => {
534
+ const dir = missingLocalDirOf(message)
535
+ return dir ? h('div', { className: styles.failNetworkTarget }, t('failLinkedDirTarget', { path: dir })) : null
536
+ })(),
537
+ h('div', { className: styles.failPrepareHint }, t('failPnpmLinkedDirMissingHint')),
538
+ ])
520
539
  : failureKind === 'fileLocked'
521
540
  // profile 文件被其他进程占用
522
541
  ? h('div', { className: styles.failPrepareHint }, t('failFileLockedHint'))
@@ -530,6 +549,26 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
530
549
  // 请求来源没通过本地 hub 服务的校验(服务端 403 untrusted origin):请求根本没进
531
550
  // 安装流程,与插件无关 —— 引导从本机地址重新打开市场,不引导提 Issue
532
551
  ? h('div', { className: styles.failPrepareHint }, t('failOriginRejectedHint', { profile: env?.profile ?? '<profile>' }))
552
+ : failureKind === 'profileExclusive'
553
+ // desktop profile 只能由桌面应用管理:外部 dsh CLI 被宿主拒绝,属环境约束。
554
+ // 但日志里有 [desktop-cli-fallback] 时是我们没找到应用内 CLI 引导脚本才退回 PATH
555
+ // —— 那是安装器自身的问题,必须留一条回到插件中心的通道
556
+ ? (() => {
557
+ const profile = exclusiveProfileOf(message)
558
+ const ourBug = desktopCliFallbackOf(message)
559
+ return h('div', null, [
560
+ h('div', { className: styles.failPrepareHint },
561
+ t(profile ? 'failProfileExclusiveHint' : 'failProfileExclusiveHintNoProfile', profile ? { profile } : undefined)),
562
+ ourBug ? h('div', { className: styles.failPrepareHint }, t('failDesktopCliFallbackHint')) : null,
563
+ ourBug ? h('a', {
564
+ className: styles.failBigIssue,
565
+ href: hubIssueUrl(repo, message, env, command, attempts, 'triage', action),
566
+ target: '_blank',
567
+ rel: 'noopener noreferrer',
568
+ title: t('failHubIssueHint'),
569
+ }, t('failIssueBig')) : null,
570
+ ])
571
+ })()
533
572
  : failureKind === 'registryAuth'
534
573
  // npm 源返回 401/403:私有源需要凭据,或当前网络/IP 不被允许 —— 本机环境问题,
535
574
  // 给「配认证 / 换回官方源 / 切公共镜像」指引,不引导提 Issue
@@ -538,6 +577,38 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
538
577
  return h('div', { className: styles.failPrepareHint },
539
578
  t(host ? 'failRegistryAuthHint' : 'failRegistryAuthHintNoHost', host ? { host } : undefined))
540
579
  })()
580
+ : failureKind === 'dshTooOld'
581
+ // 安装器读插件 npm 元数据得出的结论:插件要求的 DSH 下限高于本机宿主。
582
+ // 用户升级 DSH 即可解决,不属插件作者、也不属插件中心 —— 不引导提 Issue
583
+ ? (() => {
584
+ const info = dshTooOldInfoOf(message)
585
+ return h('div', { className: styles.failPrepareHint },
586
+ t('failDshTooOldHint', info ?? { pkg: repo ?? 'plugin', floor: '?', current: '?' }))
587
+ })()
588
+ : failureKind === 'localEnv'
589
+ // 未命中具体成因、但特征指向本机环境:给通用自查清单 + 直达系统诊断,不推给作者,
590
+ // 也默认不推给我们 —— 只留小字反馈入口,防环境问题堆进插件中心的 issue 列表
591
+ ? h('div', null, [
592
+ h('div', { className: styles.failPrepareHint }, t('failLocalEnvHint')),
593
+ onRunDiagnostics ? h('button', { className: styles.failDiagBtn, onClick: onRunDiagnostics }, t('failNetworkRunDiag')) : null,
594
+ h('div', null, [
595
+ h('div', { className: styles.failPrepareHint }, t('failMisjudgeHint')),
596
+ h('a', {
597
+ className: styles.failMisjudgeLink,
598
+ href: hubIssueUrl(repo, message, env, command, attempts, 'triage', action),
599
+ target: '_blank',
600
+ rel: 'noopener noreferrer',
601
+ title: t('failHubIssueHint'),
602
+ }, t('failMisjudgeLink')),
603
+ ]),
604
+ ])
605
+ : failureKind === 'tlsTrust'
606
+ // 本机证书链不被信任(公司 SSL 审计 / 杀软 / 抓包代理):给 CA 与镜像的具体处置,
607
+ // 不引导提 Issue —— 换任何插件都会一样失败
608
+ ? h('div', null, [
609
+ h('div', { className: styles.failPrepareHint }, t('failTlsTrustHint')),
610
+ onRunDiagnostics ? h('button', { className: styles.failDiagBtn, onClick: onRunDiagnostics }, t('failNetworkRunDiag')) : null,
611
+ ])
541
612
  : failureKind === 'network'
542
613
  // 安装前网络探测 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
543
614
  // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
@@ -580,6 +651,14 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
580
651
  }, t('failMisjudgeLink')),
581
652
  ]) : null,
582
653
  ])
654
+ : failureKind === 'profileBundleMissing'
655
+ // profile 声明的某个 bundle 没装进 node_modules:宿主在安装前就中止,
656
+ // 与本次插件无关 —— 给「让宿主补装 profile 依赖 / 重装应用」指引,不引导提 Issue
657
+ ? (() => {
658
+ const info = unresolvedBundleOf(message)
659
+ return h('div', { className: styles.failPrepareHint },
660
+ t('failProfileBundleMissingHint', { pkg: info?.pkg ?? 'unknown', profile: env?.profile ?? '<profile>' }))
661
+ })()
583
662
  : failureKind === 'hubState'
584
663
  ? h('div', null, [
585
664
  // 插件中心的列表状态已过期(安装器自身守卫消息):不是插件问题、也不是 bug,只给可行动提示,不引导提 Issue
@@ -15,8 +15,8 @@ import type { HubAboutInfo, HubUpdateInfo, HubUpdateState } from '../types.ts'
15
15
  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
- import { fetchInstalled, fetchNpmReleaseAge } from '../data/host.ts'
19
- import { isReleaseTooFresh, pickHubUpdate } from '../logic/release-age.ts'
18
+ import { fetchInstalled, fetchNpmReleaseAge, type NpmReleaseInfo } from '../data/host.ts'
19
+ import { isReleaseTooFresh, isUpdateOffered, pickHubUpdate } from '../logic/release-age.ts'
20
20
  import {
21
21
  hasUpdateOf, installedItemsOf, installedNameOf, installedVersionOf, npmPackageOf,
22
22
  } from '../logic/installed.ts'
@@ -25,8 +25,8 @@ import type { InstalledItem, InstalledVersionSignal } from '../logic/installed.t
25
25
  /** 插件市场自身仓库:DSH Plugin Hub 不显示在目录里(自己不进自己的插件列表) */
26
26
  const SELF_REPO = 'dshplugin/dsh-plugin-hub'
27
27
 
28
- /** 共享的空门禁集合:无暂缓包时复用,避免每次渲染新建 Set。 */
29
- const EMPTY_PACKAGES: ReadonlySet<string> = new Set<string>()
28
+ /** 共享的空发布信息表:无候选包时复用,避免每次渲染新建对象。 */
29
+ const EMPTY_RELEASES: Record<string, NpmReleaseInfo | null> = {}
30
30
 
31
31
  /** 市场各排序的默认方向:全部按倒序(Star/Fork 多、更新/收录近的在前) */
32
32
  const SORT_DEFAULT_DIR: Record<SortKey, 'asc' | 'desc'> = {
@@ -190,18 +190,18 @@ export function useCatalog(lang: LocaleId) {
190
190
  const installedVersion = (p: HubPlugin): string | null => installedVersionOf(p, versions)
191
191
 
192
192
  /**
193
- * 供应链门禁状态:包名集合为当前「暂缓提示」的 npm 包(最新版本发布未满门槛)。
193
+ * 更新可用性校验状态:npm registry 上每个包的最新可安装版本与发布时间。
194
194
  * key 是这批候选包名的指纹,与已查出的 key 一致才算就绪,避免查询未回来时先显示更新。
195
195
  */
196
- const [releaseAge, setReleaseAge] = useState<{ key: string; held: ReadonlySet<string> }>({ key: '', held: EMPTY_PACKAGES })
196
+ const [releaseAge, setReleaseAge] = useState<{ key: string; releases: Record<string, NpmReleaseInfo | null> }>({ key: '', releases: EMPTY_RELEASES })
197
197
 
198
- /** 已安装项原始列表(未套门禁):先算出来才能收集需要查发布时间的候选包名。 */
198
+ /** 已安装项原始列表(未套校验):先算出来才能收集需要查发布信息的候选包名。 */
199
199
  const rawInstalledItems = useMemo<InstalledItem[]>(
200
200
  () => installedItemsOf(plugins, installed, versions, installPaths, loadedNames, dshCapableNames),
201
201
  [plugins, installed, versions, installPaths, loadedNames, dshCapableNames],
202
202
  )
203
203
 
204
- /** 门禁候选:有更新、且有 npm 包名(更新走 npm registry)的已安装插件包名。 */
204
+ /** 校验候选:有更新、且有 npm 包名(更新走 npm registry)的已安装插件包名。 */
205
205
  const releaseAgeCandidates = useMemo(() => {
206
206
  const names = new Set<string>()
207
207
  for (const item of rawInstalledItems) {
@@ -212,53 +212,73 @@ export function useCatalog(lang: LocaleId) {
212
212
  return [...names].sort()
213
213
  }, [rawInstalledItems, versions])
214
214
  const releaseAgeKey = releaseAgeCandidates.join('\n')
215
+ /** 包名 → 已装版本:判断 npm 上最新版是否真的比已装版本新。 */
216
+ const installedByPackage = useMemo(() => {
217
+ const map = new Map<string, string>()
218
+ for (const item of rawInstalledItems) {
219
+ if (!item.plugin) continue
220
+ const pkg = npmPackageOf(item.plugin, versions)
221
+ const version = installedVersionOf(item.plugin, versions)
222
+ if (pkg !== null && version !== null) map.set(pkg, version)
223
+ }
224
+ return map
225
+ }, [rawInstalledItems, versions])
215
226
 
216
227
  useEffect(() => {
217
228
  if (releaseAgeKey === releaseAge.key) return
218
229
  if (releaseAgeCandidates.length === 0) {
219
- setReleaseAge({ key: '', held: EMPTY_PACKAGES })
230
+ setReleaseAge({ key: '', releases: EMPTY_RELEASES })
220
231
  return
221
232
  }
222
233
  let cancelled = false
223
- // 批量一次查询;失败/返回 null 时 held 为空 → 按原逻辑照常提示,网络问题不吃掉真实更新
234
+ // 批量一次查询;失败/返回 null 时按原逻辑照常提示,网络问题不吃掉真实更新
224
235
  void fetchNpmReleaseAge(releaseAgeCandidates).then((result) => {
225
236
  if (cancelled) return
226
- const now = Date.now()
227
- const held = new Set<string>()
228
- for (const name of releaseAgeCandidates) {
229
- const info = result?.[name]
230
- if (info && isReleaseTooFresh(info.publishedAt, now)) held.add(name)
231
- }
232
- setReleaseAge({ key: releaseAgeKey, held })
237
+ setReleaseAge({ key: releaseAgeKey, releases: result ?? EMPTY_RELEASES })
233
238
  })
234
239
  return () => { cancelled = true }
235
240
  }, [releaseAgeKey, releaseAge.key])
236
241
 
237
- /** 门禁结果是否已就绪(候选为空也算就绪):未就绪时不写更新提醒,等查询回来再定。 */
242
+ /** 校验结果是否已就绪(候选为空也算就绪):未就绪时不写更新提醒,等查询回来再定。 */
238
243
  const releaseAgeReady = releaseAgeKey === releaseAge.key
239
- const heldPackages = releaseAgeReady ? releaseAge.held : EMPTY_PACKAGES
244
+
245
+ /**
246
+ * 不能提示更新的包:
247
+ * - npm 上最新版还没过供应链门禁(发布不满 24h,pnpm 会静默回退,点了也装不上);
248
+ * - npm 上最新版并不高于已装版本 —— 更新走的是 `pkg@latest`,此时无论目录标到哪个 tag,
249
+ * 都不可能装上(作者可能只打了 GitHub release 没发 npm),提示只会变成永远点不掉的循环。
250
+ */
251
+ const updateBlocked = useMemo(() => {
252
+ const blocked = new Set<string>()
253
+ if (!releaseAgeReady) return blocked
254
+ const now = Date.now()
255
+ for (const pkg of releaseAgeCandidates) {
256
+ if (!isUpdateOffered(releaseAge.releases[pkg] ?? null, installedByPackage.get(pkg) ?? null, now)) blocked.add(pkg)
257
+ }
258
+ return blocked
259
+ }, [releaseAgeReady, releaseAgeCandidates, releaseAge.releases, installedByPackage])
240
260
 
241
261
  /**
242
262
  * 是否有更新:仅对已安装插件有意义。
243
263
  * 双信号判定——有 release 版本的比版本;无版本(repo 不打 tag)的比仓库最近更新时间
244
264
  * (repoUpdatedAt,ISO 字符串字典序 = 时间序),更新时间变新说明有新提交。
245
- * 另加供应链门禁:npm 包最新版本发布未满门槛时视为暂缓,不打更新标记。
265
+ * 再用 npm registry 的实际最新版本复核:装不上的(太新 / 不比已装新)不打更新标记。
246
266
  */
247
267
  const hasUpdate = (p: HubPlugin): boolean => {
248
268
  if (!hasUpdateOf(p, installed, versions)) return false
249
269
  const pkg = npmPackageOf(p, versions)
250
- return pkg === null || !heldPackages.has(pkg)
270
+ return pkg === null || !updateBlocked.has(pkg)
251
271
  }
252
272
 
253
- /** 已安装项统一列表(目录元数据 + 运行时信息合并):门禁命中的去掉更新标记,驱动「已安装」tab。 */
273
+ /** 已安装项统一列表(目录元数据 + 运行时信息合并):被拦下的去掉更新标记,驱动「已安装」tab。 */
254
274
  const installedItems = useMemo<InstalledItem[]>(() => {
255
- if (heldPackages.size === 0) return rawInstalledItems
275
+ if (updateBlocked.size === 0) return rawInstalledItems
256
276
  return rawInstalledItems.map((item) => {
257
277
  if (!item.hasUpdate || !item.plugin) return item
258
278
  const pkg = npmPackageOf(item.plugin, versions)
259
- return pkg !== null && heldPackages.has(pkg) ? { ...item, hasUpdate: false } : item
279
+ return pkg !== null && updateBlocked.has(pkg) ? { ...item, hasUpdate: false } : item
260
280
  })
261
- }, [rawInstalledItems, heldPackages, versions])
281
+ }, [rawInstalledItems, updateBlocked, versions])
262
282
 
263
283
  /**
264
284
  * Hub 自身更新状态:Hub 就是当前运行的应用(始终已安装,无需 installedName 命中)。
@@ -36,6 +36,8 @@ export interface QueueTask {
36
36
  /** 安装时的目录信号快照(版本 + 仓库更新时间):任务成功后在本地记录,供「有更新」比对 */
37
37
  version?: string
38
38
  updatedAt?: string
39
+ /** 服务端在成功终态带出的**实际**安装版本(读 profile 的 node_modules):优先于目录版本写账本 */
40
+ installedVersion?: string
39
41
  /** pending 排队中 / running 执行中 / cancelling 取消中(客户端乐观过渡态) */
40
42
  status: 'pending' | 'running' | 'cancelling'
41
43
  progress: number
@@ -186,20 +188,36 @@ export function useTaskQueue(opts: TaskQueueOptions) {
186
188
  setPendingRestarts(next)
187
189
  }
188
190
 
189
- /** 向宿主记录某插件的安装时目录信号(version + updatedAt);version 为空时清除记录(卸载)。 */
191
+ /** 向宿主记录某插件的安装时目录信号(version + updatedAt)。
192
+ * 版本未知时**不写**:服务端把空版本当作「卸载清理」会删掉整条记录(连 npm 包名映射一起),
193
+ * 刷新页面后任务丢了快照就会走到这条路径 —— 宁可保留旧记录,也不能把账本清空。 */
190
194
  const syncInstalledVersion = async (repo: string | null, version: string | undefined, updatedAt: string | undefined) => {
191
- if (!repo) return
195
+ if (!repo || version === undefined || version === '') return
192
196
  try {
193
197
  await fetch('/dsh-plugin-hub/installed-version', {
194
198
  method: 'POST',
195
199
  headers: { 'content-type': 'application/json' },
196
- body: JSON.stringify({ repo, version: version ?? null, updatedAt }),
200
+ body: JSON.stringify({ repo, version, updatedAt }),
197
201
  })
198
202
  } catch {
199
203
  // 宿主未挂本地路由:静默忽略,仅本机展示受影响
200
204
  }
201
205
  }
202
206
 
207
+ /** 清除某仓库的版本记录(卸载成功后调用):空版本是服务端的「删除记录」语义,只在这里显式发送。 */
208
+ const clearInstalledVersion = async (repo: string | null) => {
209
+ if (!repo) return
210
+ try {
211
+ await fetch('/dsh-plugin-hub/installed-version', {
212
+ method: 'POST',
213
+ headers: { 'content-type': 'application/json' },
214
+ body: JSON.stringify({ repo, version: null }),
215
+ })
216
+ } catch {
217
+ // 同上:清理失败不影响卸载结果
218
+ }
219
+ }
220
+
203
221
  /** 后台任务结束(active 中消失后查终态确认):完成 → 刷新安装表 + 结果视图/toast;失败 → 完整错误弹窗 */
204
222
  const finishQueueTask = (ok: boolean, q: QueueTask, lines: string[]) => {
205
223
  applyQueue((prev) => prev.filter((x) => x.id !== q.id))
@@ -207,13 +225,15 @@ export function useTaskQueue(opts: TaskQueueOptions) {
207
225
  refreshInstalled()
208
226
  if (q.kind === 'uninstall') {
209
227
  // 卸载成功:清掉本地版本记录
210
- void syncInstalledVersion(q.repo, undefined, undefined)
228
+ void clearInstalledVersion(q.repo)
211
229
  onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.needsReload ?? false)
212
230
  } else {
213
231
  // 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
214
232
  // 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
215
- void syncInstalledVersion(q.repo, q.version, q.updatedAt)
216
- pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
233
+ // 账本记**实际装上的版本**:目录版本可能滞后,抄目录字段会让「有更新」永久失真
234
+ const recordedVersion = q.installedVersion ?? q.version
235
+ void syncInstalledVersion(q.repo, recordedVersion, q.updatedAt)
236
+ pendingInfoRef.current.set(q.target, { desc: q.desc, version: recordedVersion })
217
237
  onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.action === 'update', q.needsReload ?? false, q.bundleDeselected === true)
218
238
  }
219
239
  } else {
@@ -260,21 +280,23 @@ export function useTaskQueue(opts: TaskQueueOptions) {
260
280
  let lines: string[] = []
261
281
  let attempts: string[] | undefined
262
282
  let bundleDeselected: boolean | undefined
283
+ let installedVersion: string | undefined
263
284
  try {
264
285
  const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
265
286
  if (res.ok) {
266
- const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown; needsReload?: unknown; bundleDeselected?: unknown } }
287
+ const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown; needsReload?: unknown; bundleDeselected?: unknown; installedVersion?: unknown } }
267
288
  const task = data.task
268
289
  if (task !== undefined) {
269
290
  status = task.status ?? 'failed'
270
291
  lines = task.lines ?? []
271
292
  attempts = Array.isArray(task.attempts) ? task.attempts : undefined
272
293
  bundleDeselected = task.bundleDeselected === true ? true : undefined
294
+ installedVersion = typeof task.installedVersion === 'string' && task.installedVersion !== '' ? task.installedVersion : undefined
273
295
  // 终态带出的「是否需要重启 / 是否只需刷新」覆盖本地(服务端 done 时按 loader 热挂/即时移除情况设置)
274
296
  const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
275
297
  const needsReload = typeof task.needsReload === 'boolean' ? task.needsReload : (q.needsReload ?? false)
276
298
  if (status === 'done') {
277
- const settled = { ...q, needsRestart, needsReload, bundleDeselected: bundleDeselected === true ? true : q.bundleDeselected }
299
+ const settled = { ...q, needsRestart, needsReload, bundleDeselected: bundleDeselected === true ? true : q.bundleDeselected, installedVersion }
278
300
  // 卸载成功走进度过渡动画;安装成功直接收尾
279
301
  if (q.kind === 'uninstall') settleDone(settled, lines)
280
302
  else finishQueueTask(true, settled, lines)