dsh-plugin 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/client.js +317 -299
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +29 -2
- package/lib/services/install/npm-resolve.js +53 -4
- package/lib/services/install/preflight.js +25 -2
- package/lib/types/services/install/npm-resolve.d.ts +4 -10
- package/lib/types/services/install/preflight.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/components/modals/NotificationsModal.tsx +2 -2
- package/src/client/components/modals/modals.tsx +2 -2
- package/src/client/hooks/useTaskQueue.ts +2 -2
- package/src/client/locales.ts +2 -0
- package/src/client/logic/failures.ts +13 -3
- package/src/client/logic/install-command.ts +10 -0
- package/src/client/logic/urls.ts +7 -4
- package/src/server/http/routes.ts +28 -2
- package/src/server/services/install/npm-resolve.ts +54 -5
- package/src/server/services/install/preflight.ts +25 -2
package/lib/http/routes.js
CHANGED
|
@@ -16,7 +16,7 @@ import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/pr
|
|
|
16
16
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
|
|
17
17
|
import { githubReleaseTarget } from '../services/install/release-target.js';
|
|
18
18
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
|
|
19
|
-
import { resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
19
|
+
import { isDshNpmPackageForRepo, resolveNpmPackage } from '../services/install/npm-resolve.js';
|
|
20
20
|
import { preflightTarget } from '../services/install/preflight.js';
|
|
21
21
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.js';
|
|
22
22
|
import { loadSettings, saveSettings, resetSettings } from '../services/settings.js';
|
|
@@ -814,13 +814,40 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
|
|
|
814
814
|
sendJson(response, 400, { error: 'unsupported install target' });
|
|
815
815
|
return;
|
|
816
816
|
}
|
|
817
|
+
const attempts = [];
|
|
818
|
+
// Catalog metadata may outlive a packaging split: one repository can publish both a
|
|
819
|
+
// CLI/helper package and a dedicated DSH plugin package. Keep the catalog target when
|
|
820
|
+
// registry metadata confirms it is a DSH package for this repo. Otherwise inspect the
|
|
821
|
+
// repository root package.json and use its package name only when that exact registry
|
|
822
|
+
// package also points back to the repo and identifies as a DSH plugin. This avoids
|
|
823
|
+
// relying on npm search ranking, which is not a repository index and can omit the
|
|
824
|
+
// correct package even with a large result window.
|
|
825
|
+
const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null;
|
|
826
|
+
if (catalogRepo !== null && release === null && repoTarget === null && validPackageName(rawRepo)) {
|
|
827
|
+
const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry);
|
|
828
|
+
if (catalogTargetIsDsh) {
|
|
829
|
+
recordResolvedNpmPackage(profile, catalogRepo, rawRepo);
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
const catalogGitTarget = githubTarget(catalogRepo);
|
|
833
|
+
if (catalogGitTarget !== null) {
|
|
834
|
+
const root = await preflightTarget(catalogGitTarget);
|
|
835
|
+
const rootName = root.ok && root.name !== null && validPackageName(root.name) ? root.name : null;
|
|
836
|
+
if (rootName !== null && rootName !== rawRepo
|
|
837
|
+
&& await isDshNpmPackageForRepo(rootName, catalogRepo, settings.npmRegistry)) {
|
|
838
|
+
attempts.push(`catalog npm target: \`${target}\` → repository root DSH package \`${rootName}\``);
|
|
839
|
+
target = rootName;
|
|
840
|
+
recordResolvedNpmPackage(profile, catalogRepo, rootName);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
817
845
|
// npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
|
|
818
846
|
// git 分发常缺构建产物/子模块,npm 包是作者发布的完整产物;未命中或
|
|
819
847
|
// 查询失败保留 github 直装(不阻塞安装,预检仍会拦截缺入口文件的情况)。
|
|
820
848
|
// 通用机制,不针对具体插件。
|
|
821
849
|
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
822
850
|
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
823
|
-
const attempts = [];
|
|
824
851
|
if (repoTarget !== null) {
|
|
825
852
|
// 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
|
|
826
853
|
const repoIdentity = gitRepo ?? rawRepo;
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 避免同一会话内重复查询 registry。
|
|
13
13
|
*/
|
|
14
14
|
import { get } from 'node:https';
|
|
15
|
+
import { githubRepoOf } from '../profile/profile.js';
|
|
15
16
|
/** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
|
|
16
17
|
const cache = new Map();
|
|
17
18
|
/** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
|
|
@@ -46,15 +47,59 @@ export function resolveNpmPackage(repo, registry = '') {
|
|
|
46
47
|
* DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
|
|
47
48
|
* keyword instead of whichever result npm happened to rank first.
|
|
48
49
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
function repositoryUrlOf(pkg) {
|
|
51
|
+
if (typeof pkg.repository === 'string')
|
|
52
|
+
return pkg.repository;
|
|
53
|
+
if (pkg.repository !== null && typeof pkg.repository === 'object') {
|
|
54
|
+
const url = pkg.repository.url;
|
|
55
|
+
if (typeof url === 'string')
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
return String(pkg.links?.repository ?? '');
|
|
59
|
+
}
|
|
60
|
+
export function isDshPackageMetadataForRepo(pkg, repo) {
|
|
61
|
+
if (githubRepoOf(repositoryUrlOf(pkg))?.toLowerCase() !== repo.toLowerCase())
|
|
62
|
+
return false;
|
|
63
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
|
|
64
|
+
const keywordMarked = keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin');
|
|
65
|
+
const manifestMarked = pkg.dsh !== null && typeof pkg.dsh === 'object';
|
|
66
|
+
return keywordMarked || manifestMarked;
|
|
67
|
+
}
|
|
68
|
+
export function isDshNpmPackageForRepo(packageName, repo, registry = '') {
|
|
69
|
+
if (packageName === '' || repo === '')
|
|
70
|
+
return Promise.resolve(false);
|
|
71
|
+
const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '');
|
|
72
|
+
const url = `${base}/${encodeURIComponent(packageName)}/latest`;
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
75
|
+
if (res.statusCode !== 200) {
|
|
76
|
+
res.resume();
|
|
77
|
+
resolve(false);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
let body = '';
|
|
81
|
+
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
82
|
+
res.on('end', () => {
|
|
83
|
+
try {
|
|
84
|
+
resolve(isDshPackageMetadataForRepo(JSON.parse(body), repo));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
resolve(false);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
req.on('timeout', () => req.destroy());
|
|
92
|
+
req.on('error', () => resolve(false));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function matchingPackagesForRepo(objects, repo) {
|
|
51
96
|
const matches = [];
|
|
52
97
|
for (const obj of objects) {
|
|
53
98
|
const pkg = obj.package;
|
|
54
99
|
if (typeof pkg?.name !== 'string' || pkg.name === '')
|
|
55
100
|
continue;
|
|
56
|
-
const repoUrl =
|
|
57
|
-
if (
|
|
101
|
+
const repoUrl = repositoryUrlOf(pkg);
|
|
102
|
+
if (githubRepoOf(repoUrl)?.toLowerCase() !== repo.toLowerCase())
|
|
58
103
|
continue;
|
|
59
104
|
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : [];
|
|
60
105
|
matches.push({
|
|
@@ -62,6 +107,10 @@ export function selectNpmPackageForRepo(objects, repo) {
|
|
|
62
107
|
isDshPlugin: keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin'),
|
|
63
108
|
});
|
|
64
109
|
}
|
|
110
|
+
return matches;
|
|
111
|
+
}
|
|
112
|
+
export function selectNpmPackageForRepo(objects, repo) {
|
|
113
|
+
const matches = matchingPackagesForRepo(objects, repo);
|
|
65
114
|
return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null;
|
|
66
115
|
}
|
|
67
116
|
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。
|
|
@@ -50,11 +50,15 @@ export async function preflightTarget(target) {
|
|
|
50
50
|
try {
|
|
51
51
|
const tar = join(dir, 'pkg.tar.gz');
|
|
52
52
|
await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar);
|
|
53
|
-
const
|
|
53
|
+
const rootPackage = await rootPackageEntry(tar);
|
|
54
|
+
if (rootPackage === null)
|
|
55
|
+
return { ok: true, missing: null, name: null };
|
|
56
|
+
const rootPrefix = rootPackage.slice(0, -'package.json'.length);
|
|
57
|
+
const meta = await readTarJson(tar, rootPackage);
|
|
54
58
|
if (meta === null)
|
|
55
59
|
return { ok: true, missing: null, name: null };
|
|
56
60
|
const entry = resolvePackageEntry(meta);
|
|
57
|
-
const inTar = await hasTarEntry(tar,
|
|
61
|
+
const inTar = await hasTarEntry(tar, `${rootPrefix}${entry}`);
|
|
58
62
|
return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null };
|
|
59
63
|
}
|
|
60
64
|
catch {
|
|
@@ -88,6 +92,25 @@ function download(url, dest) {
|
|
|
88
92
|
req.on('error', reject);
|
|
89
93
|
});
|
|
90
94
|
}
|
|
95
|
+
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
96
|
+
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
97
|
+
* single archive root; nested monorepo package manifests are intentionally
|
|
98
|
+
* ignored. Exported for a small parser regression test. */
|
|
99
|
+
export function rootPackageEntryOfTarList(listing) {
|
|
100
|
+
const candidates = listing
|
|
101
|
+
.split(/\r?\n/)
|
|
102
|
+
.filter((line) => /^[^/]+\/package\.json$/u.test(line));
|
|
103
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
104
|
+
}
|
|
105
|
+
async function rootPackageEntry(tar) {
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 });
|
|
108
|
+
return rootPackageEntryOfTarList(stdout);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
91
114
|
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
92
115
|
async function readTarJson(tar, entry) {
|
|
93
116
|
try {
|
|
@@ -15,19 +15,13 @@ type NpmSearchPackage = {
|
|
|
15
15
|
links?: {
|
|
16
16
|
repository?: unknown;
|
|
17
17
|
};
|
|
18
|
-
repository?:
|
|
19
|
-
|
|
20
|
-
};
|
|
18
|
+
repository?: unknown;
|
|
19
|
+
dsh?: unknown;
|
|
21
20
|
};
|
|
22
21
|
type NpmSearchObject = {
|
|
23
22
|
package?: NpmSearchPackage;
|
|
24
23
|
};
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*/
|
|
24
|
+
export declare function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string): boolean;
|
|
25
|
+
export declare function isDshNpmPackageForRepo(packageName: string, repo: string, registry?: string): Promise<boolean>;
|
|
32
26
|
export declare function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null;
|
|
33
27
|
export {};
|
|
@@ -8,3 +8,8 @@ export interface PreflightResult {
|
|
|
8
8
|
name: string | null;
|
|
9
9
|
}
|
|
10
10
|
export declare function preflightTarget(target: string): Promise<PreflightResult>;
|
|
11
|
+
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
12
|
+
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
13
|
+
* single archive root; nested monorepo package manifests are intentionally
|
|
14
|
+
* ignored. Exported for a small parser regression test. */
|
|
15
|
+
export declare function rootPackageEntryOfTarList(listing: string): string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -18,7 +18,7 @@ import type { MouseEvent } from 'react'
|
|
|
18
18
|
import styles from '../../styles/Modal.module.css'
|
|
19
19
|
import type { EnvInfo, Translate } from '../../types.ts'
|
|
20
20
|
import type { NotificationRecord } from '../../logic/failures.ts'
|
|
21
|
-
import { classifyFailure, missingRegistryPackageOf, npmTooLowVersion, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
|
|
21
|
+
import { classifyFailure, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
|
|
22
22
|
import type { PendingRestart, QueueTask } from '../../hooks/useTaskQueue.ts'
|
|
23
23
|
import { pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
|
|
24
24
|
import { CloseIcon } from '../ui/icons.tsx'
|
|
@@ -300,7 +300,7 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
300
300
|
if (kind === 'pnpmPolicy') {
|
|
301
301
|
// pnpm 11 供应链安全策略拦截(minimumReleaseAge 拒收发布太新的包 / untrusted origin):
|
|
302
302
|
// 本机 pnpm 策略不放行,装任何「刚发布/新来源」插件都撞墙 —— 给豁免指引,不引导提 Issue
|
|
303
|
-
return h('div', { className: styles.failPrepareHint }, t(
|
|
303
|
+
return h('div', { className: styles.failPrepareHint }, t(pnpmPolicyHintOf(r.message)))
|
|
304
304
|
}
|
|
305
305
|
if (kind === 'pnpmUnusedPatch') {
|
|
306
306
|
// profile 里留着指向旧版本 dsh-plugin 的补丁声明(ERR_PNPM_UNUSED_PATCH):pnpm
|
|
@@ -16,7 +16,7 @@ import { CloseIcon, ConfirmIcon, CopyIcon, LinkIcon } from '../ui/icons.tsx'
|
|
|
16
16
|
import { ProgressView } from './ProgressView.tsx'
|
|
17
17
|
import { installCommandOf } from '../../logic/install-command.ts'
|
|
18
18
|
import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../../logic/urls.ts'
|
|
19
|
-
import { classifyFailure, missingRegistryPackageOf, npmTooLowVersion, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
|
|
19
|
+
import { classifyFailure, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
|
|
20
20
|
|
|
21
21
|
/** 完成结果视图:绿色对勾 + 标题/描述;
|
|
22
22
|
* needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
|
|
@@ -416,7 +416,7 @@ export function ErrorModal({ message, repo, kind, command, attempts, t, env, onC
|
|
|
416
416
|
: failureKind === 'pnpmPolicy'
|
|
417
417
|
// pnpm 11 供应链安全策略拦截(minimumReleaseAge 拒收发布太新的包 / untrusted origin):
|
|
418
418
|
// 本机 pnpm 策略不放行,装任何「刚发布/新来源」插件都撞墙 —— 给豁免指引,不引导提 Issue
|
|
419
|
-
? h('div', { className: styles.failPrepareHint }, t(
|
|
419
|
+
? h('div', { className: styles.failPrepareHint }, t(pnpmPolicyHintOf(message)))
|
|
420
420
|
: failureKind === 'pnpmUnusedPatch'
|
|
421
421
|
// profile 里留着指向旧版本 dsh-plugin 的补丁声明(ERR_PNPM_UNUSED_PATCH):pnpm 发现
|
|
422
422
|
// 补丁没被用上就中止整次安装,任何插件都装不进来 —— 给删除条目的指引,不引导提 Issue
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { useEffect, useRef, useState } from 'react'
|
|
13
13
|
import type { HubPlugin, InstallChannel, LocaleId, Translate } from '../types.ts'
|
|
14
|
-
import { installCommandOf, installTargetOf, repoFromInstallTarget } from '../logic/install-command.ts'
|
|
14
|
+
import { executedCommandOf, installCommandOf, installTargetOf, repoFromInstallTarget } from '../logic/install-command.ts'
|
|
15
15
|
import type { InstalledItem } from '../logic/installed.ts'
|
|
16
16
|
|
|
17
17
|
/** 安装/卸载请求超时(ms):服务端 preflight + npm 反查可能耗时(各自都有超时,合计约 20~30s),
|
|
@@ -222,7 +222,7 @@ export function useTaskQueue(opts: TaskQueueOptions) {
|
|
|
222
222
|
: q.kind === 'uninstall' ? t('uninstallFail') : t('installFail')
|
|
223
223
|
// 自定义安装(带入口渠道):错误正文顶部精准提示「从哪张卡片发起」,目录安装保持原样
|
|
224
224
|
const entry = entryLineOf(t, q.channel)
|
|
225
|
-
onError(entry ? `${entry}\n${rawDetail}` : rawDetail, q.repo, q.kind, q.command, q.attempts, q.action === 'update')
|
|
225
|
+
onError(entry ? `${entry}\n${rawDetail}` : rawDetail, q.repo, q.kind, executedCommandOf(q.command, q.attempts), q.attempts, q.action === 'update')
|
|
226
226
|
}
|
|
227
227
|
maybeStopPoll()
|
|
228
228
|
}
|
package/src/client/locales.ts
CHANGED
|
@@ -333,6 +333,7 @@ export const zh = {
|
|
|
333
333
|
failAccessDeniedHint: '安装过程中 pnpm 写入 profile 里的文件被系统拒绝访问(Windows 常见报错 os error 5「拒绝访问」,英文为 Access is denied;Node 下的 EPERM / EACCES 同理)。常见原因是该文件或目录正被其他进程占用、文件带只读属性、目录权限受限,或杀毒软件实时防护拦截了写入——这与「文件正被占用」(os error 32)同属本机文件系统问题,任何插件装进该目录都会一样失败,不是插件本身的问题。请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试;若仍然失败,请检查 profile 目录及其文件是否被设为只读,并以管理员身份重新运行。',
|
|
334
334
|
failFsUnavailableHint: '安装过程中 pnpm 无法把文件写进本机磁盘(这类报错与装哪个插件无关,任何插件装在同一目录都会失败,不是插件本身的问题)。请按报错对号入座:① 磁盘空间不足(ENOSPC / no space left on device,Windows 为 os error 112)——请清理 profile 所在磁盘的剩余空间后重试;② 磁盘或挂载点只读、带写保护(EROFS / read-only file system,Windows 为 os error 19)——请确认 profile 目录所在磁盘可写(外接盘/U 盘、带写保护的移动盘或只读挂载的分区都会触发),必要时把插件目录换到系统盘的可写位置;③ 进程可用的文件句柄被耗尽(EMFILE / ENFILE / too many open files)——请关闭占用大量文件的程序并重启 DeepSeek Harness 后重试。',
|
|
335
335
|
failPnpmWorkspaceHint: '安装器调用 pnpm 时报「不能把依赖装到 workspace 根目录」(ERR_PNPM_ADDING_TO_ROOT):你的 profile 目录被 pnpm 视为 workspace 根目录,而本次安装命令没有声明在根目录操作,pnpm 直接拒绝执行,因此任何插件装进来都会失败。这不是插件本身的问题——可在 profile 目录(~/.dsh/profiles/<profile>)的 .npmrc 里加一行 ignore-workspace-root-check=true 后重试,或把 DeepSeek Harness 升级到最新版再试。',
|
|
336
|
+
failPnpmTarballIntegrityHint: 'pnpm 报 ERR_PNPM_MISSING_TARBALL_INTEGRITY:锁文件中的 tarball 条目没有校验值,无法验证下载的包。先查看完整日志,完全退出 DeepSeek Harness,并检查、备份当前 profile 的 package.json、pnpm-lock.yaml 和 pnpm-workspace.yaml。确认依赖来源后,将 pnpm-lock.yaml 改名保留,再通过同一 DSH 安装器安装适配当前宿主版本的插件,重新生成锁文件。重建会重新解析 profile 依赖,请核对新旧版本;保持完整性校验开启。',
|
|
336
337
|
failPnpmPolicyHint: '安装器调用 pnpm 时被其供应链安全策略拦截——装任何插件都会撞墙,不是插件本身的问题。分两种情况处理:① 提示「Minimum release age」(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION):包发布还不满 24 小时,pnpm 11 出于安全会拒收这种刚发布的新包。可在 pnpm 配置(pnpm-workspace.yaml)里加 minimumReleaseAge: 0 豁免,或等满 24 小时后重试。② 提示「untrusted origin」(ERR_PNPM_UNTRUSTED_ORIGIN):依赖来源不被本机 pnpm 信任,通常是该 profile 里之前用 github: 或非官方 npm 源装过插件,锁文件记录了不受信任的来源。请删除该 profile 目录下的 node_modules 与 pnpm-lock.yaml 后重新安装(可先到「系统日志」或 ~/.dsh/profiles/<profile>/hub.log 查看完整报错)。',
|
|
337
338
|
// 自定义安装三卡片入口的渠道名(报错/日志溯源:本次安装从哪个入口发起)
|
|
338
339
|
installChannelNpm: 'NPM 包',
|
|
@@ -711,6 +712,7 @@ export const en = {
|
|
|
711
712
|
failAccessDeniedHint: 'During install, Windows denied pnpm access to a file inside the profile (this shows as os error 5, "Access is denied"; on Node the equivalent codes are EPERM / EACCES). Typical causes: the file or directory is held by another process, the file is marked read-only, the directory permissions are restricted, or a real-time antivirus scan blocked the write. This is the same class of local file-system problem as a locked file (os error 32) — no plugin can be installed into that directory, so it is not a problem with the plugin itself. Fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry. If it still fails, check whether the profile directory or its files are read-only and run as administrator.',
|
|
712
713
|
failFsUnavailableHint: 'During install, pnpm could not write files to the local disk — this has nothing to do with which plugin you are installing: any plugin installed into the same directory would fail the same way, so it is not a problem with the plugin itself. Match the error you see: ① Out of space (ENOSPC / no space left on device, os error 112 on Windows) — free up space on the drive that holds the profile and retry. ② Read-only or write-protected volume (EROFS / read-only file system, os error 19 on Windows) — make sure the drive holding the profile directory is writable (external or USB drives, write-protected media, and read-only mounts all trigger this); move the plugin directory to a writable location on the system drive if needed. ③ File handles exhausted (EMFILE / ENFILE / too many open files) — close programs that hold many files open, restart DeepSeek Harness, then retry.',
|
|
713
714
|
failPnpmWorkspaceHint: 'pnpm refused to add the dependency to the workspace root (ERR_PNPM_ADDING_TO_ROOT): the profile directory is treated as a pnpm workspace root, and this install command did not declare that it operates at the root, so pnpm rejected it outright — no plugin can be installed into that profile. This is not a problem with the plugin itself. Add `ignore-workspace-root-check=true` to the `.npmrc` inside the profile directory (~/.dsh/profiles/<profile>) and retry, or upgrade DeepSeek Harness and try again.',
|
|
715
|
+
failPnpmTarballIntegrityHint: 'pnpm reported ERR_PNPM_MISSING_TARBALL_INTEGRITY: a tarball entry in the lockfile has no checksum to verify the downloaded package. Read the full log, fully quit DeepSeek Harness, and inspect and back up package.json, pnpm-lock.yaml and pnpm-workspace.yaml in the affected profile. Once the dependency sources are verified, rename pnpm-lock.yaml to preserve it, then use the same DSH installer to install a plugin version compatible with the host and regenerate the lockfile. This resolves profile dependencies again: compare the old and new versions, and keep integrity checks enabled.',
|
|
714
716
|
failPnpmPolicyHint: 'The installer was blocked by pnpm supply-chain security policy — every plugin install would hit the same wall, so this is not a problem with the plugin itself. Two cases: ① "Minimum release age" (ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION): the package was published less than 24 hours ago, and pnpm 11 refuses freshly-published packages for safety. Add minimumReleaseAge: 0 to your pnpm config (pnpm-workspace.yaml) to opt out, or wait until the 24 hours have passed and retry. ② "untrusted origin" (ERR_PNPM_UNTRUSTED_ORIGIN): a dependency source is not trusted by this machine, usually because plugins were previously installed via github: or a non-official npm registry, so the lockfile records an untrusted source. Delete node_modules and pnpm-lock.yaml under the profile directory (see System Logs or ~/.dsh/profiles/<profile>/hub.log for the full output), then reinstall.',
|
|
715
717
|
// Custom install entry channel names (the three cards) — used in error/log traceability
|
|
716
718
|
installChannelNpm: 'NPM package',
|
|
@@ -153,6 +153,16 @@ export function removeNotification(id: number): NotificationRecord[] {
|
|
|
153
153
|
|
|
154
154
|
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
|
|
155
155
|
|
|
156
|
+
/** Match the pnpm error code, not a generic mention of checksum or integrity. */
|
|
157
|
+
function missingTarballIntegrity(message: string): boolean {
|
|
158
|
+
return /\bERR_PNPM_MISSING_TARBALL_INTEGRITY\b/.test(message)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The same policy subtype selects recovery copy in live and saved failures. */
|
|
162
|
+
export function pnpmPolicyHintOf(message: string): 'failPnpmTarballIntegrityHint' | 'failPnpmPolicyHint' {
|
|
163
|
+
return missingTarballIntegrity(message) ? 'failPnpmTarballIntegrityHint' : 'failPnpmPolicyHint'
|
|
164
|
+
}
|
|
165
|
+
|
|
156
166
|
/**
|
|
157
167
|
* 失败归类:把安装输出归到具体成因,供弹窗文案、是否引导提 Issue 与 issue 预填原因使用。
|
|
158
168
|
* 分界是「本机环境问题」(装任何插件都会同样失败 → 不引导提 Issue)与
|
|
@@ -183,7 +193,7 @@ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissi
|
|
|
183
193
|
* - originRejected:请求的来源没通过本地 hub 服务的校验(服务端 403,正文是裸的
|
|
184
194
|
* `untrusted origin`)—— 请求在进入安装流程前就被拒,与 pnpm 无关;常见于用非 localhost 的
|
|
185
195
|
* 地址(如局域网 IP)打开市场页面,或宿主页面的 origin 未被识别 → 提示从本机地址打开市场后重试
|
|
186
|
-
* - pnpmPolicy:pnpm
|
|
196
|
+
* - pnpmPolicy:pnpm 的供应链安全策略拒绝安装(缺少 tarball integrity、`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` /
|
|
187
197
|
* `Minimum release age` —— 锁文件里包的发布时间还不满 24 小时被拒;`untrusted origin` ——
|
|
188
198
|
* 依赖来源未被本机 pnpm 信任)→ 提示按子场景给解法(发布未满 24 小时 → `minimumReleaseAge: 0`
|
|
189
199
|
* 豁免或等满 24 小时;untrusted origin → 删除 profile 的 node_modules + pnpm-lock.yaml 清掉
|
|
@@ -266,11 +276,11 @@ export function classifyFailure(message: string): FailureKind {
|
|
|
266
276
|
// 用「整行以它结尾」来区隔 pnpm 的真实报错:pnpm 的形态后面一定跟着 dsh 的上下文
|
|
267
277
|
// (如 `untrusted origin\ndsh: pnpm failed in profile directory …`),不会被本规则命中
|
|
268
278
|
if (/(^|\n)untrusted origin\s*$/.test(message)) return 'originRejected'
|
|
269
|
-
// pnpm
|
|
279
|
+
// pnpm 供应链安全策略拦截(缺少 tarball 校验值 / minimumReleaseAge 拒收「刚发布」的包 /
|
|
270
280
|
// untrusted origin 来源不受信任):pnpm 在、也连得上,纯粹是本机策略不放行 ——
|
|
271
281
|
// 装任何「新发布/非信任来源」的插件都会同样失败。
|
|
272
282
|
// 必须在 pnpmIgnoredBuild 之前 —— 该策略优先于「构建脚本被白名单拦截」,且两者都不引导提 Issue。
|
|
273
|
-
if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|Minimum release age|untrusted origin/i.test(message)) return 'pnpmPolicy'
|
|
283
|
+
if (missingTarballIntegrity(message) || /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|Minimum release age|untrusted origin/i.test(message)) return 'pnpmPolicy'
|
|
274
284
|
// profile 里留着指向旧版本 dsh-plugin 的 patch 声明(ERR_PNPM_UNUSED_PATCH /
|
|
275
285
|
// `The following patches were not used: dsh-plugin@1.4.2`):本机 pnpm 配置与本次解析到的版本
|
|
276
286
|
// 对不上,pnpm 出于安全直接中止整次安装 —— 任何插件都装不进来 → 提示删条目后重试。
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
import type { HubPlugin } from '../types.ts'
|
|
11
11
|
import { githubReleaseTarget } from '../../server/services/install/release-target.ts'
|
|
12
12
|
|
|
13
|
+
/** Backend attempts are chronological; report the last executed command before a display fallback.
|
|
14
|
+
* These prefixes are the task queue's recorded command shapes, never commands to execute here.
|
|
15
|
+
*/
|
|
16
|
+
export function executedCommandOf(command?: string, attempts?: string[]): string | undefined {
|
|
17
|
+
if (attempts === undefined) return command
|
|
18
|
+
return attempts.slice().reverse().find((attempt) =>
|
|
19
|
+
/^dsh plugin --profile(?:=| )\S+ (?:add|remove|update) \S/.test(attempt)
|
|
20
|
+
|| /^npm install -g \S/.test(attempt))
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
|
|
15
25
|
* (走 npm registry tarball,更快、与 GitHub 网络无关);其次用「同仓库」的权威
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { EnvInfo, HubPlugin, LocaleId } from '../types.ts'
|
|
11
11
|
import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from './constants.ts'
|
|
12
|
-
import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, summarizeError } from './failures.ts'
|
|
12
|
+
import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, pnpmPolicyHintOf, summarizeError } from './failures.ts'
|
|
13
|
+
import { executedCommandOf } from './install-command.ts'
|
|
13
14
|
import type { FailureKind } from './failures.ts'
|
|
14
15
|
|
|
15
16
|
/** dsh-plugin.org 中文页挂在 /zh/ 前缀下,英文在根路径。 */
|
|
@@ -78,6 +79,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
78
79
|
// [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整
|
|
79
80
|
// (package.json 声明的入口文件在发布物里缺失),作者照此补齐即可。
|
|
80
81
|
const kind = classifyFailure(message)
|
|
82
|
+
const executed = executedCommandOf(command, attempts)
|
|
81
83
|
// 标题 = 错误原因(非动作词),让 issue 列表一眼可分流
|
|
82
84
|
const title = `[dsh-plugin.org | dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}`
|
|
83
85
|
const reason = /\[packaging\]/i.test(message)
|
|
@@ -91,7 +93,9 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
91
93
|
: kind === 'originRejected'
|
|
92
94
|
? 'the request never reached the install flow — the local service rejected its origin (local environment issue)'
|
|
93
95
|
: kind === 'pnpmPolicy'
|
|
94
|
-
?
|
|
96
|
+
? pnpmPolicyHintOf(message) === 'failPnpmTarballIntegrityHint'
|
|
97
|
+
? 'pnpm rejected a lockfile entry without tarball integrity; inspect and back up the profile lockfile before resolving it again, keeping integrity checks enabled'
|
|
98
|
+
: 'the pnpm supply-chain policy on the user machine blocked the install (minimum release age for freshly published packages / untrusted origin)'
|
|
95
99
|
: kind === 'pnpmUnusedPatch'
|
|
96
100
|
? 'the profile keeps a pnpm patch entry for an older dsh-plugin version, so pnpm aborted the install (local config issue)'
|
|
97
101
|
: kind === 'fileLocked'
|
|
@@ -112,7 +116,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
112
116
|
// 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的安装命令(含 --profile)与执行结果,链接由常量动态拼接
|
|
113
117
|
`## [DSH Plugin 插件市场](${SITE_URL}) · 安装 Plugin 失败错误信息`,
|
|
114
118
|
`本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件市场的安装程序自动生成,随本次安装失败一并提交。`,
|
|
115
|
-
`-
|
|
119
|
+
`- 实际执行的安装命令:${executed ? `\`${executed}\`` : 'unknown (no executed command recorded)'}`,
|
|
116
120
|
`- 执行结果:安装失败,未能安装该插件。`,
|
|
117
121
|
// 尝试过的安装方式(npm 反查 + 实际执行命令,按先后顺序):作者据此反推正确的
|
|
118
122
|
// npm 包名 —— 组织 scope 与 GitHub 用户名不一致时仅凭仓库名猜不到,作者看到我们
|
|
@@ -176,4 +180,3 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
176
180
|
}
|
|
177
181
|
return url
|
|
178
182
|
}
|
|
179
|
-
|
|
@@ -17,7 +17,7 @@ import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/pr
|
|
|
17
17
|
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
|
|
18
18
|
import { githubReleaseTarget } from '../services/install/release-target.ts'
|
|
19
19
|
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
|
|
20
|
-
import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
|
|
20
|
+
import { isDshNpmPackageForRepo, resolveNpmPackage } from '../services/install/npm-resolve.ts'
|
|
21
21
|
import { preflightTarget } from '../services/install/preflight.ts'
|
|
22
22
|
import { isDshPlugin, isEntryLoaded } from '../services/loader.ts'
|
|
23
23
|
import { loadSettings, saveSettings, resetSettings, type HubSettings } from '../services/settings.ts'
|
|
@@ -804,13 +804,39 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
|
|
|
804
804
|
sendJson(response, 400, { error: 'unsupported install target' })
|
|
805
805
|
return
|
|
806
806
|
}
|
|
807
|
+
const attempts: string[] = []
|
|
808
|
+
// Catalog metadata may outlive a packaging split: one repository can publish both a
|
|
809
|
+
// CLI/helper package and a dedicated DSH plugin package. Keep the catalog target when
|
|
810
|
+
// registry metadata confirms it is a DSH package for this repo. Otherwise inspect the
|
|
811
|
+
// repository root package.json and use its package name only when that exact registry
|
|
812
|
+
// package also points back to the repo and identifies as a DSH plugin. This avoids
|
|
813
|
+
// relying on npm search ranking, which is not a repository index and can omit the
|
|
814
|
+
// correct package even with a large result window.
|
|
815
|
+
const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null
|
|
816
|
+
if (catalogRepo !== null && release === null && repoTarget === null && validPackageName(rawRepo)) {
|
|
817
|
+
const catalogTargetIsDsh = await isDshNpmPackageForRepo(rawRepo, catalogRepo, settings.npmRegistry)
|
|
818
|
+
if (catalogTargetIsDsh) {
|
|
819
|
+
recordResolvedNpmPackage(profile, catalogRepo, rawRepo)
|
|
820
|
+
} else {
|
|
821
|
+
const catalogGitTarget = githubTarget(catalogRepo)
|
|
822
|
+
if (catalogGitTarget !== null) {
|
|
823
|
+
const root = await preflightTarget(catalogGitTarget)
|
|
824
|
+
const rootName = root.ok && root.name !== null && validPackageName(root.name) ? root.name : null
|
|
825
|
+
if (rootName !== null && rootName !== rawRepo
|
|
826
|
+
&& await isDshNpmPackageForRepo(rootName, catalogRepo, settings.npmRegistry)) {
|
|
827
|
+
attempts.push(`catalog npm target: \`${target}\` → repository root DSH package \`${rootName}\``)
|
|
828
|
+
target = rootName
|
|
829
|
+
recordResolvedNpmPackage(profile, catalogRepo, rootName)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
807
834
|
// npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
|
|
808
835
|
// git 分发常缺构建产物/子模块,npm 包是作者发布的完整产物;未命中或
|
|
809
836
|
// 查询失败保留 github 直装(不阻塞安装,预检仍会拦截缺入口文件的情况)。
|
|
810
837
|
// 通用机制,不针对具体插件。
|
|
811
838
|
// 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
|
|
812
839
|
// 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
|
|
813
|
-
const attempts: string[] = []
|
|
814
840
|
if (repoTarget !== null) {
|
|
815
841
|
// 反查/记录统一用归一化后的 owner/repo(gitRepo),保证「输入完整链接」也走同一套 npm 反查
|
|
816
842
|
const repoIdentity = gitRepo ?? rawRepo
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* 避免同一会话内重复查询 registry。
|
|
13
13
|
*/
|
|
14
14
|
import { get } from 'node:https'
|
|
15
|
+
import { githubRepoOf } from '../profile/profile.ts'
|
|
15
16
|
|
|
16
17
|
/** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包;网络异常不缓存)。 */
|
|
17
18
|
const cache = new Map<string, string | null>()
|
|
@@ -45,7 +46,8 @@ type NpmSearchPackage = {
|
|
|
45
46
|
name?: unknown
|
|
46
47
|
keywords?: unknown
|
|
47
48
|
links?: { repository?: unknown }
|
|
48
|
-
repository?:
|
|
49
|
+
repository?: unknown
|
|
50
|
+
dsh?: unknown
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
type NpmSearchObject = { package?: NpmSearchPackage }
|
|
@@ -58,15 +60,57 @@ type NpmSearchResponse = { objects?: NpmSearchObject[] }
|
|
|
58
60
|
* DSH plugin, prefer the package that explicitly advertises the `dsh-plugin`
|
|
59
61
|
* keyword instead of whichever result npm happened to rank first.
|
|
60
62
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
function repositoryUrlOf(pkg: NpmSearchPackage): string {
|
|
64
|
+
if (typeof pkg.repository === 'string') return pkg.repository
|
|
65
|
+
if (pkg.repository !== null && typeof pkg.repository === 'object') {
|
|
66
|
+
const url = (pkg.repository as { url?: unknown }).url
|
|
67
|
+
if (typeof url === 'string') return url
|
|
68
|
+
}
|
|
69
|
+
return String(pkg.links?.repository ?? '')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isDshPackageMetadataForRepo(pkg: NpmSearchPackage, repo: string): boolean {
|
|
73
|
+
if (githubRepoOf(repositoryUrlOf(pkg))?.toLowerCase() !== repo.toLowerCase()) return false
|
|
74
|
+
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
|
|
75
|
+
const keywordMarked = keywords.some((keyword) => typeof keyword === 'string' && keyword.toLowerCase() === 'dsh-plugin')
|
|
76
|
+
const manifestMarked = pkg.dsh !== null && typeof pkg.dsh === 'object'
|
|
77
|
+
return keywordMarked || manifestMarked
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isDshNpmPackageForRepo(packageName: string, repo: string, registry = ''): Promise<boolean> {
|
|
81
|
+
if (packageName === '' || repo === '') return Promise.resolve(false)
|
|
82
|
+
const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '')
|
|
83
|
+
const url = `${base}/${encodeURIComponent(packageName)}/latest`
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
86
|
+
if (res.statusCode !== 200) {
|
|
87
|
+
res.resume()
|
|
88
|
+
resolve(false)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
let body = ''
|
|
92
|
+
res.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
|
93
|
+
res.on('end', () => {
|
|
94
|
+
try {
|
|
95
|
+
resolve(isDshPackageMetadataForRepo(JSON.parse(body) as NpmSearchPackage, repo))
|
|
96
|
+
} catch {
|
|
97
|
+
resolve(false)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
req.on('timeout', () => req.destroy())
|
|
102
|
+
req.on('error', () => resolve(false))
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function matchingPackagesForRepo(objects: NpmSearchObject[], repo: string): Array<{ name: string; isDshPlugin: boolean }> {
|
|
63
107
|
const matches: Array<{ name: string; isDshPlugin: boolean }> = []
|
|
64
108
|
|
|
65
109
|
for (const obj of objects) {
|
|
66
110
|
const pkg = obj.package
|
|
67
111
|
if (typeof pkg?.name !== 'string' || pkg.name === '') continue
|
|
68
|
-
const repoUrl =
|
|
69
|
-
if (
|
|
112
|
+
const repoUrl = repositoryUrlOf(pkg)
|
|
113
|
+
if (githubRepoOf(repoUrl)?.toLowerCase() !== repo.toLowerCase()) continue
|
|
70
114
|
const keywords = Array.isArray(pkg.keywords) ? pkg.keywords : []
|
|
71
115
|
matches.push({
|
|
72
116
|
name: pkg.name,
|
|
@@ -74,6 +118,11 @@ export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string
|
|
|
74
118
|
})
|
|
75
119
|
}
|
|
76
120
|
|
|
121
|
+
return matches
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null {
|
|
125
|
+
const matches = matchingPackagesForRepo(objects, repo)
|
|
77
126
|
return matches.find((candidate) => candidate.isDshPlugin)?.name ?? matches[0]?.name ?? null
|
|
78
127
|
}
|
|
79
128
|
|
|
@@ -59,10 +59,13 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
|
|
|
59
59
|
try {
|
|
60
60
|
const tar = join(dir, 'pkg.tar.gz')
|
|
61
61
|
await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
|
|
62
|
-
const
|
|
62
|
+
const rootPackage = await rootPackageEntry(tar)
|
|
63
|
+
if (rootPackage === null) return { ok: true, missing: null, name: null }
|
|
64
|
+
const rootPrefix = rootPackage.slice(0, -'package.json'.length)
|
|
65
|
+
const meta = await readTarJson(tar, rootPackage)
|
|
63
66
|
if (meta === null) return { ok: true, missing: null, name: null }
|
|
64
67
|
const entry = resolvePackageEntry(meta)
|
|
65
|
-
const inTar = await hasTarEntry(tar,
|
|
68
|
+
const inTar = await hasTarEntry(tar, `${rootPrefix}${entry}`)
|
|
66
69
|
return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null }
|
|
67
70
|
} catch {
|
|
68
71
|
return { ok: true, missing: null, name: null }
|
|
@@ -96,6 +99,26 @@ function download(url: string, dest: string): Promise<void> {
|
|
|
96
99
|
})
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
/** GitHub codeload tarballs use a dynamic top-level directory (`repo-<sha>/`),
|
|
103
|
+
* not npm's `package/` prefix. Pick only a package.json directly under that
|
|
104
|
+
* single archive root; nested monorepo package manifests are intentionally
|
|
105
|
+
* ignored. Exported for a small parser regression test. */
|
|
106
|
+
export function rootPackageEntryOfTarList(listing: string): string | null {
|
|
107
|
+
const candidates = listing
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
.filter((line) => /^[^/]+\/package\.json$/u.test(line))
|
|
110
|
+
return candidates.length === 1 ? candidates[0] : null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function rootPackageEntry(tar: string): Promise<string | null> {
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 })
|
|
116
|
+
return rootPackageEntryOfTarList(stdout)
|
|
117
|
+
} catch {
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
100
123
|
async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
|
|
101
124
|
try {
|