dsh-plugin 1.4.13 → 1.4.14

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.
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
14
14
  import { spawn, spawnSync } from 'node:child_process';
15
15
  import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
16
16
  import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
17
+ import { githubReleaseTarget } from '../services/install/release-target.js';
17
18
  import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
18
19
  import { resolveNpmPackage } from '../services/install/npm-resolve.js';
19
20
  import { preflightTarget } from '../services/install/preflight.js';
@@ -801,12 +802,14 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
801
802
  sendJson(response, 200, { ok: true, task: task.id });
802
803
  return;
803
804
  }
804
- // 目标语法两态:GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式
805
- // HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
805
+ // 目标语法三态:固定 GitHub release .tgz(保留其包根目录,不转成仓库 HEAD);
806
+ // GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式 HTTPS Git 源
807
+ // (走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
806
808
  // githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
807
809
  const gitRepo = githubRepoOf(rawRepo);
808
- const repoTarget = gitRepo !== null ? githubTarget(gitRepo) : null;
809
- let target = repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
810
+ const release = githubReleaseTarget(rawRepo);
811
+ const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null;
812
+ let target = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
810
813
  if (target === null) {
811
814
  sendJson(response, 400, { error: 'unsupported install target' });
812
815
  return;
@@ -836,9 +839,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
836
839
  }
837
840
  // 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
838
841
  // npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
839
- // 通道判定看反查后的最终 target:仍是 git 目标 = 走 GitHub 源码,否则走 npm。
842
+ // 通道判定看反查后的最终 target:release 包与 git 目标同属 GitHub 通道,否则走 npm。
840
843
  if (source !== 'catalog') {
841
- const isGitChannel = repoTarget !== null && target === repoTarget;
844
+ const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
842
845
  if (isGitChannel && !settings.enableGitInstall) {
843
846
  sendJson(response, 403, { error: 'git installs are disabled by the security settings' });
844
847
  return;
@@ -874,8 +877,12 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
874
877
  const effectiveProxy = settings.proxy !== ''
875
878
  ? settings.proxy
876
879
  : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
877
- const isGitChannel = repoTarget !== null && target === repoTarget;
878
- const npmProbeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
880
+ const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
881
+ // release 包是 https://github.com/ 直链下载(非 git 克隆),探针按 HTTP 打 github.com 主页;
882
+ // npm 通道探 registry 根。两者都是通道的稳定入口,不探具体包/仓库(404 会被误判成网络故障)。
883
+ const httpProbeTarget = release !== null
884
+ ? 'https://github.com/'
885
+ : `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
879
886
  // git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
880
887
  // https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
881
888
  // 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
@@ -893,9 +900,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
893
900
  }
894
901
  }
895
902
  else {
896
- net = await probeUrl(npmProbeTarget, effectiveProxy, 6000);
903
+ net = await probeUrl(httpProbeTarget, effectiveProxy, 6000);
897
904
  }
898
- const probeTarget = gitProbeTarget ?? npmProbeTarget;
905
+ const probeTarget = gitProbeTarget ?? httpProbeTarget;
899
906
  if (!net.ok) {
900
907
  // 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
901
908
  // 让用户一眼看懂是网络问题而非插件问题
@@ -16,6 +16,8 @@ import { get } from 'node:https';
16
16
  const cache = new Map();
17
17
  /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
18
18
  const REQUEST_TIMEOUT_MS = 8000;
19
+ /** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */
20
+ const SEARCH_RESULT_LIMIT = 250;
19
21
  /**
20
22
  * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
21
23
  * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
@@ -37,6 +39,31 @@ export function resolveNpmPackage(repo, registry = '') {
37
39
  });
38
40
  return name.then((found) => found ?? null);
39
41
  }
42
+ /**
43
+ * Pick the installable npm package for one GitHub repository. npm search is a
44
+ * relevance-ranked text search, not an exact repository index, so first filter
45
+ * by repository URL. If a monorepo publishes both a CLI/helper package and a
46
+ * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
47
+ * keyword instead of whichever result npm happened to rank first.
48
+ */
49
+ export function selectNpmPackageForRepo(objects, repo) {
50
+ const needle = `github.com/${repo}`.toLowerCase();
51
+ const matches = [];
52
+ for (const obj of objects) {
53
+ const pkg = obj.package;
54
+ if (typeof pkg?.name !== 'string' || pkg.name === '')
55
+ continue;
56
+ const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
57
+ if (!repoUrl.toLowerCase().includes(needle))
58
+ continue;
59
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
60
+ matches.push({
61
+ name: pkg.name,
62
+ isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
63
+ });
64
+ }
65
+ return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null;
66
+ }
40
67
  /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
41
68
  * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
42
69
  function searchRepo(repo, registry) {
@@ -44,7 +71,7 @@ function searchRepo(repo, registry) {
44
71
  if (owner === undefined || name === undefined || name === '')
45
72
  return Promise.resolve(null);
46
73
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '');
47
- const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`;
74
+ const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`;
48
75
  return new Promise((resolve) => {
49
76
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
50
77
  if (res.statusCode !== 200) {
@@ -57,18 +84,7 @@ function searchRepo(repo, registry) {
57
84
  res.on('end', () => {
58
85
  try {
59
86
  const data = JSON.parse(body);
60
- const needle = `github.com/${owner}/${name}`.toLowerCase();
61
- for (const obj of data.objects ?? []) {
62
- const pkg = obj.package;
63
- if (typeof pkg?.name !== 'string' || pkg.name === '')
64
- continue;
65
- const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
66
- if (repoUrl.toLowerCase().includes(needle)) {
67
- resolve(pkg.name);
68
- return;
69
- }
70
- }
71
- resolve(null);
87
+ resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`));
72
88
  }
