dsh-plugin 2.2.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.
@@ -95,7 +95,7 @@ export function backfillOperationLog(output, profileDir) {
95
95
  export function hasSubstantiveOutput(output) {
96
96
  return output.split(/\r?\n/).some((raw) => {
97
97
  const line = raw.trim();
98
- return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\])/.test(line);
98
+ return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\]|registry-fallback\]|allow build\])/.test(line);
99
99
  });
100
100
  }
101
101
  /**
@@ -71,11 +71,16 @@ export function userNpmRegistry(cwd) {
71
71
  /**
72
72
  * 传输层失败才值得换源:超时、连接重置、DNS、5xx。
73
73
  * 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
74
+ * 本机证书链不被信任(TLS 校验失败)同样不换源:换哪个公共镜像都会以同样方式失败,
75
+ * 白白多等两轮超时、把真正的根因淹进换源噪音里。
74
76
  */
75
77
  export function isTransportFailure(text) {
76
78
  if (/\b(?:ERR_PNPM_FETCH_404|E404|ERR_PNPM_NO_MATCHING_VERSION|ETARGET|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|E401|E403)\b/i.test(text)) {
77
79
  return false;
78
80
  }
81
+ if (/unable to verify the first certificate|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|ERR_TLS_CERT_ALTNAME_INVALID|CERT_HAS_EXPIRED|CERT_UNTRUSTED|UNABLE_TO_GET_ISSUER_CERT/i.test(text)) {
82
+ return false;
83
+ }
79
84
  return /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EPIPE)\b|socket hang up|getaddrinfo|network request failed|connect timeout|request to .+ failed|FetchError|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL/i.test(text);
80
85
  }
81
86
  /** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
@@ -612,6 +612,12 @@ function spawnMutation(options) {
612
612
  task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done';
613
613
  if (task.status === 'done') {
614
614
  task.progress = 100;
615
+ // 实际装上的版本:目录版本可能滞后,客户端账本必须记这个值,否则「有更新」永久失真
616
+ if (!isGlobalNpm && action !== 'remove') {
617
+ const installed = installedVersionOf(profile, target);
618
+ if (installed !== null)
619
+ task.installedVersion = installed;
620
+ }
615
621
  // 市场自更新:若 bundle 已被取消勾选,重启后市场不会出现,结果页要明确提示
616
622
  if ((action === 'add' || action === 'update') && (target === 'dsh-plugin' || target.startsWith('dsh-plugin@'))
617
623
  && !bundleIncludes(profile, 'dsh-plugin')) {
@@ -660,9 +666,39 @@ function spawnMutation(options) {
660
666
  pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`);
661
667
  }
662
668
  }
663
- return result;
669
+ // 带上最终生效的源:后续注解(如 [dsh-too-old])读元数据必须与安装同源,
670
+ // 否则换过源之后会去另一个源查,查不到就静默漏报
671
+ return activeRegistry === undefined ? result : { ...result, registry: activeRegistry };
664
672
  })();
665
673
  }
674
+ /**
675
+ * 安装成功后从 profile 的 `node_modules/<pkg>/package.json` 读**实际**版本;
676
+ * 定位不到包名或读不到返回 null。客户端拿它写版本账本 —— 目录里的版本可能滞后。
677
+ */
678
+ export function installedVersionOf(profile, target) {
679
+ const profileDir = profileDirectory(profile);
680
+ let deps = {};
681
+ try {
682
+ const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
683
+ deps = pkg.dependencies ?? {};
684
+ }
685
+ catch {
686
+ return null;
687
+ }
688
+ const targetRepo = githubRepoOf(target)?.toLowerCase();
689
+ const name = targetRepo !== undefined
690
+ ? Object.keys(deps).find((k) => githubRepoOf(deps[k])?.toLowerCase() === targetRepo)
691
+ : deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target);
692
+ if (!name)
693
+ return null;
694
+ try {
695
+ const meta = JSON.parse(readFileSync(join(profileDir, 'node_modules', name, 'package.json'), 'utf8'));
696
+ return typeof meta.version === 'string' && meta.version !== '' ? meta.version : null;
697
+ }
698
+ catch {
699
+ return null;
700
+ }
701
+ }
666
702
  /**
667
703
  * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
668
704
  * dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
@@ -762,8 +798,9 @@ async function annotateDshTooOld(result, options) {
762
798
  // 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
763
799
  if ((options.globalNpm ?? []).length > 0)
764
800
  return result;
765
- // 用安装实际使用的源读元数据:私有源/镜像里也能读到同一个包的声明
766
- const line = await dshTooOldMarker(options.target, { registry: options.registries?.[0] });
801
+ // 用安装**实际**使用的源读元数据:换源重试后候选表首位可能并不是真正生效的那个,
802
+ // 用错源会读不到包而静默漏报;私有源/镜像里同样能读到包声明的 engines
803
+ const line = await dshTooOldMarker(options.target, { registry: result.registry ?? options.registries?.[0] });
767
804
  if (line === null)
768
805
  return result;
769
806
  if (options.task && options.task.status !== 'cancelled')
@@ -18,7 +18,10 @@ export const RELEASE_AGE_TIMEOUT_MS = 8000;
18
18
  /** 响应体上限:完整 packument 可能达数 MB,超限直接放弃,避免异常大包拖垮服务。 */
