dsh-plugin 2.3.1 → 2.3.3

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 (84) hide show
  1. package/README.en.md +15 -21
  2. package/README.md +15 -21
  3. package/client/client.js +412 -382
  4. package/client/client.js.map +1 -1
  5. package/lib/http/catalog.js +4 -4
  6. package/lib/http/diagnostics.js +4 -4
  7. package/lib/http/install.js +21 -10
  8. package/lib/http/npm-release-age.js +9 -3
  9. package/lib/http/shared.js +1 -1
  10. package/lib/services/host-version.js +2 -2
  11. package/lib/services/install/dsh-compat.js +1 -1
  12. package/lib/services/install/npm-resolve.js +1 -1
  13. package/lib/services/install/operation-log.js +3 -3
  14. package/lib/services/install/preflight.js +1 -1
  15. package/lib/services/install/registry-fallback.js +3 -3
  16. package/lib/services/install/release-target.js +2 -2
  17. package/lib/services/install/task-queue.js +35 -20
  18. package/lib/services/loader.js +8 -8
  19. package/lib/services/npm-release-age.js +46 -9
  20. package/lib/services/probe.js +12 -12
  21. package/lib/services/profile/installed-versions.js +2 -2
  22. package/lib/services/profile/profile.js +2 -2
  23. package/lib/types/services/host-version.d.ts +1 -1
  24. package/lib/types/services/install/install-types.d.ts +5 -5
  25. package/lib/types/services/install/operation-log.d.ts +1 -1
  26. package/lib/types/services/install/preflight.d.ts +1 -1
  27. package/lib/types/services/install/registry-fallback.d.ts +1 -1
  28. package/lib/types/services/install/release-target.d.ts +1 -1
  29. package/lib/types/services/install/task-queue.d.ts +10 -7
  30. package/lib/types/services/loader.d.ts +5 -5
  31. package/lib/types/services/npm-release-age.d.ts +14 -2
  32. package/lib/types/services/probe.d.ts +4 -4
  33. package/package.json +1 -1
  34. package/src/client/components/PluginHubSection.tsx +59 -28
  35. package/src/client/components/catalog/CatalogList.tsx +1 -1
  36. package/src/client/components/catalog/PluginCard.tsx +3 -3
  37. package/src/client/components/modals/AboutModal.tsx +2 -2
  38. package/src/client/components/modals/ConfirmDialog.tsx +2 -2
  39. package/src/client/components/modals/HubUpdateModal.tsx +3 -3
  40. package/src/client/components/modals/InstalledDetailModal.tsx +1 -1
  41. package/src/client/components/modals/LogsPathModal.tsx +1 -1
  42. package/src/client/components/modals/NotificationsModal.tsx +2 -2
  43. package/src/client/components/modals/modals.tsx +13 -11
  44. package/src/client/components/views/CustomInstallView.tsx +2 -2
  45. package/src/client/components/views/InstalledView.tsx +2 -2
  46. package/src/client/components/views/SettingsView.tsx +3 -3
  47. package/src/client/data/catalog.ts +1 -1
  48. package/src/client/data/host.ts +3 -1
  49. package/src/client/data/hub.ts +3 -3
  50. package/src/client/hooks/useCatalog.ts +14 -14
  51. package/src/client/hooks/useSettings.ts +2 -2
  52. package/src/client/hooks/useTaskQueue.ts +31 -24
  53. package/src/client/locales.ts +21 -21
  54. package/src/client/logic/constants.ts +1 -1
  55. package/src/client/logic/ensure-plugin-css.ts +2 -2
  56. package/src/client/logic/failures.ts +9 -9
  57. package/src/client/logic/install-command.ts +5 -3
  58. package/src/client/logic/installed.ts +1 -1
  59. package/src/client/logic/release-age.ts +10 -10
  60. package/src/client/logic/urls.ts +3 -3
  61. package/src/client/styles/Header.module.css +1 -1
  62. package/src/client/styles/Modal.module.css +2 -2
  63. package/src/client/styles/SectionTabs.module.css +1 -1
  64. package/src/client/styles/tokens.module.css +1 -1
  65. package/src/client/types.ts +4 -4
  66. package/src/server/http/catalog.ts +4 -4
  67. package/src/server/http/diagnostics.ts +4 -4
  68. package/src/server/http/install.ts +20 -10
  69. package/src/server/http/npm-release-age.ts +9 -3
  70. package/src/server/http/shared.ts +1 -1
  71. package/src/server/services/host-version.ts +2 -2
  72. package/src/server/services/install/dsh-compat.ts +1 -1
  73. package/src/server/services/install/install-types.ts +5 -5
  74. package/src/server/services/install/npm-resolve.ts +1 -1
  75. package/src/server/services/install/operation-log.ts +3 -3
  76. package/src/server/services/install/preflight.ts +1 -1
  77. package/src/server/services/install/registry-fallback.ts +3 -3
  78. package/src/server/services/install/release-target.ts +2 -2
  79. package/src/server/services/install/task-queue.ts +40 -26
  80. package/src/server/services/loader.ts +10 -10
  81. package/src/server/services/npm-release-age.ts +50 -10
  82. package/src/server/services/probe.ts +12 -12
  83. package/src/server/services/profile/installed-versions.ts +2 -2
  84. package/src/server/services/profile/profile.ts +2 -2
