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.
- package/README.en.md +70 -26
- package/README.md +70 -26
- package/client/client.js +320 -311
- package/client/client.js.map +1 -1
- package/lib/services/install/task-queue.js +10 -1
- package/lib/services/profile/profile.js +75 -31
- package/lib/types/services/profile/profile.d.ts +12 -0
- package/package.json +2 -2
- package/src/client/components/modals/NotificationsModal.tsx +5 -0
- package/src/client/components/modals/modals.tsx +5 -0
- package/src/client/locales.ts +6 -0
- package/src/client/logic/failures.ts +5 -1
- package/src/client/logic/urls.ts +3 -0
- package/src/server/services/install/task-queue.ts +7 -1
- package/src/server/services/profile/profile.ts +69 -27
|
@@ -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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
4
|
-
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install
|
|
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 内的安装都会失败 ——
|
package/src/client/locales.ts
CHANGED
|
@@ -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 不被源允许。
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
}
|