19
19
  const MAX_PACKUMENT_BYTES = 10 * 1024 * 1024;
20
20
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
21
- /** 会话内缓存(包名 → 版本与发布时间):只存成功结果,一次网络抖动不会把判断永久粘住。 */
21
+ /** 会话内缓存(源 + 包名 → 结果):只存成功结果,一次网络抖动不会把判断永久粘住。
22
+ * 必须带过期时间 —— 插件中心可能连续开着几小时,作者发了新版本后不能一直沿用旧结果;
23
+ * 也必须把源算进键 —— 同一个包在官方源与镜像上的 latest / 发布时间不同,混用会算错门禁。 */
24
+ const CACHE_TTL_MS = 10 * 60 * 1000;
22
25
  const releaseCache = new Map();
23
26
  /** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
24
27
  export function latestReleaseOf(packument) {
@@ -96,12 +99,13 @@ async function fetchPackument(url, proxy, timeoutMs) {
96
99
  }
97
100
  return getJson(url, timeoutMs);
98
101
  }
99
- /** 查询一个包的最新版本与发布时间(成功结果缓存,同会话内同包不重复请求);registry 为空回退官方源,失败返回 null。 */
102
+ /** 查询一个包的最新版本与发布时间(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
100
103
  export async function lookupReleaseAge(packageName, registry, proxy, timeoutMs = RELEASE_AGE_TIMEOUT_MS) {
101
- const cached = releaseCache.get(packageName);
102
- if (cached !== undefined)
103
- return cached;
104
104
  const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY;
105
+ const key = `${base}\n${packageName}`;
106
+ const cached = releaseCache.get(key);
107
+ if (cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS)
108
+ return cached.release;
105
109
  const body = await fetchPackument(`${base}/${encodeURIComponent(packageName)}`, proxy, timeoutMs);
106
110
  if (body === null)
107
111
  return null;
@@ -114,6 +118,6 @@ export async function lookupReleaseAge(packageName, registry, proxy, timeoutMs =
114
118
  }
115
119
  const release = latestReleaseOf(parsed);
116
120
  if (release !== null)
117
- releaseCache.set(packageName, release);
121
+ releaseCache.set(key, { at: Date.now(), release });
118
122
  return release;
119
123
  }
@@ -17,7 +17,7 @@
17
17
  * 存储:`DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
18
18
  * { "<owner>/<repo>"(小写): { version, updatedAt, installedAt } }
19
19
  */
