diff --git a/lib/services/install/npm-resolve.js b/lib/services/install/npm-resolve.js index f6e5776..b3dae4e 100644 --- a/lib/services/install/npm-resolve.js +++ b/lib/services/install/npm-resolve.js @@ -16,6 +16,8 @@ import { get } from 'node:https'; const cache = new Map(); /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */ const REQUEST_TIMEOUT_MS = 8000; +/** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */ +const SEARCH_RESULT_LIMIT = 250; /** * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。 * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留 @@ -37,6 +39,31 @@ export function resolveNpmPackage(repo, registry = '') { }); return name.then((found) => found ?? null); } +/** + * Pick the installable npm package for one GitHub repository. npm search is a + * relevance-ranked text search, not an exact repository index, so first filter + * by repository URL. If a monorepo publishes both a CLI/helper package and a + * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin` + * keyword instead of whichever result npm happened to rank first. + */ +export function selectNpmPackageForRepo(objects, repo) { + const needle = `github.com/${repo}`.toLowerCase(); + const matches = []; + for (const obj of objects) { + const pkg = obj.package; + if (typeof pkg?.name !== 'string' || pkg.name === '') + continue; + const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? ''); + if (!repoUrl.toLowerCase().includes(needle)) + continue; + const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []; + matches.push({ + name: pkg.name, + isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'), + }); + } + return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null; +} /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */ function searchRepo(repo, registry) { @@ -44,7 +71,7 @@ function searchRepo(repo, registry) { if (owner === undefined || name === undefined || name === '') return Promise.resolve(null); const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, ''); - const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`; + const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}`; return new Promise((resolve) => { const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => { if (res.statusCode !== 200) { @@ -57,18 +84,7 @@ function searchRepo(repo, registry) { res.on('end', () => { try { const data = JSON.parse(body); - const needle = `github.com/${owner}/${name}`.toLowerCase(); - for (const obj of data.objects ?? []) { - const pkg = obj.package; - if (typeof pkg?.name !== 'string' || pkg.name === '') - continue; - const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? ''); - if (repoUrl.toLowerCase().includes(needle)) { - resolve(pkg.name); - return; - } - } - resolve(null); + resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`)); } catch { resolve(undefined); diff --git a/lib/types/services/install/npm-resolve.d.ts b/lib/types/services/install/npm-resolve.d.ts index e1a9ec3..80f2106 100644 --- a/lib/types/services/install/npm-resolve.d.ts +++ b/lib/types/services/install/npm-resolve.d.ts @@ -9,3 +9,25 @@ * 慢网络下失败不阻塞安装。 */ export declare function resolveNpmPackage(repo: string, registry?: string): Promise; +type NpmSearchPackage = { + name?: unknown; + keywords?: unknown; + links?: { + repository?: unknown; + }; + repository?: { + url?: unknown; + }; +}; +type NpmSearchObject = { + package?: NpmSearchPackage; +}; +/** + * Pick the installable npm package for one GitHub repository. npm search is a + * relevance-ranked text search, not an exact repository index, so first filter + * by repository URL. If a monorepo publishes both a CLI/helper package and a + * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin` + * keyword instead of whichever result npm happened to rank first. + */ +export declare function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null; +export {}; diff --git a/src/server/services/install/npm-resolve.ts b/src/server/services/install/npm-resolve.ts index 5aec4e8..a8bfd59 100644 --- a/src/server/services/install/npm-resolve.ts +++ b/src/server/services/install/npm-resolve.ts @@ -18,6 +18,8 @@ const cache = new Map() /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */ const REQUEST_TIMEOUT_MS = 8000 +/** npm search 是相关性搜索而非 repository 精确索引;拉满单页结果后再本地做严格 repo 过滤。 */ +const SEARCH_RESULT_LIMIT = 250 /** * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。 @@ -39,13 +41,49 @@ export function resolveNpmPackage(repo: string, registry = ''): Promise found ?? null) } +type NpmSearchPackage = { + name?: unknown + keywords?: unknown + links?: { repository?: unknown } + repository?: { url?: unknown } +} + +type NpmSearchObject = { package?: NpmSearchPackage } +type NpmSearchResponse = { objects?: NpmSearchObject[] } + +/** + * Pick the installable npm package for one GitHub repository. npm search is a + * relevance-ranked text search, not an exact repository index, so first filter + * by repository URL. If a monorepo publishes both a CLI/helper package and a + * DSH plugin, prefer the package that explicitly advertises the `dsh-plugin` + * keyword instead of whichever result npm happened to rank first. + */ +export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null { + const needle = `github.com/${repo}`.toLowerCase() + const matches: Array<{ name: string; isDshPlugin: boolean }> = [] + + for (const obj of objects) { + const pkg = obj.package + if (typeof pkg?.name !== 'string' || pkg.name === '') continue + const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '') + if (!repoUrl.toLowerCase().includes(needle)) continue + const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [] + matches.push({ + name: pkg.name, + isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'), + }) + } + + return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null +} + /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 * 返回:包名 = 命中;null = 200 响应下确认无匹配;undefined = 本次未能确认(网络/超时/解析失败)。 */ function searchRepo(repo: string, registry: string): Promise { const [owner, name] = repo.split('/') if (owner === undefined || name === undefined || name === '') return Promise.resolve(null) const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '') - const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10` + const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=${SEARCH_RESULT_LIMIT}` return new Promise((resolve) => { const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => { if (res.statusCode !== 200) { @@ -57,18 +95,8 @@ function searchRepo(repo: string, registry: string): Promise { body += chunk.toString() }) res.on('end', () => { try { - const data = JSON.parse(body) as { objects?: Array<{ package?: { name?: unknown; links?: { repository?: unknown }; repository?: { url?: unknown } } }> } - const needle = `github.com/${owner}/${name}`.toLowerCase() - for (const obj of data.objects ?? []) { - const pkg = obj.package - if (typeof pkg?.name !== 'string' || pkg.name === '') continue - const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '') - if (repoUrl.toLowerCase().includes(needle)) { - resolve(pkg.name) - return - } - } - resolve(null) + const data = JSON.parse(body) as NpmSearchResponse + resolve(selectNpmPackageForRepo(data.objects ?? [], `${owner}/${name}`)) } catch { resolve(undefined) } diff --git a/tests/npm-resolve.test.ts b/tests/npm-resolve.test.ts index 0c83a0e..1cb2ef4 100644 --- a/tests/npm-resolve.test.ts +++ b/tests/npm-resolve.test.ts @@ -13,7 +13,7 @@ */ import { test } from 'node:test' import assert from 'node:assert/strict' -import { resolveNpmPackage } from '../src/server/services/install/npm-resolve.ts' +import { resolveNpmPackage, selectNpmPackageForRepo } from '../src/server/services/install/npm-resolve.ts' const ONLINE = process.env.DSH_HUB_TEST_OFFLINE !== '1' @@ -34,3 +34,26 @@ test('resolveNpmPackage: 非法 repo 直接返回 null', async () => { assert.equal(await resolveNpmPackage('norepo'), null) assert.equal(await resolveNpmPackage('a/b/c'), null) }) + +test('selectNpmPackageForRepo: 同仓库存在 launcher 和 DSH plugin 时优先 plugin', () => { + const objects = [ + { package: { name: 'local-shell-mcp', keywords: ['mcp', 'shell'], links: { repository: 'git+https://github.com/fwerkor/local-shell-mcp.git' } } }, + { package: { name: 'local-shell-mcp-dsh', keywords: ['dsh-plugin', 'mcp'], links: { repository: 'git+https://github.com/fwerkor/local-shell-mcp.git' } } }, + ] + assert.equal(selectNpmPackageForRepo(objects, 'fwerkor/local-shell-mcp'), 'local-shell-mcp-dsh') +}) + +test('selectNpmPackageForRepo: 单个同仓库包保持现有 fallback 行为', () => { + const objects = [ + { package: { name: 'some-plugin', keywords: ['deepseek'], repository: { url: 'https://github.com/example/plugin.git' } } }, + ] + assert.equal(selectNpmPackageForRepo(objects, 'example/plugin'), 'some-plugin') +}) + +test('selectNpmPackageForRepo: 忽略 npm search 中的非目标仓库结果', () => { + const objects = [ + { package: { name: 'wrong-dsh', keywords: ['dsh-plugin'], links: { repository: 'https://github.com/other/repo' } } }, + { package: { name: 'right', links: { repository: 'https://github.com/example/plugin' } } }, + ] + assert.equal(selectNpmPackageForRepo(objects, 'example/plugin'), 'right') +})