dsh-plugin 2.3.7 → 2.3.9

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.
@@ -18,7 +18,7 @@ import { backfillOperationLog, createOperationLogTap, lacksDiagnosticContent, op
18
18
  import { cleanLine, estimateProgress } from '../profile/progress.js';
19
19
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
20
20
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
21
- import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory, validPackageName } from '../profile/profile.js';
21
+ import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory, sanitizeAllowBuilds, validPackageName } from '../profile/profile.js';
22
22
  import { appendLog } from '../log.js';
23
23
  import { commandOnPath, gitMissingDetail } from './preflight.js';
24
24
  import { isTransportFailure, registryHost } from './registry-fallback.js';
@@ -881,6 +881,15 @@ export async function runPluginMutation(options) {
881
881
  // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读取作为后备
882
882
  const profileDir = profileDirectory(options.profile);
883
883
  const logTap = createOperationLogTap(profileDir);
884
+ // 装前修掉 allowBuilds 里的非版本 spec(`pkg@https://…` / `pkg@file:…`):pnpm v11 在依赖
885
+ // 解析前就会抛 ERR_PNPM_INVALID_VERSION_UNION,该 profile 里之后任何安装都会失败(#730)。
886
+ // 这一步失败不影响主流程 —— 真有问题仍由 pnpm 的报错决定怎么提示。
887
+ if (options.action !== 'remove' && (options.globalNpm ?? []).length === 0) {
888
+ try {
889
+ sanitizeAllowBuilds(options.profile);
890
+ }
891
+ catch { /* 读不到/写不进就按原样继续 */ }
892
+ }
884
893
  let result = await spawnMutation(options);
885
894
  // allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
886
895
  // 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
@@ -152,38 +152,22 @@ function quoteYamlKey(key) {
152
152
  }
153
153
  return key;
154
154
  }