20
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  export function versionsFilePath(profile) {
@@ -60,5 +60,9 @@ export function removeInstalledVersion(profile, repo) {
60
60
  function writeVersions(profile, table) {
61
61
  const file = versionsFilePath(profile);
62
62
  mkdirSync(dirname(file), { recursive: true });
63
- writeFileSync(file, JSON.stringify(table, null, 2) + '\n', 'utf8');
63
+ // 先写临时文件再原子替换:直接覆盖时进程被杀/磁盘满会留下截断的 JSON,
64
+ // 下次读取整份账本都会失效(更新检测静默停摆),而用户看不到任何报错
65
+ const tmp = `${file}.tmp`;
66
+ writeFileSync(tmp, JSON.stringify(table, null, 2) + '\n', 'utf8');
67
+ renameSync(tmp, file);
64
68
  }
@@ -12,6 +12,8 @@ export interface InstallResult {
12
12
  error: string | null;
13
13
  stdout: string;
14
14
  stderr: string;
15
+ /** 实际执行安装用的 npm 源(换源重试后是最终那一个):后续读元数据的注解要跟它同源 */
16
+ registry?: string;
15
17
  }
16
18
  export interface InstallTask {
17
19
  id: number;
@@ -44,6 +46,9 @@ export interface InstallTask {
44
46
  needsReload?: boolean;
45
47
  /** 自更新成功、但该 profile 的 dsh.profile.bundles 已不含 dsh-plugin:重启后市场不会出现 */
46
48
  bundleDeselected?: boolean;
49
+ /** 安装/更新成功后,从 profile 的 node_modules 读到的**实际**版本。
50
+ * 客户端用它写版本账本(目录版本可能滞后,抄目录字段会让「有更新」永久失真)。 */
51
+ installedVersion?: string;
47
52
  /** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
48
53
  * 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
49
54
  hadClientUi?: boolean;
@@ -12,6 +12,8 @@ export declare function userNpmRegistry(cwd: string): string;
12
12
  /**
13
13
  * 传输层失败才值得换源:超时、连接重置、DNS、5xx。
14
14
  * 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
15
+ * 本机证书链不被信任(TLS 校验失败)同样不换源:换哪个公共镜像都会以同样方式失败,
16
+ * 白白多等两轮超时、把真正的根因淹进换源噪音里。
15
17
  */
16
18
  export declare function isTransportFailure(text: string): boolean;
17
19
  /** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
@@ -77,6 +77,11 @@ export declare function startPluginMutation(options: {
77
77
  * 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
78
78
  installSpec?: string;
79
79
  }): InstallTask;
80
+ /**
81
+ * 安装成功后从 profile 的 `node_modules/<pkg>/package.json` 读**实际**版本;
82
+ * 定位不到包名或读不到返回 null。客户端拿它写版本账本 —— 目录里的版本可能滞后。
83
+ */
84
+ export declare function installedVersionOf(profile: string, target: string): string | null;
80
85
  /**
81
86
  * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
82
87
  * dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
@@ -11,5 +11,5 @@ export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
11
11
  export declare function latestReleaseOf(packument: unknown): NpmRelease | null;
12
12
  /** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
13
13
  export declare function parseReleaseAgeRequest(body: unknown): string[] | null;
14
- /** 查询一个包的最新版本与发布时间(成功结果缓存,同会话内同包不重复请求);registry 为空回退官方源,失败返回 null。 */
14
+ /** 查询一个包的最新版本与发布时间(成功结果按源缓存 10 分钟);registry 为空回退官方源,失败返回 null。 */
15
15
  export declare function lookupReleaseAge(packageName: string, registry: string, proxy: string, timeoutMs?: number): Promise<NpmRelease | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -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, exclusiveProfileOf, desktopCliFallbackOf, dshTooOldInfoOf, missingLocalDirOf } 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,10 +306,10 @@ 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 === 'pnpmVirtualStore') {
310
- // profile 的 modules 目录与当前 pnpm 的 virtual-store-dir-max-length 不一致:
309
+ if (kind === 'pnpmModulesMismatch') {
310
+ // profile 的 modules 目录与当前 pnpm 配置不一致(hoist / 虚拟目录长度 / node-linker 等):
311
311
  // 该 profile 内的安装/卸载都会失败 —— 重建 modules 目录或统一配置即可,不引导提 Issue
312
- return h('div', { className: styles.failPrepareHint }, t('failPnpmVirtualStoreHint'))
312
+ return h('div', { className: styles.failPrepareHint }, t('failPnpmModulesMismatchHint'))
313
313
  }
314
314
  if (kind === 'pnpmStore') {
315
315
  // pnpm 存在但 store 大版本不匹配(ERR_PNPM_UNEXPECTED_STORE):profile 目录的依赖是
@@ -327,6 +327,16 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
327
327
  // 发现补丁没被用上就中止安装,任何插件都装不进来 —— 不引导提 Issue
328
328
  return h('div', { className: styles.failPrepareHint }, t('failPnpmUnusedPatchHint'))
329
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
+ }
330
340
  if (kind === 'pnpmMissingDep') {
331
341
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
332
342
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
@@ -454,6 +464,14 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
454
464
  ]),
455
465
  ])
