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.
- package/client/client.js +665 -352
- package/client/client.js.map +1 -1
- package/lib/http/diagnostics.js +2 -63
- package/lib/services/host-version.js +72 -0
- package/lib/services/install/dsh-compat.js +105 -0
- package/lib/services/install/operation-log.js +1 -1
- package/lib/services/install/registry-fallback.js +5 -0
- package/lib/services/install/task-queue.js +64 -1
- package/lib/services/npm-release-age.js +10 -6
- package/lib/services/profile/installed-versions.js +6 -2
- package/lib/types/services/host-version.d.ts +10 -0
- package/lib/types/services/install/dsh-compat.d.ts +19 -0
- package/lib/types/services/install/install-types.d.ts +5 -0
- package/lib/types/services/install/registry-fallback.d.ts +2 -0
- package/lib/types/services/install/task-queue.d.ts +5 -0
- package/lib/types/services/npm-release-age.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/components/modals/NotificationsModal.tsx +80 -5
- package/src/client/components/modals/modals.tsx +84 -5
- package/src/client/hooks/useCatalog.ts +45 -25
- package/src/client/hooks/useTaskQueue.ts +30 -8
- package/src/client/locales.ts +56 -2
- package/src/client/logic/failures.ts +98 -7
- package/src/client/logic/installed.ts +4 -1
- package/src/client/logic/release-age.ts +52 -4
- package/src/client/logic/urls.ts +23 -3
- package/src/server/http/diagnostics.ts +2 -59
- package/src/server/services/host-version.ts +69 -0
- package/src/server/services/install/dsh-compat.ts +99 -0
- package/src/server/services/install/install-types.ts +5 -0
- package/src/server/services/install/operation-log.ts +1 -1
- package/src/server/services/install/registry-fallback.ts +5 -0
- package/src/server/services/install/task-queue.ts +64 -1
- package/src/server/services/npm-release-age.ts +10 -6
- 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
|
-
//
|
|
377
|
-
|
|
378
|
-
return h('div',
|
|
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
|
-
// 与本次要装的插件无关 ——
|
|
519
|
-
? h('div',
|
|
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
|
-
/**
|
|
29
|
-
const
|
|
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
|
-
*
|
|
193
|
+
* 更新可用性校验状态:npm registry 上每个包的最新可安装版本与发布时间。
|
|
194
194
|
* key 是这批候选包名的指纹,与已查出的 key 一致才算就绪,避免查询未回来时先显示更新。
|
|
195
195
|
*/
|
|
196
|
-
const [releaseAge, setReleaseAge] = useState<{ key: string;
|
|
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
|
-
/**
|
|
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: '',
|
|
230
|
+
setReleaseAge({ key: '', releases: EMPTY_RELEASES })
|
|
220
231
|
return
|
|
221
232
|
}
|
|
222
233
|
let cancelled = false
|
|
223
|
-
// 批量一次查询;失败/返回 null
|
|
234
|
+
// 批量一次查询;失败/返回 null 时按原逻辑照常提示,网络问题不吃掉真实更新
|
|
224
235
|
void fetchNpmReleaseAge(releaseAgeCandidates).then((result) => {
|
|
225
236
|
if (cancelled) return
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 || !
|
|
270
|
+
return pkg === null || !updateBlocked.has(pkg)
|
|
251
271
|
}
|
|
252
272
|
|
|
253
|
-
/** 已安装项统一列表(目录元数据 +
|
|
273
|
+
/** 已安装项统一列表(目录元数据 + 运行时信息合并):被拦下的去掉更新标记,驱动「已安装」tab。 */
|
|
254
274
|
const installedItems = useMemo<InstalledItem[]>(() => {
|
|
255
|
-
if (
|
|
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 &&
|
|
279
|
+
return pkg !== null && updateBlocked.has(pkg) ? { ...item, hasUpdate: false } : item
|
|
260
280
|
})
|
|
261
|
-
}, [rawInstalledItems,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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)
|