dsh-plugin 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/client/client.js +665 -352
  2. package/client/client.js.map +1 -1
  3. package/lib/http/diagnostics.js +2 -63
  4. package/lib/services/host-version.js +72 -0
  5. package/lib/services/install/dsh-compat.js +105 -0
  6. package/lib/services/install/operation-log.js +1 -1
  7. package/lib/services/install/registry-fallback.js +5 -0
  8. package/lib/services/install/task-queue.js +64 -1
  9. package/lib/services/npm-release-age.js +10 -6
  10. package/lib/services/profile/installed-versions.js +6 -2
  11. package/lib/types/services/host-version.d.ts +10 -0
  12. package/lib/types/services/install/dsh-compat.d.ts +19 -0
  13. package/lib/types/services/install/install-types.d.ts +5 -0
  14. package/lib/types/services/install/registry-fallback.d.ts +2 -0
  15. package/lib/types/services/install/task-queue.d.ts +5 -0
  16. package/lib/types/services/npm-release-age.d.ts +1 -1
  17. package/package.json +1 -1
  18. package/src/client/components/modals/NotificationsModal.tsx +80 -5
  19. package/src/client/components/modals/modals.tsx +84 -5
  20. package/src/client/hooks/useCatalog.ts +45 -25
  21. package/src/client/hooks/useTaskQueue.ts +30 -8
  22. package/src/client/locales.ts +56 -2
  23. package/src/client/logic/failures.ts +98 -7
  24. package/src/client/logic/installed.ts +4 -1
  25. package/src/client/logic/release-age.ts +52 -4
  26. package/src/client/logic/urls.ts +23 -3
  27. package/src/server/http/diagnostics.ts +2 -59
  28. package/src/server/services/host-version.ts +69 -0
  29. package/src/server/services/install/dsh-compat.ts +99 -0
  30. package/src/server/services/install/install-types.ts +5 -0
  31. package/src/server/services/install/operation-log.ts +1 -1
  32. package/src/server/services/install/registry-fallback.ts +5 -0
  33. package/src/server/services/install/task-queue.ts +64 -1
  34. package/src/server/services/npm-release-age.ts +10 -6
  35. package/src/server/services/profile/installed-versions.ts +6 -2
@@ -5,75 +5,14 @@
5
5
  *
6
6
  * 诊断路由:流式连通性自检、代理校验,以及与任务模块 /env 路由共享的宿主环境快照。
7
7
  */
8
- import { readFileSync } from 'node:fs';
9
8
  import { homedir, release } from 'node:os';
10
- import { dirname, join, resolve } from 'node:path';
9
+ import { join } from 'node:path';
11
10
  import { spawnSync } from 'node:child_process';
12
11
  import { loadSettings } from '../services/settings.js';