@@ -148,7 +148,7 @@ function pushLine(task: InstallTask, line: string): void {
148
148
  if (estimate > 0) {
149
149
  task.progress = Math.max(task.progress, estimate)
150
150
  } else if (task.status === 'running' && task.progress < 85) {
151
- // 兜底:行无解析格式也随输出量推进,保证进度条始终在动(不会卡在 0)
151
+ // 补充:行无解析格式也随输出量推进,保证进度条始终在动(不会卡在 0)
152
152
  task.progress = Math.max(task.progress, 6 + Math.min(79, task.lines.length * 2))
153
153
  }
154
154
  }
@@ -220,7 +220,7 @@ export function desktopCliBootstrap(entry: string): string | null {
220
220
  }
221
221
 
222
222
  /**
223
- * 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
223
+ * 传给 CLI 的安装目标:锁定版本优先(Hub 自我更新提示的是已过发布冷却期的版本),
224
224
  * 其次 npm 的 add/update 都显式 `@latest`;git 源、全局安装、已带版本/tag 的 spec 保持原样。
225
225
  */
226
226
  export function npmInstallTargetOf(target: string, opts: { update: boolean; global: boolean; pinned?: string; add?: boolean }): string {
@@ -231,6 +231,13 @@ export function npmInstallTargetOf(target: string, opts: { update: boolean; glob
231
231
  return `${target}@latest`
232
232
  }
233
233
 
234
+ /** 重复卸载:目标已不在 profile 依赖里,pnpm 报「没有这个依赖」。
235
+ * 结果与卸载成功等价 —— 按失败上报会弹出错误并自动提一条不存在的 Issue。 */
236
+ export function isAlreadyRemoved(action: 'add' | 'remove' | 'update', output: string): boolean {
237
+ if (action !== 'remove') return false
238
+ return /ERR_PNPM_CANNOT_REMOVE_MISSING_DEPS|no such dependency found/i.test(output)
239
+ }
240
+
234
241
  function stopChild(child: ReturnType<typeof spawn>): void {
235
242
  if (process.platform === 'win32' && child.pid !== undefined) {
236
243
  const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
@@ -280,7 +287,7 @@ export function startPluginMutation(options: {
280
287
  globalNpm?: string[]
281
288
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
282
289
  displayTarget?: string
283
- /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
290
+ /** 运行中 loader:安装成功后热加载新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
284
291
  * (缺失时安装/卸载仍需重启) */
285
292
  loader?: LoaderHandle
286
293
  /** 入队前已尝试的安装方式:失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
@@ -290,10 +297,10 @@ export function startPluginMutation(options: {
290
297
  /** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
291
298
  * 目录插件安装不传 */
292
299
  installChannel?: 'npm'
293
- /** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
300
+ /** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不换源 */
294
301
  registries?: string[]
295
- /** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
296
- * 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
302
+ /** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过发布冷却期的版本,
303
+ * 必须锁定安装,不能再用 @latest 被带回冷却期内的最新版 */
297
304
  installSpec?: string
298
305
  }): InstallTask {
299
306
  // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
@@ -414,7 +421,7 @@ function spawnMutation(options: {
414
421
  task?: InstallTask
415
422
  /** 待重启行的展示目标(owner/repo):与 mutation 目标解耦——卸载的 mutation 目标是 npm 包名,展示要回退到仓库名 */
416
423
  displayTarget?: string
417
- /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
424
+ /** 运行中 loader:安装成功后热加载新条目、卸载成功后停用旧条目(缺失时仍需重启) */
418
425
  loader?: LoaderHandle
419
426
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
420
427
  globalNpm?: string[]
@@ -439,10 +446,10 @@ function spawnMutation(options: {
439
446
  }
440
447
  // 安装/更新 npm 包都显式 @latest:pnpm add <pkg>(无版本)对已存在的精确 spec 是幂等的,
441
448
  // 会原样留用旧版本(更新不生效,普通安装也可能装回不支持当前宿主的旧包)。
442
- // update 动作同样执行 `add <target>@latest`,CLI 动词透传 add,不与官方 update 动词分叉。
449
+ // update 动作同样执行 `add <target>@latest`,CLI 动词原样传递 add,不与官方 update 动词分叉。
443
450
  const isUpdate = action === 'update' || updateNpm === true
444
451
  const cliAction = action === 'update' ? 'add' : action
445
- // 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
452
+ // 锁定版本优先于 @latest:提示的是「已过冷却期」的版本,装 latest 会被 pnpm 拒绝
446
453
  const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec, add: cliAction === 'add' })
447
454
  const args = isGlobalNpm
448
455
  ? ['install', '-g', ...(globalNpm ?? [])]
@@ -454,7 +461,7 @@ function spawnMutation(options: {
454
461
  if (task && !task.attempts.includes(executed)) {
455
462
  task.attempts.push(executed)
456
463
  }
457
- // 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
464
+ // 单次 spawn 并捕获结果:流式输出打日志 + 超时、取消时收尾,close 后 resolve。
458
465
  // 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
459
466
  // 这样全局 npm 首次失败后可以干净地换参数重试一次。
460
467
  const spawnOnce = (attemptArgs: string[], registry?: string): Promise<InstallResult> => new Promise((resolveOnce) => {
@@ -497,7 +504,7 @@ function spawnMutation(options: {
497
504
  }
498
505
  }
499
506
  // invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
500
- // 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
507
+ // 没有它时保持 env 原样传递,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
501
508
  // registry 最后写入:换源重试只改这一层,pnpm/npm 都认 npm_config_registry。
502
509
  const childEnv: NodeJS.ProcessEnv = { ...(env ?? process.env), ...(invocation.env ?? {}) }
503
510
  if (registry !== undefined) {
@@ -534,13 +541,13 @@ function spawnMutation(options: {
534
541
  }
535
542
  if (task) runningChildren.set(task.id, child)
536
543
 
537
- // 兜底定时器句柄:整段超时 / exit 后 close 宽限 / 强制收尾
544
+ // 最终超时定时器句柄:整段超时 / exit 后 close 宽限 / 强制收尾
538
545
  let timer: ReturnType<typeof setTimeout> | undefined
539
546
  let progressTimer: ReturnType<typeof setInterval> | undefined
540
547
  let exitWatch: ReturnType<typeof setTimeout> | undefined
541
548
  let forceSettle: ReturnType<typeof setTimeout> | undefined
542
549
 
543
- // 唯一收尾入口:close / error / 兜底超时 三者只放行一次,确保任务持续推进
550
+ // 唯一收尾入口:close / error / 最终超时 三者只放行一次,确保任务持续推进
544
551
  const settleOnce = (result: InstallResult): void => {
545
552
  if (settled) return
546
553
  settled = true
@@ -556,7 +563,7 @@ function spawnMutation(options: {
556
563
  timedOut = true
557
564
  stopChild(child)
558
565
  }, timeoutMs)
559
- // 时间兜底:即使 CLI 静默不吐进度行,进度条也单调推进,不会卡在 0
566
+ // 时间补充:即使 CLI 悄悄不吐进度行,进度条也单调推进,不会卡在 0
560
567
  progressTimer = setInterval(() => {
561
568
  if (task && task.status === 'running' && task.progress < 85) task.progress += 1
562
569
  }, 500)
@@ -565,7 +572,7 @@ function spawnMutation(options: {
565
572
  if (kind === 'stdout') stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES)
566
573
  else stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES)
567
574
  if (task) {
568
- // 原生透传 pnpm/npm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
575
+ // 原生原样传递 pnpm/npm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
569
576
  for (const line of text.split(/\r?\n|\r/)) {
570
577
  if (line.trim() !== '') pushLine(task, line.trimEnd())
571
578
  }
@@ -582,7 +589,7 @@ function spawnMutation(options: {
582
589
  if (settled) return
583
590
  if (task && task.status !== 'cancelled') pushLine(task, '[stale pipe] force closing child')
584
591
  stopChild(child)
585
- // 清理后极端情况下 close 仍未触发(kill 失败):再兜底强制收尾
592
+ // 清理后极端情况下 close 仍未触发(kill 失败):再强制收尾一次
586
593
  forceSettle = setTimeout(() => {
587
594
  if (settled) return
588
595
  if (task && task.status !== 'cancelled') {
@@ -642,7 +649,7 @@ function spawnMutation(options: {
642
649
  if (task) pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps')
643
650
  result = await spawnOnce(retryArgs, activeRegistry)
644
651
  }
645
- // 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
652
+ // 超时被强杀 ≠ 没装上:命中的安装可能已经写入磁盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
646
653
  // 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
647
654
  // 就按成功收尾,避免「其实装好了却报安装失败」并被自动归档成未知失败。
648
655
  if (result.timedOut && !wasInstalled && action !== 'remove' && !isGlobalNpm && task?.status !== 'cancelled') {
@@ -652,6 +659,13 @@ function spawnMutation(options: {
652
659
  if (task) pushLine(task, '[timeout] the install had already completed — treating it as a success')
653
660
  }
654
661
  }
662
+ // 重复卸载:依赖已经不在 profile 里,pnpm 拒绝移除并报「没有这个依赖」。
663
+ // 与卸载成功同义(用户要的结果已达成),按成功收尾,避免误报失败、误提 Issue。
664
+ if (!result.timedOut && task?.status !== 'cancelled'
665
+ && isAlreadyRemoved(action, `${result.stderr}\n${result.stdout}`)) {
666
+ result = { ...result, exitCode: 0, error: null }
667
+ if (task) pushLine(task, '[already removed] the package is no longer in the profile — treating it as uninstalled')
668
+ }
655
669
  // 统一终态后处理:exitCode/status/needsRestart 以最后一次尝试为准
656
670
  if (task) {
657
671
  task.exitCode = result.exitCode
@@ -676,17 +690,17 @@ function spawnMutation(options: {
676
690
  } else if (action === 'remove' && loader) {
677
691
  // 卸载成功 → 主动从运行中 loader 停用该包条目:
678
692
  // - 停用成功:服务端即时卸载;带客户端 UI 的插件面板要页面刷新才摘除(无需重启宿主)
679
- // - 停用失败 / 超时:回退「待重启」兜底清理(刷新解决不了,登记常驻提醒)
693
+ // - 停用失败 / 超时:回退「待重启」提醒(刷新解决不了,登记常驻提醒)
680
694
  const removal = await removeLoadedEntry(loader, target)
681
695
  task.needsRestart = !removal.ok
682
696
  task.needsReload = removal.ok && removal.live && task.hadClientUi === true
683
697
  if (task.needsRestart) addPendingRestart(displayTarget ?? target, 'uninstall')
684
698
  else clearPendingRestart(displayTarget ?? target)
685
699
  } else if (action !== 'remove' && !isUpdate && loader) {
686
- // 安装成功 → 尝试把新插件热挂进运行中 loader,让插件无需重启宿主即生效。
700
+ // 安装成功 → 尝试把新插件热加载进运行中 loader,让插件无需重启宿主即生效。
687
701
  // 挂上了:服务端即时生效;带客户端 UI 的插件还需页面重新加载(宿主按请求现场
688
702
  // 拼装 __DSH_BOOT__ 注入清单,网页端与桌面端一致,刷新即可)。
689
- // 挂不上 / 非 dsh 插件 / 无法定位包名:回退「待重启」提醒(保证不静默失效)。
703
+ // 挂不上 / 非 dsh 插件 / 无法定位包名:回退「待重启」提醒(保证不会无声失效)。
690
704
  const { name } = verifyInstalledEntry(profile, target)
691
705
  if (name !== null && isDshPlugin(profile, name) && await mountLoadedEntry(loader, name)) {
692
706
  task.needsReload = hasClientUi(profile, name)
@@ -696,7 +710,7 @@ function spawnMutation(options: {
696
710
  addPendingRestart(displayTarget ?? target, 'install')
697
711
  }
698
712
  } else {
699
- // 更新(ESM 缓存下热挂取不到新代码)/ 无 loader 的安装或卸载 → 登记待重启:
713
+ // 更新(ESM 缓存下热加载取不到新代码)/ 无 loader 的安装或卸载 → 登记待重启:
700
714
  // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
701
715
  task.needsRestart = true
702
716
  addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install')
@@ -708,7 +722,7 @@ function spawnMutation(options: {
708
722
  }
709
723
  }
710
724
  // 带上最终生效的源:后续注解(如 [dsh-too-old])读元数据必须与安装同源,
711
- // 否则换过源之后会去另一个源查,查不到就静默漏报
725
+ // 否则换过源之后会去另一个源查,查不到就漏报
712
726
  return activeRegistry === undefined ? result : { ...result, registry: activeRegistry }
713
727
  })()
714
728
  }
@@ -854,7 +868,7 @@ async function annotateDshTooOld(result: InstallResult, options: {
854
868
  // 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
855
869
  if ((options.globalNpm ?? []).length > 0) return result
856
870
  // 用安装**实际**使用的源读元数据:换源重试后候选表首位可能并不是真正生效的那个,
857
- // 用错源会读不到包而静默漏报;私有源/镜像里同样能读到包声明的 engines
871
+ // 用错源会读不到包而漏报;私有源/镜像里同样能读到包声明的 engines
858
872
  const line = await dshTooOldMarker(options.target, { registry: result.registry ?? options.registries?.[0] })
859
873
  if (line === null) return result
860
874
  if (options.task && options.task.status !== 'cancelled') pushLine(options.task, line)
@@ -873,7 +887,7 @@ export function hostSpawnMarker(invocation: Invocation, error: unknown): string
873
887
  }
874
888
 
875
889
  /**
876
- * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 门禁拦下构建脚本而失败 ——
890
+ * 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 限制拦下构建脚本而失败 ——
877
891
  * 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
878
892
  * key@url 提示),或是某依赖的原生构建(`ERR_PNPM_IGNORED_BUILDS`,key = 列出的 name@version)——
879
893
  * 就把确切的 key 写进 profile 的 pnpm-workspace.yaml 并重试一次。
@@ -888,7 +902,7 @@ export async function runPluginMutation(options: {
888
902
  task?: InstallTask
889
903
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
890
904
  displayTarget?: string
891
- /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
905
+ /** 运行中 loader:安装成功后热加载新条目、卸载成功后停用旧条目(缺失时仍需重启) */
892
906
  loader?: LoaderHandle
893
907
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
894
908
  globalNpm?: string[]
@@ -897,7 +911,7 @@ export async function runPluginMutation(options: {
897
911
  /** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
898
912
  installSpec?: string
899
913
  }): Promise<InstallResult> {
900
- // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
914
+ // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读取作为后备
901
915
  const profileDir = profileDirectory(options.profile)
902
916
  const logTap = createOperationLogTap(profileDir)
903
917
  let result = await spawnMutation(options)
@@ -3,8 +3,8 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 运行中 loader 的读取、停用与热挂载:卸载成功后对匹配条目做 live-disable、
7
- * 安装成功后把新插件条目热挂进运行中的 loader,让两类操作都尽量立即生效
6
+ * 运行中 loader 的读取、停用与热加载:卸载成功后对匹配条目做 live-disable、
7
+ * 安装成功后把新插件条目热加载进运行中的 loader,让两类操作都尽量立即生效
8
8
  * (刷新页面即可,无需重启宿主)。
9
9
  */
10
10
  import { readFileSync } from 'node:fs'
@@ -12,7 +12,7 @@ import { join } from 'node:path'
12
12
  import { profileDirectory } from './profile/profile.ts'
13
13
 
14
14
  /**
15
- * 运行中 loader 的最小接口:卸载即时停用 / 安装即时热挂载用。
15
+ * 运行中 loader 的最小接口:卸载即时停用 / 安装即时热加载用。
16
16
  * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取、新建与移除面。
17
17
  * 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
18
18
  * 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
@@ -32,7 +32,7 @@ export interface LoaderHandle {
32
32
  * `- insert: - name: <pkg>` 行;Loader.write() 为空实现,不会写盘(重启后由
33
33
  * profile 的 bundles 清单重新挂载,不会重复挂载)。返回新条目的 id。 */
34
34
  create(options: { id?: string; name: string }): Promise<string>
35
- /** 按 id 解析条目:热挂载后确认 fiber 真的起来了(import 失败时条目在、但 fiber 为空)。 */
35
+ /** 按 id 解析条目:热加载后确认 fiber 真的起来了(import 失败时条目在、但 fiber 为空)。 */
36
36
  resolve(id: string): { id: string; options?: { name?: string; disabled?: boolean | null }; fiber?: unknown; subgroup?: unknown }
37
37
  }
38
38
 
@@ -82,7 +82,7 @@ function nameMatches(entryName: string | undefined, target: string): boolean {
82
82
  // 2) 条目是去 scope 的短名(bundle patch 手写短名最常见)
83
83
  if (e === tShort) return true
84
84
  // 3) 反向变体:目标短名可能就是条目的完整形式(条目 `scope-widget`,
85
- // 目标短名 `widget` 是它的尾段)——用「条目名以短名结尾」收口,避免误伤。
85
+ // 目标短名 `widget` 是它的尾段)——以「条目名以短名结尾」作为匹配条件,避免误伤。
86
86
  if (tShort.length >= 2 && e.endsWith(`-${tShort}`)) return true
87
87
  // 4) 条目名是目标包内的模块路径(`aegis/extensions/dsh/index.js` 属于包 `aegis`,
88
88
  // `@scope/pkg/dist/x.js` 属于 `@scope/pkg`):归一化会吞掉路径分隔符,
@@ -102,12 +102,12 @@ function nameMatches(entryName: string | undefined, target: string): boolean {
102
102
  * - `{ ok: true, live: false }`:宿主从未加载过它(磁盘已干净),无需刷新/重启;
103
103
  * - `{ ok: true, live: true }`:曾在 loader 中存活且已 live-disable —— 服务端即时卸载;
104
104
  * 带客户端 UI 的插件面板要页面刷新才摘除(由调用方按 `hadClientUi` 决定 needsReload);
105
- * - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启兜底清理。
105
+ * - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启后清理。
106
106
  * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
107
107
  */
108
108
  export async function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<{ ok: boolean; live: boolean }> {
109
109
  if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/')) return { ok: false, live: true }
110
- // 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
110
+ // 5s 超时保护:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
111
111
  const removal = (async () => {
112
112
  const entries = dumpLoaderEntries(loader)
113
113
  const match = entries.find((e) => e.name === name)
@@ -151,7 +151,7 @@ export async function removeLoadedEntry(loader: LoaderHandle, name: string): Pro
151
151
  }
152
152
 
153
153
  /**
154
- * 安装成功后把新插件热挂进运行中 loader(等价 profile bundle patch 的
154
+ * 安装成功后把新插件热加载进运行中 loader(等价 profile bundle patch 的
155
155
  * `- insert: - name: <pkg>` 行),让插件无需重启宿主即可生效。
156
156
  * 做法:在 Loader 根组新建条目并启动 —— Loader.write() 为空实现,不写盘;
157
157
  * 重启后由 profile 的 bundles 清单重新挂载,不会重复。
@@ -171,7 +171,7 @@ export async function mountLoadedEntry(loader: LoaderHandle, name: string): Prom
171
171
  console.log(`[hub-install] hot mount ${name}: entry ${id} created but not running (import failed?)`)
172
172
  return false
173
173
  })()
174
- // 10s 超时兜底:import 卡住时不能让装完收尾(乃至整个队列)被拖死
174
+ // 10s 超时保护:import 卡住时不能让装完收尾(乃至整个队列)被拖死
175
175
  return Promise.race([
176
176
  mounting.catch((error) => {
177
177
  console.log(`[hub-install] hot mount ${name} failed: ${error instanceof Error ? error.message : String(error)}`)
@@ -183,7 +183,7 @@ export async function mountLoadedEntry(loader: LoaderHandle, name: string): Prom
183
183
 
184
184
  /**
185
185
  * 已安装包是否带客户端 UI(package.json 的 `dsh.client` 声明):client-modules 据它把
186
- * 客户端 bundle 注入宿主页面。带 UI 的插件热挂载后需要页面重新加载才会渲染,
186
+ * 客户端 bundle 注入宿主页面。带 UI 的插件热加载后需要页面重新加载才会渲染,
187
187
  * 网页端与桌面端一致 —— `__DSH_BOOT__` 注入清单由宿主服务端按请求现场拼装,
188
188
  * 壳不缓存,所以桌面端刷新页面同样能拿到新清单,无需重开 App。
189
189
  */
@@ -3,9 +3,9 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * npm 包最新版本的发布时间查询:为「已安装插件是否有更新」提供供应链门禁依据。
7
- * pnpm 对发布不满 24h(dsh profile minimumReleaseAge=1440)的版本会静默回退到旧版本,
8
- * 因此发布未满门槛的版本不该提示更新(否则用户点了也装不上)。
6
+ * npm 包最新版本的发布时间查询:为「已安装插件是否有更新」提供发布冷却期依据。
7
+ * pnpm 对发布不满 24h(dsh profile minimumReleaseAge=1440)的版本会不提示地回退到旧版本,
8
+ * 因此发布还不满 24 小时的版本不该提示更新(否则用户点了也装不上)。
9
9
  * 结果按包名内存缓存,同一次会话内不重复请求;失败/超时/响应过大一律返回 null,绝不抛错、绝不阻塞。
10
10
  */
11
11
  import { get as httpsGet } from 'node:https'
@@ -17,6 +17,15 @@ export interface NpmRelease {
17
17
  publishedAt: string
18
18
  }
19
19
 
20
+ /** 最新版 + 可锁定的安装版本:安装锁定后者,pnpm 才不会回退到旧版本。 */
21
+ export interface NpmReleaseWithInstallable extends NpmRelease {
22
+ /** 已过发布冷却期的最高稳定版;时间表缺失或没有满足项时为 null(调用方退回不锁定版本)。 */
23
+ installable: NpmRelease | null
24
+ }
25
+
26
+ /** pnpm profile 默认 minimumReleaseAge=1440 分钟;+30 分钟缓冲与客户端 release-age 判定同一套规则。 */
27
+ export const MIN_RELEASE_AGE_MS = 24 * 60 * 60 * 1000 + 30 * 60 * 1000
28
+
20
29
  /** 请求体包名数量上限:只为已安装插件按需查询,几十个足矣,封顶防滥用。 */
21
30
  export const MAX_RELEASE_AGE_PACKAGES = 50
22
31
  /** 单包查询超时(ms):慢网络下失败即返回 null,不拖住整个响应。 */
@@ -27,9 +36,9 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
27
36
 
28
37
  /** 会话内缓存(源 + 包名 → 结果):只存成功结果,一次网络抖动不会把判断永久粘住。
29
38
  * 必须带过期时间 —— 插件中心可能连续开着几小时,作者发了新版本后不能一直沿用旧结果;
30
- * 也必须把源算进键 —— 同一个包在官方源与镜像上的 latest / 发布时间不同,混用会算错门禁。 */
39
+ * 也必须把源算进键 —— 同一个包在官方源与镜像上的 latest / 发布时间不同,混用会算错冷却期。 */
31
40
  const CACHE_TTL_MS = 10 * 60 * 1000
32
- const releaseCache = new Map<string, { at: number; release: NpmRelease }>()
41
+ const releaseCache = new Map<string, { at: number; release: NpmReleaseWithInstallable }>()
33
42
 
34
43
  /** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
35
44
  export function latestReleaseOf(packument: unknown): NpmRelease | null {
@@ -44,6 +53,35 @@ export function latestReleaseOf(packument: unknown): NpmRelease | null {
44
53
  return { version, publishedAt }
45
54
  }
46
55
 
56
+ /**
57
+ * 已过发布冷却期的最高稳定版本:安装锁定它,pnpm 不会回退到旧版本。
58
+ * 只看纯 x.y.z(预发布版不参与,避免把用户带到不兼容预览版);时间表缺失或无满足项返回 null。
59
+ */
60
+ export function installableReleaseOf(packument: unknown, now = Date.now()): NpmRelease | null {
61
+ if (packument === null || typeof packument !== 'object') return null
62
+ const time = (packument as { time?: unknown }).time
63
+ if (time === null || typeof time !== 'object') return null
64
+ let best: NpmRelease | null = null
65
+ for (const [version, published] of Object.entries(time as Record<string, unknown>)) {
66
+ if (!/^\d+\.\d+\.\d+$/.test(version) || typeof published !== 'string') continue
67
+ const at = Date.parse(published)
68
+ if (Number.isNaN(at) || now - at < MIN_RELEASE_AGE_MS) continue
69
+ if (best === null || compareVersion(version, best.version) > 0) best = { version, publishedAt: published }
70
+ }
71
+ return best
72
+ }
73
+
74
+ /** 纯数字段版本比较(installableReleaseOf 只在 x.y.z 里用)。 */
75
+ function compareVersion(a: string, b: string): number {
76
+ const pa = a.split('.')
77
+ const pb = b.split('.')
78
+ for (let i = 0; i < 3; i++) {
79
+ const d = Number.parseInt(pa[i] ?? '0', 10) - Number.parseInt(pb[i] ?? '0', 10)
80
+ if (d !== 0) return d
81
+ }
82
+ return 0
83
+ }
84
+
47
85
  /** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
48
86
  export function parseReleaseAgeRequest(body: unknown): string[] | null {
49
87
  if (body === null || typeof body !== 'object') return null
@@ -81,7 +119,7 @@ function getJson(url: string, timeoutMs: number): Promise<string | null> {
81
119
  })
82
120
  }
83
121
 
84
- /** 拉取一个包的完整 packument 正文;配了代理时复用与安装/目录同一套代理口径(curl 子进程注入 proxy env)。 */
122
+ /** 拉取一个包的完整 packument 正文;配了代理时复用与安装/目录同一套代理规则(curl 子进程注入 proxy env)。 */
85
123
  async function fetchPackument(url: string, proxy: string, timeoutMs: number): Promise<string | null> {
86
124
  if (proxy !== '') {
87
125
  const r = await fetchViaCurl(url, proxy, timeoutMs)
@@ -90,13 +128,13 @@ async function fetchPackument(url: string, proxy: string, timeoutMs: number): Pr
90
128
  return getJson(url, timeoutMs)
91
129
  }
92
130
 
93
- /** 查询一个包的最新版本与发布时间(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
131
+ /** 查询一个包的最新版本、发布时间与可锁定的安装版本(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
94
132
  export async function lookupReleaseAge(
95
133
  packageName: string,
96
134
  registry: string,
97
135
  proxy: string,
98
136
  timeoutMs = RELEASE_AGE_TIMEOUT_MS,
99
- ): Promise<NpmRelease | null> {
137
+ ): Promise<NpmReleaseWithInstallable | null> {
100
138
  const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY
101
139
  const key = `${base}\n${packageName}`
102
140
  const cached = releaseCache.get(key)
@@ -105,7 +143,9 @@ export async function lookupReleaseAge(
105
143
  if (body === null) return null
106
144
  let parsed: unknown
107
145
  try { parsed = JSON.parse(body) } catch { return null }
108
- const release = latestReleaseOf(parsed)
109
- if (release !== null) releaseCache.set(key, { at: Date.now(), release })
146
+ const latest = latestReleaseOf(parsed)
147
+ if (latest === null) return null
148
+ const release: NpmReleaseWithInstallable = { ...latest, installable: installableReleaseOf(parsed) }
149
+ releaseCache.set(key, { at: Date.now(), release })
110
150
  return release
111
151
  }
@@ -51,7 +51,7 @@ export function curlRevokeFlagUnsupported(code: number | null, stderr: string):
51
51
  return code === 2 && /ssl-revoke-best-effort/i.test(stderr) && /unknown|unrecognized/i.test(stderr)
52
52
  }
53
53
 
54
- /** 彻底关闭证书吊销检查的兜底参数(curl ≥ 7.19),比降级参数覆盖更老的 Windows curl。 */
54
+ /** 彻底关闭证书吊销检查的保底参数(curl ≥ 7.19),比降级参数覆盖更老的 Windows curl。 */
55
55
  const NO_REVOKE_ARG = '--ssl-no-revoke'
56
56
 
57
57
  /** stderr 是否指向 Schannel 取不到证书吊销信息(据此归因为环境 TLS 问题,而非网络不通)。 */
@@ -59,7 +59,7 @@ export function tlsRevocationBlocked(stderr: string): boolean {
59
59
  return /CRYPT_E_NO_REVOCATION_CHECK|CRYPT_E_REVOCATION_OFFLINE|0x80092012/i.test(stderr)
60
60
  }
61
61
 
62
- /** 探测/安装同一套代理口径:curl 与 git 子进程都读 HTTP(S)_PROXY 环境变量。 */
62
+ /** 探测/安装同一套代理规则:curl 与 git 子进程都读 HTTP(S)_PROXY 环境变量。 */
63
63
  function proxyEnv(proxy: string): NodeJS.ProcessEnv {
64
64
  if (proxy === '') return { ...process.env }
65
65
  return {
@@ -156,12 +156,12 @@ function runCurl(args: string[], env: NodeJS.ProcessEnv, timeoutMs: number): Pro
156
156
  child.stderr.on('data', (c: Buffer) => { stderr += c.toString() })
157
157
  child.on('error', () => finish(null))
158
158
  child.on('close', (code) => finish(code))
159
- // 超时兜底:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
159
+ // 超时保护:curl --max-time 到点自行退出,此处再设一个进程级超时防止悬挂
160
160
  const timer = setTimeout(() => { child.kill(); finish(null) }, timeoutMs + 1000)
161
161
  })
162
162
  }
163
163
 
164
- /** 带 TLS 兜底的 curl 调用:先按平台参数跑(Windows 带吊销检查降级),
164
+ /** 带 TLS 重试的 curl 调用:先按平台参数跑(Windows 带吊销检查降级),
165
165
  * 若本机 curl 不认这个参数(curl < 7.70)则去掉参数重跑一次,保证老版本不因此直接失败;
166
166
  * 降级后仍被吊销检查拦死(CRYPT_E_NO_REVOCATION_CHECK)时,再用 --ssl-no-revoke
167
167
  * 彻底关掉吊销检查跑一次 —— 该参数 curl ≥ 7.19 即存在,覆盖面比降级参数更广,
@@ -175,7 +175,7 @@ async function curlWithTlsFallback(args: string[], env: NodeJS.ProcessEnv, timeo
175
175
  }
176
176
  if (tlsArgs.length > 0 && tlsRevocationBlocked(run.stderr)) {
177
177
  const retry = await runCurl([NO_REVOKE_ARG, ...args], env, timeoutMs)
178
- // 只在兜底本身跑起来(非「不认识该参数」)时采用其结果,否则保留原始失败线索
178
+ // 只在重试本身跑起来(非「不认识该参数」)时采用其结果,否则保留原始失败线索
179
179
  if (retry.code !== null && !curlRevokeFlagUnsupported(retry.code, retry.stderr)) run = retry
180
180
  }
181
181
  return run
@@ -183,7 +183,7 @@ async function curlWithTlsFallback(args: string[], env: NodeJS.ProcessEnv, timeo
183
183
 
184
184
  /**
185
185
  * curl 子进程探测:spawn curl -s -w '%{http_code}' 并注入代理 env。
186
- * curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理口径;
186
+ * curl 读 HTTP_PROXY/HTTPS_PROXY 是原生行为,与 pnpm/git 安装通道同一套代理规则;
187
187
  * 失败(curl 不存在 / 连接失败 / 超时)都归为不可达,全程不抛错。
188
188
  * 证书吊销检查受阻(Windows Schannel)另记 reason,供诊断给出对症提示。
189
189
  */
@@ -240,7 +240,7 @@ export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Prom
240
240
  const child = spawn('git', ['ls-remote', '--exit-code', url, 'HEAD'], { env })
241
241
  let out = ''
242
242
  child.stdout.on('data', (c: Buffer) => { out += c.toString() })
243
- // git 的报错不透传给终端:结果以退出码 + HEAD ref 是否返回为准
243
+ // git 的报错不原样传递给终端:结果以退出码 + HEAD ref 是否返回为准
244
244
  child.stderr.on('data', () => {})
245
245
  let done = false
246
246
  const finish = (r: ProbeResult) => {
@@ -255,7 +255,7 @@ export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Prom
255
255
  // status 复用为 git 退出码(0 = 克隆握手成功);客户端 git 行不把它显示成 HTTP 码
256
256
  finish({ ok, ms: Date.now() - started, status: code })
257
257
  })
258
- // 超时兜底:与 curlProbe 同口径,防止 git 挂起
258
+ // 超时保护:与 curlProbe 同一套规则,防止 git 挂起
259
259
  const timer = setTimeout(() => { child.kill(); finish(probeFail()) }, timeoutMs + 1000)
260
260
  })
261
261
  }
@@ -263,7 +263,7 @@ export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Prom
263
263
  /**
264
264
  * 请求一个 URL 并返回重定向后的最终地址(curl -I -L -w %{url_effective})。
265
265
  * 供 GitHub 仓库改名解析使用:对已改名仓库 GitHub 回 301,跟随跳转后的最终地址
266
- * 即规范地址。与其它 GitHub 访问同口径 —— curl 子进程注入代理 env(Node 内置
266
+ * 即规范地址。与其它 GitHub 访问同一套规则 —— curl 子进程注入代理 env(Node 内置
267
267
  * http(s) 读不到系统代理,直连在部分网络下不可达)。
268
268
  * 未发生跳转(200/404)、网络失败或 curl 不可用都返回 null,调用方保留原始地址。
269
269
  */
@@ -279,11 +279,11 @@ export async function finalUrl(url: string, proxy: string, timeoutMs: number): P
279
279
  }
280
280
 
281
281
  /**
282
- * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数兜底)。
282
+ * curl 子进程抓取响应体(与 probeUrl 同一套代理 env 注入与 TLS 参数重试)。
283
283
  * 供服务端 /catalog 代理路由使用:目录/统计数据经此拉到服务端再转给浏览器,
284
- * 使「目录数据请求走设置里的代理」与 npm / git 安装通道口径一致。
284
+ * 使「目录数据请求走设置里的代理」与 npm / git 安装通道规则一致。
285
285
  * 失败(curl 不存在 / 连接失败 / 超时 / 非零退出)时 ok=false,全程不抛错;
286
- * reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定兜底策略。
286
+ * reason 区分「证书吊销受阻」这类环境 TLS 问题,供调用方决定备用策略。
287
287
  */
288
288
  export function fetchViaCurl(
289
289
  url: string, proxy: string, timeoutMs: number,
@@ -79,7 +79,7 @@ export function recordInstalledVersion(profile: string, repo: string, version: s
79
79
  export function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void {
80
80
  const table = readInstalledVersions(profile)
81
81
  const key = repo.toLowerCase()
82
- // 显式写全字段:这一步发生在安装开始时,版本还不知道 —— 但落盘记录必须是完整形状,
82
+ // 显式写全字段:这一步发生在安装开始时,版本还不知道 —— 但写入磁盘记录必须是完整形状,
83
83
  // 否则只带 npmPackage 的残缺记录会在客户端版本比较时崩掉整块界面
84
84
  table[key] = {
85
85
  version: table[key]?.version ?? '',
@@ -101,7 +101,7 @@ function writeVersions(profile: string, table: InstalledVersionTable): void {
101
101
  const file = versionsFilePath(profile)
102
102
  mkdirSync(dirname(file), { recursive: true })
103
103
  // 先写临时文件再原子替换:直接覆盖时进程被杀/磁盘满会留下截断的 JSON,
104
- // 下次读取整份账本都会失效(更新检测静默停摆),而用户看不到任何报错
104
+ // 下次读取整份账本都会失效(更新检测随之失效),而用户看不到任何报错
105
105
  const tmp = `${file}.tmp`
106
106
  writeFileSync(tmp, JSON.stringify(table, null, 2) + '\n', 'utf8')
107
107
  renameSync(tmp, file)
@@ -77,7 +77,7 @@ export function githubTarget(repo: string, ref?: string | null): string | null {
77
77
  * 非命令输入原样返回。兼容「直接粘贴完整命令」(防用户输错):--profile 必填且只有长选项
78
78
  * (官方 CLI 无 -p 简写,省略即报错),支持 `--profile=<name>` 等号形式,命令大小写不敏感。
79
79
  * update = 更新已安装目标到最新;remove 不在此列(卸载走 /uninstall 路由,按已装包名直卸)。
80
- * 与客户端 CustomInstallView 口径一致。
80
+ * 与客户端 CustomInstallView 规则一致。
81
81
  */
82
82
  const DSH_PLUGIN_CMD_RE = /^dsh\s+plugin\s+--profile(?:\s+|=)\S+\s+(add|update)\s+(.+)$/i
83
83
 
@@ -90,7 +90,7 @@ export function installTargetOf(value: string): string {
90
90
  /**
91
91
  * npm 全局安装命令(官方 README 的 `npm install -g <pkgs>`,如 dsh-tui 的安装命令)——
92
92
  * 提取 -g 之后的包列表。支持 `npm install|i` 与 `-g|--global`;任一包名非法(或格式
93
- * 不匹配)返回 null,由调用方按「不支持的目标」处理。与客户端 CustomInstallView 同口径。
93
+ * 不匹配)返回 null,由调用方按「不支持的目标」处理。与客户端 CustomInstallView 同一套规则。
94
94
  */
95
95
  const NPM_GLOBAL_CMD_RE = /^npm\s+(?:install|i)\s+(?:-g|--global)\s+(.+)$/i
96
96