155
- /**
156
- * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
157
- * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
158
- * 字面量 `set this to true or false`)。
159
- *
160
- * 任何形态的既有 `allowBuilds` 都会被**整体替换**成规范化块:块写法、行内数组
161
- * `allowBuilds: ["a@1"]`、以及末尾无换行的 `allowBuilds:` 都算命中。否则会在文件里
162
- * 再多写一个同名键 —— pnpm 解析时直接报 `duplicated mapping key`,该 profile 里
163
- * 所有插件都装不上(#562)。
164
- */
165
- export function addAllowBuildsKey(profile, key) {
166
- const file = join(profileDirectory(profile), 'pnpm-workspace.yaml');
167
- let yaml = '';
168
- try {
169
- yaml = readFileSync(file, 'utf8');
170
- }
171
- catch { /* 文件不存在,下方创建 */ }
172
- const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
173
- // header 行(可选行内值)+ 其后任意缩进条目行;行内写法没有后续缩进行也能匹配
174
- const blockRe = /^[ \t]*allowBuilds:[^\r\n]*(?:(?:\r?\n)[ \t]{2,}[^\r\n]*)*/gm;
155
+ /** `allowBuilds` 段:header 行(可带行内数组值)+ 其后任意缩进条目行。 */
156
+ const ALLOW_BLOCK_RE = /^[ \t]*allowBuilds:[^\r\n]*(?:(?:\r?\n)[ \t]{2,}[^\r\n]*)*/gm;
157
+ function unquoteYamlKey(key) {
158
+ const quoted = key.length >= 2 && ((key[0] === "'" && key[key.length - 1] === "'") || (key[0] === '"' && key[key.length - 1] === '"'));
159
+ return quoted ? key.slice(1, -1) : key;
160
+ }
161
+ /** 读出 yaml 里所有 `allowBuilds` 段的条目(key → 值,缺省 true)。 */
162
+ function readAllowBuilds(yaml) {
175
163
  const map = new Map();
176
- for (const match of Array.from(yaml.matchAll(blockRe))) {
164
+ for (const match of Array.from(yaml.matchAll(ALLOW_BLOCK_RE))) {
177
165
  const [header, ...rest] = match[0].split(/\r?\n/);
178
166
  for (const line of rest) {
179
167
  const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line);
180
168
  if (m === null || m[1] === '')
181
169
  continue;
182
- let k = m[1];
183
- if (k.length >= 2 && ((k[0] === "'" && k[k.length - 1] === "'") || (k[0] === '"' && k[k.length - 1] === '"'))) {
184
- k = k.slice(1, -1);
185
- }
186
- map.set(k, m[2] ?? 'true');
170
+ map.set(unquoteYamlKey(m[1]), m[2] ?? 'true');
187
171
  }
188
172
  // 行内数组写法:allowBuilds: ["a@1.0.0", 'b@2'] —— 保留已有条目,别丢
189
173
  const inline = /^[ \t]*allowBuilds:\s*\[(.*)\]\s*$/.exec(header);
@@ -195,12 +179,72 @@ export function addAllowBuildsKey(profile, key) {
195
179
  }
196
180
  }
197
181
  }
198
- map.set(key, 'true');
182
+ return map;
183
+ }
184
+ /** 把所有 `allowBuilds` 段合并写成一段规范化块,其余 yaml 原样保留;无该段时追加。 */
185
+ function writeAllowBuilds(yaml, map) {
186
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
199
187
  const block = Array.from(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
200
188
  const blockText = `allowBuilds:${eol}${block}${eol}`;
201
189
  let seen = 0;
202
- let next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
203
- if (seen === 0)
204
- next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
205
- writeFileSync(file, next);
190
+ const next = yaml.replace(ALLOW_BLOCK_RE, () => (seen++ === 0 ? blockText : ''));
191
+ return seen === 0 ? `${yaml.replace(/\r?\n?$/, eol)}${blockText}` : next;
192
+ }
193
+ /**
194
+ * `allowBuilds` 的 key 必须是 `包名@精确版本`。pnpm v11 在真正安装前会把每条规则解析成
195
+ * 「包名 + 精确版本」,遇到 URL/git/file spec(`pkg@https://…`、`pkg@file:…`)直接抛
196
+ * `ERR_PNPM_INVALID_VERSION_UNION` —— 该 profile 里之后**任何**安装都会失败(#730)。
197
+ * 非 semver 的 spec 一律降级为裸包名(pnpm 按包名匹配所有版本)。
198
+ */
199
+ export function safeAllowBuildsKey(key) {
200
+ const at = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@');
201
+ if (at <= 0)
202
+ return key;
203
+ const spec = key.slice(at + 1);
204
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(spec) ? key : key.slice(0, at);
205
+ }
206
+ /**
207
+ * 安装前修复**已有** `allowBuilds` 里的非法 key。pnpm 的报错发生在依赖解析之前,
208
+ * 走不到 `addAllowBuildsKey` 的失败恢复路径,只能主动清理。返回是否改写了文件。
209
+ */
210
+ export function sanitizeAllowBuilds(profile) {
211
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml');
212
+ let yaml = '';
213
+ try {
214
+ yaml = readFileSync(file, 'utf8');
215
+ }
216
+ catch {
217
+ return false;
218
+ }
219
+ if (Array.from(yaml.matchAll(ALLOW_BLOCK_RE)).length === 0)
220
+ return false;
221
+ const before = readAllowBuilds(yaml);
222
+ const after = new Map();
223
+ for (const [k, v] of before)
224
+ after.set(safeAllowBuildsKey(k), v);
225
+ if (after.size === before.size && Array.from(after).every(([k, v]) => before.get(k) === v))
226
+ return false;
227
+ writeFileSync(file, writeAllowBuilds(yaml, after));
228
+ return true;
229
+ }
230
+ /**
231
+ * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
232
+ * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
233
+ * 字面量 `set this to true or false`)。
234
+ *
235
+ * 任何形态的既有 `allowBuilds` 都会被**整体替换**成规范化块:块写法、行内数组
236
+ * `allowBuilds: ["a@1"]`、以及末尾无换行的 `allowBuilds:` 都算命中。否则会在文件里
237
+ * 再多写一个同名键 —— pnpm 解析时直接报 `duplicated mapping key`,该 profile 里
238
+ * 所有插件都装不上(#562)。
239
+ */
240
+ export function addAllowBuildsKey(profile, key) {
241
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml');
242
+ let yaml = '';
243
+ try {
244
+ yaml = readFileSync(file, 'utf8');
245
+ }
246
+ catch { /* 文件不存在,下方创建 */ }
247
+ const map = readAllowBuilds(yaml);
248
+ map.set(safeAllowBuildsKey(key), 'true');
249
+ writeFileSync(file, writeAllowBuilds(yaml, map));
206
250
  }
@@ -32,6 +32,18 @@ export declare function validPackageName(name: string): boolean;
32
32
  * 无则返回 null。
33
33
  */
34
34
  export declare function parseAllowBuildsKey(output: string): string | null;
35
+ /**
36
+ * `allowBuilds` 的 key 必须是 `包名@精确版本`。pnpm v11 在真正安装前会把每条规则解析成
37
+ * 「包名 + 精确版本」,遇到 URL/git/file spec(`pkg@https://…`、`pkg@file:…`)直接抛
38
+ * `ERR_PNPM_INVALID_VERSION_UNION` —— 该 profile 里之后**任何**安装都会失败(#730)。
39
+ * 非 semver 的 spec 一律降级为裸包名(pnpm 按包名匹配所有版本)。
40
+ */
41
+ export declare function safeAllowBuildsKey(key: string): string;
42
+ /**
43
+ * 安装前修复**已有** `allowBuilds` 里的非法 key。pnpm 的报错发生在依赖解析之前,
44
+ * 走不到 `addAllowBuildsKey` 的失败恢复路径,只能主动清理。返回是否改写了文件。
45
+ */
46
+ export declare function sanitizeAllowBuilds(profile: string): boolean;
35
47
  /**
36
48
  * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
37
49
  * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "2.3.7",
4
- "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 20000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):20000+ 人工精选社区插件,每日更新。",
3
+ "version": "2.3.9",
4
+ "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 6000+ hand-verified npm plugins out of 23000+ indexed, without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):收录 23000+ 社区插件,其中 6000+ 已发布 npm 包、可一键安装,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -338,6 +338,11 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
338
338
  // pnpm 每次 add 都重校验整棵树,该 profile 内的安装都会失败 —— 给定位/移除指引,不引导提 Issue
339
339
  return h('div', { className: styles.failPrepareHint }, t('failProfileLocalBuildHint'))
340
340
  }
341
+ if (kind === 'profileBadAllowBuilds') {
342
+ // allowBuilds 里有 pnpm 解析不了的 spec(`pkg@https://…` / `pkg@file:…`):
343
+ // pnpm v11 在依赖解析前就中止,该 profile 内的安装都会失败 —— 给删除/改正指引,不引导提 Issue
344
+ return h('div', { className: styles.failPrepareHint }, t('failProfileBadAllowBuildsHint'))
345
+ }
341
346
  if (kind === 'profileState') {
342
347
  // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
343
348
  // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
@@ -472,6 +472,11 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
472
472
  // 被拦的构建脚本属于 profile 里已有的 file:/link: 本地插件,不是本次装的插件:
473
473
  // pnpm 每次 add 都重校验整棵树,该 profile 内的安装都会失败 —— 给定位/移除指引,不引导提 Issue
474
474
  ? h('div', { className: styles.failPrepareHint }, t('failProfileLocalBuildHint'))
475
+ : failureKind === 'profileBadAllowBuilds'
476
+ // allowBuilds 里有 pnpm 解析不了的 spec(`pkg@https://…` / `pkg@file:…`):pnpm v11 在
477
+ // 依赖解析前就把每条规则当「包名@精确版本」校验并中止,该 profile 内的安装都会失败 ——
478
+ // 给删除/改正指引,不引导提 Issue
479
+ ? h('div', { className: styles.failPrepareHint }, t('failProfileBadAllowBuildsHint'))
475
480
  : failureKind === 'profileState'
476
481
  // profile 的 pnpm 元数据与本机不一致(lockfile 过期 / packageManager 版本不符 /
477
482
  // 补丁未生效):pnpm 在真正装插件之前就中止,该 profile 内的安装都会失败 ——
@@ -394,7 +394,10 @@ export const zh = {
394
394
  failKind_profileYamlCorrupted: 'profile 的 YAML 配置损坏',
395
395
  failReason_profileYamlCorrupted: 'profile 目录里的 pnpm-workspace.yaml 或 pnpm-lock.yaml 有重复的键,pnpm 在解析阶段就退出了。',
396
396
  failKind_profileLocalBuild: 'profile 里已有的本地插件构建失败',
397
+ failKind_profileBadAllowBuilds: 'profile 的 allowBuilds 有条目无法解析',
398
+ failProfileBadAllowBuildsHint: '安装器调用 pnpm 时,pnpm 在解析依赖之前就中止了:这台机器 profile 的 pnpm-workspace.yaml 里,allowBuilds 有一条写成「包名@版本」的规则,而版本位置填的是 URL 或本地路径(报错形如 Invalid versions union. Found: "xxx@https")。pnpm 会把每条规则当成「包名@精确版本」解析,解析不了就直接报错,所以这个 profile 里装任何插件都会失败——与本次要装的插件无关,也不该去找它的作者。请打开 ~/.dsh/profiles/<profile>/pnpm-workspace.yaml,把 allowBuilds 下那条带 https://、git、file: 的键删掉(或只保留包名,例如改成 `dsh-purge: true`),保存后重试。升级到 2.3.9 之后,插件中心会在安装前自动把这类键修正成包名。',
397
399
  failReason_profileLocalBuild: 'pnpm 每次安装都会重校验整个 profile,这次失败来自 profile 里已有的 file:/link: 本地插件的构建脚本,和本次要装的插件无关。',
400
+ failReason_profileBadAllowBuilds: 'profile 的 pnpm-workspace.yaml 里 allowBuilds 有一条写成了「包名@URL / 本地路径」,pnpm 解析不了这条规则,安装还没开始就中止了——和本次要装的插件无关。',
398
401
  failKind_profileState: 'profile 的 pnpm 元数据与本机不一致',
399
402
  failReason_profileState: 'profile 的 lockfile / package.json / packageManager 版本与当前 pnpm 对不上,安装没开始就被中止。',
400
403
  failKind_nodeEngine: '本机 Node / pnpm 版本不满足要求',
@@ -905,7 +908,10 @@ export const en = {
905
908
  failKind_profileYamlCorrupted: 'profile YAML config is corrupted',
906
909
  failReason_profileYamlCorrupted: 'A YAML file in the profile directory has a duplicate key, so pnpm aborts at parse time.',
907
910
  failKind_profileLocalBuild: 'a local plugin already in the profile fails its build',
911
+ failKind_profileBadAllowBuilds: 'an allowBuilds entry in the profile cannot be parsed',
912
+ failProfileBadAllowBuildsHint: 'pnpm aborted before resolving dependencies: the allowBuilds list in this machine\'s profile (pnpm-workspace.yaml) has a rule written as "package@version" where the version part is a URL or local path (the error looks like Invalid versions union. Found: "xxx@https"). pnpm parses every rule as an exact "package@version" pair and fails on anything else, so every install into this profile fails — it is unrelated to the plugin you were installing, and reporting it to that plugin\'s author will not help. Open ~/.dsh/profiles/<profile>/pnpm-workspace.yaml, delete that key containing https://, git or file:, or keep just the package name (for example `dsh-purge: true`), then retry. From 2.3.9 the hub repairs such keys automatically before installing.',
908
913
  failReason_profileLocalBuild: 'pnpm re-validates the whole profile on every install; this failure comes from the build script of a local (file:/link:) package already in the profile, not from the plugin being installed.',
914
+ failReason_profileBadAllowBuilds: 'The allowBuilds list in the profile\'s pnpm-workspace.yaml has an entry written as "package@URL / local path", which pnpm cannot parse, so the install aborts before starting — unrelated to the plugin being installed.',
909
915
  failKind_profileState: 'profile pnpm metadata does not match this machine',
910
916
  failReason_profileState: 'The profile lockfile / package.json / packageManager version disagrees with the current pnpm, so the install aborts before starting.',
911
917
  failKind_nodeEngine: 'Node / pnpm on this machine is below the required version',
@@ -148,7 +148,7 @@ export function removeNotification(id: number): NotificationRecord[] {
148
148
  return next
149
149
  }
150
150
 
151
- export type FailureKind = 'npmTooOld' | 'nodeEngine' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmModulesMismatch' | 'profileYamlCorrupted' | 'profileLocalBuild' | 'profileState' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'registryAuth' | 'profileExclusive' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'tlsTrust' | 'network' | 'hostCliSpawn' | 'hubState' | 'profileBundleMissing' | 'timeout' | 'profileLock' | 'dshTooOld' | 'localEnv' | 'hub'
151
+ export type FailureKind = 'npmTooOld' | 'nodeEngine' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmModulesMismatch' | 'profileYamlCorrupted' | 'profileLocalBuild' | 'profileBadAllowBuilds' | 'profileState' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'registryAuth' | 'profileExclusive' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'buildToolchainMissing' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'tlsTrust' | 'network' | 'hostCliSpawn' | 'hubState' | 'profileBundleMissing' | 'timeout' | 'profileLock' | 'dshTooOld' | 'localEnv' | 'hub'
152
152
 
153
153
  /** 失败动作规则:标题、上报正文与提示文案按安装/更新/卸载取词,不写死「安装」。 */
154
154
  export type FailureAction = 'install' | 'update' | 'uninstall'
@@ -286,6 +286,10 @@ export function classifyFailure(message: string): FailureKind {
286
286
  // 名单全是本地 file:/link: 依赖时,失败只能来自 profile 自身的包重校验:归本机状态,
287
287
  // 不能记到本次插件头上。必须在 pnpmIgnoredBuild 之前,否则会被它先按插件侧接走
288
288
  if (localBuildScriptsOnly(message)) return 'profileLocalBuild'
289
+ // profile 的 allowBuilds 里有 pnpm 解析不了的 spec(`pkg@https://…` / `pkg@file:…`):
290
+ // pnpm v11 在依赖解析之前就把每条规则当成「包名@精确版本」解析并抛错,该 profile 里
291
+ // 之后任何安装都会失败 —— 与本次要装的插件无关(#730)
292
+ if (/ERR_PNPM_INVALID_VERSION_UNION|Invalid versions union/i.test(message)) return 'profileBadAllowBuilds'
289
293
  // 构建脚本被 allowBuilds 拦截。必须在 network 之前判:日志尾部混重试残留的连接失败特征
290
294
  if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/i.test(message)) return 'pnpmIgnoredBuild'
291
295
  // npm 源拒绝了访问(401/403):私有源要登录凭据,或当前网络/IP 不被源允许。
@@ -52,6 +52,7 @@ function reasonTitleOf(kind: FailureKind, action: FailureAction): string {
52
52
  case 'profileState': return `the user profile's pnpm metadata does not match the local pnpm for this ${word}`
53
53
  case 'profileYamlCorrupted': return 'the user profile has a corrupted YAML config (duplicated mapping key)'
54
54
  case 'profileLocalBuild': return 'a local (file:/link:) plugin already in the user profile has a failing build script'
55
+ case 'profileBadAllowBuilds': return 'the user profile has an allowBuilds entry that pnpm cannot parse'
55
56
  case 'dshMissing': return 'dsh command not found on the user machine'
56
57
  case 'pnpmMissing': return 'pnpm command not found on the user machine'
57
58
  case 'pnpmStore': return 'pnpm store version mismatch on the user machine'
@@ -118,6 +119,8 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
118
119
  ? 'the local certificate chain is not trusted by Node/pnpm (corporate SSL inspection, antivirus web shield, or a local MITM proxy) — local environment issue'
119
120
  : kind === 'profileLocalBuild'
120
121
  ? 'no plugin-side cause found — pnpm rejects a build script of a local (file:/link:) package that already exists in the user profile, so every install into that profile fails the same way (local environment issue)'
122
+ : kind === 'profileBadAllowBuilds'
123
+ ? 'no plugin-side cause found — the allowBuilds list in the user profile\'s pnpm-workspace.yaml contains a spec pnpm cannot parse (a URL, git or file path where an exact version is required), so pnpm aborts every install into that profile before resolving the plugin (local environment issue)'
121
124
  : kind === 'localEnv'
122
125
  ? 'no evidence of a plugin-side cause; the log shows local-environment signals (system error codes, certificates/proxy, or a broken local config), so this is reported for triage of the classifier only'
123
126
  : kind === 'dshTooOld'
@@ -20,7 +20,7 @@ import { cleanLine, estimateProgress } from '../profile/progress.ts'
20
20
  import type { LoaderHandle } from '../loader.ts'
21
21
  import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
22
22
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.ts'
23
- import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory, validPackageName } from '../profile/profile.ts'
23
+ import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory, sanitizeAllowBuilds, validPackageName } from '../profile/profile.ts'
24
24
  import { appendLog } from '../log.ts'