73
89
  catch {
74
90
  resolve(undefined);
@@ -18,12 +18,17 @@ import { join } from 'node:path';
18
18
  import { pipeline } from 'node:stream/promises';
19
19
  import { promisify } from 'node:util';
20
20
  import { githubRepoOf } from '../profile/profile.js';
21
+ import { githubReleaseTarget } from './release-target.js';
21
22
  import { resolvePackageEntry } from './package-entry.js';
22
23
  const execFileAsync = promisify(execFile);
23
24
  /** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
24
25
  * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
25
26
  const PREFLIGHT_TIMEOUT_MS = 15_000;
26
27
  export async function preflightTarget(target) {
28
+ // release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
29
+ // 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
30
+ if (githubReleaseTarget(target) !== null)
31
+ return { ok: true, missing: null, name: null };
27
32
  const source = githubRepoOf(target);
28
33
  if (source === null)
29
34
  return { ok: true, missing: null, name: null };
@@ -0,0 +1,38 @@
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
+ * 预构建 GitHub release 安装目标(目录权威命令 → 可安装直链)。
7
+ * 部分插件(如 loopx)把构建产物发布成 GitHub release 的 .tgz,仓库 HEAD 反而是
8
+ * monorepo 根、走 git 直装会因缺入口文件失败;目录下发的权威安装命令直接指向该
9
+ * release 包。这里把「目录命令」解析成安全的 release 目标,供客户端选路与服务端
10
+ * 校验共用(单一事实来源,避免两端口径漂移)。
11
+ *
12
+ * 只接受固定形态的 HTTPS `github.com/<owner>/<repo>/releases/download/<tag>/<asset>.tgz`:
13
+ * 目录命令是展示数据、绝非 shell 代码,绝不执行命令文本;跨仓跳转、外部主机、凭据、
14
+ * 移动的 latest 链接、query/fragment、多余 CLI 参数与 shell 片段一律拒绝。
15
+ */
16
+ /** 目录权威命令:`dsh plugin --profile <p> add|update <target>`(与 profile.ts installTargetOf 同口径)。 */
17
+ const DSH_PLUGIN_CMD_RE = /^dsh[ \t]+plugin[ \t]+--profile(?:[ \t]+|=)[A-Za-z0-9_-]+[ \t]+(?:add|update)[ \t]+(.+)$/i;
18
+ /** 固定形态的 release .tgz 直链:主机、路径段与扩展名全部锁死。 */
19
+ const RELEASE_URL_RE = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+\.tgz)$/;
20
+ /**
21
+ * 解析一个 release 安装目标。
22
+ * @param value - 目录命令(`dsh plugin --profile web add <url>`)或裸直链。
23
+ * @returns 命中返回 `{ target, repo }`,否则 null。
24
+ */
25
+ export function githubReleaseTarget(value) {
26
+ const input = typeof value === 'string' ? value.trim() : '';
27
+ if (input === '')
28
+ return null;
29
+ const command = DSH_PLUGIN_CMD_RE.exec(input);
30
+ const raw = command !== null ? command[1].trim() : input;
31
+ const match = RELEASE_URL_RE.exec(raw);
32
+ if (match === null)
33
+ return null;
34
+ // 拒绝 `.` / `..` 路径段,杜绝借路径穿越跳出 release 目录
35
+ if (match.slice(1).some((part) => part === '.' || part === '..'))
36
+ return null;
37
+ return { target: raw, repo: `${match[1]}/${match[2]}` };
38
+ }
@@ -10,6 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, dirname, join } from 'node:path';
12
12
  import { PACKAGE_RE, REPO_RE } from '../install/install-types.js';