12
+ import { hostDshVersion, toolVersion } from '../services/host-version.js';
13
13
  import { gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
14
14
  import { appendLog } from '../services/log.js';
15
15
  import { readJsonBody, requireTrustedPost, sendJson } from './shared.js';
16
- /** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
17
- * 绝不挂起);拿不到再退回从入口 `process.argv[1]` 向上找 package.json(dsh 不在 PATH 时兜底)。 */
18
- function hostDshVersion() {
19
- const viaCli = toolVersion('dsh', ['--version']);
20
- if (viaCli)
21
- return viaCli;
22
- const entry = process.argv[1];
23
- if (entry === undefined)
24
- return null;
25
- let dir = dirname(resolve(entry));
26
- for (let depth = 0; depth < 4; depth++) {
27
- try {
28
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
29
- const name = manifest.name ?? '';
30
- if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
31
- return manifest.version;
32
- }
33
- }
34
- catch {
35
- /* 不是包根,继续向上查找 */
36
- }
37
- const parent = dirname(dir);
38
- if (parent === dir)
39
- break;
40
- dir = parent;
41
- }
42
- return null;
43
- }
44
- /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
45
- * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文(统一再加 `v` 前缀)拼成
46
- * `vgit version 2.55.0.windows.5`。只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
47
- function versionOf(output) {
48
- const first = output.trim().split(/\r?\n/)[0]?.trim() ?? '';
49
- if (!first)
50
- return null;
51
- const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first);
52
- return match === null ? null : match[0];
53
- }
54
- /** 探测命令行工具的版本号(pnpm --version / npm --version / git --version):
55
- * 未安装 / 不在 PATH / 超时一律返回 null —— issue 环境快照如实写 unknown,绝不挂起。
56
- * (同步探测仅用于启动期/请求期的低频率 /env 快照,安装通道不经过这里。)
57
- *
58
- * Windows 上 pnpm/npm 是 .cmd shim,不带 shell 的 spawnSync 起不来 —— 上报的 issue 里
59
- * 因此长期是 `pnpm: unknown` / `npm: unknown`,而 pnpm 版本恰恰是 allowBuilds 那类问题的
60
- * 关键信息(门禁行为随版本变)。故 win32 走 shell 让 cmd 解析 shim;参数是固定的
61
- * `--version`,没有注入面。 */
62
- function toolVersion(cmd, args) {
63
- try {
64
- const r = spawnSync(cmd, args, {
65
- timeout: 1500,
66
- encoding: 'utf8',
67
- shell: process.platform === 'win32',
68
- });
69
- if (r.error || r.status !== 0)
70
- return null;
71
- return versionOf(r.stdout ?? '');
72
- }
73
- catch {
74
- return null;
75
- }
76
- }
77
16
  /** 本机实际生效的 npm registry:诊断与安装同口径 —— 设置里留空时读用户 npm 配置
78
17
  * (含 profile 目录的 .npmrc),而不是硬探官方源,避免「能装但诊断说源不通」。
79
18
  * 读不到(npm 缺失/超时/输出异常)时回退官方源。 */
