dsh-plugin 1.4.0 → 1.4.2

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.
@@ -121,6 +121,20 @@ export function readInstalled(profile) {
121
121
  return {};
122
122
  }
123
123
  }
124
+ /** 读已装依赖实际落在磁盘上的仓库身份(`owner/repo`):解析 node_modules/<name>/package.json
125
+ * 的 repository 字段。已装 spec 可能是纯版本号(丢了仓库身份),但真实包元数据仍指向来源仓库,
126
+ * 据此可区分「同名依赖其实是同一仓库的残留(应转 update)」还是「跨仓库包名冲突(应报错)」。
127
+ * 读不到 / 解析不出返回 null(交给调用方按冲突处理)。 */
128
+ function installedRepoOf(profile, name) {
129
+ try {
130
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8'));
131
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
132
+ return repo ? githubRepoOf(repo) : null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
124
138
  /** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
125
139
  * 绝不挂起)—— 原先从 process.argv[1] 向上找 package.json 猜版本在部分宿主加载方式下取不到,
126
140
  * 导致 issue 环境快照 DSH: unknown;拿不到再退回入口 package.json 查找(dsh 不在 PATH 时兜底)。 */
@@ -655,8 +669,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
655
669
  // 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
656
670
  try {
657
671
  const body = await readJsonBody(request);
658
- // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
659
- const mode = body !== null && typeof body === 'object' && body.mode === 'update'
672
+ // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)。
673
+ // 后续「同仓库残留」检测可能把 install 就地升级为 update(见包名冲突检测),故用 let。
674
+ let mode = body !== null && typeof body === 'object' && body.mode === 'update'
660
675
  ? 'update'
661
676
  : 'install';
662
677
  // installTargetOf 先把完整 DSH 命令(`dsh plugin [--profile <p>] add <target>`)剥成裸目标,
@@ -829,6 +844,32 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
829
844
  });
830
845
  return;
831
846
  }
847
+ // 包名冲突检测(仅 git 通道):git 目标包内声明的 name 若与 profile 已装依赖同名,
848
+ // pnpm 会以该 name 做依赖键撞车,抛出的 CLI 报错晦涩难懂(issue #25:sandbase-harness
849
+ // 声明 managed-agents,但该包名在 registry 上被另一仓库占用,npm 反查搜不到 → git 直装
850
+ // 撞已装同名依赖)。到这里已装依赖要么来源不同仓库、要么 spec 是版本号解析不出仓库身份。
851
+ // 读 node_modules 里已装包的真实 repository 区分两种情况:
852
+ // - 指向同一仓库 → 是「同仓库残留」(spec 丢了仓库身份),就地转 update 覆盖重装(幂等);
853
+ // - 指向别的仓库 / 读不到 → 跨仓库包名冲突,入队前转成明确 409,指导用户先卸载或改装。
854
+ if (preflight.name !== null && repoTarget !== null && target === repoTarget) {
855
+ const declared = preflight.name;
856
+ if (installed[declared] !== undefined) {
857
+ const installedRepo = installedRepoOf(profile, declared);
858
+ if (installedRepo !== null && gitRepo !== null && installedRepo.toLowerCase() === gitRepo.toLowerCase()) {
859
+ // 同名且同源:已装 spec 丢了仓库身份导致的残留,直接按 update 覆盖重装,不打扰用户
860
+ attempts.push(`same-repo residual: \`${declared}\` resolves to \`${installedRepo}\` → reinstall as update`);
861
+ mode = 'update';
862
+ }
863
+ else {
864
+ attempts.push(`package name conflict: \`${gitRepo}\` declares npm name \`${declared}\`, which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository)`);
865
+ const error = lang === 'zh'
866
+ ? `already installed: ${declared} —— ${gitRepo} 声明的 npm 包名「${declared}」已存在于当前 profile,且无法匹配到本仓库(该包名在 npm 上可能被其他仓库占用)。请先移除现有的 ${declared}(dsh plugin remove ${declared}),再重新安装;或改装该包名对应的官方 npm 包。`
867
+ : `already installed: ${declared} — ${gitRepo} declares the npm package name "${declared}", which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository). Remove the existing ${declared} dependency (dsh plugin remove ${declared}) first, then retry; or install the official npm package under that name.`;
868
+ sendJson(response, 409, { error, attempts });
869
+ return;
870
+ }
871
+ }
872
+ }
832
873
  }