456
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
+ }
457
475
  if (kind === 'network') {
458
476
  // 安装前网络探测 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
459
477
  // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
@@ -497,6 +515,13 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
497
515
  ]) : null,
498
516
  ])
499
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
+ }
500
525
  if (kind === 'hubState') {
501
526
  // 插件中心的列表状态已过期(安装器自身守卫消息):非插件问题、也不是 bug,只给可行动提示,不引导提 Issue
502
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, exclusiveProfileOf, desktopCliFallbackOf, dshTooOldInfoOf, missingLocalDirOf } 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,10 +445,10 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
445
445
  // 调 pnpm add 时未声明在根操作,任何插件都会失败,不是插件问题 ——
446
446
  // 给 .npmrc 豁免指引,不引导提 Issue
447
447
  ? h('div', { className: styles.failPrepareHint }, t('failPnpmWorkspaceHint'))
448
- : failureKind === 'pnpmVirtualStore'
449
- // profile 的 modules 目录与当前 pnpm 的 virtual-store-dir-max-length 不一致:
448
+ : failureKind === 'pnpmModulesMismatch'
449
+ // profile 的 modules 目录与当前 pnpm 配置不一致(hoist / 虚拟目录长度 / node-linker 等):
450
450
  // 该 profile 内的安装/卸载都会失败 —— 重建 modules 目录或统一配置即可,不引导提 Issue
451
- ? h('div', { className: styles.failPrepareHint }, t('failPnpmVirtualStoreHint'))
451
+ ? h('div', { className: styles.failPrepareHint }, t('failPnpmModulesMismatchHint'))
452
452
  : failureKind === 'pnpmStore'
453
453
  // pnpm 存在但 store 大版本不匹配(ERR_PNPM_UNEXPECTED_STORE):profile 目录的依赖是
454
454
  // 另一大版本 pnpm 装的,当前 pnpm 不认,任何插件装进该 profile 都会失败 ——
@@ -462,6 +462,15 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
462
462
  // profile 里留着指向旧版本 dsh-plugin 的补丁声明(ERR_PNPM_UNUSED_PATCH):pnpm 发现
463
463
  // 补丁没被用上就中止整次安装,任何插件都装不进来 —— 给删除条目的指引,不引导提 Issue
464
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'))
465
474
  : failureKind === 'pnpmMissingDep'
466
475
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
467
476
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
@@ -593,6 +602,13 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
593
602
  }, t('failMisjudgeLink')),
594
603
  ]),
595
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
+ ])
596
612
  : failureKind === 'network'
597
613
  // 安装前网络探测 / 安装日志里的连接失败([network] / git fetch 超时 / DNS / TLS):
598
614
  // 是本机网络不通或代理有问题,不是插件问题 —— 提示检查网络 + 直达「系统诊断」,
@@ -635,6 +651,14 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
635
651
  }, t('failMisjudgeLink')),
636
652
  ]) : null,
637
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
+ })()
638
662
  : failureKind === 'hubState'
639
663
  ? h('div', null, [
640
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)