@@ -0,0 +1,72 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 宿主与命令行工具的版本探测:dsh 版本同时用于诊断快照和「插件要求更高 DSH」判定,
7
+ * pnpm/npm/git 只用于诊断快照。同步探测 + 1500ms 超时兜底,拿不到一律 null,绝不挂起。
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
13
+ * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文拼成 `vgit version …`。
14
+ * 只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
15
+ export function versionOf(output) {
16
+ const first = output.trim().split(/\r?\n/)[0]?.trim() ?? '';
17
+ if (!first)
18
+ return null;
19
+ const match = /\d+\.\d+[0-9A-Za-z.\-+]*/.exec(first);
20
+ return match === null ? null : match[0];
21
+ }
22
+ /** 探测命令行工具版本:未安装 / 不在 PATH / 超时一律 null。
23
+ * Windows 上 pnpm/npm 是 .cmd shim,不走 shell 起不来;参数是固定的 `--version`,没有注入面。 */
24
+ export function toolVersion(cmd, args) {
25
+ try {
26
+ const r = spawnSync(cmd, args, {
27
+ timeout: 1500,
28
+ encoding: 'utf8',
29
+ shell: process.platform === 'win32',
30
+ });
31
+ if (r.error || r.status !== 0)
32
+ return null;
33
+ return versionOf(r.stdout ?? '');
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ let cachedDshVersion = null;
40
+ /** 宿主 dsh 版本:优先 `dsh --version`;拿不到再从入口 `process.argv[1]` 向上找 package.json
41
+ * (dsh 不在 PATH 时兜底)。探测成功即缓存,失败不缓存以便下次重试。 */
42
+ export function hostDshVersion() {
43
+ if (cachedDshVersion !== null)
44
+ return cachedDshVersion;
45
+ const viaCli = toolVersion('dsh', ['--version']);
46
+ if (viaCli !== null) {
47
+ cachedDshVersion = viaCli;
48
+ return viaCli;
49
+ }
50
+ const entry = process.argv[1];
51
+ if (entry === undefined)
52
+ return null;
53
+ let dir = dirname(resolve(entry));
54
+ for (let depth = 0; depth < 4; depth++) {
55
+ try {
56
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
57
+ const name = manifest.name ?? '';
58
+ if (typeof manifest.version === 'string' && (name === 'dsh' || name.includes('dsh') || name.includes('harness'))) {
59
+ cachedDshVersion = manifest.version;
60
+ return cachedDshVersion;
61
+ }
62
+ }
63
+ catch {
64
+ /* 不是包根,继续向上查找 */
65
+ }
66
+ const parent = dirname(dir);
67
+ if (parent === dir)
68
+ break;
69
+ dir = parent;
70
+ }
71
+ return null;
72
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
3
+ * Website: https://dsh-plugin.org
4
+ * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
+ *
6
+ * 「插件要求的 DSH 版本高于当前宿主」判定:安装失败后读一次 npm 元数据,取
7
+ * peerDependencies(@deepseek-ai/dsh-*)与 engines.dsh 声明的最低版本里最高的那个,
8
+ * 与宿主版本比较;宿主更低就产出 [dsh-too-old] 标记 —— 前端据此提示「先升级 DSH」,
9
+ * 不再把这类失败归成「未分类」丢给用户或插件作者。
10
+ *
11
+ * 只在失败路径调用:peer 不满足本身通常只是警告,成功安装时不该出现这个提示;
12
+ * 任何网络/解析异常都静默返回 null,不改变原有失败处理。
13
+ */
14
+ import { hostDshVersion } from '../host-version.js';
15
+ /** 从安装目标里取 npm 包名:`@scope/pkg@1.2.3` → `@scope/pkg`。
16
+ * git / URL / 本地路径等非 npm 目标返回 null。 */
17
+ export function npmNameOf(target) {
18
+ const raw = target.trim();
19
+ if (raw === '')
20
+ return null;
21
+ if (/^(?:github:|git\+|https?:|file:|link:|\.{0,2}[\\/])/i.test(raw))
22
+ return null;
23
+ // owner/repo(非 scope):git 目标的另一种写法,不是包名
24
+ if (!raw.startsWith('@') && /^[^\s@]+\/[^\s]+$/.test(raw))
25
+ return null;
26
+ const at = raw.lastIndexOf('@');
27
+ const name = at > 0 ? raw.slice(0, at) : raw;
28
+ return /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(name) ? name : null;
29
+ }
30
+ /** `0.2.1-rc.1` / `^0.2.1` / `>=0.2.0 <0.3.0` → [0,2,1];解析不出返回 null。
31
+ * 只取版本三段:DSH 的 prerelease 序号(rc.1)不参与「太低」判断,避免比较误差。 */
32
+ export function versionTriple(raw) {
33
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw);
34
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
35
+ }
36
+ function compareTriple(a, b) {
37
+ for (let i = 0; i < 3; i++) {
38
+ if (a[i] !== b[i])
39
+ return a[i] - b[i];
40
+ }
41
+ return 0;
42
+ }
43
+ /** 插件声明的最低 DSH 版本:engines.dsh 与 peerDependencies 里 @deepseek-ai/dsh-* 的下限取最大。
44
+ * 返回原始声明文本(如 `^0.2.1-rc.1`)供提示直接展示;没有声明返回 null。 */
45
+ export function dshFloorOf(manifest) {
46
+ const m = manifest;
47
+ const ranges = [];
48
+ const engineDsh = m?.engines?.dsh;
49
+ if (typeof engineDsh === 'string')
50
+ ranges.push(engineDsh);
51
+ for (const [name, range] of Object.entries(m?.peerDependencies ?? {})) {
52
+ if (/^@deepseek-ai\/dsh(?:-|$)/.test(name) && typeof range === 'string')
53
+ ranges.push(range);
54
+ }
55
+ let floor = null;
56
+ let floorTriple = null;
57
+ for (const range of ranges) {
58
+ const triple = versionTriple(range);
59
+ if (triple === null)
60
+ continue;
61
+ if (floorTriple === null || compareTriple(triple, floorTriple) > 0) {
62
+ floor = range;
63
+ floorTriple = triple;
64
+ }
65
+ }
66
+ return floor;
67
+ }
68
+ /**
69
+ * 产出「插件要求更高的 DSH」标记行;没有声明、读不到元数据、宿主版本未知或已满足时返回 null。
70
+ * 用版本级 manifest(`/<pkg>/latest`):packument 的精简形态不带 peerDependencies。
71
+ */
72
+ export async function dshTooOldMarker(target, opts = {}) {
73
+ const name = npmNameOf(target);
74
+ if (name === null)
75
+ return null;
76
+ const current = opts.currentVersion ?? hostDshVersion();
77
+ if (current === null)
78
+ return null;
79
+ const currentTriple = versionTriple(current);
80
+ if (currentTriple === null)
81
+ return null;
82
+ const base = (opts.registry ?? 'https://registry.npmjs.org').replace(/\/+$/, '');
83
+ // scope 包的路径要编码:`@scope/name` → `%40scope%2Fname`
84
+ const url = `${base}/${name.replace('@', '%40').replace('/', '%2F')}/latest`;
85
+ let manifest;
86
+ try {
87
+ const res = await (opts.fetchImpl ?? fetch)(url, {
88
+ headers: { accept: 'application/json' },
89
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 4000),
90
+ });
91
+ if (!res.ok)
92
+ return null;
93
+ manifest = await res.json();
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ const floor = dshFloorOf(manifest);
99
+ const floorTriple = floor === null ? null : versionTriple(floor);
100
+ if (floor === null || floorTriple === null)
101
+ return null;
102
+ if (compareTriple(currentTriple, floorTriple) >= 0)
103
+ return null;
104
+ return `[dsh-too-old] ${name} requires DSH >= ${floor} (declared in engines / peerDependencies), but this host runs ${current} — upgrade DSH before installing`;
105
+ }
@@ -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)。 */
@@ -22,6 +22,7 @@ import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, p
22
22
  import { appendLog } from '../log.js';
23
23
  import { commandOnPath, gitMissingDetail } from './preflight.js';
24
24
  import { isTransportFailure, registryHost } from './registry-fallback.js';
25
+ import { dshTooOldMarker } from './dsh-compat.js';
25
26
  import { OutputDecoder } from './decode-output.js';
26
27
  /** 内存任务注册表,按任务 id 索引。 */
27
28
  const tasks = new Map();
@@ -365,6 +366,12 @@ function spawnMutation(options) {
365
366
  const invocation = isGlobalNpm
366
367
  ? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
367
368
  : cliInvocation(profile);
369
+ // 打包桌面端却回退到 PATH 上的 dsh:说明 desktop CLI 引导路径没命中,desktop profile 必被宿主拒绝。
370
+ // 留一行 ASCII 标记,后续同类上报可直接区分「用户自起 CLI」与「我们没走对引导路径」。
371
+ if (task && task.status !== 'cancelled' && invocation.file === 'dsh'
372
+ && process.versions.electron !== undefined) {
373
+ pushLine(task, '[desktop-cli-fallback] running inside Electron but no desktop CLI bootstrap was found — falling back to dsh on PATH (a desktop-managed profile will be rejected)');
374
+ }
368
375
  // 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
369
376
  // (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
370
377
  // 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
@@ -605,6 +612,12 @@ function spawnMutation(options) {
605
612
  task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done';
606
613
  if (task.status === 'done') {
607
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
+ }
608
621
  // 市场自更新:若 bundle 已被取消勾选,重启后市场不会出现,结果页要明确提示
609
622
  if ((action === 'add' || action === 'update') && (target === 'dsh-plugin' || target.startsWith('dsh-plugin@'))
610
623
  && !bundleIncludes(profile, 'dsh-plugin')) {
@@ -653,9 +666,39 @@ function spawnMutation(options) {
653
666
  pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`);
654
667
  }
655
668
  }
656
- return result;
669
+ // 带上最终生效的源:后续注解(如 [dsh-too-old])读元数据必须与安装同源,
670
+ // 否则换过源之后会去另一个源查,查不到就静默漏报
671
+ return activeRegistry === undefined ? result : { ...result, registry: activeRegistry };
657
672
  })();
658
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
+ }
659
702
  /**
660
703
  * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
661
704
  * dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
@@ -746,6 +789,24 @@ async function annotateRegistryMissing(result, options) {
746
789
  pushLine(options.task, line);
747
790
  return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line };
748
791
  }
792
+ /** 安装失败且插件声明的最低 DSH 版本高于当前宿主 → 注入 [dsh-too-old] 标记。
793
+ * 只在失败路径上发生:peer 不满足本身通常只是警告,装成功时不该冒这个提示。
794
+ * 超时也算失败 —— 旧宿主上插件可能卡在半兼容状态,这个信息同样有用。 */
795
+ async function annotateDshTooOld(result, options) {
796
+ if (options.action === 'remove' || result.exitCode === 0)
797
+ return result;
798
+ // 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
799
+ if ((options.globalNpm ?? []).length > 0)
800
+ return result;
801
+ // 用安装**实际**使用的源读元数据:换源重试后候选表首位可能并不是真正生效的那个,
802
+ // 用错源会读不到包而静默漏报;私有源/镜像里同样能读到包声明的 engines
803
+ const line = await dshTooOldMarker(options.target, { registry: result.registry ?? options.registries?.[0] });
804
+ if (line === null)
805
+ return result;
806
+ if (options.task && options.task.status !== 'cancelled')
807
+ pushLine(options.task, line);
808
+ return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line };
809
+ }
749
810
  /**
750
811
  * 宿主自身 CLI 调不起来时的 ASCII 标记行:spawn 我们解析出的宿主入口报 ENOENT 时返回
751
812
  * `[host-spawn]`,供前端单独归因、不引导提 Issue。PATH 上的 dsh/npm 各有自己的标记,返回 null。
@@ -860,6 +921,8 @@ export async function runPluginMutation(options) {
860
921
  }
861
922
  // 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
862
923
  result = await annotateRegistryMissing(result, options);
924
+ // 插件声明的最低 DSH 版本高于当前宿主 → 追加 [dsh-too-old] 标记,前端提示先升级 DSH
925
+ result = await annotateDshTooOld(result, options);
863
926
  // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
864
927
  return annotateNpmTooLow(result, options);
865
928
  }
@@ -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
  }
@@ -0,0 +1,10 @@
1
+ /** 从命令输出里抽出版本号本体:pnpm/npm 直接打印 `11.7.0`,而 `git --version` 打印
2
+ * `git version 2.55.0.windows.5` —— 原样取首行会让 issue 正文拼成 `vgit version …`。
3
+ * 只取版本号,顺带兼容 `v1.2.3` 这类自带前缀的输出。 */
4
+ export declare function versionOf(output: string): string | null;
5
+ /** 探测命令行工具版本:未安装 / 不在 PATH / 超时一律 null。
6
+ * Windows 上 pnpm/npm 是 .cmd shim,不走 shell 起不来;参数是固定的 `--version`,没有注入面。 */
7
+ export declare function toolVersion(cmd: string, args: string[]): string | null;
8
+ /** 宿主 dsh 版本:优先 `dsh --version`;拿不到再从入口 `process.argv[1]` 向上找 package.json
9
+ * (dsh 不在 PATH 时兜底)。探测成功即缓存,失败不缓存以便下次重试。 */
10
+ export declare function hostDshVersion(): string | null;
@@ -0,0 +1,19 @@
1
+ /** 从安装目标里取 npm 包名:`@scope/pkg@1.2.3` → `@scope/pkg`。
2
+ * git / URL / 本地路径等非 npm 目标返回 null。 */
3
+ export declare function npmNameOf(target: string): string | null;
4
+ /** `0.2.1-rc.1` / `^0.2.1` / `>=0.2.0 <0.3.0` → [0,2,1];解析不出返回 null。
5
+ * 只取版本三段:DSH 的 prerelease 序号(rc.1)不参与「太低」判断,避免比较误差。 */
6
+ export declare function versionTriple(raw: string): [number, number, number] | null;
7
+ /** 插件声明的最低 DSH 版本:engines.dsh 与 peerDependencies 里 @deepseek-ai/dsh-* 的下限取最大。
8
+ * 返回原始声明文本(如 `^0.2.1-rc.1`)供提示直接展示;没有声明返回 null。 */
9
+ export declare function dshFloorOf(manifest: unknown): string | null;
10
+ /**
11
+ * 产出「插件要求更高的 DSH」标记行;没有声明、读不到元数据、宿主版本未知或已满足时返回 null。
12
+ * 用版本级 manifest(`/<pkg>/latest`):packument 的精简形态不带 peerDependencies。
13
+ */
14
+ export declare function dshTooOldMarker(target: string, opts?: {
15
+ currentVersion?: string | null;
16
+ fetchImpl?: typeof fetch;
17
+ timeoutMs?: number;
18
+ registry?: string;
19
+ }): Promise<string | null>;
@@ -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.1.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",