dsh-plugin 1.3.11 → 1.4.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.
@@ -151,7 +151,7 @@ export function removeNotification(id: number): NotificationRecord[] {
151
151
  return next
152
152
  }
153
153
 
154
- export type FailureKind = 'npmTooOld' | 'dshMissing' | 'pnpmMissing' | 'pnpmStore' | 'pnpmPolicy' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
154
+ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmPolicy' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'repo'
155
155
 
156
156
  /**
157
157
  * 失败归类,七态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
@@ -163,19 +163,31 @@ export type FailureKind = 'npmTooOld' | 'dshMissing' | 'pnpmMissing' | 'pnpmStor
163
163
  * - dshMissing:安装器 spawn 的 `dsh` 命令找不到(Windows cmd「不是内部或外部命令」/ POSIX
164
164
  * 「command not found」/ spawn ENOENT)—— 是本机 DSH 未正确安装或不在 PATH,不是插件问题
165
165
  * → 提示检查 PATH/重装 DSH,不引导提 Issue
166
+ * - gitMissing:安装器调用 `git` 时找不到可执行文件(Windows cmd「'git' is not recognized」/
167
+ * POSIX「git: command not found」/ spawn ENOENT)—— 是本机 Git 未安装或不在 PATH,不是插件
168
+ * 问题。pnpm 会把缺失 git 报成 `ERR_PNPM_GIT_RESOLVE_FAILED`(git ls-remote failed),若只看
169
+ * 错误码会误归插件侧失败;且 dshMissing 的通用「not recognized」模式会先把它吞成 dsh 缺失,
170
+ * 所以必须在 dshMissing 之前判断(dsh-plugin-hub#21:Win 下装 git 源插件,
171
+ * `'git' is not recognized` 被误归仓库问题引导去提 Issue)→ 提示安装 Git / 加入 PATH,不引导提 Issue
166
172
  * - pnpmMissing:dsh 存在但调用的 `pnpm` 找不到(dsh 报 `pnpm not found on PATH`/POSIX
167
173
  * 「pnpm: command not found」/ spawn ENOENT)—— 本机缺 pnpm(dsh 用 pnpm 管理 profile 插件),
168
174
  * 不是插件问题(dsh-plugin-hub#13:Linux 下 `dsh: pnpm not found on PATH` 被误归插件侧失败)
169
175
  * → 提示安装/开启 pnpm,不引导提 Issue
176
+ * - npmMissing:全局 npm 安装通道(`npm install -g ...`)spawn 的 `npm` 命令找不到
177
+ * (Windows cmd「'npm' is not recognized」/ POSIX「npm: command not found」/ spawn ENOENT)——
178
+ * 本机 npm 未安装或不在 PATH,不是插件问题。dshMissing 的通用「not recognized」模式会把
179
+ * `'npm' is not recognized` 吞成 dsh 缺失,所以必须在 dshMissing 之前判断
180
+ * → 提示安装 npm(Node.js 自带)/加入 PATH,不引导提 Issue
170
181
  * - pnpmStore:pnpm 存在但报 store 位置不匹配(`ERR_PNPM_UNEXPECTED_STORE` / `Unexpected store
171
182
  * location`)—— profile 目录的 node_modules 是用不同大版本的 pnpm 生成的,当前 pnpm 不认,
172
183
  * 任何插件装进该 profile 都会失败;不是插件问题(dsh-plugin-hub#14:macOS 下
173
184
  * `ERR_PNPM_UNEXPECTED_STORE` 被误归插件侧失败)→ 提示清理 profile 依赖目录重建,不引导提 Issue
174
185
  * - pnpmPolicy:pnpm 11 的供应链安全策略拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` /
175
- * `Minimum release age` —— 锁文件里包的发布时间太近被拒;`untrusted origin` —— 依赖来源未被
176
- * 本机 pnpm 信任)。拦的是「发布太新的包」与「未被信任的来源」,装任何新插件都会撞墙,不是
186
+ * `Minimum release age` —— 锁文件里包的发布时间还不满 24 小时被拒;`untrusted origin` —— 依赖来源未被
187
+ * 本机 pnpm 信任)。拦的是「刚发布的新包」与「未被信任的来源」,装任何新插件都会撞墙,不是
177
188
  * 插件问题(dsh-plugin-hub#15/#16:用户装官方 dsh-plugin 也被这两类策略拦下并误归插件侧失败)
178
- * → 提示在 pnpm 配置里豁免该策略(如 `minimumReleaseAge: 0`)或等发布冷却后重试,不引导提 Issue
189
+ * → 提示按子场景给解法(发布未满 24 小时 → `minimumReleaseAge: 0` 豁免或等满 24 小时;untrusted
190
+ * origin → 删除 profile 的 node_modules + pnpm-lock.yaml 清掉不受信任来源后重装),不引导提 Issue
179
191
  * - network:安装前连通性预检拦截(服务端 `[network]` 标记)或底层连接失败
180
192
  * (ERR_PNPM_GIT_FETCH_FAILED / ETIMEDOUT / DNS 解析 / TLS 握手 / 代理拒绝)——
181
193
  * 是本机网络不通/被墙/代理有问题,不是插件问题 → 提示检查网络,不引导提 Issue
@@ -198,6 +210,18 @@ export function classifyFailure(message: string): FailureKind {
198
210
  // found」,会把 `pnpm: command not found` 吞成「dsh 缺失」,误引导用户去装 DSH
199
211
  // (dsh-plugin-hub#13:Linux 下 `dsh: pnpm not found on PATH` 被误归插件侧失败)
200
212
  if (/\[pnpm-missing\]|pnpm not found|pnpm: command not found|spawn pnpm ENOENT|'pnpm' 不是内部或外部命令|"pnpm" 不是内部或外部命令|pnpm['"]?\s*is not recognized/i.test(message)) return 'pnpmMissing'
213
+ // 找不到 git 命令(服务端 [git-missing] 标记 / Windows cmd 中英文「'git' is not recognized」/
214
+ // POSIX「git: command not found」/ spawn git ENOENT):本机 Git 未安装或不在 PATH,不是插件问题。
215
+ // 必须在 dshMissing 之前 —— dshMissing 正则含裸「is not recognized / command not found」,
216
+ // 会把 git 缺失('git' is not recognized as an internal or external command)吞成「dsh 缺失」,
217
+ // 误导用户去装 DSH;且 pnpm 报 `ERR_PNPM_GIT_RESOLVE_FAILED`(git ls-remote failed)时
218
+ // 若只看错误码会落到插件侧失败、引导去提 Issue(dsh-plugin-hub#21)
219
+ if (/\[git-missing\]|spawn git ENOENT|'git' 不是内部或外部命令|"git" 不是内部或外部命令|git['"]?\s*is not recognized|git: command not found/i.test(message)) return 'gitMissing'
220
+ // 找不到 npm 命令(全局 npm 安装通道 `npm install -g` spawn 的 npm 缺失 / Windows cmd 中英文
221
+ // 「'npm' is not recognized」/ POSIX「npm: command not found」/ spawn npm ENOENT):本机 npm 未安装
222
+ // 或不在 PATH,不是插件问题。必须在 dshMissing 之前 —— dshMissing 正则含裸「is not recognized /
223
+ // command not found」,会把 npm 缺失('npm' is not recognized)吞成「dsh 缺失」,误导用户去装 DSH
224
+ if (/\[npm-missing\]|spawn npm ENOENT|'npm' 不是内部或外部命令|"npm" 不是内部或外部命令|npm['"]?\s*is not recognized|npm: command not found/i.test(message)) return 'npmMissing'
201
225
  // 找不到 dsh 命令(服务端 [dsh-missing] 标记 —— 乱码免疫:Windows cmd 中文版输出 GBK,
202
226
  // 经 UTF-8 解码成乱码无法匹配原文,故服务端在 spawn 前用 which/where 探测并打 ASCII 标记;
203
227
  // 其余形态:Windows cmd「不是内部或外部命令」/ POSIX「command not found」/
@@ -211,7 +235,7 @@ export function classifyFailure(message: string): FailureKind {
211
235
  // `ERR_PNPM_UNEXPECTED_STORE` 被误归插件侧失败);提示清理依赖目录用当前 pnpm 重建,
212
236
  // 不引导提 Issue。必须在 pnpmMissing 之后 —— pnpm 在(能跑起来报错),不是「找不到命令」。
213
237
  if (/ERR_PNPM_UNEXPECTED_STORE|Unexpected store location/i.test(message)) return 'pnpmStore'
214
- // pnpm 供应链安全策略拦截(pnpm 11:minimumReleaseAge 拒收「发布太新」的包 /
238
+ // pnpm 供应链安全策略拦截(pnpm 11:minimumReleaseAge 拒收「刚发布」的包 /
215
239
  // untrusted origin 来源不受信任):pnpm 在、也连得上,纯粹是本机策略不放行 ——
216
240
  // 装任何「新发布/非信任来源」的插件都会同样失败,不是插件问题(dsh-plugin-hub#15/#16)。
217
241
  // 必须在 pnpmIgnoredBuild 之前 —— 该策略优先于「构建脚本被白名单拦截」,且两者都不引导提 Issue。
@@ -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
  /** 按失败类型给出简洁的错误原因标题(对外用英文):
@@ -87,8 +93,8 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
87
93
  ...(code ? [`- Key error: \`${code}\``] : []),
88
94
  '',
89
95
  // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的安装命令(含 --profile)与执行结果,链接由常量动态拼接
90
- `## [DSH Plugin 插件中心](${SITE_URL}) · 安装 Plugin 失败错误信息`,
91
- `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件中心的安装程序自动生成,随本次安装失败一并提交。`,
96
+ `## [DSH Plugin 插件市场](${SITE_URL}) · 安装 Plugin 失败错误信息`,
97
+ `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件市场的安装程序自动生成,随本次安装失败一并提交。`,
92
98
  `- 实际执行的安装命令:\`${command ?? `dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add git+https://github.com/${repo}.git`}\``,
93
99
  `- 执行结果:安装失败,未能安装该插件。`,
94
100
  // 尝试过的安装方式(npm 反查 + 实际执行命令,按先后顺序):作者据此反推正确的
@@ -126,7 +132,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
126
132
  `- Full log: \`~/.dsh/profiles/${env.profile}/hub.log\` (paste or attach for the full output)`,
127
133
  ]
128
134
  : []),
129
- // 该插件在插件中心的收录位置(详情页链接)
135
+ // 该插件在插件市场的收录位置(详情页链接)
130
136
  `- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`,
131
137
  '',
132
138
  '## Error (core)',
@@ -270,7 +270,7 @@
270
270
  }
271
271
 
272
272
  /* 来源标签列(行尾、操作按钮前):固定宽度等宽,所有条目标签在同一列右对齐(整齐不乱)。
273
- 「插件中心」= 目录插件(品牌蓝描边),「手动安装」= 命令行安装(琥珀描边)。
273
+ 「插件市场」= 目录插件(品牌蓝描边),「手动安装」= 命令行安装(琥珀描边)。
274
274
  两词等宽(各 4 字),宽度固定后标签列严格对齐 */