25
25
  import { commandOnPath, gitMissingDetail } from './preflight.ts'
26
26
  import { isTransportFailure, registryHost } from './registry-fallback.ts'
@@ -939,6 +939,12 @@ export async function runPluginMutation(options: {
939
939
  // operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读取作为后备
940
940
  const profileDir = profileDirectory(options.profile)
941
941
  const logTap = createOperationLogTap(profileDir)
942
+ // 装前修掉 allowBuilds 里的非版本 spec(`pkg@https://…` / `pkg@file:…`):pnpm v11 在依赖
943
+ // 解析前就会抛 ERR_PNPM_INVALID_VERSION_UNION,该 profile 里之后任何安装都会失败(#730)。
944
+ // 这一步失败不影响主流程 —— 真有问题仍由 pnpm 的报错决定怎么提示。
945
+ if (options.action !== 'remove' && (options.globalNpm ?? []).length === 0) {
946
+ try { sanitizeAllowBuilds(options.profile) } catch { /* 读不到/写不进就按原样继续 */ }
947
+ }
942
948
  let result = await spawnMutation(options)
943
949
  // allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
944
950
  // 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
@@ -153,34 +153,23 @@ function quoteYamlKey(key: string): string {
153
153
  return key
154
154
  }
155
155
 
156
- /**
157
- * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
158
- * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
159
- * 字面量 `set this to true or false`)。
160
- *
161
- * 任何形态的既有 `allowBuilds` 都会被**整体替换**成规范化块:块写法、行内数组
162
- * `allowBuilds: ["a@1"]`、以及末尾无换行的 `allowBuilds:` 都算命中。否则会在文件里
163
- * 再多写一个同名键 —— pnpm 解析时直接报 `duplicated mapping key`,该 profile 里
164
- * 所有插件都装不上(#562)。
165
- */
166
- export function addAllowBuildsKey(profile: string, key: string): void {
167
- const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
168
- let yaml = ''
169
- try { yaml = readFileSync(file, 'utf8') } catch { /* 文件不存在,下方创建 */ }
170
- const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
171
- // header 行(可选行内值)+ 其后任意缩进条目行;行内写法没有后续缩进行也能匹配
172
- const blockRe = /^[ \t]*allowBuilds:[^\r\n]*(?:(?:\r?\n)[ \t]{2,}[^\r\n]*)*/gm
156
+ /** `allowBuilds` 段:header 行(可带行内数组值)+ 其后任意缩进条目行。 */
157
+ const ALLOW_BLOCK_RE = /^[ \t]*allowBuilds:[^\r\n]*(?:(?:\r?\n)[ \t]{2,}[^\r\n]*)*/gm
158
+
159
+ function unquoteYamlKey(key: string): string {
160
+ const quoted = key.length >= 2 && ((key[0] === "'" && key[key.length - 1] === "'") || (key[0] === '"' && key[key.length - 1] === '"'))
161
+ return quoted ? key.slice(1, -1) : key
162
+ }
163
+
164
+ /** 读出 yaml 里所有 `allowBuilds` 段的条目(key → 值,缺省 true)。 */
165
+ function readAllowBuilds(yaml: string): Map<string, string> {
173
166
  const map = new Map<string, string>()
174
- for (const match of Array.from(yaml.matchAll(blockRe))) {
167
+ for (const match of Array.from(yaml.matchAll(ALLOW_BLOCK_RE))) {
175
168
  const [header, ...rest] = match[0].split(/\r?\n/)
176
169
  for (const line of rest) {
177
170
  const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line)
178
171
  if (m === null || m[1] === '') continue
179
- let k = m[1]
180
- if (k.length >= 2 && ((k[0] === "'" && k[k.length - 1] === "'") || (k[0] === '"' && k[k.length - 1] === '"'))) {
181
- k = k.slice(1, -1)
182
- }
183
- map.set(k, m[2] ?? 'true')
172
+ map.set(unquoteYamlKey(m[1]), m[2] ?? 'true')
184
173
  }
185
174
  // 行内数组写法:allowBuilds: ["a@1.0.0", 'b@2'] —— 保留已有条目,别丢
186
175
  const inline = /^[ \t]*allowBuilds:\s*\[(.*)\]\s*$/.exec(header)
@@ -191,11 +180,64 @@ export function addAllowBuildsKey(profile: string, key: string): void {
191
180
  }
192
181
  }
193
182
  }
194
- map.set(key, 'true')
183
+ return map
184
+ }
185
+
186
+ /** 把所有 `allowBuilds` 段合并写成一段规范化块,其余 yaml 原样保留;无该段时追加。 */
187
+ function writeAllowBuilds(yaml: string, map: Map<string, string>): string {
188
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
195
189
  const block = Array.from(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol)
196
190
  const blockText = `allowBuilds:${eol}${block}${eol}`
197
191
  let seen = 0
198
- let next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''))
199
- if (seen === 0) next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`
200
- writeFileSync(file, next)
192
+ const next = yaml.replace(ALLOW_BLOCK_RE, () => (seen++ === 0 ? blockText : ''))
193
+ return seen === 0 ? `${yaml.replace(/\r?\n?$/, eol)}${blockText}` : next
194
+ }
195
+
196
+ /**
197
+ * `allowBuilds` 的 key 必须是 `包名@精确版本`。pnpm v11 在真正安装前会把每条规则解析成
198
+ * 「包名 + 精确版本」,遇到 URL/git/file spec(`pkg@https://…`、`pkg@file:…`)直接抛
199
+ * `ERR_PNPM_INVALID_VERSION_UNION` —— 该 profile 里之后**任何**安装都会失败(#730)。
200
+ * 非 semver 的 spec 一律降级为裸包名(pnpm 按包名匹配所有版本)。
201
+ */
202
+ export function safeAllowBuildsKey(key: string): string {
203
+ const at = key.startsWith('@') ? key.indexOf('@', 1) : key.indexOf('@')
204
+ if (at <= 0) return key
205
+ const spec = key.slice(at + 1)
206
+ return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(spec) ? key : key.slice(0, at)
207
+ }
208
+
209
+ /**
210
+ * 安装前修复**已有** `allowBuilds` 里的非法 key。pnpm 的报错发生在依赖解析之前,
211
+ * 走不到 `addAllowBuildsKey` 的失败恢复路径,只能主动清理。返回是否改写了文件。
212
+ */
213
+ export function sanitizeAllowBuilds(profile: string): boolean {
214
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
215
+ let yaml = ''
216
+ try { yaml = readFileSync(file, 'utf8') } catch { return false }
217
+ if (Array.from(yaml.matchAll(ALLOW_BLOCK_RE)).length === 0) return false
218
+ const before = readAllowBuilds(yaml)
219
+ const after = new Map<string, string>()
220
+ for (const [k, v] of before) after.set(safeAllowBuildsKey(k), v)
221
+ if (after.size === before.size && Array.from(after).every(([k, v]) => before.get(k) === v)) return false
222
+ writeFileSync(file, writeAllowBuilds(yaml, after))
223
+ return true
224
+ }
225
+
226
+ /**
227
+ * 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
228
+ * 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
229
+ * 字面量 `set this to true or false`)。
230
+ *
231
+ * 任何形态的既有 `allowBuilds` 都会被**整体替换**成规范化块:块写法、行内数组
232
+ * `allowBuilds: ["a@1"]`、以及末尾无换行的 `allowBuilds:` 都算命中。否则会在文件里
233
+ * 再多写一个同名键 —— pnpm 解析时直接报 `duplicated mapping key`,该 profile 里
234
+ * 所有插件都装不上(#562)。
235
+ */
236
+ export function addAllowBuildsKey(profile: string, key: string): void {
237
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
238
+ let yaml = ''
239
+ try { yaml = readFileSync(file, 'utf8') } catch { /* 文件不存在,下方创建 */ }
240
+ const map = readAllowBuilds(yaml)
241
+ map.set(safeAllowBuildsKey(key), 'true')
242
+ writeFileSync(file, writeAllowBuilds(yaml, map))
201
243
  }