13
+ import { githubReleaseTarget } from '../install/release-target.js';
13
14
  /** Resolve the active profile from the booted CLI args, falling back to `web`. */
14
15
  export function readProfileArg(fallback = 'web') {
15
16
  return profileFromArgv(process.argv, fallback);
@@ -90,6 +91,11 @@ export function githubRepoOf(value) {
90
91
  if (typeof value !== 'string')
91
92
  return null;
92
93
  const input = value.trim();
94
+ // release 直链先归一到仓库身份:目录命令里的固定 HTTPS release 包不是 git 地址,
95
+ // 但它的所属仓库就是 owner/repo —— 已装态读回、去重、展示都要拿到这层身份。
96
+ const release = githubReleaseTarget(input);
97
+ if (release !== null)
98
+ return release.repo;
93
99
  if (REPO_RE.test(input))
94
100
  return input;
95
101
  const patterns = [
@@ -9,3 +9,25 @@
9
9
  * 慢网络下失败不阻塞安装。
10
10
  */
11
11
  export declare function resolveNpmPackage(repo: string, registry?: string): Promise<string | null>;
12
+ type NpmSearchPackage = {
13
+ name?: unknown;
14
+ keywords?: unknown;
15
+ links?: {
16
+ repository?: unknown;
17
+ };
18
+ repository?: {
19
+ url?: unknown;
20
+ };
21
+ };
22
+ type NpmSearchObject = {
23
+ package?: NpmSearchPackage;
24
+ };
25
+ /**
26
+ * Pick the installable npm package for one GitHub repository. npm search is a
27
+ * relevance-ranked text search, not an exact repository index, so first filter
28
+ * by repository URL. If a monorepo publishes both a CLI/helper package and a
29
+ * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
30
+ * keyword instead of whichever result npm happened to rank first.
31
+ */
32
+ export declare function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null;
33
+ export {};
@@ -0,0 +1,28 @@
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
+ * 预构建 GitHub release 安装目标(目录权威命令 → 可安装直链)。
7
+ * 部分插件(如 loopx)把构建产物发布成 GitHub release 的 .tgz,仓库 HEAD 反而是
8
+ * monorepo 根、走 git 直装会因缺入口文件失败;目录下发的权威安装命令直接指向该
9
+ * release 包。这里把「目录命令」解析成安全的 release 目标,供客户端选路与服务端
10
+ * 校验共用(单一事实来源,避免两端口径漂移)。
11
+ *
12
+ * 只接受固定形态的 HTTPS `github.com/<owner>/<repo>/releases/download/<tag>/<asset>.tgz`:
13
+ * 目录命令是展示数据、绝非 shell 代码,绝不执行命令文本;跨仓跳转、外部主机、凭据、
14
+ * 移动的 latest 链接、query/fragment、多余 CLI 参数与 shell 片段一律拒绝。
15
+ */
16
+ /** release 包目标及其所属仓库身份(owner/repo);非法返回 null。 */
17
+ export interface GithubReleaseTarget {
18
+ /** 规范化后的安装目标(HTTPS 直链) */
19
+ target: string;
20
+ /** 该直链所属仓库(owner/repo):供已装态读回、队列去重与展示身份复用 */
21
+ repo: string;
22
+ }
23
+ /**
24
+ * 解析一个 release 安装目标。
25
+ * @param value - 目录命令(`dsh plugin --profile web add <url>`)或裸直链。
26
+ * @returns 命中返回 `{ target, repo }`,否则 null。
27
+ */
28
+ export declare function githubReleaseTarget(value: string): GithubReleaseTarget | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.4.13",
3
+ "version": "1.4.14",
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",
@@ -8,7 +8,7 @@
8
8
  * 客户端按接口返回渲染,不内置固定文案。
9
9
  * 图片(反馈群二维码)经 renderMarkdown 的 ![](url) 语法嵌入,居中、最大高度受控。
10
10
  */
11
- import { createElement as h } from 'react'
11
+ import { createElement as h, useMemo } from 'react'
12
12
  import type { MouseEvent } from 'react'
13
13
  import styles from '../../styles/Modal.module.css'
14
14
  import type { HubAboutInfo } from '../../types.ts'
@@ -16,6 +16,19 @@ import type { LocaleId, Translate } from '../../types.ts'
16
16
  import { renderMarkdown } from '../../logic/renderMarkdown.ts'
17
17
  import { CloseIcon } from '../ui/icons.tsx'
18
18
 
19
+ /**
20
+ * 反馈群二维码 URL 恒定(每次换码都是同名覆盖,文件名不变),浏览器 / CDN 会按
21
+ * URL 缓存旧图。这里在渲染前给二维码 URL 实时拼一个新的时间戳,强制每次打开
22
+ * 「关注我们」都回源拉取最新图片,无需重新构建 / 部署接口中心。
23
+ * 匹配规则与 renderMarkdown 的二维码识别保持一致(dsh-plugin-user-group-qr 前缀)。
24
+ */
25
+ function bustQrCache(md: string, ts: number): string {
26
+ return md.replace(
27
+ /(!\[[^\]]*\]\()([^)\s]*dsh-plugin-user-group-qr[^)\s]*)/g,
28
+ (_m, pre, url) => `${pre}${String(url).split('?')[0]}?v=${ts}`,
29
+ )
30
+ }
31
+
19
32
  export function AboutModal({ info, lang, t, onClose }: {
20
33
  info: HubAboutInfo | null
21
34
  lang: LocaleId
@@ -33,7 +46,10 @@ export function AboutModal({ info, lang, t, onClose }: {
33
46
  : (info.content.zh ?? info.content.en ?? '')
34
47
  : ''
35
48
  : ''
36
- const contentHtml = contentRaw.trim() ? renderMarkdown(contentRaw) : null
49
+ // 每次打开弹窗(组件重新挂载)生成一个时间戳,用于二维码破缓存;
50
+ // 同一次打开内保持稳定,避免父组件重渲染导致图片被反复重载。
51
+ const qrTs = useMemo(() => Date.now(), [])
52
+ const contentHtml = contentRaw.trim() ? renderMarkdown(bustQrCache(contentRaw, qrTs)) : null
37
53
  // 更新时间:ISO 字符串转本地可读格式;非法值静默隐藏
38
54
  let updated: string | null = null
39
55
  if (info?.updatedAt) {
@@ -310,8 +310,7 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
310
310
  if (kind === 'pnpmMissingDep') {
311
311
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
312
312
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
313
- // 插件无关(#73 的 issue 就是这么被误挂到正在安装的插件上的)→ 点名缺失的包 +
314
- // 删除条目指引,不引导提 Issue
313
+ // 插件无关 → 点名缺失的包 + 删除条目指引,不引导提 Issue
315
314
  const pkg = missingRegistryPackageOf(r.message)
316
315
  return h('div', null, [
317
316
  pkg ? h('div', { className: styles.failNetworkTarget }, t('failPnpmMissingDepTarget', { pkg })) : null,
@@ -424,8 +424,7 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
424
424
  : failureKind === 'pnpmMissingDep'
425
425
  // profile 里留着指向 registry 上不存在(404)的包的依赖:pnpm 连得上 registry,
426
426
  // 是被明确告知包不存在 —— 该 profile 的任何安装都会先卡在这条依赖上,与本次要装的
427
- // 插件无关(#73 的 issue 就是这么被误挂到正在安装的插件上的)→ 点名缺失的包 +
428
- // 删除条目指引,不引导提 Issue
427
+ // 插件无关 → 点名缺失的包 + 删除条目指引,不引导提 Issue
429
428
  ? h('div', null, [
430
429
  (() => {
431
430
  const pkg = missingRegistryPackageOf(message)
@@ -315,17 +315,16 @@ export function classifyFailure(message: string): FailureKind {
315
315
  // 与 pluginPrepare 同类(插件打包/分发问题),引导去仓库提 Issue
316
316
  if (/\[packaging\]|entry file missing/i.test(message)) return 'pluginPrepare'
317
317
  // 依赖在 registry 上不存在(`ERR_PNPM_FETCH_404` / `Not Found - 404`):pnpm 连得上 registry,
318
- // 是被明确告知「这个包不存在」—— 不是插件问题。profile 里留着一条指向未发布包的依赖条目时,
319
- // 该 profile 的**任何**安装都会先卡在这条依赖上(宿主尾部只有 `dsh: plugin command failed`)。
320
- // 必须在 pluginPrepare 之前:否则该尾部会被下面的 `Command failed` 判定吞成
321
- // 「插件分发不完整」,把 Issue 误挂到正在安装的插件上,而不是 404 的那个依赖(#73)
318
+ // 是被明确告知「这个包不存在」—— 属于 profile 的本地依赖问题,不是插件问题。profile 里留着
319
+ // 一条指向未发布包的依赖条目时,该 profile 的**任何**安装都会先卡在这条依赖上。
320
+ // 必须在 pluginPrepare 之前判定:此时宿主尾部只有通用的 `dsh: plugin command failed`,
321
+ // 落到下面会被当成构建失败。
322
322
  // (两个形态:pnpm 的 `[ERR_PNPM_FETCH_404] GET <url>: Not Found - 404` 与 npm 的
323
323
  // `npm error 404 Not Found - GET <url> - Not found`)
324
324
  if (/ERR_PNPM_FETCH_404|404 Not Found|Not Found - 404/i.test(message)) return 'pnpmMissingDep'
325
- // 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题
326
- // (`Command failed` 区分大小写:宿主尾部提示是小写的 `dsh: plugin command failed`,
327
- // 那只是「这条命令失败了」的通用说明 —— 带 /i 会让任何带该尾部的未知失败都被判成
328
- // 「插件分发不完整」,把 Issue 误挂到正在安装的插件上,见 #73)
325
+ // 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题。
326
+ // `Command failed` 区分大小写 —— 只匹配 pnpm 包装出的报错,不匹配宿主那句小写的
327
+ // `dsh: plugin command failed`(那只是「这条命令失败了」的通用说明)。
329
328
  if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
330
329
  // 其余失败(含 git prepare 被 pnpm 白名单拦截):当前通道装不上 = 插件分发/依赖的问题,一律提 Issue
331
330
  return 'repo'
@@ -386,16 +385,15 @@ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHAR
386
385
  }
387
386
 
388
387
  /** 提取首个错误代码(如 ERR_PNPM_PREPARE_PACKAGE),无则 null。
389
- * 代码里可能含数字(ERR_PNPM_FETCH_404 / ERR_PNPM_EBADPLATFORM_...),字符类必须带上 0-9,
390
- * 否则会被截成 `ERR_PNPM_FETCH_`(#73 的 issue 正文就是这么被截断的)。 */
388
+ * 代码里可能含数字(ERR_PNPM_FETCH_404 / ERR_PNPM_EBADPLATFORM_...),字符类需带上 0-9,
389
+ * 否则会被截断成 `ERR_PNPM_FETCH_`。 */
391
390
  export function coreErrorCode(message: string): string | null {
392
391
  const m = message.match(/\[?ERR_[A-Z0-9_]+\]?/)
393
392
  return m ? m[0].replace(/^\[|\]$/g, '') : null
394
393
  }
395
394
 
396
395
  /** 提取「registry 上不存在的包名」:从 404 行(`ERR_PNPM_FETCH_404 GET <registry>/<pkg>: Not Found - 404`)
397
- * 里取请求路径中的包名(含 scope,如 `@scope/name`);非 404 或提取不到返回 null。
398
- * 调用方据此在提示里点名具体是哪个依赖缺失 —— 光说「有个包 404 了」用户无从下手。 */
396
+ * 里取请求路径中的包名(含 scope,如 `@scope/name`);非 404 或提取不到返回 null。 */
399
397
  export function missingRegistryPackageOf(message: string): string | null {
400
398
  for (const line of message.split(/\r?\n/)) {
401
399
  if (!/ERR_PNPM_FETCH_404|404/.test(line)) continue
@@ -4,20 +4,29 @@
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
6
  * Install target/command helpers: decide the install channel (npm package
7
- * vs explicit-HTTPS GitHub), build the display command and normalize a raw
8
- * install spec back to its owner/repo identity.
7
+ * vs prebuilt GitHub release vs explicit-HTTPS GitHub), build the display
8
+ * command and normalize a raw install spec back to its owner/repo identity.
9
9
  */
10
10
  import type { HubPlugin } from '../types.ts'
11
+ import { githubReleaseTarget } from '../../server/services/install/release-target.ts'
11
12
 
12
13
  /**
13
14
  * 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
14
- * (走 npm registry tarball,更快、与 GitHub 网络无关);无 npm 包名 → git 直装。
15
- * 返回值 target 即传给后端 /install 的安装目标(npm 包名 或 owner/repo)。
15
+ * (走 npm registry tarball,更快、与 GitHub 网络无关);其次用「同仓库」的权威
16
+ * release 包直链(仓库 HEAD 往往是 monorepo 根、装不上,release .tgz 才是完整产物);
17
+ * 都没有 → git 直装。
18
+ * 返回值 target 即传给后端 /install 的安装目标(npm 包名 / release URL / owner/repo)。
16
19
  */
17
- export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
20
+ export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'release' | 'github' } {
18
21
  const pkg = (p.source?.npmPackage ?? '').trim()
19
22
  const repo = (p.source?.repo ?? '').trim()
20
23
  if (pkg && repo) return { target: pkg, via: 'npm' }
24
+ // 目录下发的权威命令若是「同仓库」的固定 release 直链,改走该预构建包;
25
+ // 跨仓 / 非法 release 目标不采信,退回 git 直装。
26
+ const release = githubReleaseTarget(p.install?.githubCommand ?? '')
27
+ if (release !== null && repo !== '' && release.repo.toLowerCase() === repo.toLowerCase()) {
28
+ return { target: release.target, via: 'release' }
29
+ }
21
30
  return { target: repo, via: 'github' }
22
31
  }
23
32
 
@@ -30,12 +39,13 @@ export function installCommandOf(p: HubPlugin, withProfile = false): string {
30
39
  const cmd = p.install?.command
31
40
  if (cmd) return cmd
32
41
  } else {
42
+ // release 与 git 通道共用目录下发的 githubCommand(git+ 显式 HTTPS 或 release 直链)
33
43
  const cmd = p.install?.githubCommand
34
44
  if (cmd) return cmd
35
45
  }
36
- return via === 'npm'
37
- ? `dsh plugin${withProfile ? ' --profile web' : ''} add ${target}`
38
- : `dsh plugin${withProfile ? ' --profile web' : ''} add git+https://github.com/${target}.git`
46
+ const prefix = `dsh plugin${withProfile ? ' --profile web' : ''} add `
47
+ if (via === 'npm' || via === 'release') return `${prefix}${target}`
48
+ return `${prefix}git+https://github.com/${target}.git`
39
49
  }
40
50
 
41
51
  /** Normalize a task/install target to its owner/repo display identity. */
@@ -15,6 +15,7 @@ import { dirname, join, resolve } from 'node:path'
15
15
  import { spawn, spawnSync } from 'node:child_process'
16
16
  import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.ts'
17
17
  import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
18
+ import { githubReleaseTarget } from '../services/install/release-target.ts'
18
19
  import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
19
20
  import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
20
21
  import { preflightTarget } from '../services/install/preflight.ts'
@@ -791,12 +792,14 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
791
792
  sendJson(response, 200, { ok: true, task: task.id })
792
793
  return
793
794
  }
794
- // 目标语法两态:GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式
795
- // HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
795
+ // 目标语法三态:固定 GitHub release .tgz(保留其包根目录,不转成仓库 HEAD);
796
+ // GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式 HTTPS Git 源
797
+ // (走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
796
798
  // githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
797
799
  const gitRepo = githubRepoOf(rawRepo)
798
- const repoTarget = gitRepo !== null ? githubTarget(gitRepo) : null
799
- let target: string | null = repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
800
+ const release = githubReleaseTarget(rawRepo)
801
+ const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null
802
+ let target: string | null = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
800
803
  if (target === null) {
801
804
  sendJson(response, 400, { error: 'unsupported install target' })
802
805
  return
@@ -825,9 +828,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
825
828
  }
826
829
  // 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
827
830
  // npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
828
- // 通道判定看反查后的最终 target:仍是 git 目标 = 走 GitHub 源码,否则走 npm。
831
+ // 通道判定看反查后的最终 target:release 包与 git 目标同属 GitHub 通道,否则走 npm。
829
832
  if (source !== 'catalog') {
830
- const isGitChannel = repoTarget !== null && target === repoTarget
833
+ const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
831
834
  if (isGitChannel && !settings.enableGitInstall) {
832
835
  sendJson(response, 403, { error: 'git installs are disabled by the security settings' })
833
836
  return
@@ -863,8 +866,12 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
863
866
  const effectiveProxy = settings.proxy !== ''
864
867
  ? settings.proxy
865
868
  : (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
866
- const isGitChannel = repoTarget !== null && target === repoTarget
867
- const npmProbeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
869
+ const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
870
+ // release 包是 https://github.com/ 直链下载(非 git 克隆),探针按 HTTP 打 github.com 主页;
871
+ // npm 通道探 registry 根。两者都是通道的稳定入口,不探具体包/仓库(404 会被误判成网络故障)。
872
+ const httpProbeTarget = release !== null
873
+ ? 'https://github.com/'
874
+ : `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
868
875
  // git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
869
876
  // https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
870
877
  // 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
@@ -880,9 +887,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
880
887
  if (reachable.ok) net = reachable
881
888
  }
882
889
  } else {
883
- net = await probeUrl(npmProbeTarget, effectiveProxy, 6000)
890
+ net = await probeUrl(httpProbeTarget, effectiveProxy, 6000)
884
891
  }
885
- const probeTarget = gitProbeTarget ?? npmProbeTarget
892
+ const probeTarget = gitProbeTarget ?? httpProbeTarget
886
893
  if (!net.ok) {
887
894
  // 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
888
895
  // 让用户一眼看懂是网络问题而非插件问题
@@ -18,6 +18,8 @@ const cache = new Map<string, string | null>()
18
18
 
19
19
  /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
20
20
  const REQUEST_TIMEOUT_MS = 8000
21
+ /** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */
22
+ const SEARCH_RESULT_LIMIT = 250
21
23
 
22
24
  /**
23
25
  * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
@@ -39,13 +41,49 @@ export function resolveNpmPackage(repo: string, registry = ''): Promise<string |
39
41
  return name.then((found) => found ?? null)
40
42
  }
41
43
 
44
+ type NpmSearchPackage = {
45
+ name?: unknown
46
+ keywords?: unknown
47
+ links?: { repository?: unknown }
48
+ repository?: { url?: unknown }
49
+ }
50
+
51
+ type NpmSearchObject = { package?: NpmSearchPackage }
52
+ type NpmSearchResponse = { objects?: NpmSearchObject[] }
53
+
54
+ /**
55
+ * Pick the installable npm package for one GitHub repository. npm search is a
56
+ * relevance-ranked text search, not an exact repository index, so first filter
57
+ * by repository URL. If a monorepo publishes both a CLI/helper package and a
58
+ * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
59
+ * keyword instead of whichever result npm happened to rank first.
60
+ */
61
+ export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null {
62
+ const needle = `github.com/${repo}`.toLowerCase()
63
+ const matches: Array<{ name: string; isDshPlugin: boolean }> = []
64
+
65
+ for (const obj of objects) {
66
+ const pkg = obj.package
67
+ if (typeof pkg?.name !== 'string' || pkg.name === '') continue
68
+ const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
69
+ if (!repoUrl.toLowerCase().includes(needle)) continue
70
+ const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
71
+ matches.push({
72
+ name: pkg.name,
73
+ isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
74
+ })
75
+ }
76
+
77
+ return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null
78
+ }
79
+
42
80
  /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
43
81
  * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */
44
82
  function searchRepo(repo: string, registry: string): Promise<string | null | undefined> {
45
83
  const [owner, name] = repo.split('/')
46
84
  if (owner === undefined || name === undefined || name === '') return Promise.resolve(null)
47
85
  const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
48
- const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`
86
+ const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`
49
87
  return new Promise((resolve) => {
50
88
  const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
51
89
  if (res.statusCode !== 200) {
@@ -57,18 +95,8 @@ function searchRepo(repo: string, registry: string): Promise<string | null | und
57
95
  res.on('data', (chunk: Buffer) => { body += chunk.toString() })
58
96
  res.on('end', () => {
59
97
  try {
60
- const data = JSON.parse(body) as { objects?: Array<{ package?: { name?: unknown; links?: { repository?: unknown }; repository?: { url?: unknown } } }> }
61
- const needle = `github.com/${owner}/${name}`.toLowerCase()
62
- for (const obj of data.objects ?? []) {
63
- const pkg = obj.package
64
- if (typeof pkg?.name !== 'string' || pkg.name === '') continue
65
- const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
66
- if (repoUrl.toLowerCase().includes(needle)) {
67
- resolve(pkg.name)
68
- return
69
- }
70
- }
71
- resolve(null)
98
+ const data = JSON.parse(body) as NpmSearchResponse
99
+ resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`))
72
100
  } catch {
73
101
  resolve(undefined)
74
102
  }
@@ -18,6 +18,7 @@ import { join } from 'node:path'
18
18
  import { pipeline } from 'node:stream/promises'
19
19
  import { promisify } from 'node:util'
20
20
  import { githubRepoOf } from '../profile/profile.ts'
21
+ import { githubReleaseTarget } from './release-target.ts'
21
22
  import { resolvePackageEntry } from './package-entry.ts'
22
23
 
23
24
  const execFileAsync = promisify(execFile)
@@ -37,6 +38,9 @@ export interface PreflightResult {
37
38
  }
38
39
 
39
40
  export async function preflightTarget(target: string): Promise<PreflightResult> {
41
+ // release 包自带包根:拿 monorepo HEAD 的源码分发去校验它没有意义,会把完整产物
42
+ // 误判成缺入口文件而拦下。该包的真实性交给安装本身与装后 verifyInstalledEntry 兜底。
43
+ if (githubReleaseTarget(target) !== null) return { ok: true, missing: null, name: null }
40
44
  const source = githubRepoOf(target)
41
45
  if (source === null) return { ok: true, missing: null, name: null }
42
46
  const [owner, repo] = source.split('/')