dsh-plugin 2.0.6 → 2.1.0
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 +11 -11
- package/README.md +11 -11
- package/client/client.js +421 -326
- package/client/client.js.map +1 -1
- package/lib/http/install.js +42 -11
- package/lib/http/settings.js +1 -1
- package/lib/services/install/decode-output.js +23 -0
- package/lib/services/install/operation-log.js +61 -11
- package/lib/services/install/registry-fallback.js +89 -0
- package/lib/services/install/task-queue.js +69 -19
- package/lib/services/settings.js +1 -0
- package/lib/types/services/install/decode-output.d.ts +4 -0
- package/lib/types/services/install/install-types.d.ts +4 -0
- package/lib/types/services/install/operation-log.d.ts +10 -4
- package/lib/types/services/install/registry-fallback.d.ts +18 -0
- package/lib/types/services/install/task-queue.d.ts +18 -0
- package/lib/types/services/settings.d.ts +3 -1
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +11 -2
- package/src/client/components/modals/NotificationsModal.tsx +8 -1
- package/src/client/components/modals/modals.tsx +9 -1
- package/src/client/components/views/SettingsView.tsx +10 -0
- package/src/client/hooks/useCatalog.ts +10 -8
- package/src/client/hooks/useSettings.ts +4 -1
- package/src/client/hooks/useTaskQueue.ts +7 -1
- package/src/client/locales.ts +12 -0
- package/src/client/logic/failures.ts +17 -1
- package/src/client/logic/release-age.ts +40 -0
- package/src/client/logic/urls.ts +4 -1
- package/src/client/types.ts +13 -0
- package/src/server/http/install.ts +46 -11
- package/src/server/http/settings.ts +1 -1
- package/src/server/services/install/decode-output.ts +22 -0
- package/src/server/services/install/install-types.ts +4 -0
- package/src/server/services/install/operation-log.ts +58 -11
- package/src/server/services/install/registry-fallback.ts +89 -0
- package/src/server/services/install/task-queue.ts +78 -19
- package/src/server/services/settings.ts +4 -1
package/lib/http/install.js
CHANGED
|
@@ -11,10 +11,12 @@ import { recordResolvedNpmPackage } from '../services/profile/installed-versions
|
|
|
11
11
|
import { isDshNpmPackageForRepo } from '../services/install/npm-resolve.js';
|
|
12
12
|
import { probeUrl, systemProxy } from '../services/probe.js';
|
|
13
13
|
import { loadSettings } from '../services/settings.js';
|
|
14
|
+
import { registryCandidates, registryHost, userNpmRegistry } from '../services/install/registry-fallback.js';
|
|
15
|
+
import { profileDirectory } from '../services/profile/profile.js';
|
|
14
16
|
import { readInstalled } from './catalog.js';
|
|
15
17
|
import { COMMAND_TIMEOUT_MS, readJsonBody, requireTrustedPost, sendJson } from './shared.js';
|
|
16
18
|
/** 安装/卸载 CLI 的子进程环境:透传宿主环境并注入 HTTP(S) 代理,
|
|
17
|
-
* 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env
|
|
19
|
+
* 代理来源与诊断一致(设置代理 → 系统代理 → 宿主 env);registry 由任务队列按候选源逐次注入。 */
|
|
18
20
|
function mutationEnv(settings) {
|
|
19
21
|
const env = { ...process.env, CI: 'true' };
|
|
20
22
|
const proxy = settings.proxy !== '' ? settings.proxy : (systemProxy() ?? '');
|
|
@@ -63,6 +65,13 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
63
65
|
const lang = typeof body === 'object' && body !== null && body.lang === 'en'
|
|
64
66
|
? 'en'
|
|
65
67
|
: 'zh';
|
|
68
|
+
// 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
|
|
69
|
+
const effectiveProxy = settings.proxy !== ''
|
|
70
|
+
? settings.proxy
|
|
71
|
+
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
72
|
+
// 安装用的 npm 源:设置里显式选择优先;未配置时读本机 npm 配置(与诊断同口径)。
|
|
73
|
+
// 只有公共源允许失败后换源;自定义/内网源保持单一候选,避免私有包名泄露给公共源。
|
|
74
|
+
const registries = registryCandidates(settings.npmRegistry, settings.npmRegistry === '' ? userNpmRegistry(profileDirectory(profile)) : '', settings.registryFallback);
|
|
66
75
|
// 全局 npm 安装(`npm install -g <pkgs>`):不进任何 profile、无需宿主重启。
|
|
67
76
|
// 包列表优先取 body.globalNpm,兜底从 rawRepo 解析,保证直接调 API 传完整命令也能识别。
|
|
68
77
|
const bodyGlobalNpm = Array.isArray(body.globalNpm)
|
|
@@ -94,6 +103,8 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
94
103
|
globalNpm,
|
|
95
104
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
96
105
|
env: mutationEnv(settings),
|
|
106
|
+
// 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
|
|
107
|
+
registries,
|
|
97
108
|
// 全局 npm 安装由 NPM 包卡片/官方命令发起:日志体现入口渠道
|
|
98
109
|
installChannel: installChannel ?? 'npm',
|
|
99
110
|
});
|
|
@@ -106,12 +117,17 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
106
117
|
return;
|
|
107
118
|
}
|
|
108
119
|
const target = rawRepo;
|
|
120
|
+
// 钉住精确版本(Hub 自我更新):最新版可能仍在 pnpm 供应链门禁内,
|
|
121
|
+
// 客户端提示的是「已过门禁」的版本,服务端必须按该版本安装而不是 @latest。
|
|
122
|
+
const pinVersion = typeof body.version === 'string'
|
|
123
|
+
? body.version.trim()
|
|
124
|
+
: '';
|
|
125
|
+
if (pinVersion !== '' && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/.test(pinVersion)) {
|
|
126
|
+
sendJson(response, 400, { error: 'invalid version' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
109
129
|
// 已尝试的安装方式(供失败提 Issue 预填):保持为空
|
|
110
130
|
const attempts = [];
|
|
111
|
-
// 代理口径与诊断/安装通道一致:设置里的代理 → 系统代理 → 环境变量 → 直连。
|
|
112
|
-
const effectiveProxy = settings.proxy !== ''
|
|
113
|
-
? settings.proxy
|
|
114
|
-
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
|
|
115
131
|
// 目录插件安装:目录数据可能滞后于打包拆分,确认目录下发的 npm 目标确为该仓库的 DSH 包,
|
|
116
132
|
// 命中则记录 repo → 包名映射,客户端据此把依赖 key 匹配回仓库、列表立即显示「已安装」。
|
|
117
133
|
const catalogRepo = source === 'catalog' ? githubRepoOf(displayRepo) : null;
|
|
@@ -137,18 +153,30 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
137
153
|
return;
|
|
138
154
|
}
|
|
139
155
|
// 安装前网络探测:探 registry 根而非具体包(404 是「包不存在」而非网络不通);仅新安装执行,更新跳过。
|
|
140
|
-
//
|
|
156
|
+
// 首选源不通时按候选顺序继续探下一个,能通的那个提到首位供实际安装使用。
|
|
157
|
+
// 全部不通则 400 拦下并打 [network] 标记,客户端据此提示网络问题,而非插件问题。
|
|
158
|
+
let ordered = registries;
|
|
141
159
|
if (already === null) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const tried = [];
|
|
161
|
+
let chosen = null;
|
|
162
|
+
for (const candidate of registries) {
|
|
163
|
+
tried.push(candidate);
|
|
164
|
+
const net = await probeUrl(`${candidate}/`, effectiveProxy, 6000);
|
|
165
|
+
if (net.ok) {
|
|
166
|
+
chosen = candidate;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (chosen === null) {
|
|
145
171
|
// 错误消息按界面语言输出,让用户看清是网络问题而非插件问题
|
|
172
|
+
const hosts = tried.map(registryHost).join(' → ');
|
|
146
173
|
const error = lang === 'zh'
|
|
147
|
-
? `[network] 安装已中止:安装前无法连接到 npm 源(${
|
|
148
|
-
: `[network] install aborted: cannot reach the npm registry (${
|
|
174
|
+
? `[network] 安装已中止:安装前无法连接到 npm 源(${hosts})—— 您的网络似乎不通或被拦截(断网 / DNS 解析失败 / 防火墙 / 代理配置问题)。这不是插件本身的问题,请检查网络连接或代理设置,运行「系统诊断」检测各通道连通性后重试。`
|
|
175
|
+
: `[network] install aborted: cannot reach the npm registry (${hosts}) before install — your network connection appears to be down or blocked (DNS / proxy / firewall). This is not a problem with the plugin itself. Check your connection or proxy settings, run the connectivity diagnostic, then retry.`;
|
|
149
176
|
sendJson(response, 400, { error, attempts });
|
|
150
177
|
return;
|
|
151
178
|
}
|
|
179
|
+
ordered = [chosen, ...registries.filter((r) => r !== chosen)];
|
|
152
180
|
}
|
|
153
181
|
// 入队前复检:网络探测的 await 期间并发请求可能已通过上方检查,而此处到 startPluginMutation
|
|
154
182
|
// 之间无 await,JS 单线程保证同一目标至多一个任务,杜绝双击/并发双任务。
|
|
@@ -171,6 +199,9 @@ export function registerInstallRoutes(webServer, profile, loader) {
|
|
|
171
199
|
loader,
|
|
172
200
|
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
173
201
|
env: mutationEnv(settings),
|
|
202
|
+
// 候选源(首选在前):任务内遇到传输层失败时按顺序换源重试
|
|
203
|
+
registries: ordered,
|
|
204
|
+
installSpec: pinVersion !== '' ? `${target}@${pinVersion}` : undefined,
|
|
174
205
|
// 自定义安装入口渠道:日志/报错据此溯源
|
|
175
206
|
installChannel,
|
|
176
207
|
});
|
package/lib/http/settings.js
CHANGED
|
@@ -28,7 +28,7 @@ export function registerSettingsRoutes(webServer, profile) {
|
|
|
28
28
|
// 白名单字段:只接受已知设置项,防客户端把任意键写进文件
|
|
29
29
|
const patch = {};
|
|
30
30
|
const source = typeof body === 'object' && body !== null ? body : {};
|
|
31
|
-
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'proxy', 'logPath']) {
|
|
31
|
+
for (const key of ['checkUpdatesOnStart', 'enableNpmInstall', 'npmRegistry', 'registryFallback', 'proxy', 'logPath']) {
|
|
32
32
|
if (source[key] !== undefined)
|
|
33
33
|
patch[key] = source[key];
|
|
34
34
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 子进程输出解码:默认 UTF-8;出现替换字符(中文 Windows 的 GBK 输出)时改用 GBK 重解。
|
|
3
|
+
* 用 StringDecoder 跨 chunk 缓冲,避免 UTF-8 多字节字符被 chunk 边界切断后误判为 GBK。
|
|
4
|
+
*/
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
6
|
+
export class OutputDecoder {
|
|
7
|
+
utf8 = new StringDecoder('utf8');
|
|
8
|
+
push(chunk) {
|
|
9
|
+
const text = this.utf8.write(chunk);
|
|
10
|
+
if (!text.includes('\uFFFD'))
|
|
11
|
+
return text;
|
|
12
|
+
try {
|
|
13
|
+
const gbk = new TextDecoder('gbk').decode(chunk);
|
|
14
|
+
// GBK 能完整解出且含中文才采用:纯 ASCII 或仍有替换字符时保持 UTF-8 结果
|
|
15
|
+
if (!gbk.includes('\uFFFD') && /[\u4e00-\u9fff]/.test(gbk))
|
|
16
|
+
return gbk;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// 无完整 ICU(TextDecoder 不支持 gbk)时退回 UTF-8 结果
|
|
20
|
+
}
|
|
21
|
+
return text;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -6,36 +6,75 @@
|
|
|
6
6
|
* 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
|
|
7
7
|
* 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
|
|
8
8
|
* 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
|
|
9
|
-
* 只允许读
|
|
9
|
+
* 只允许读 profile 目录下的 pnpm.log(dsh 自己的 operation 日志),
|
|
10
10
|
* 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
|
|
11
11
|
* 不改变原有失败处理。
|
|
12
12
|
*/
|
|
13
13
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
-
import { join } from 'node:path';
|
|
14
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
15
15
|
import { CAPTURE_LIMIT_BYTES } from './install-types.js';
|
|
16
|
+
/** 去掉 ANSI 颜色码:宿主的摘要行可能带终端着色,直接匹配路径会失败。 */
|
|
17
|
+
function stripAnsi(text) {
|
|
18
|
+
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
19
|
+
}
|
|
16
20
|
/**
|
|
17
21
|
* 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* 去掉首尾引号与句末标点(不同版本/语言的摘要行尾部可能带 `。`、`.`、`)`);
|
|
23
|
+
* 相对路径原样返回,由读取方按 profile 目录解析。
|
|
20
24
|
*/
|
|
21
25
|
export function operationLogPathOf(output) {
|
|
22
26
|
for (const raw of output.split(/\r?\n/)) {
|
|
23
|
-
|
|
27
|
+
// 兼容纯文本与 JSON 包裹两种形态:`diagnostics: <path>` / `"diagnostics": "<path>"}`
|
|
28
|
+
const m = /"?diagnostics"?\s*:\s*(.+)$/i.exec(stripAnsi(raw).trim());
|
|
24
29
|
if (m === null)
|
|
25
30
|
continue;
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
let path = m[1]
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/[.,;:,。;、)\]}]+$/, '')
|
|
34
|
+
.trim();
|
|
35
|
+
// JSON 形态:引号包裹且反斜杠被转义过,按字符串字面量还原;普通文本里的引号直接剥掉
|
|
36
|
+
if (path.startsWith('"') && path.endsWith('"')) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(path);
|
|
39
|
+
if (typeof parsed === 'string')
|
|
40
|
+
path = parsed;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
path = path.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
path = path.trim();
|
|
47
|
+
// URL 不是本地日志:避免把网络地址当文件读
|
|
48
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
|
|
28
49
|
continue;
|
|
29
|
-
|
|
50
|
+
if (path.toLowerCase().endsWith('pnpm.log'))
|
|
51
|
+
return path;
|
|
30
52
|
}
|
|
31
53
|
return null;
|
|
32
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* 只允许读 profile 目录下的 pnpm.log(dsh 自己的操作日志):
|
|
57
|
+
* 绝对路径必须落在该目录内,相对路径按该目录解析。
|
|
58
|
+
* 未提供 profileDir 时退回旧口径(路径须含 .plugin-manager),防止插件打印任意路径造成越权读取。
|
|
59
|
+
*/
|
|
60
|
+
function allowedLogPath(raw, profileDir) {
|
|
61
|
+
const path = resolve(profileDir ?? '', raw);
|
|
62
|
+
if (basename(path).toLowerCase() !== 'pnpm.log')
|
|
63
|
+
return null;
|
|
64
|
+
if (profileDir === undefined)
|
|
65
|
+
return path.includes('.plugin-manager') ? path : null;
|
|
66
|
+
const root = resolve(profileDir);
|
|
67
|
+
return path.startsWith(`${root}${sep}`) ? path : null;
|
|
68
|
+
}
|
|
33
69
|
/**
|
|
34
70
|
* 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
|
|
35
|
-
*
|
|
71
|
+
* 内容为空 / 路径越界)返回 null。尾部截到与子进程输出相同的上限。
|
|
36
72
|
*/
|
|
37
|
-
export function backfillOperationLog(output) {
|
|
38
|
-
const
|
|
73
|
+
export function backfillOperationLog(output, profileDir) {
|
|
74
|
+
const raw = operationLogPathOf(output);
|
|
75
|
+
if (raw === null)
|
|
76
|
+
return null;
|
|
77
|
+
const path = allowedLogPath(raw, profileDir);
|
|
39
78
|
if (path === null)
|
|
40
79
|
return null;
|
|
41
80
|
try {
|
|
@@ -48,6 +87,17 @@ export function backfillOperationLog(output) {
|
|
|
48
87
|
return null;
|
|
49
88
|
}
|
|
50
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
|
|
92
|
+
* 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
|
|
93
|
+
* 否则上报正文只剩退出码,无从排查。
|
|
94
|
+
*/
|
|
95
|
+
export function hasSubstantiveOutput(output) {
|
|
96
|
+
return output.split(/\r?\n/).some((raw) => {
|
|
97
|
+
const line = raw.trim();
|
|
98
|
+
return line !== '' && !/^\[(?:exit\b|timed out\]|install-channel\]|stale pipe\]|error\]|pnpm log\])/.test(line);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
51
101
|
/**
|
|
52
102
|
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
53
103
|
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm 源降级:当前源是公共镜像时,传输层失败后按「当前源 → 官方 → npmmirror」
|
|
3
|
+
* 自动换源重试;自定义/内网源不降级,避免私有包名泄露给公共源。
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
/** 允许自动切换的公共源:只放官方与大厂/高校镜像。 */
|
|
7
|
+
const PUBLIC_REGISTRIES = [
|
|
8
|
+
'https://registry.npmjs.org',
|
|
9
|
+
'https://registry.npmmirror.com',
|
|
10
|
+
'https://mirrors.cloud.tencent.com/npm',
|
|
11
|
+
'https://mirrors.tuna.tsinghua.edu.cn/npm',
|
|
12
|
+
];
|
|
13
|
+
const PUBLIC_HOSTS = new Set([
|
|
14
|
+
'registry.npmjs.org',
|
|
15
|
+
'registry.npmmirror.com',
|
|
16
|
+
'mirrors.cloud.tencent.com',
|
|
17
|
+
'mirrors.tuna.tsinghua.edu.cn',
|
|
18
|
+
]);
|
|
19
|
+
/** 去掉尾部斜杠,避免同一源因写法不同被当成两个候选。 */
|
|
20
|
+
export function normalizeRegistry(value) {
|
|
21
|
+
return value.trim().replace(/\/+$/, '');
|
|
22
|
+
}
|
|
23
|
+
/** 是否属于允许自动切换的公共源白名单。 */
|
|
24
|
+
export function isPublicRegistry(value) {
|
|
25
|
+
const v = normalizeRegistry(value);
|
|
26
|
+
if (!/^https?:\/\//i.test(v))
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
return PUBLIC_HOSTS.has(new URL(v).hostname.toLowerCase());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 依次尝试的源:显式配置 → 本机 npm 配置 → 官方源。
|
|
37
|
+
* 只有公共源才追加后备(最多 3 个);关闭降级或源不在白名单时只返回它自己。
|
|
38
|
+
*/
|
|
39
|
+
export function registryCandidates(configured, userRegistry, enabled) {
|
|
40
|
+
const base = normalizeRegistry(configured) || normalizeRegistry(userRegistry) || PUBLIC_REGISTRIES[0];
|
|
41
|
+
if (!enabled || !isPublicRegistry(base))
|
|
42
|
+
return [base];
|
|
43
|
+
const out = [base];
|
|
44
|
+
for (const candidate of PUBLIC_REGISTRIES) {
|
|
45
|
+
if (out.length >= 3)
|
|
46
|
+
break;
|
|
47
|
+
if (!out.includes(candidate))
|
|
48
|
+
out.push(candidate);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** 本机 npm 实际生效的源(含 profile 目录的 .npmrc);读不到返回空串。 */
|
|
53
|
+
export function userNpmRegistry(cwd) {
|
|
54
|
+
try {
|
|
55
|
+
const res = spawnSync('npm', ['config', 'get', 'registry'], {
|
|
56
|
+
cwd,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
timeout: 3000,
|
|
59
|
+
shell: process.platform === 'win32',
|
|
60
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
61
|
+
});
|
|
62
|
+
if (res.status !== 0)
|
|
63
|
+
return '';
|
|
64
|
+
const value = (res.stdout ?? '').trim();
|
|
65
|
+
return /^https?:\/\//i.test(value) ? normalizeRegistry(value) : '';
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 传输层失败才值得换源:超时、连接重置、DNS、5xx。
|
|
73
|
+
* 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
|
|
74
|
+
*/
|
|
75
|
+
export function isTransportFailure(text) {
|
|
76
|
+
if (/\b(?:ERR_PNPM_FETCH_404|E404|ERR_PNPM_NO_MATCHING_VERSION|ETARGET|ERR_PNPM_FETCH_401|ERR_PNPM_FETCH_403|E401|E403)\b/i.test(text)) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return /\b(?:ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EPIPE)\b|socket hang up|getaddrinfo|network request failed|connect timeout|request to .+ failed|FetchError|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL/i.test(text);
|
|
80
|
+
}
|
|
81
|
+
/** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
|
|
82
|
+
export function registryHost(value) {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(value).host;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -14,13 +14,15 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
14
14
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
|
|
15
15
|
import { npmTooLowMarker } from './npm-check.js';
|
|
16
16
|
import { registryMissingMarker } from './registry-check.js';
|
|
17
|
-
import { backfillOperationLog, createOperationLogTap, operationLogPathOf } from './operation-log.js';
|
|
17
|
+
import { backfillOperationLog, createOperationLogTap, hasSubstantiveOutput, operationLogPathOf } from './operation-log.js';
|
|
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
21
|
import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
|
|
22
22
|
import { appendLog } from '../log.js';
|
|
23
23
|
import { commandOnPath, gitMissingDetail } from './preflight.js';
|
|
24
|
+
import { isTransportFailure, registryHost } from './registry-fallback.js';
|
|
25
|
+
import { OutputDecoder } from './decode-output.js';
|
|
24
26
|
/** 内存任务注册表,按任务 id 索引。 */
|
|
25
27
|
const tasks = new Map();
|
|
26
28
|
let nextTaskId = 1;
|
|
@@ -195,6 +197,17 @@ export function desktopCliBootstrap(entry) {
|
|
|
195
197
|
}
|
|
196
198
|
return null;
|
|
197
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
|
|
202
|
+
* 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
|
|
203
|
+
*/
|
|
204
|
+
export function npmInstallTargetOf(target, opts) {
|
|
205
|
+
if (opts.global || githubRepoOf(target) !== null)
|
|
206
|
+
return target;
|
|
207
|
+
if (opts.pinned !== undefined && opts.pinned !== '')
|
|
208
|
+
return opts.pinned;
|
|
209
|
+
return opts.update ? `${target}@latest` : target;
|
|
210
|
+
}
|
|
198
211
|
function stopChild(child) {
|
|
199
212
|
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
200
213
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' });
|
|
@@ -346,7 +359,7 @@ function pumpQueue() {
|
|
|
346
359
|
* 跟踪的任务上,并以捕获结果 resolve。绝不 reject;失败通过结果/任务状态体现。
|
|
347
360
|
*/
|
|
348
361
|
function spawnMutation(options) {
|
|
349
|
-
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options;
|
|
362
|
+
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm, registries, installSpec } = options;
|
|
350
363
|
// 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
|
|
351
364
|
const isGlobalNpm = (globalNpm ?? []).length > 0;
|
|
352
365
|
const invocation = isGlobalNpm
|
|
@@ -359,9 +372,8 @@ function spawnMutation(options) {
|
|
|
359
372
|
// CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
|
|
360
373
|
const isUpdate = action === 'update' || updateNpm === true;
|
|
361
374
|
const cliAction = action === 'update' ? 'add' : action;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
: target;
|
|
375
|
+
// 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
|
|
376
|
+
const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec });
|
|
365
377
|
const args = isGlobalNpm
|
|
366
378
|
? ['install', '-g', ...(globalNpm ?? [])]
|
|
367
379
|
: [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget];
|
|
@@ -375,9 +387,12 @@ function spawnMutation(options) {
|
|
|
375
387
|
// 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
|
|
376
388
|
// 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
|
|
377
389
|
// 这样全局 npm 首次失败后可以干净地换参数重试一次。
|
|
378
|
-
const spawnOnce = (attemptArgs) => new Promise((resolveOnce) => {
|
|
390
|
+
const spawnOnce = (attemptArgs, registry) => new Promise((resolveOnce) => {
|
|
379
391
|
let stdout = '';
|
|
380
392
|
let stderr = '';
|
|
393
|
+
// 每次 spawn 独立解码器:中文 Windows 下 dsh/pnpm 可能输出 GBK,需按流判定编码
|
|
394
|
+
const stdoutDecoder = new OutputDecoder();
|
|
395
|
+
const stderrDecoder = new OutputDecoder();
|
|
381
396
|
let timedOut = false;
|
|
382
397
|
let settled = false;
|
|
383
398
|
let child;
|
|
@@ -411,11 +426,17 @@ function spawnMutation(options) {
|
|
|
411
426
|
return;
|
|
412
427
|
}
|
|
413
428
|
}
|
|
429
|
+
// invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
|
|
430
|
+
// 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
|
|
431
|
+
// registry 最后写入:换源重试只改这一层,pnpm/npm 都认 npm_config_registry。
|
|
432
|
+
const childEnv = { ...(env ?? process.env), ...(invocation.env ?? {}) };
|
|
433
|
+
if (registry !== undefined) {
|
|
434
|
+
childEnv.npm_config_registry = registry;
|
|
435
|
+
childEnv.NPM_CONFIG_REGISTRY = registry;
|
|
436
|
+
}
|
|
414
437
|
child = spawn(invocation.file, attemptArgs, {
|
|
415
438
|
cwd: invocation.cwd,
|
|
416
|
-
|
|
417
|
-
// 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
|
|
418
|
-
env: invocation.env === undefined ? env : { ...(env ?? process.env), ...invocation.env },
|
|
439
|
+
env: childEnv,
|
|
419
440
|
shell: invocation.useShell,
|
|
420
441
|
// detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
|
|
421
442
|
// 且宿主进程崩溃后安装任务不会被连带误杀
|
|
@@ -477,7 +498,7 @@ function spawnMutation(options) {
|
|
|
477
498
|
task.progress += 1;
|
|
478
499
|
}, 500);
|
|
479
500
|
const collect = (kind, chunk) => {
|
|
480
|
-
const text = chunk.
|
|
501
|
+
const text = kind === 'stdout' ? stdoutDecoder.push(chunk) : stderrDecoder.push(chunk);
|
|
481
502
|
if (kind === 'stdout')
|
|
482
503
|
stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES);
|
|
483
504
|
else
|
|
@@ -536,7 +557,23 @@ function spawnMutation(options) {
|
|
|
536
557
|
// 超时复核用基线:本次任务开始前目标是否已在 profile 里。已安装的话,超时后核对无法区分
|
|
537
558
|
// 「旧版本还在」与「刚装上」,那种情形(更新超时)不冒认成功。
|
|
538
559
|
const wasInstalled = action !== 'remove' && !isGlobalNpm && verifyInstalledEntry(profile, target).name !== null;
|
|
539
|
-
|
|
560
|
+
// 候选源:卸载不拉包、git 源装的是仓库快照,都不受 npm 镜像影响,不参与换源
|
|
561
|
+
const candidates = action === 'remove' || githubRepoOf(target) !== null ? [] : (registries ?? []);
|
|
562
|
+
let activeRegistry = candidates[0];
|
|
563
|
+
let result = await spawnOnce(args, activeRegistry);
|
|
564
|
+
// 传输层失败(超时 / 连接重置 / DNS / 5xx)才换下一个公共镜像;404、版本不存在、鉴权错误
|
|
565
|
+
// 保持原样,否则会掩盖作者侧问题或装到滞后版本。整段超时也不再换源,避免耗时翻倍。
|
|
566
|
+
for (const candidate of candidates.slice(1)) {
|
|
567
|
+
if (task?.status === 'cancelled' || result.exitCode === 0 || result.timedOut)
|
|
568
|
+
break;
|
|
569
|
+
if (!isTransportFailure(`${result.stderr}\n${result.stdout}`))
|
|
570
|
+
break;
|
|
571
|
+
if (task) {
|
|
572
|
+
pushLine(task, `[registry-fallback] ${registryHost(activeRegistry ?? '')} unreachable — retrying with ${registryHost(candidate)}`);
|
|
573
|
+
}
|
|
574
|
+
activeRegistry = candidate;
|
|
575
|
+
result = await spawnOnce(args, candidate);
|
|
576
|
+
}
|
|
540
577
|
// npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
|
|
541
578
|
// 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
|
|
542
579
|
// 跳过 peer 自动解析即可绕过该 bug;二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
|
|
@@ -547,7 +584,7 @@ function spawnMutation(options) {
|
|
|
547
584
|
task.attempts.push(retryExecuted);
|
|
548
585
|
if (task)
|
|
549
586
|
pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps');
|
|
550
|
-
result = await spawnOnce(retryArgs);
|
|
587
|
+
result = await spawnOnce(retryArgs, activeRegistry);
|
|
551
588
|
}
|
|
552
589
|
// 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
|
|
553
590
|
// 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
|
|
@@ -611,7 +648,9 @@ function spawnMutation(options) {
|
|
|
611
648
|
addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install');
|
|
612
649
|
}
|
|
613
650
|
}
|
|
614
|
-
|
|
651
|
+
// 子进程一个字节都没吐时在退出行上标注:日志面板与失败上报都能看出「没有输出可排查」
|
|
652
|
+
const printedNothing = `${result.stdout}${result.stderr}`.trim() === '';
|
|
653
|
+
pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`);
|
|
615
654
|
}
|
|
616
655
|
}
|
|
617
656
|
return result;
|
|
@@ -728,7 +767,8 @@ export function hostSpawnMarker(invocation, error) {
|
|
|
728
767
|
*/
|
|
729
768
|
export async function runPluginMutation(options) {
|
|
730
769
|
// operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
|
|
731
|
-
const
|
|
770
|
+
const profileDir = profileDirectory(options.profile);
|
|
771
|
+
const logTap = createOperationLogTap(profileDir);
|
|
732
772
|
let result = await spawnMutation(options);
|
|
733
773
|
// allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
|
|
734
774
|
// 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
|
|
@@ -791,9 +831,10 @@ export async function runPluginMutation(options) {
|
|
|
791
831
|
// 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
|
|
792
832
|
// 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
|
|
793
833
|
// 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
|
|
794
|
-
|
|
834
|
+
// 超时同样回捞:被强杀前 pnpm 往往已经把真实原因写进 operation 日志。
|
|
835
|
+
if ((result.exitCode !== 0 || result.timedOut) && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
795
836
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
796
|
-
const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output);
|
|
837
|
+
const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output, profileDir);
|
|
797
838
|
if (backfilled !== null) {
|
|
798
839
|
if (options.task && options.task.status !== 'cancelled') {
|
|
799
840
|
pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk');
|
|
@@ -803,9 +844,18 @@ export async function runPluginMutation(options) {
|
|
|
803
844
|
}
|
|
804
845
|
result = { ...result, stderr: `${result.stderr}\n${backfilled}` };
|
|
805
846
|
}
|
|
806
|
-
else if (options.task && options.task.status !== 'cancelled'
|
|
807
|
-
|
|
808
|
-
|
|
847
|
+
else if (options.task && options.task.status !== 'cancelled') {
|
|
848
|
+
if (operationLogPathOf(output) !== null) {
|
|
849
|
+
// 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
|
|
850
|
+
pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)');
|
|
851
|
+
}
|
|
852
|
+
else if (!hasSubstantiveOutput(output)) {
|
|
853
|
+
// 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
|
|
854
|
+
const what = result.timedOut
|
|
855
|
+
? 'printed nothing before the timeout'
|
|
856
|
+
: `exited with code ${result.exitCode ?? '?'} without printing anything`;
|
|
857
|
+
pushLine(options.task, `[no-output] the dsh CLI ${what}, and no pnpm log was written under .plugin-manager/logs — attach ~/.dsh/profiles/${options.profile}/hub.log when reporting this`);
|
|
858
|
+
}
|
|
809
859
|
}
|
|
810
860
|
}
|
|
811
861
|
// 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
|
package/lib/services/settings.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* 去掉首尾引号与句末标点(不同版本/语言的摘要行尾部可能带 `。`、`.`、`)`);
|
|
4
|
+
* 相对路径原样返回,由读取方按 profile 目录解析。
|
|
5
5
|
*/
|
|
6
6
|
export declare function operationLogPathOf(output: string): string | null;
|
|
7
7
|
/**
|
|
8
8
|
* 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
|
|
9
|
-
*
|
|
9
|
+
* 内容为空 / 路径越界)返回 null。尾部截到与子进程输出相同的上限。
|
|
10
10
|
*/
|
|
11
|
-
export declare function backfillOperationLog(output: string): string | null;
|
|
11
|
+
export declare function backfillOperationLog(output: string, profileDir?: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* 输出里是否有 dsh 自己的内容 —— 只有 `[exit N]`、`[install-channel]`、`[timed out]`
|
|
14
|
+
* 这类本队列自己打的标记时返回 false。用于把「宿主什么都没回显」的失败显式留痕,
|
|
15
|
+
* 否则上报正文只剩退出码,无从排查。
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasSubstantiveOutput(output: string): boolean;
|
|
12
18
|
/**
|
|
13
19
|
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
14
20
|
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 去掉尾部斜杠,避免同一源因写法不同被当成两个候选。 */
|
|
2
|
+
export declare function normalizeRegistry(value: string): string;
|
|
3
|
+
/** 是否属于允许自动切换的公共源白名单。 */
|
|
4
|
+
export declare function isPublicRegistry(value: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* 依次尝试的源:显式配置 → 本机 npm 配置 → 官方源。
|
|
7
|
+
* 只有公共源才追加后备(最多 3 个);关闭降级或源不在白名单时只返回它自己。
|
|
8
|
+
*/
|
|
9
|
+
export declare function registryCandidates(configured: string, userRegistry: string, enabled: boolean): string[];
|
|
10
|
+
/** 本机 npm 实际生效的源(含 profile 目录的 .npmrc);读不到返回空串。 */
|
|
11
|
+
export declare function userNpmRegistry(cwd: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* 传输层失败才值得换源:超时、连接重置、DNS、5xx。
|
|
14
|
+
* 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
|
|
15
|
+
*/
|
|
16
|
+
export declare function isTransportFailure(text: string): boolean;
|
|
17
|
+
/** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
|
|
18
|
+
export declare function registryHost(value: string): string;
|
|
@@ -38,6 +38,15 @@ export declare function cancelTask(id: number): boolean;
|
|
|
38
38
|
* 导出供单测覆盖路径推导。
|
|
39
39
|
*/
|
|
40
40
|
export declare function desktopCliBootstrap(entry: string): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
|
|
43
|
+
* 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
|
|
44
|
+
*/
|
|
45
|
+
export declare function npmInstallTargetOf(target: string, opts: {
|
|
46
|
+
update: boolean;
|
|
47
|
+
global: boolean;
|
|
48
|
+
pinned?: string;
|
|
49
|
+
}): string;
|
|
41
50
|
/**
|
|
42
51
|
* 插件变更入队并返回其任务。任务严格串行:队列 worker 只在上一个结束后才启动下一个。
|
|
43
52
|
* 完成前可通过 `getTask(id)` / `activeTask()` 查看进度。
|
|
@@ -62,6 +71,11 @@ export declare function startPluginMutation(options: {
|
|
|
62
71
|
/** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
|
|
63
72
|
* 目录插件安装不传 */
|
|
64
73
|
installChannel?: 'npm';
|
|
74
|
+
/** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
|
|
75
|
+
registries?: string[];
|
|
76
|
+
/** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
|
|
77
|
+
* 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
|
|
78
|
+
installSpec?: string;
|
|
65
79
|
}): InstallTask;
|
|
66
80
|
/**
|
|
67
81
|
* 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
|
|
@@ -101,4 +115,8 @@ export declare function runPluginMutation(options: {
|
|
|
101
115
|
loader?: LoaderHandle;
|
|
102
116
|
/** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
|
|
103
117
|
globalNpm?: string[];
|
|
118
|
+
/** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
|
|
119
|
+
registries?: string[];
|
|
120
|
+
/** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
|
|
121
|
+
installSpec?: string;
|
|
104
122
|
}): Promise<InstallResult>;
|