275
275
  .rowSourceTag {
276
276
  flex-shrink: 0;
@@ -406,7 +406,7 @@
406
406
  border-color: var(--hub-warning);
407
407
  }
408
408
 
409
- /* 更新:琥珀色实底小按钮(与插件中心「可更新」同色),仅在「有更新」时出现 */
409
+ /* 更新:琥珀色实底小按钮(与插件市场「可更新」同色),仅在「有更新」时出现 */
410
410
  .rowUpdate {
411
411
  font-family: inherit;
412
412
  font-size: 12px;
@@ -23,7 +23,7 @@
23
23
  /* 弹窗基座:安装/卸载确认、错误、失败记录共用;错误类弹窗(.errorModal)单独加宽 */
24
24
  .modal,
25
25
  .errorModal {
26
- width: 420px;
26
+ width: 540px;
27
27
  max-width: calc(100vw - 32px);
28
28
  /* 高度自适应屏幕(桌面/手机视口):内容超长时窗体内滚动,头部与按钮不消失 */
29
29
  max-height: calc(100dvh - 64px);
@@ -41,21 +41,21 @@
41
41
 
42
42
  /* 错误/失败记录弹窗:宽一些,错误详情长行少折行、阅读更顺;两边留白也更稳 */
43
43
  .errorModal {
44
- width: 640px;
44
+ width: 720px;
45
45
  max-width: calc(100vw - 48px);
46
46
  }
47
47
 
48
48
  /* 日志存放位置弹窗:加宽 —— 路径随用户主目录/用户名变化(长短不一),
49
49
  输入框内要完整显示长路径,窄了显示不下 */
50
50
  .modalWide {
51
- width: 560px;
51
+ width: 640px;
52
52
  max-width: calc(100vw - 48px);
53
53
  }
54
54
 
55
55
  /* 自定义安装「帮助」弹窗:比错误弹窗更宽 —— 命令格式 + 中文说明要排在一行,
56
56
  窄了容易折行,宽窗让每条格式整行展示 */
57
57
  .helpModal {
58
- width: 720px;
58
+ width: 760px;
59
59
  max-width: calc(100vw - 48px);
60
60
  }
61
61
 
@@ -63,7 +63,7 @@
63
63
  加上筛选栏与底部操作栏后需要横向空间,窄了长行容易折行。
64
64
  高度固定(540px,小视口才让位给窗口上限):内容多寡不再让弹窗忽高忽低 */
65
65
  .logModal {
66
- width: 780px;
66
+ width: 820px;
67
67
  height: min(540px, calc(100vh - 64px));
68
68
  max-width: calc(100vw - 48px);
69
69
  }
@@ -1285,7 +1285,7 @@
1285
1285
  /* 关注弹窗:比普通弹窗略宽,给 Markdown 介绍 + 反馈群二维码留出阅读空间;
1286
1286
  max-width 覆盖普通弹窗的 calc(100vw - 32px),窄屏时仍不贴边 */
1287
1287
  .aboutModal {
1288
- width: 460px;
1288
+ width: 560px;
1289
1289
  max-width: calc(100vw - 48px);
1290
1290
  }
1291
1291
 
@@ -1418,7 +1418,7 @@
1418
1418
 
1419
1419
  /* 更新说明弹窗:比普通弹窗略宽,给变更记录留出阅读空间 */
1420
1420
  .hubUpdateModal {
1421
- width: 520px;
1421
+ width: 620px;
1422
1422
  max-width: calc(100vw - 48px);
1423
1423
  }
1424
1424
 
@@ -1536,7 +1536,7 @@
1536
1536
 
1537
1537
  /* 详情弹窗:比普通弹窗略宽,给信息网格/路径留出横向空间 */
1538
1538
  .detailModal {
1539
- width: 480px;
1539
+ width: 580px;
1540
1540
  max-width: calc(100vw - 48px);
1541
1541
  }
1542
1542
 
@@ -22,6 +22,9 @@ export interface LocaleService {
22
22
  bind(namespace: string): (key: string, params?: Record<string, string | number>) => string
23
23
  getSnapshot(): { active: LocaleId }
24
24
  subscribe(fn: () => void): () => void
25
+ /** 切换宿主(系统)语言偏好:Hub 语言跟随宿主,右上角语言按钮直接写入宿主偏好,
26
+ * 宿主左侧菜单/设置弹窗与 Hub 面板一起切换,不再与宿主语言脱节 */
27
+ setLocale(id: string): void
25
28
  }
26
29
 
27
30
  /** The subset of the slots service this plugin touches. */
@@ -74,13 +77,11 @@ export interface TaskState {
74
77
  lines: string[]
75
78
  }
76
79
 
77
- export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail' | 'updates'
80
+ export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail'
78
81
 
79
82
  export interface ToastState {
80
83
  id: number
81
84
  kind: ToastKind
82
- /** 「发现更新」类提示的插件数量(按 kind 语义使用) */
83
- n?: number
84
85
  }
85
86
 
86
87
  /** 宿主机器环境快照(后端 /dsh-plugin-hub/env):提交 bug 时拼进 issue 正文,便于作者复现。 */
@@ -93,7 +93,7 @@ const PROFILE_RE = /^[A-Za-z0-9_-]+$/
93
93
  const BODY_LIMIT_BYTES = 4 * 1024
94
94
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000
95
95
 
96
- /** 目录/统计数据代理的本地缓存时长:1 小时内重复打开插件中心直接读盘,
96
+ /** 目录/统计数据代理的本地缓存时长:1 小时内重复打开插件市场直接读盘,
97
97
  * 不重复走 curl 拉远程 —— 重启宿主后首次打开同样秒开(缓存跨重启存活)。 */
98
98
  const CATALOG_CACHE_TTL_MS = 60 * 60 * 1000
99
99
 
@@ -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 时兜底)。 */
@@ -519,8 +535,8 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
519
535
  { key: 'npm', url: `${registry}/dsh-plugin`, display: settings.npmRegistry !== '' ? registry : '', cmd: `npm view dsh-plugin version --registry ${registry}` },
520
536
  // GitHub 行用真实 git 克隆握手(git ls-remote)打 dshplugin/hello-dsh 小仓库:
521
537
  // 「网页能打开」和「git 能克隆」是两码事,握手成功才算通道通;小仓库秒级完成,不打 17MB 的 dsh-plugin-hub。
522
- { key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com/dshplugin/hello-dsh', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
523
- { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: 'api.dsh-plugin.org', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
538
+ { key: 'github', url: 'https://github.com/dshplugin/hello-dsh', display: 'github.com', cmd: 'git ls-remote https://github.com/dshplugin/hello-dsh', git: true },
539
+ { key: 'catalog', url: 'https://api.dsh-plugin.org/stats.json', display: '', cmd: 'curl -s https://api.dsh-plugin.org/stats.json' },
524
540
  ]
525
541
  // 配置了 HTTP 代理:追加一行代理诊断 —— 用该代理打 github.com(安装通道真实访问的地址),
526
542
  // 验证「代理能不能把请求带出去」。与安装同口径:curl 子进程注入该代理 env。
@@ -617,7 +633,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
617
633
  const target = isStats
618
634
  ? 'https://api.dsh-plugin.org/stats.json'
619
635
  : `https://api.dsh-plugin.org/plugins.${lang}.json`
620
- // 1 小时本地缓存:命中直接返回(跨重启生效,插件中心秒开),未命中才走 curl 拉远程
636
+ // 1 小时本地缓存:命中直接返回(跨重启生效,插件市场秒开),未命中才走 curl 拉远程
621
637
  const cacheFile = catalogCacheFile(profile, isStats ? 'stats' : `plugins-${lang}`)
622
638
  const cached = readCatalogCache(cacheFile)
623
639
  if (cached !== null) {
@@ -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
  }