dsh-plugin 1.1.2 → 1.2.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/client/client.js +4469 -1445
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +438 -22
- package/lib/index.js +1 -1
- package/lib/services/install/install.js +4 -0
- package/lib/services/install/npm-check.js +55 -0
- package/lib/services/{npm-resolve.js → install/npm-resolve.js} +7 -5
- package/lib/services/{preflight.js → install/preflight.js} +15 -4
- package/lib/services/{task-queue.js → install/task-queue.js} +155 -66
- package/lib/services/loader.js +56 -1
- package/lib/services/log.js +138 -0
- package/lib/services/probe.js +157 -0
- package/lib/services/{profile.js → profile/profile.js} +29 -1
- package/lib/services/settings.js +60 -0
- package/lib/types/http/routes.d.ts +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/services/{install-types.d.ts → install/install-types.d.ts} +3 -0
- package/lib/types/services/{install.d.ts → install/install.d.ts} +5 -5
- package/lib/types/services/install/npm-check.d.ts +12 -0
- package/lib/types/services/{npm-resolve.d.ts → install/npm-resolve.d.ts} +2 -1
- package/lib/types/services/{task-queue.d.ts → install/task-queue.d.ts} +5 -1
- package/lib/types/services/loader.d.ts +14 -8
- package/lib/types/services/log.d.ts +49 -0
- package/lib/types/services/probe.d.ts +30 -0
- package/lib/types/services/{pending-restart.d.ts → profile/pending-restart.d.ts} +1 -1
- package/lib/types/services/{profile.d.ts → profile/profile.d.ts} +2 -0
- package/lib/types/services/settings.d.ts +24 -0
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +341 -120
- package/src/client/components/{CatalogList.tsx → catalog/CatalogList.tsx} +11 -7
- package/src/client/components/{PluginCard.tsx → catalog/PluginCard.tsx} +5 -4
- package/src/client/components/layout/CatalogControls.tsx +80 -0
- package/src/client/components/{CatalogHeader.tsx → layout/CatalogHeader.tsx} +4 -4
- package/src/client/components/{CategoryTabs.tsx → layout/CategoryTabs.tsx} +3 -3
- package/src/client/components/layout/SectionTabs.tsx +60 -0
- package/src/client/components/{AboutModal.tsx → modals/AboutModal.tsx} +7 -7
- package/src/client/components/{HubUpdateModal.tsx → modals/HubUpdateModal.tsx} +5 -5
- package/src/client/components/modals/InstalledDetailModal.tsx +193 -0
- package/src/client/components/modals/LogsModal.tsx +301 -0
- package/src/client/components/modals/LogsPathModal.tsx +161 -0
- package/src/client/components/{NotificationsModal.tsx → modals/NotificationsModal.tsx} +47 -20
- package/src/client/components/{ProgressView.tsx → modals/ProgressView.tsx} +2 -2
- package/src/client/components/{modals.tsx → modals/modals.tsx} +117 -40
- package/src/client/components/{Dropdown.tsx → ui/Dropdown.tsx} +14 -6
- package/src/client/components/ui/Toggle.tsx +28 -0
- package/src/client/components/ui/icons.tsx +343 -0
- package/src/client/components/views/CustomInstallView.tsx +250 -0
- package/src/client/components/views/DiagnosticsView.tsx +172 -0
- package/src/client/components/views/InstalledView.tsx +328 -0
- package/src/client/components/views/LogsView.tsx +150 -0
- package/src/client/components/views/MarketView.tsx +66 -0
- package/src/client/components/views/SettingsView.tsx +273 -0
- package/src/client/data/catalog.ts +38 -0
- package/src/client/data/host.ts +107 -0
- package/src/client/data/hub.ts +41 -0
- package/src/client/hooks/useCatalog.ts +71 -115
- package/src/client/hooks/useIncrementalList.ts +46 -0
- package/src/client/hooks/useLanguage.ts +40 -0
- package/src/client/hooks/useSettings.ts +90 -0
- package/src/client/hooks/useTaskQueue.ts +156 -39
- package/src/client/locales.ts +358 -38
- package/src/client/logic/constants.ts +87 -0
- package/src/client/{lib → logic}/failures.ts +95 -74
- package/src/client/logic/install-command.ts +56 -0
- package/src/client/logic/installed.ts +207 -0
- package/src/client/logic/normalize.ts +52 -0
- package/src/client/logic/urls.ts +125 -0
- package/src/client/styles/CustomInstallView.module.css +191 -0
- package/src/client/styles/DiagnosticsView.module.css +231 -0
- package/src/client/styles/Dropdown.module.css +42 -41
- package/src/client/styles/Header.module.css +100 -132
- package/src/client/styles/InstalledView.module.css +447 -0
- package/src/client/styles/List.module.css +6 -0
- package/src/client/styles/LogsView.module.css +503 -0
- package/src/client/styles/Modal.module.css +352 -70
- package/src/client/styles/SectionTabs.module.css +142 -0
- package/src/client/styles/SettingsView.module.css +232 -0
- package/src/client/styles/Toggle.module.css +60 -0
- package/src/client/styles/tokens.module.css +1 -0
- package/src/client/types.ts +23 -3
- package/src/server/http/routes.ts +414 -22
- package/src/server/index.ts +1 -1
- package/src/server/services/{install-types.ts → install/install-types.ts} +3 -1
- package/src/server/services/{install.ts → install/install.ts} +5 -5
- package/src/server/services/install/npm-check.ts +53 -0
- package/src/server/services/{npm-resolve.ts → install/npm-resolve.ts} +7 -5
- package/src/server/services/{preflight.ts → install/preflight.ts} +16 -4
- package/src/server/services/{task-queue.ts → install/task-queue.ts} +157 -63
- package/src/server/services/loader.ts +55 -1
- package/src/server/services/log.ts +167 -0
- package/src/server/services/probe.ts +157 -0
- package/src/server/services/{pending-restart.ts → profile/pending-restart.ts} +1 -1
- package/src/server/services/{profile.ts → profile/profile.ts} +31 -1
- package/src/server/services/settings.ts +79 -0
- package/lib/services/install.js +0 -4
- package/src/client/components/CatalogControls.tsx +0 -108
- package/src/client/components/icons.tsx +0 -129
- package/src/client/lib/catalog.ts +0 -293
- package/src/client/lib/env.ts +0 -20
- /package/lib/services/{install-types.js → install/install-types.js} +0 -0
- /package/lib/services/{installed-versions.js → profile/installed-versions.js} +0 -0
- /package/lib/services/{pending-restart.js → profile/pending-restart.js} +0 -0
- /package/lib/services/{progress.js → profile/progress.js} +0 -0
- /package/lib/types/services/{preflight.d.ts → install/preflight.d.ts} +0 -0
- /package/lib/types/services/{installed-versions.d.ts → profile/installed-versions.d.ts} +0 -0
- /package/lib/types/services/{progress.d.ts → profile/progress.d.ts} +0 -0
- /package/src/client/{lib → logic}/format.ts +0 -0
- /package/src/client/{lib → logic}/renderMarkdown.ts +0 -0
- /package/src/server/services/{installed-versions.ts → profile/installed-versions.ts} +0 -0
- /package/src/server/services/{progress.ts → profile/progress.ts} +0 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
|
|
10
10
|
* GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
|
|
11
|
-
*
|
|
11
|
+
* 从而把 git 分发不完整的插件切换到 npm 通道安装。命中结果带内存缓存,
|
|
12
12
|
* 避免同一会话内重复查询 registry。
|
|
13
13
|
*/
|
|
14
14
|
import { get } from 'node:https';
|
|
@@ -20,21 +20,23 @@ const REQUEST_TIMEOUT_MS = 8000;
|
|
|
20
20
|
* 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
|
|
21
21
|
* 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
|
|
22
22
|
* 原有错误路径,反查只是额外的一次尝试。
|
|
23
|
+
* @param registry - npm 镜像源地址(空串 = 官方 https://registry.npmjs.org)
|
|
23
24
|
*/
|
|
24
|
-
export function resolveNpmPackage(repo) {
|
|
25
|
+
export function resolveNpmPackage(repo, registry = '') {
|
|
25
26
|
const key = repo.toLowerCase();
|
|
26
27
|
if (cache.has(key))
|
|
27
28
|
return Promise.resolve(cache.get(key) ?? null);
|
|
28
|
-
const name = searchRepo(repo);
|
|
29
|
+
const name = searchRepo(repo, registry);
|
|
29
30
|
name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null));
|
|
30
31
|
return name;
|
|
31
32
|
}
|
|
32
33
|
/** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
|
|
33
|
-
function searchRepo(repo) {
|
|
34
|
+
function searchRepo(repo, registry) {
|
|
34
35
|
const [owner, name] = repo.split('/');
|
|
35
36
|
if (owner === undefined || name === undefined || name === '')
|
|
36
37
|
return Promise.resolve(null);
|
|
37
|
-
const
|
|
38
|
+
const base = registry === '' ? 'https://registry.npmjs.org' : registry.replace(/\/+$/, '');
|
|
39
|
+
const url = `${base}/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`;
|
|
38
40
|
return new Promise((resolve) => {
|
|
39
41
|
const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
40
42
|
if (res.statusCode !== 200) {
|
|
@@ -17,8 +17,11 @@ import { tmpdir } from 'node:os';
|
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { pipeline } from 'node:stream/promises';
|
|
19
19
|
import { promisify } from 'node:util';
|
|
20
|
-
import { githubRepoOf } from '
|
|
20
|
+
import { githubRepoOf } from '../profile/profile.js';
|
|
21
21
|
const execFileAsync = promisify(execFile);
|
|
22
|
+
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
|
|
23
|
+
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
24
|
+
const PREFLIGHT_TIMEOUT_MS = 15_000;
|
|
22
25
|
export async function preflightTarget(target) {
|
|
23
26
|
const source = githubRepoOf(target);
|
|
24
27
|
if (source === null)
|
|
@@ -26,7 +29,10 @@ export async function preflightTarget(target) {
|
|
|
26
29
|
const [owner, repo] = source.split('/');
|
|
27
30
|
let commit = '';
|
|
28
31
|
try {
|
|
29
|
-
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD']
|
|
32
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD'], {
|
|
33
|
+
timeout: PREFLIGHT_TIMEOUT_MS,
|
|
34
|
+
maxBuffer: 1024 * 1024,
|
|
35
|
+
});
|
|
30
36
|
commit = stdout.split(/\s+/)[0] ?? '';
|
|
31
37
|
}
|
|
32
38
|
catch {
|
|
@@ -60,13 +66,15 @@ export async function preflightTarget(target) {
|
|
|
60
66
|
}
|
|
61
67
|
function download(url, dest) {
|
|
62
68
|
return new Promise((resolve, reject) => {
|
|
63
|
-
get(url, (res) => {
|
|
69
|
+
const req = get(url, { timeout: PREFLIGHT_TIMEOUT_MS }, (res) => {
|
|
64
70
|
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
|
|
65
71
|
res.resume();
|
|
66
72
|
if (res.headers.location) {
|
|
67
73
|
void download(res.headers.location, dest).then(resolve, reject);
|
|
68
74
|
return;
|
|
69
75
|
}
|
|
76
|
+
reject(new Error('download failed: redirect without location'));
|
|
77
|
+
return;
|
|
70
78
|
}
|
|
71
79
|
if (res.statusCode !== 200) {
|
|
72
80
|
res.resume();
|
|
@@ -74,7 +82,10 @@ function download(url, dest) {
|
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
76
84
|
void pipeline(res, createWriteStream(dest)).then(resolve, reject);
|
|
77
|
-
})
|
|
85
|
+
});
|
|
86
|
+
// 网络不可达时 connect/响应可能无限挂起:超时销毁,避免预检卡死(放行交给装后校验)
|
|
87
|
+
req.on('timeout', () => req.destroy(new Error(`download timeout after ${PREFLIGHT_TIMEOUT_MS}ms`)));
|
|
88
|
+
req.on('error', reject);
|
|
78
89
|
});
|
|
79
90
|
}
|
|
80
91
|
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
@@ -11,10 +11,12 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
import { readFileSync, statSync } from 'node:fs';
|
|
12
12
|
import { dirname, join, resolve } from 'node:path';
|
|
13
13
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
14
|
+
import { npmTooLowMarker } from './npm-check.js';
|
|
15
|
+
import { cleanLine, estimateProgress } from '../profile/progress.js';
|
|
16
|
+
import { removeLoadedEntry } from '../loader.js';
|
|
17
|
+
import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
|
|
18
|
+
import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
|
|
19
|
+
import { appendLog } from '../log.js';
|
|
18
20
|
/** In-memory task registry, keyed by task id. */
|
|
19
21
|
const tasks = new Map();
|
|
20
22
|
let nextTaskId = 1;
|
|
@@ -146,7 +148,7 @@ function stopChild(child) {
|
|
|
146
148
|
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
147
149
|
*/
|
|
148
150
|
export function startPluginMutation(options) {
|
|
149
|
-
const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false };
|
|
151
|
+
const task = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false };
|
|
150
152
|
nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
|
|
151
153
|
tasks.set(task.id, task);
|
|
152
154
|
if (tasks.size > MAX_TASKS) {
|
|
@@ -159,6 +161,15 @@ export function startPluginMutation(options) {
|
|
|
159
161
|
});
|
|
160
162
|
}
|
|
161
163
|
queue.push({ task, options: { ...options } });
|
|
164
|
+
// 系统日志:记录任务入队(安装/卸载开始)
|
|
165
|
+
const category = options.action === 'add' ? 'install' : 'uninstall';
|
|
166
|
+
appendLog(options.profile, {
|
|
167
|
+
at: Date.now(),
|
|
168
|
+
level: 'info',
|
|
169
|
+
category,
|
|
170
|
+
event: `${category}.start`,
|
|
171
|
+
message: `${options.action === 'add' ? '开始安装' : '开始卸载'} ${options.displayTarget ?? options.target}`,
|
|
172
|
+
});
|
|
162
173
|
pumpQueue();
|
|
163
174
|
return task;
|
|
164
175
|
}
|
|
@@ -173,6 +184,36 @@ function pumpQueue() {
|
|
|
173
184
|
task.status = 'running';
|
|
174
185
|
void runPluginMutation({ ...options, task }).finally(() => {
|
|
175
186
|
runningChildren.delete(task.id);
|
|
187
|
+
// 系统日志:任务终态(成功/失败/取消),按动作归类安装/卸载日志
|
|
188
|
+
const show = task.displayTarget ?? task.target;
|
|
189
|
+
const category = task.action === 'add' ? 'install' : 'uninstall';
|
|
190
|
+
if (task.status === 'done') {
|
|
191
|
+
appendLog(options.profile, {
|
|
192
|
+
at: Date.now(),
|
|
193
|
+
level: 'success',
|
|
194
|
+
category,
|
|
195
|
+
event: `${category}.done`,
|
|
196
|
+
message: `${task.action === 'add' ? '安装成功' : '卸载成功'} ${show}${task.needsRestart ? '(待重启生效)' : ''}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (task.status === 'failed') {
|
|
200
|
+
appendLog(options.profile, {
|
|
201
|
+
at: Date.now(),
|
|
202
|
+
level: 'error',
|
|
203
|
+
category,
|
|
204
|
+
event: `${category}.fail`,
|
|
205
|
+
message: `${task.action === 'add' ? '安装失败' : '卸载失败'} ${show}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else if (task.status === 'cancelled') {
|
|
209
|
+
appendLog(options.profile, {
|
|
210
|
+
at: Date.now(),
|
|
211
|
+
level: 'warn',
|
|
212
|
+
category,
|
|
213
|
+
event: 'task.cancel',
|
|
214
|
+
message: `已取消 ${show}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
176
217
|
// 已终态的任务移出队列:既防 queue 数组无限增长(内存泄漏),也让下一轮 pump 干净
|
|
177
218
|
const idx = queue.findIndex((it) => it.task === task);
|
|
178
219
|
if (idx >= 0)
|
|
@@ -186,23 +227,34 @@ function pumpQueue() {
|
|
|
186
227
|
* Never rejects; failures surface through the result / task state.
|
|
187
228
|
*/
|
|
188
229
|
function spawnMutation(options) {
|
|
189
|
-
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader } = options;
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
230
|
+
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm } = options;
|
|
231
|
+
// 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
|
|
232
|
+
const isGlobalNpm = (globalNpm ?? []).length > 0;
|
|
233
|
+
const invocation = isGlobalNpm
|
|
234
|
+
? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
|
|
235
|
+
: cliInvocation();
|
|
236
|
+
const args = isGlobalNpm
|
|
237
|
+
? ['install', '-g', ...(globalNpm ?? [])]
|
|
238
|
+
: [...invocation.prefixArgs, 'plugin', '--profile', profile, action, target];
|
|
239
|
+
// 记录实际执行的命令(npm 通道显示包名,git 通道显示 HTTPS 源),
|
|
240
|
+
// 失败提 Issue 时作为「已尝试的安装方式」随附
|
|
241
|
+
const executed = isGlobalNpm
|
|
242
|
+
? `npm install -g ${(globalNpm ?? []).join(' ')}`
|
|
243
|
+
: `dsh plugin --profile ${profile} ${action} ${target}`;
|
|
195
244
|
if (task && !task.attempts.includes(executed)) {
|
|
196
245
|
task.attempts.push(executed);
|
|
197
246
|
}
|
|
198
|
-
|
|
247
|
+
// 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
|
|
248
|
+
// 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
|
|
249
|
+
// 这样全局 npm 首次失败后可以干净地换参数重试一次。
|
|
250
|
+
const spawnOnce = (attemptArgs) => new Promise((resolveOnce) => {
|
|
199
251
|
let stdout = '';
|
|
200
252
|
let stderr = '';
|
|
201
253
|
let timedOut = false;
|
|
202
254
|
let settled = false;
|
|
203
255
|
let child;
|
|
204
256
|
try {
|
|
205
|
-
child = spawn(invocation.file,
|
|
257
|
+
child = spawn(invocation.file, attemptArgs, {
|
|
206
258
|
cwd: invocation.cwd,
|
|
207
259
|
env,
|
|
208
260
|
shell: invocation.useShell,
|
|
@@ -218,7 +270,7 @@ function spawnMutation(options) {
|
|
|
218
270
|
task.exitCode = null;
|
|
219
271
|
pushLine(task, `[error] ${error instanceof Error ? error.message : String(error)}`);
|
|
220
272
|
}
|
|
221
|
-
|
|
273
|
+
resolveOnce({
|
|
222
274
|
exitCode: null,
|
|
223
275
|
timedOut: false,
|
|
224
276
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -234,9 +286,8 @@ function spawnMutation(options) {
|
|
|
234
286
|
let progressTimer;
|
|
235
287
|
let exitWatch;
|
|
236
288
|
let forceSettle;
|
|
237
|
-
// 唯一收尾入口:close / error / 兜底超时
|
|
238
|
-
|
|
239
|
-
const settle = (result) => {
|
|
289
|
+
// 唯一收尾入口:close / error / 兜底超时 三者只放行一次,确保任务持续推进
|
|
290
|
+
const settleOnce = (result) => {
|
|
240
291
|
if (settled)
|
|
241
292
|
return;
|
|
242
293
|
settled = true;
|
|
@@ -250,7 +301,7 @@ function spawnMutation(options) {
|
|
|
250
301
|
clearTimeout(forceSettle);
|
|
251
302
|
if (task)
|
|
252
303
|
runningChildren.delete(task.id);
|
|
253
|
-
|
|
304
|
+
resolveOnce(result);
|
|
254
305
|
};
|
|
255
306
|
timer = setTimeout(() => {
|
|
256
307
|
timedOut = true;
|
|
@@ -268,7 +319,7 @@ function spawnMutation(options) {
|
|
|
268
319
|
else
|
|
269
320
|
stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES);
|
|
270
321
|
if (task) {
|
|
271
|
-
// 原生透传 pnpm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
|
|
322
|
+
// 原生透传 pnpm/npm 输出:stdout/stderr 原样入日志,不做格式化,保证报错信息完整可见
|
|
272
323
|
for (const line of text.split(/\r?\n|\r/)) {
|
|
273
324
|
if (line.trim() !== '')
|
|
274
325
|
pushLine(task, line.trimEnd());
|
|
@@ -280,7 +331,7 @@ function spawnMutation(options) {
|
|
|
280
331
|
child.once('exit', () => {
|
|
281
332
|
// dsh 内部会 fork pnpm:pnpm 退出后仍攥着 stdout/stderr 管道时,
|
|
282
333
|
// close 事件不会到来。exit 后给 close 一段宽限,未按时触发则整组清理强制结束,
|
|
283
|
-
//
|
|
334
|
+
// 确保任务进入终态、后续排队任务自动启动。
|
|
284
335
|
exitWatch = setTimeout(() => {
|
|
285
336
|
if (settled)
|
|
286
337
|
return;
|
|
@@ -297,7 +348,7 @@ function spawnMutation(options) {
|
|
|
297
348
|
task.timedOut = true;
|
|
298
349
|
pushLine(task, '[timed out]');
|
|
299
350
|
}
|
|
300
|
-
|
|
351
|
+
settleOnce({ exitCode: null, timedOut: true, error: 'child failed to close', stdout, stderr });
|
|
301
352
|
}, 5_000);
|
|
302
353
|
}, 3_000);
|
|
303
354
|
});
|
|
@@ -307,46 +358,66 @@ function spawnMutation(options) {
|
|
|
307
358
|
task.exitCode = null;
|
|
308
359
|
pushLine(task, `[error] ${error.message}`);
|
|
309
360
|
}
|
|
310
|
-
|
|
361
|
+
settleOnce({ exitCode: null, timedOut: false, error: error.message, stdout, stderr });
|
|
311
362
|
});
|
|
312
|
-
child.once('close',
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
363
|
+
child.once('close', (code) => {
|
|
364
|
+
settleOnce({ exitCode: code, timedOut, error: null, stdout, stderr });
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
return (async () => {
|
|
368
|
+
let result = await spawnOnce(args);
|
|
369
|
+
// npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`,npm/cli#9911):
|
|
370
|
+
// 某些包的 peerDependencies 用「双段 prerelease 范围」(如 dsh-tui@0.9.0 的 `^0.1.0-rc.6 || ^0.1.1-rc.1`,
|
|
371
|
+
// 26 个必需 peer 全是这种形状)会让 `npm install -g` 在解析阶段直接崩溃 —— 退出码 1、什么都没装,
|
|
372
|
+
// 与是不是从我们界面发起无关(终端里同样复现)。首次失败后自动带 `--legacy-peer-deps` 重试一次:
|
|
373
|
+
// 跳过 peer 自动解析即可绕过该 bug(peer 通常由同装的 @deepseek-ai/dsh 依赖树天然提供)。
|
|
374
|
+
// 二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
|
|
375
|
+
if (isGlobalNpm && result.exitCode !== 0 && !result.timedOut && task?.status !== 'cancelled') {
|
|
376
|
+
const retryArgs = ['install', '-g', '--legacy-peer-deps', ...(globalNpm ?? [])];
|
|
377
|
+
const retryExecuted = `npm install -g --legacy-peer-deps ${(globalNpm ?? []).join(' ')}`;
|
|
378
|
+
if (task && !task.attempts.includes(retryExecuted))
|
|
379
|
+
task.attempts.push(retryExecuted);
|
|
380
|
+
if (task)
|
|
381
|
+
pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps');
|
|
382
|
+
result = await spawnOnce(retryArgs);
|
|
383
|
+
}
|
|
384
|
+
// 统一终态后处理:exitCode/status/needsRestart 以最后一次尝试为准
|
|
385
|
+
if (task) {
|
|
386
|
+
task.exitCode = result.exitCode;
|
|
387
|
+
task.timedOut = result.timedOut;
|
|
388
|
+
if (task.status !== 'cancelled') {
|
|
389
|
+
task.status = result.timedOut || result.exitCode !== 0 ? 'failed' : 'done';
|
|
390
|
+
if (task.status === 'done') {
|
|
391
|
+
task.progress = 100;
|
|
392
|
+
if (isGlobalNpm) {
|
|
393
|
+
// 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
|
|
394
|
+
task.needsRestart = false;
|
|
395
|
+
}
|
|
396
|
+
else if (action === 'remove' && uninstallLoader) {
|
|
397
|
+
// 卸载成功 → 主动从运行中 loader 停用该包条目;
|
|
398
|
+
// 仅当宿主从未加载过它(磁盘已干净)才判「无需重启」;
|
|
399
|
+
// 只要曾在 loader 中存活(停用过 live entry)就仍要求重启——
|
|
400
|
+
// 带 UI 的插件(侧边栏面板等宿主启动时渲染的槽位)disable 后不会
|
|
401
|
+
// 主动消失,重启才能立即摘除面板
|
|
402
|
+
const removed = await removeLoadedEntry(uninstallLoader, target);
|
|
403
|
+
task.needsRestart = !removed;
|
|
404
|
+
if (removed)
|
|
405
|
+
clearPendingRestart(displayTarget ?? target);
|
|
406
|
+
else
|
|
407
|
+
addPendingRestart(displayTarget ?? target, 'uninstall');
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
// 安装成功(或卸载但无 loader 引用)→ 登记待重启:
|
|
411
|
+
// 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
|
|
412
|
+
task.needsRestart = true;
|
|
413
|
+
addPendingRestart(displayTarget ?? target, action === 'add' ? 'install' : 'uninstall');
|
|
342
414
|
}
|
|
343
415
|
}
|
|
416
|
+
pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]`);
|
|
344
417
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
});
|
|
349
|
-
});
|
|
418
|
+
}
|
|
419
|
+
return result;
|
|
420
|
+
})();
|
|
350
421
|
}
|
|
351
422
|
/**
|
|
352
423
|
* 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。
|
|
@@ -401,6 +472,19 @@ function ignoredBuildKeys(output) {
|
|
|
401
472
|
.map((s) => s.trim())
|
|
402
473
|
.filter((s) => s !== '');
|
|
403
474
|
}
|
|
475
|
+
/** npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 把 [npm-too-low] 标记追加到任务输出,
|
|
476
|
+
* 前端据此展示「本机 npm 版本过低」的准确原因 —— 避免该报错被外层 ERR_PNPM_PREPARE_PACKAGE
|
|
477
|
+
* 误判成插件打包/分发问题、误导用户提 Issue。检测逻辑在独立的 npm-check.ts(本机 npm 环境职责)。 */
|
|
478
|
+
function annotateNpmTooLow(result, options) {
|
|
479
|
+
if (options.action !== 'add' || result.exitCode === 0 || result.timedOut)
|
|
480
|
+
return result;
|
|
481
|
+
const line = npmTooLowMarker(`${result.stderr}\n${result.stdout}`, options.env);
|
|
482
|
+
if (line === null)
|
|
483
|
+
return result;
|
|
484
|
+
if (options.task && options.task.status !== 'cancelled')
|
|
485
|
+
pushLine(options.task, line);
|
|
486
|
+
return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line };
|
|
487
|
+
}
|
|
404
488
|
/**
|
|
405
489
|
* Run a plugin mutation with one recovery path: when `add` fails because
|
|
406
490
|
* pnpm's allowBuilds gate blocks a build script — either the git-hosted
|
|
@@ -411,8 +495,10 @@ function ignoredBuildKeys(output) {
|
|
|
411
495
|
* Other failures fall through to the single-spawn result.
|
|
412
496
|
*/
|
|
413
497
|
export async function runPluginMutation(options) {
|
|
414
|
-
|
|
415
|
-
|
|
498
|
+
let result = await spawnMutation(options);
|
|
499
|
+
// allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
|
|
500
|
+
// 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
|
|
501
|
+
if (options.action === 'add' && result.exitCode !== 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
416
502
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
417
503
|
// git 源:pnpm 打印的 key@url 提示
|
|
418
504
|
if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
|
|
@@ -426,14 +512,15 @@ export async function runPluginMutation(options) {
|
|
|
426
512
|
pushLine(options.task, `[allow build] ${key}`);
|
|
427
513
|
}
|
|
428
514
|
addAllowBuildsKey(options.profile, key);
|
|
429
|
-
|
|
515
|
+
result = await spawnMutation(options);
|
|
430
516
|
}
|
|
431
517
|
}
|
|
432
518
|
// 依赖原生模块/构建脚本被 pnpm v11 拦截(ERR_PNPM_IGNORED_BUILDS):git 与 npm 源都会出现。
|
|
433
519
|
// allowBuilds 的 key 是输出里列出的 name@version(如 node-pty@1.1.0),逐个放行后重试一次;
|
|
434
520
|
// 输出解析不到时回退用目标名(插件自身构建脚本被拦的 npm 场景)。
|
|
435
|
-
|
|
436
|
-
|
|
521
|
+
// 用重试后的最终结果判断:git 源放行 prepare 后可能又在依赖层撞上原生构建拦截。
|
|
522
|
+
if (result.exitCode !== 0 && !result.timedOut && `${result.stderr}\n${result.stdout}`.includes('ERR_PNPM_IGNORED_BUILDS')) {
|
|
523
|
+
const keys = ignoredBuildKeys(`${result.stderr}\n${result.stdout}`);
|
|
437
524
|
if (keys.length === 0 && githubRepoOf(options.target) === null)
|
|
438
525
|
keys.push(options.target);
|
|
439
526
|
if (keys.length > 0) {
|
|
@@ -444,13 +531,14 @@ export async function runPluginMutation(options) {
|
|
|
444
531
|
}
|
|
445
532
|
for (const key of keys)
|
|
446
533
|
addAllowBuildsKey(options.profile, key);
|
|
447
|
-
|
|
534
|
+
result = await spawnMutation(options);
|
|
448
535
|
}
|
|
449
536
|
}
|
|
450
537
|
}
|
|
451
538
|
// pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败:
|
|
452
|
-
//
|
|
453
|
-
|
|
539
|
+
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
|
|
540
|
+
// 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
|
|
541
|
+
if (options.action === 'add' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
454
542
|
const verified = verifyInstalledEntry(options.profile, options.target);
|
|
455
543
|
if (verified.name !== null && verified.missing !== null) {
|
|
456
544
|
clearPendingRestart(options.displayTarget ?? options.target);
|
|
@@ -464,5 +552,6 @@ export async function runPluginMutation(options) {
|
|
|
464
552
|
return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` };
|
|
465
553
|
}
|
|
466
554
|
}
|
|
467
|
-
|
|
555
|
+
// npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
|
|
556
|
+
return annotateNpmTooLow(result, options);
|
|
468
557
|
}
|
package/lib/services/loader.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
|
|
7
7
|
* 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
|
|
8
8
|
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { profileDirectory } from './profile/profile.js';
|
|
9
12
|
/** 运行中 loader 的全部条目(id + options.name),诊断用。 */
|
|
10
13
|
export function dumpLoaderEntries(loader) {
|
|
11
14
|
if (!loader)
|
|
@@ -16,6 +19,20 @@ export function dumpLoaderEntries(loader) {
|
|
|
16
19
|
}
|
|
17
20
|
return entries;
|
|
18
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* 判断某 npm 包名是否已加载进运行中 loader(宽匹配:条目名存在别名变体)。
|
|
24
|
+
* 官方 `ctx.loader.entries()` 遍历整棵树(reference.md §1.3),条目名 == 插件作者声明的名字。
|
|
25
|
+
* 安装完成但未重启时条目不在 loader → 返回 false,UI 据此标「待重启/未加载」。
|
|
26
|
+
*/
|
|
27
|
+
export function isEntryLoaded(loader, name) {
|
|
28
|
+
if (!loader)
|
|
29
|
+
return false;
|
|
30
|
+
for (const entry of Array.from(loader.entries())) {
|
|
31
|
+
if (nameMatches(entry.options?.name, name))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
19
36
|
/**
|
|
20
37
|
* 卸载目标(npm 包名,如 `@scope/widget`)与 loader 条目名的匹配。
|
|
21
38
|
* loader 条目名由插件作者/工具写入,形式不可控,常见变体:
|
|
@@ -23,7 +40,8 @@ export function dumpLoaderEntries(loader) {
|
|
|
23
40
|
* - 去 `@` 前缀:`scope/widget`
|
|
24
41
|
* - 去 scope 的短名:`widget`
|
|
25
42
|
* - scope 分隔符变 `-`:`scope-widget`
|
|
26
|
-
*
|
|
43
|
+
* - 包内模块路径:`widget/extensions/dsh/index.js`(bundle patch 的 name 常写成模块路径)
|
|
44
|
+
* 因此归一化后比较全串、短名、路径前缀三个维度。profile 内包名唯一,宽松匹配不会误伤其它包。
|
|
27
45
|
*/
|
|
28
46
|
function nameMatches(entryName, target) {
|
|
29
47
|
if (!entryName)
|
|
@@ -45,6 +63,13 @@ function nameMatches(entryName, target) {
|
|
|
45
63
|
// 目标短名 `widget` 是它的尾段)——用「条目名以短名结尾」收口,避免误伤。
|
|
46
64
|
if (tShort.length >= 2 && e.endsWith(`-${tShort}`))
|
|
47
65
|
return true;
|
|
66
|
+
// 4) 条目名是目标包内的模块路径(`aegis/extensions/dsh/index.js` 属于包 `aegis`,
|
|
67
|
+
// `@scope/pkg/dist/x.js` 属于 `@scope/pkg`):归一化会吞掉路径分隔符,
|
|
68
|
+
// 必须在归一化前用原始形态做「target/」前缀判断(大小写不敏感)。
|
|
69
|
+
const rawE = entryName.trim().toLowerCase();
|
|
70
|
+
const rawT = target.trim().toLowerCase();
|
|
71
|
+
if (rawE === rawT || rawE.startsWith(`${rawT}/`))
|
|
72
|
+
return true;
|
|
48
73
|
return false;
|
|
49
74
|
}
|
|
50
75
|
/**
|
|
@@ -107,3 +132,33 @@ export async function removeLoadedEntry(loader, name) {
|
|
|
107
132
|
console.log(`[hub-uninstall] disable ${name} -> ${result ? 'ok/clean' : 'timed out or failed'}`);
|
|
108
133
|
return result;
|
|
109
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* 判断某已安装包是否是真正的 dsh 插件(宿主会加载 / 值得提示「待重启」):
|
|
137
|
+
* 1) 包名已写进 profile 的 `dsh.profile.bundles`(宿主启动时按清单加载);
|
|
138
|
+
* 2) 或包内 package.json 声明了 dsh 配置(`dsh.bundle` / `dsh.profile` 等)或 dsh 相关 keywords。
|
|
139
|
+
* 两者都满足不了(如 GitHub 官方示例仓库 octocat/Hello-World)说明它不是 dsh 插件,
|
|
140
|
+
* 装上也不会被宿主加载——UI 据此不再提示「待重启」,避免误导用户反复重启一个不会生效的东西。
|
|
141
|
+
*/
|
|
142
|
+
export function isDshPlugin(profile, name) {
|
|
143
|
+
// 1) profile 级 bundles 清单:包名在清单里 → 宿主启动会加载它
|
|
144
|
+
try {
|
|
145
|
+
const profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
|
|
146
|
+
if (Array.isArray(profilePkg.dsh?.profile?.bundles)) {
|
|
147
|
+
if (profilePkg.dsh.profile.bundles.includes(name))
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch { /* profile 读取失败按无 bundles 处理 */ }
|
|
152
|
+
// 2) 包内声明:dsh 字段(bundle patch / profile 配置)或 dsh 关键字
|
|
153
|
+
try {
|
|
154
|
+
const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8'));
|
|
155
|
+
if (pkg.dsh)
|
|
156
|
+
return true;
|
|
157
|
+
if (Array.isArray(pkg.keywords)) {
|
|
158
|
+
if (pkg.keywords.some((k) => typeof k === 'string' && /^(?:dsh|dsh-plugin)$/i.test(k)))
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch { /* 包缺失 / 无 package.json → 不是 dsh 插件 */ }
|
|
163
|
+
return false;
|
|
164
|
+
}
|