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.
@@ -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
- export function selectNpmPackageForRepo(objects, repo) {
50
- const needle = `github.com/${repo}`.toLowerCase();
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 = String(pkg.repository?.url ?? pkg.links?.repository ?? '');
57
- if (!repoUrl.toLowerCase().includes(needle))
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 meta = await readTarJson(tar, 'package/package.json');
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, `package/${entry}`);
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
- url?: unknown;
20
- };
18
+ repository?: unknown;
19
+ dsh?: unknown;
21
20
  };
22
21
  type NpmSearchObject = {
23
22
  package?: NpmSearchPackage;
24
23
  };
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
- */
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.0",
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('failPnpmPolicyHint'))
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('failPnpmPolicyHint'))
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
  }
@@ -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 11 的供应链安全策略拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` /
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 供应链安全策略拦截(pnpm 11:minimumReleaseAge 拒收「刚发布」的包 /
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 网络无关);其次用「同仓库」的权威
@@ -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
- ? 'the pnpm supply-chain policy on the user machine blocked the install (minimum release age for freshly published packages / untrusted origin)'
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
- `- 实际执行的安装命令:\`${command ?? `dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add git+https://github.com/${repo}.git`}\``,
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?: { url?: unknown }
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
- export function selectNpmPackageForRepo(objects: NpmSearchObject[], repo: string): string | null {
62
- const needle = `github.com/${repo}`.toLowerCase()
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 = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
69
- if (!repoUrl.toLowerCase().includes(needle)) continue
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 meta = await readTarJson(tar, 'package/package.json')
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, `package/${entry}`)
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 {