833
874
  // 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
834
875
  // 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
@@ -25,7 +25,7 @@ const PREFLIGHT_TIMEOUT_MS = 15_000;
25
25
  export async function preflightTarget(target) {
26
26
  const source = githubRepoOf(target);
27
27
  if (source === null)
28
- return { ok: true, missing: null };
28
+ return { ok: true, missing: null, name: null };
29
29
  const [owner, repo] = source.split('/');
30
30
  let commit = '';
31
31
  try {
@@ -36,17 +36,17 @@ export async function preflightTarget(target) {
36
36
  commit = stdout.split(/\s+/)[0] ?? '';
37
37
  }
38
38
  catch {
39
- return { ok: true, missing: null };
39
+ return { ok: true, missing: null, name: null };
40
40
  }
41
41
  if (!commit)
42
- return { ok: true, missing: null };
42
+ return { ok: true, missing: null, name: null };
43
43
  const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'));
44
44
  try {
45
45
  const tar = join(dir, 'pkg.tar.gz');
46
46
  await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar);
47
47
  const meta = await readTarJson(tar, 'package/package.json');
48
48
  if (meta === null)
49
- return { ok: true, missing: null };
49
+ return { ok: true, missing: null, name: null };
50
50
  const dot = meta.exports?.['.'];
51
51
  const resolved = typeof dot === 'string'
52
52
  ? dot
@@ -55,10 +55,10 @@ export async function preflightTarget(target) {
55
55
  : undefined;
56
56
  const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js';
57
57
  const inTar = await hasTarEntry(tar, `package/${entry}`);
58
- return { ok: inTar, missing: inTar ? null : entry };
58
+ return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null };
59
59
  }
60
60
  catch {
61
- return { ok: true, missing: null };
61
+ return { ok: true, missing: null, name: null };
62
62
  }
63
63
  finally {
64
64
  rmSync(dir, { recursive: true, force: true });
@@ -1,6 +1,10 @@
1
- /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径。 */
1
+ /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径;
2
+ * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
3
+ * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
4
+ * 需要把晦涩的 CLI 报错转成明确的拦截。 */
2
5
  export interface PreflightResult {
3
6
  ok: boolean;
4
7
  missing: string | null;
8
+ name: string | null;
5
9
  }
6
10
  export declare function preflightTarget(target: string): Promise<PreflightResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 4000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):4000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  import { createElement as h, useEffect, useRef } from 'react'
12
12
  import styles from '../../styles/List.module.css'
13
13
  import type { HubPlugin, LocaleId, Translate } from '../../types.ts'
14
- import { SITE_URL } from '../../logic/constants.ts'
14
+ import { siteUrl } from '../../logic/urls.ts'
15
15
  import { useIncrementalList } from '../../hooks/useIncrementalList.ts'
16
16
  import { PluginCard } from './PluginCard.tsx'
17
17
 
@@ -82,7 +82,7 @@ export function CatalogList({ plugins, failed, visible, total, t, langPath, relo
82
82
  plugins !== null && !failed && h('div', { className: styles.footer },
83
83
  h('a', {
84
84
  className: styles.footLink,
85
- href: `${SITE_URL}${langPath}`,
85
+ href: siteUrl(langPath),
86
86
  target: '_blank',
87
87
  rel: 'noopener noreferrer',
88
88
  }, t('browseAll', { n: total })),
@@ -9,7 +9,8 @@
9
9
  import { createElement as h, Fragment } from 'react'
10
10
  import styles from '../../styles/Header.module.css'
11
11
  import type { Translate } from '../../types.ts'
12
- import { GITHUB_URL, PLUGIN_VERSION, SITE_URL } from '../../logic/constants.ts'
12
+ import { GITHUB_URL, PLUGIN_VERSION } from '../../logic/constants.ts'
13
+ import { siteUrl } from '../../logic/urls.ts'
13
14
  import { GitHubIcon, LogoIcon } from '../ui/icons.tsx'
14
15
 
15
16
  export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggleLang, hubUpdate, onVersionClick, onAboutClick }: {
@@ -31,7 +32,7 @@ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggle
31
32
  h('div', { className: styles.headerTitleRow },
32
33
  h('a', {
33
34
  className: styles.brandTitle,
34
- href: `${SITE_URL}${langPath}`,
35
+ href: siteUrl(langPath),
35
36
  target: '_blank',
36
37
  rel: 'noopener noreferrer',
37
38
  title: t('openHint'),
@@ -86,7 +87,7 @@ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggle
86
87
  // 第二行:副标题仍是官网链接,点击跳 dsh-plugin.org
87
88
  h('a', {
88
89
  className: styles.taglineLink,
89
- href: `${SITE_URL}${langPath}`,
90
+ href: siteUrl(langPath),
90
91
  target: '_blank',
91
92
  rel: 'noopener noreferrer',
92
93
  title: t('openHint'),
@@ -96,7 +97,7 @@ export function CatalogHeader({ t, langPath, statsTotal, statsVerified, onToggle
96
97
  ),
97
98
  h('a', {
98
99
  className: styles.adBanner,
99
- href: `${SITE_URL}${langPath}`,
100
+ href: siteUrl(langPath),
100
101
  target: '_blank',
101
102
  rel: 'noopener noreferrer',
102
103
  },
@@ -178,10 +178,12 @@ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissi
178
178
  * 本机 npm 未安装或不在 PATH,不是插件问题。dshMissing 的通用「not recognized」模式会把
179
179
  * `'npm' is not recognized` 吞成 dsh 缺失,所以必须在 dshMissing 之前判断
180
180
  * → 提示安装 npm(Node.js 自带)/加入 PATH,不引导提 Issue
181
- * - pnpmStore:pnpm 存在但报 store 位置不匹配(`ERR_PNPM_UNEXPECTED_STORE` / `Unexpected store
182
- * location`)—— profile 目录的 node_modules 是用不同大版本的 pnpm 生成的,当前 pnpm 不认,
183
- * 任何插件装进该 profile 都会失败;不是插件问题(dsh-plugin-hub#14:macOS 下
184
- * `ERR_PNPM_UNEXPECTED_STORE` 被误归插件侧失败)→ 提示清理 profile 依赖目录重建,不引导提 Issue
181
+ * - pnpmStore:pnpm 存在但报 store / virtual store 位置不匹配(`ERR_PNPM_UNEXPECTED_STORE` /
182
+ * `ERR_PNPM_UNEXPECTED_VIRTUAL_STORE` / `Unexpected store location`)—— profile 目录的 node_modules
183
+ * 是用不同大版本的 pnpm 生成的(或 profile 目录被复制/移动、virtual-store-dir 配置变化),
184
+ * 当前 pnpm 不认,任何插件装进该 profile 都会失败;不是插件问题(dsh-plugin-hub#14:macOS 下
185
+ * `ERR_PNPM_UNEXPECTED_STORE` 被误归插件侧失败;dsh-plugin-hub#30:`ERR_PNPM_UNEXPECTED_VIRTUAL_STORE`
186
+ * 漏判被误归插件侧失败)→ 提示清理 profile 依赖目录重建,不引导提 Issue
185
187
  * - pnpmPolicy:pnpm 11 的供应链安全策略拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` /
186
188
  * `Minimum release age` —— 锁文件里包的发布时间还不满 24 小时被拒;`untrusted origin` —— 依赖来源未被
187
189
  * 本机 pnpm 信任)。拦的是「刚发布的新包」与「未被信任的来源」,装任何新插件都会撞墙,不是
@@ -229,12 +231,15 @@ export function classifyFailure(message: string): FailureKind {
229
231
  // 否则会被外层 "Command failed" 吞成「插件打包问题」,误导用户去提 Issue
230
232
  // (dsh-plugin-hub#12:Win 下 'dsh' 不在 PATH,cmd 报「不是内部或外部命令」被误归插件侧失败)
231
233
  if (/\[dsh-missing\]|不是内部或外部命令|is not recognized as an internal or external command|command not found|spawn dsh ENOENT/i.test(message)) return 'dshMissing'
232
- // pnpm 大版本不一致(ERR_PNPM_UNEXPECTED_STORE / Unexpected store location):profile 目录里
233
- // 旧依赖是另一个大版本 pnpm 生成的,当前 pnpm 出于安全不认旧 store —— 本机环境问题,
234
- // 任何插件装进该 profile 都会同样失败,不是插件问题(dsh-plugin-hub#14:macOS 下
235
- // `ERR_PNPM_UNEXPECTED_STORE` 被误归插件侧失败);提示清理依赖目录用当前 pnpm 重建,
236
- // 不引导提 Issue。必须在 pnpmMissing 之后 —— pnpm 在(能跑起来报错),不是「找不到命令」。
237
- if (/ERR_PNPM_UNEXPECTED_STORE|Unexpected store location/i.test(message)) return 'pnpmStore'
234
+ // pnpm 大版本/虚拟 store 位置不一致(ERR_PNPM_UNEXPECTED_STORE / ERR_PNPM_UNEXPECTED_VIRTUAL_STORE /
235
+ // Unexpected store location):profile 目录里旧依赖是另一个大版本 pnpm 生成的(或 profile 目录
236
+ // 被复制/移动、virtual-store-dir 配置变化导致 virtual store 位置不匹配),当前 pnpm 出于安全
237
+ // 不认旧 store —— 本机环境问题,任何插件装进该 profile 都会同样失败,不是插件问题
238
+ // (dsh-plugin-hub#14:macOS 下 ERR_PNPM_UNEXPECTED_STORE 被误归插件侧失败;
239
+ // dsh-plugin-hub#30:ERR_PNPM_UNEXPECTED_VIRTUAL_STORE 漏判被误归插件侧失败);
240
+ // 提示清理依赖目录用当前 pnpm 重建,不引导提 Issue。必须在 pnpmMissing 之后 ——
241
+ // pnpm 在(能跑起来报错),不是「找不到命令」。
242
+ if (/ERR_PNPM_UNEXPECTED_(VIRTUAL_)?STORE|Unexpected (virtual )?store location/i.test(message)) return 'pnpmStore'
238
243
  // pnpm 供应链安全策略拦截(pnpm 11:minimumReleaseAge 拒收「刚发布」的包 /
239
244
  // untrusted origin 来源不受信任):pnpm 在、也连得上,纯粹是本机策略不放行 ——
240
245
  // 装任何「新发布/非信任来源」的插件都会同样失败,不是插件问题(dsh-plugin-hub#15/#16)。
@@ -271,6 +276,14 @@ export function npmTooLowVersion(message: string): string | null {
271
276
 
272
277
  /** 核心行特征:错误代码 / 生命周期脚本失败 / prepare 失败 / 描述性报错(子模块缺失、找不到等)/ 退出与宿主提示信息。 */
273
278
  const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed to prepare|exit code|\bprepare\b|pnpm failed in profile|git-hosted plugins build|submodule|not found|cannot find|no such|unable to|fatal|missing|error/i
279
+ /** pnpm 的 peer 依赖告警行(`missing peer …` / `Issues with peer dependencies found` /
280
+ * `Peer dependencies that should be installed`):宿主提供的 peer(@deepseek-ai/*、react、
281
+ * dsh-client-* 等,DSH profile 用 autoInstallPeers:false 不自动装)缺失是无害噪音,与插件本身
282
+ * 无关 —— 抓核心错误时必须跳过,否则会被 CORE_LINE_RE 的 `missing` 分支误抓、淹没真正的
283
+ * 错误码(如 git 源的 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED),让 issue 正文只剩一堆 peer WARN
284
+ * (dsh-plugin-hub#28:anime-find 的 auto-issue 核心错误全是 missing-peer WARN 树,真正的
285
+ * prepare 错误被盖住)。 */
286
+ const PEER_WARN_RE = /missing peer|issues with peer dependencies|peer dependencies that should be installed/i
274
287
  /** 提交 issue 时正文里错误摘要的上限字符数。GitHub 请求行上限 8192 字节,
275
288
  * 固定模板与 URL 编码开销约 1~2K,核心错误(以 ASCII 日志为主)可安全带到 ~5K;
276
289
  * 仍超长时 pluginIssueUrl 会逐档缩小核心预算,最终 URL 不会超限。 */
@@ -289,7 +302,7 @@ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHAR
289
302
  const core: string[] = []
290
303
  for (const raw of message.split(/\r?\n/)) {
291
304
  const line = raw.trim()
292
- if (!line || !CORE_LINE_RE.test(line)) continue
305
+ if (!line || PEER_WARN_RE.test(line) || !CORE_LINE_RE.test(line)) continue
293
306
  const short = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line
294
307
  if (!seen.has(short)) {
295
308
  seen.add(short)
@@ -17,13 +17,19 @@ export function langPathOf(lang: LocaleId): string {
17
17
  return lang === 'zh' ? 'zh/' : ''
18
18
  }
19
19
 
20
+ /** 官网链接统一携带 `?ref=hub` 来源参数:统计有多少访问是从插件商城跳转而来。
21
+ * 所有「从商城跳官网」的链接(详情页/首页)都应经由此函数拼接,避免来源参数散落各处。 */
22
+ export function siteUrl(path: string): string {
23
+ return `${SITE_URL}${path}?ref=hub`
24
+ }
25
+
20
26
  /**
21
27
  * 官网详情页两级路径:/plugins/{ownerSlug}/{slug};
22
28
  * 缺 ownerSlug 时从 repo 推导(卡片详情按钮与弹窗来源行共用)。
23
29
  */
24
30
  export function pluginDetailUrl(plugin: HubPlugin, langPath: string): string {
25
31
  const repo = plugin.source?.repo ?? ''
26
- return `${SITE_URL}${langPath}plugins/${plugin.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${plugin.slug}`
32
+ return siteUrl(`${langPath}plugins/${plugin.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${plugin.slug}`)
27
33
  }
28
34
 
29
35
  /**
@@ -31,7 +37,7 @@ export function pluginDetailUrl(plugin: HubPlugin, langPath: string): string {
31
37
  * 与 issue 正文的 Catalog 链接同一形式(单级 /plugins/{repo})。
32
38
  */
33
39
  export function pluginSiteUrl(repo: string): string {
34
- return `${SITE_URL}plugins/${repo}`
40
+ return siteUrl(`plugins/${repo}`)
35
41
  }
36
42
 
37
43
  /** 按失败类型给出简洁的错误原因标题(对外用英文):
@@ -149,6 +149,22 @@ export function readInstalled(profile: string): Record<string, string> {
149
149
  }
150
150
  }
151
151
 
152
+ /** 读已装依赖实际落在磁盘上的仓库身份(`owner/repo`):解析 node_modules/<name>/package.json
153
+ * 的 repository 字段。已装 spec 可能是纯版本号(丢了仓库身份),但真实包元数据仍指向来源仓库,
154
+ * 据此可区分「同名依赖其实是同一仓库的残留(应转 update)」还是「跨仓库包名冲突(应报错)」。
155
+ * 读不到 / 解析不出返回 null(交给调用方按冲突处理)。 */
156
+ function installedRepoOf(profile: string, name: string): string | null {
157
+ try {
158
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8')) as {
159
+ repository?: string | { url?: string }
160
+ }
161
+ const repo = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url
162
+ return repo ? githubRepoOf(repo) : null
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
152
168
  /** 宿主 dsh CLI 版本:优先直接跑 `dsh --version`(与 pnpm/npm/git 同款同步探测,1500ms 超时兜底,
153
169
  * 绝不挂起)—— 原先从 process.argv[1] 向上找 package.json 猜版本在部分宿主加载方式下取不到,
154
170
  * 导致 issue 环境快照 DSH: unknown;拿不到再退回入口 package.json 查找(dsh 不在 PATH 时兜底)。 */
@@ -649,8 +665,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
649
665
  // 任务自动入队:即使已有插件操作在跑也接受请求(FIFO 串行执行),不再 409 拒绝
650
666
  try {
651
667
  const body = await readJsonBody(request)
652
- // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)
653
- const mode = body !== null && typeof body === 'object' && (body as { mode?: unknown }).mode === 'update'
668
+ // mode: 'update' = 已安装目标的覆盖更新(放行 add,pnpm 对已存在依赖原位覆盖重装)。
669
+ // 后续「同仓库残留」检测可能把 install 就地升级为 update(见包名冲突检测),故用 let。
670
+ let mode = body !== null && typeof body === 'object' && (body as { mode?: unknown }).mode === 'update'
654
671
  ? 'update'
655
672
  : 'install'
656
673
  // installTargetOf 先把完整 DSH 命令(`dsh plugin [--profile <p>] add <target>`)剥成裸目标,
@@ -822,6 +839,31 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
822
839
  })
823
840
  return
824
841
  }
842
+ // 包名冲突检测(仅 git 通道):git 目标包内声明的 name 若与 profile 已装依赖同名,
843
+ // pnpm 会以该 name 做依赖键撞车,抛出的 CLI 报错晦涩难懂(issue #25:sandbase-harness
844
+ // 声明 managed-agents,但该包名在 registry 上被另一仓库占用,npm 反查搜不到 → git 直装
845
+ // 撞已装同名依赖)。到这里已装依赖要么来源不同仓库、要么 spec 是版本号解析不出仓库身份。
846
+ // 读 node_modules 里已装包的真实 repository 区分两种情况:
847
+ // - 指向同一仓库 → 是「同仓库残留」(spec 丢了仓库身份),就地转 update 覆盖重装(幂等);
848
+ // - 指向别的仓库 / 读不到 → 跨仓库包名冲突,入队前转成明确 409,指导用户先卸载或改装。
849
+ if (preflight.name !== null && repoTarget !== null && target === repoTarget) {
850
+ const declared = preflight.name
851
+ if (installed[declared] !== undefined) {
852
+ const installedRepo = installedRepoOf(profile, declared)
853
+ if (installedRepo !== null && gitRepo !== null && installedRepo.toLowerCase() === gitRepo.toLowerCase()) {
854
+ // 同名且同源:已装 spec 丢了仓库身份导致的残留,直接按 update 覆盖重装,不打扰用户
855
+ attempts.push(`same-repo residual: \`${declared}\` resolves to \`${installedRepo}\` → reinstall as update`)
856
+ mode = 'update'
857
+ } else {
858
+ attempts.push(`package name conflict: \`${gitRepo}\` declares npm name \`${declared}\`, which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository)`)
859
+ const error = lang === 'zh'
860
+ ? `already installed: ${declared} —— ${gitRepo} 声明的 npm 包名「${declared}」已存在于当前 profile,且无法匹配到本仓库(该包名在 npm 上可能被其他仓库占用)。请先移除现有的 ${declared}(dsh plugin remove ${declared}),再重新安装;或改装该包名对应的官方 npm 包。`
861
+ : `already installed: ${declared} — ${gitRepo} declares the npm package name "${declared}", which is already present in the profile and cannot be matched to this repo (that npm name is likely registered by another repository). Remove the existing ${declared} dependency (dsh plugin remove ${declared}) first, then retry; or install the official npm package under that name.`
862
+ sendJson(response, 409, { error, attempts })
863
+ return
864
+ }
865
+ }
866
+ }
825
867
  }
826
868
  // 入队前复检(与上方检查同语义,但必须在同步块内):preflight 的 await 期间并发请求
827
869
  // 可能已通过上方检查、任务却尚未入队——此处检查与 startPluginMutation 之间无 await,
@@ -25,15 +25,19 @@ const execFileAsync = promisify(execFile)
25
25
  * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
26
26
  const PREFLIGHT_TIMEOUT_MS = 15_000
27
27
 
28
- /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径。 */
28
+ /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径;
29
+ * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
30
+ * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
31
+ * 需要把晦涩的 CLI 报错转成明确的拦截。 */
29
32
  export interface PreflightResult {
30
33
  ok: boolean
31
34
  missing: string | null
35
+ name: string | null
32
36
  }
33
37
 
34
38
  export async function preflightTarget(target: string): Promise<PreflightResult> {
35
39
  const source = githubRepoOf(target)
36
- if (source === null) return { ok: true, missing: null }
40
+ if (source === null) return { ok: true, missing: null, name: null }
37
41
  const [owner, repo] = source.split('/')
38
42
  let commit = ''
39
43
  try {
@@ -43,15 +47,15 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
43
47
  })
44
48
  commit = stdout.split(/\s+/)[0] ?? ''
45
49
  } catch {
46
- return { ok: true, missing: null }
50
+ return { ok: true, missing: null, name: null }
47
51
  }
48
- if (!commit) return { ok: true, missing: null }
52
+ if (!commit) return { ok: true, missing: null, name: null }
49
53
  const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
50
54
  try {
51
55
  const tar = join(dir, 'pkg.tar.gz')
52
56
  await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
53
57
  const meta = await readTarJson(tar, 'package/package.json')
54
- if (meta === null) return { ok: true, missing: null }
58
+ if (meta === null) return { ok: true, missing: null, name: null }
55
59
  const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
56
60
  const resolved = typeof dot === 'string'
57
61
  ? dot
@@ -60,9 +64,9 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
60
64
  : undefined
61
65
  const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js'
62
66
  const inTar = await hasTarEntry(tar, `package/${entry}`)
63
- return { ok: inTar, missing: inTar ? null : entry }
67
+ return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null }
64
68
  } catch {
65
- return { ok: true, missing: null }
69
+ return { ok: true, missing: null, name: null }
66
70
  } finally {
67
71
  rmSync(dir, { recursive: true, force: true })
68
72
  }