dsh-plugin 2.0.6 → 2.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/README.en.md +11 -11
- package/README.md +11 -11
- package/client/client.js +585 -328
- package/client/client.js.map +1 -1
- package/lib/http/diagnostics.js +2 -63
- package/lib/http/install.js +42 -11
- package/lib/http/settings.js +1 -1
- package/lib/services/host-version.js +72 -0
- package/lib/services/install/decode-output.js +23 -0
- package/lib/services/install/dsh-compat.js +105 -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 +95 -19
- package/lib/services/settings.js +1 -0
- package/lib/types/services/host-version.d.ts +10 -0
- package/lib/types/services/install/decode-output.d.ts +4 -0
- package/lib/types/services/install/dsh-compat.d.ts +19 -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 +62 -5
- package/src/client/components/modals/modals.tsx +68 -5
- 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 +42 -0
- package/src/client/logic/failures.ts +86 -5
- package/src/client/logic/release-age.ts +40 -0
- package/src/client/logic/urls.ts +19 -4
- package/src/client/types.ts +13 -0
- package/src/server/http/diagnostics.ts +2 -59
- package/src/server/http/install.ts +46 -11
- package/src/server/http/settings.ts +1 -1
- package/src/server/services/host-version.ts +69 -0
- package/src/server/services/install/decode-output.ts +22 -0
- package/src/server/services/install/dsh-compat.ts +99 -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 +107 -19
- package/src/server/services/settings.ts +4 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm 源降级:当前源是公共镜像时,传输层失败后按「当前源 → 官方 → npmmirror」
|
|
3
|
+
* 自动换源重试;自定义/内网源不降级,避免私有包名泄露给公共源。
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
|
|
7
|
+
/** 允许自动切换的公共源:只放官方与大厂/高校镜像。 */
|
|
8
|
+
const PUBLIC_REGISTRIES = [
|
|
9
|
+
'https://registry.npmjs.org',
|
|
10
|
+
'https://registry.npmmirror.com',
|
|
11
|
+
'https://mirrors.cloud.tencent.com/npm',
|
|
12
|
+
'https://mirrors.tuna.tsinghua.edu.cn/npm',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const PUBLIC_HOSTS = new Set([
|
|
16
|
+
'registry.npmjs.org',
|
|
17
|
+
'registry.npmmirror.com',
|
|
18
|
+
'mirrors.cloud.tencent.com',
|
|
19
|
+
'mirrors.tuna.tsinghua.edu.cn',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/** 去掉尾部斜杠,避免同一源因写法不同被当成两个候选。 */
|
|
23
|
+
export function normalizeRegistry(value: string): string {
|
|
24
|
+
return value.trim().replace(/\/+$/, '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 是否属于允许自动切换的公共源白名单。 */
|
|
28
|
+
export function isPublicRegistry(value: string): boolean {
|
|
29
|
+
const v = normalizeRegistry(value)
|
|
30
|
+
if (!/^https?:\/\//i.test(v)) return false
|
|
31
|
+
try {
|
|
32
|
+
return PUBLIC_HOSTS.has(new URL(v).hostname.toLowerCase())
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 依次尝试的源:显式配置 → 本机 npm 配置 → 官方源。
|
|
40
|
+
* 只有公共源才追加后备(最多 3 个);关闭降级或源不在白名单时只返回它自己。
|
|
41
|
+
*/
|
|
42
|
+
export function registryCandidates(configured: string, userRegistry: string, enabled: boolean): string[] {
|
|
43
|
+
const base = normalizeRegistry(configured) || normalizeRegistry(userRegistry) || PUBLIC_REGISTRIES[0]!
|
|
44
|
+
if (!enabled || !isPublicRegistry(base)) return [base]
|
|
45
|
+
const out = [base]
|
|
46
|
+
for (const candidate of PUBLIC_REGISTRIES) {
|
|
47
|
+
if (out.length >= 3) break
|
|
48
|
+
if (!out.includes(candidate)) out.push(candidate)
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 本机 npm 实际生效的源(含 profile 目录的 .npmrc);读不到返回空串。 */
|
|
54
|
+
export function userNpmRegistry(cwd: string): string {
|
|
55
|
+
try {
|
|
56
|
+
const res = spawnSync('npm', ['config', 'get', 'registry'], {
|
|
57
|
+
cwd,
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
timeout: 3000,
|
|
60
|
+
shell: process.platform === 'win32',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
})
|
|
63
|
+
if (res.status !== 0) return ''
|
|
64
|
+
const value = (res.stdout ?? '').trim()
|
|
65
|
+
return /^https?:\/\//i.test(value) ? normalizeRegistry(value) : ''
|
|
66
|
+
} catch {
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 传输层失败才值得换源:超时、连接重置、DNS、5xx。
|
|
73
|
+
* 404 / 版本不存在 / 鉴权失败必须保持原样 —— 换源只会掩盖作者侧问题或装到滞后版本。
|
|
74
|
+
*/
|
|
75
|
+
export function isTransportFailure(text: string): boolean {
|
|
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
|
+
|
|
82
|
+
/** 日志里展示用的源主机名(不打印可能带凭据的完整 URL)。 */
|
|
83
|
+
export function registryHost(value: string): string {
|
|
84
|
+
try {
|
|
85
|
+
return new URL(value).host
|
|
86
|
+
} catch {
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -15,7 +15,7 @@ import type { InstallResult, InstallTask, Invocation, QueueItem } from './instal
|
|
|
15
15
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
|
|
16
16
|
import { npmTooLowMarker } from './npm-check.ts'
|
|
17
17
|
import { registryMissingMarker } from './registry-check.ts'
|
|
18
|
-
import { backfillOperationLog, createOperationLogTap, operationLogPathOf } from './operation-log.ts'
|
|
18
|
+
import { backfillOperationLog, createOperationLogTap, hasSubstantiveOutput, operationLogPathOf } from './operation-log.ts'
|
|
19
19
|
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'
|
|
@@ -23,6 +23,9 @@ import { addPendingRestart, clearPendingRestart } from '../profile/pending-resta
|
|
|
23
23
|
import { addAllowBuildsKey, bundleIncludes, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
|
|
24
24
|
import { appendLog } from '../log.ts'
|
|
25
25
|
import { commandOnPath, gitMissingDetail } from './preflight.ts'
|
|
26
|
+
import { isTransportFailure, registryHost } from './registry-fallback.ts'
|
|
27
|
+
import { dshTooOldMarker } from './dsh-compat.ts'
|
|
28
|
+
import { OutputDecoder } from './decode-output.ts'
|
|
26
29
|
|
|
27
30
|
/** 内存任务注册表,按任务 id 索引。 */
|
|
28
31
|
const tasks = new Map<number, InstallTask>()
|
|
@@ -213,6 +216,16 @@ export function desktopCliBootstrap(entry: string): string | null {
|
|
|
213
216
|
return null
|
|
214
217
|
}
|
|
215
218
|
|
|
219
|
+
/**
|
|
220
|
+
* 传给 CLI 的安装目标:钉住版本优先(Hub 自我更新提示的是已过供应链门禁的版本),
|
|
221
|
+
* 其次更新走 `@latest`,git 源与全局 npm 安装保持原样。
|
|
222
|
+
*/
|
|
223
|
+
export function npmInstallTargetOf(target: string, opts: { update: boolean; global: boolean; pinned?: string }): string {
|
|
224
|
+
if (opts.global || githubRepoOf(target) !== null) return target
|
|
225
|
+
if (opts.pinned !== undefined && opts.pinned !== '') return opts.pinned
|
|
226
|
+
return opts.update ? `${target}@latest` : target
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
function stopChild(child: ReturnType<typeof spawn>): void {
|
|
217
230
|
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
218
231
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' })
|
|
@@ -272,6 +285,11 @@ export function startPluginMutation(options: {
|
|
|
272
285
|
/** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
|
|
273
286
|
* 目录插件安装不传 */
|
|
274
287
|
installChannel?: 'npm'
|
|
288
|
+
/** npm 源候选(首选在前):传输层失败时按顺序换源重试,空/单个表示不降级 */
|
|
289
|
+
registries?: string[]
|
|
290
|
+
/** 精确安装 spec(如 `dsh-plugin@2.0.5`):Hub 自我更新提示的是已过供应链门禁的版本,
|
|
291
|
+
* 必须钉住安装,不能再用 @latest 被带回门禁内的最新版 */
|
|
292
|
+
installSpec?: string
|
|
275
293
|
}): InstallTask {
|
|
276
294
|
// 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
|
|
277
295
|
// 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
|
|
@@ -397,13 +415,23 @@ function spawnMutation(options: {
|
|
|
397
415
|
globalNpm?: string[]
|
|
398
416
|
/** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
|
|
399
417
|
updateNpm?: boolean
|
|
418
|
+
/** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
|
|
419
|
+
registries?: string[]
|
|
420
|
+
/** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
|
|
421
|
+
installSpec?: string
|
|
400
422
|
}): Promise<InstallResult> {
|
|
401
|
-
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options
|
|
423
|
+
const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm, registries, installSpec } = options
|
|
402
424
|
// 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
|
|
403
425
|
const isGlobalNpm = (globalNpm ?? []).length > 0
|
|
404
426
|
const invocation = isGlobalNpm
|
|
405
427
|
? { file: 'npm', prefixArgs: [], cwd: process.cwd(), useShell: process.platform === 'win32' }
|
|
406
428
|
: cliInvocation(profile)
|
|
429
|
+
// 打包桌面端却回退到 PATH 上的 dsh:说明 desktop CLI 引导路径没命中,desktop profile 必被宿主拒绝。
|
|
430
|
+
// 留一行 ASCII 标记,后续同类上报可直接区分「用户自起 CLI」与「我们没走对引导路径」。
|
|
431
|
+
if (task && task.status !== 'cancelled' && invocation.file === 'dsh'
|
|
432
|
+
&& (process.versions as Record<string, string | undefined>).electron !== undefined) {
|
|
433
|
+
pushLine(task, '[desktop-cli-fallback] running inside Electron but no desktop CLI bootstrap was found — falling back to dsh on PATH (a desktop-managed profile will be rejected)')
|
|
434
|
+
}
|
|
407
435
|
// 更新已安装的 npm 包:显式指定 @latest。pnpm add <pkg>(无版本)对已存在依赖
|
|
408
436
|
// (尤其精确 spec 如 "1.3.0")是幂等的 —— 输出 "Already up to date" 且不改 package.json,
|
|
409
437
|
// 导致「更新成功」但实际仍停留在旧版本;git 地址与全局安装不受此影响。
|
|
@@ -411,9 +439,8 @@ function spawnMutation(options: {
|
|
|
411
439
|
// CLI 动词仍透传 add —— 不执行官方 update 动词,避免与既有覆盖重装机制分叉。
|
|
412
440
|
const isUpdate = action === 'update' || updateNpm === true
|
|
413
441
|
const cliAction = action === 'update' ? 'add' : action
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
: target
|
|
442
|
+
// 钉住版本优先于 @latest:提示的是「已过门禁」的版本,装 latest 会被 pnpm 拒绝
|
|
443
|
+
const npmUpdateTarget = npmInstallTargetOf(target, { update: isUpdate, global: isGlobalNpm, pinned: installSpec })
|
|
417
444
|
const args = isGlobalNpm
|
|
418
445
|
? ['install', '-g', ...(globalNpm ?? [])]
|
|
419
446
|
: [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
|
|
@@ -427,9 +454,12 @@ function spawnMutation(options: {
|
|
|
427
454
|
// 单次 spawn 并捕获结果:流式输出打日志 + 超时/取消兜底,close 后 resolve。
|
|
428
455
|
// 不做任何终态后处理(exitCode/status/needsRestart 由下方统一收尾),
|
|
429
456
|
// 这样全局 npm 首次失败后可以干净地换参数重试一次。
|
|
430
|
-
const spawnOnce = (attemptArgs: string[]): Promise<InstallResult> => new Promise((resolveOnce) => {
|
|
457
|
+
const spawnOnce = (attemptArgs: string[], registry?: string): Promise<InstallResult> => new Promise((resolveOnce) => {
|
|
431
458
|
let stdout = ''
|
|
432
459
|
let stderr = ''
|
|
460
|
+
// 每次 spawn 独立解码器:中文 Windows 下 dsh/pnpm 可能输出 GBK,需按流判定编码
|
|
461
|
+
const stdoutDecoder = new OutputDecoder()
|
|
462
|
+
const stderrDecoder = new OutputDecoder()
|
|
433
463
|
let timedOut = false
|
|
434
464
|
let settled = false
|
|
435
465
|
let child: ReturnType<typeof spawn>
|
|
@@ -463,11 +493,17 @@ function spawnMutation(options: {
|
|
|
463
493
|
return
|
|
464
494
|
}
|
|
465
495
|
}
|
|
496
|
+
// invocation.env 是「必须叠加」的变量(打包桌面端的 ELECTRON_RUN_AS_NODE):
|
|
497
|
+
// 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
|
|
498
|
+
// registry 最后写入:换源重试只改这一层,pnpm/npm 都认 npm_config_registry。
|
|
499
|
+
const childEnv: NodeJS.ProcessEnv = { ...(env ?? process.env), ...(invocation.env ?? {}) }
|
|
500
|
+
if (registry !== undefined) {
|
|
501
|
+
childEnv.npm_config_registry = registry
|
|
502
|
+
childEnv.NPM_CONFIG_REGISTRY = registry
|
|
503
|
+
}
|
|
466
504
|
child = spawn(invocation.file, attemptArgs, {
|
|
467
505
|
cwd: invocation.cwd,
|
|
468
|
-
|
|
469
|
-
// 没有它时保持 env 原样透传,有它时必须基于完整环境叠加,否则子进程会丢掉 PATH。
|
|
470
|
-
env: invocation.env === undefined ? env : { ...(env ?? process.env), ...invocation.env },
|
|
506
|
+
env: childEnv,
|
|
471
507
|
shell: invocation.useShell,
|
|
472
508
|
// detached 使子进程成为独立进程组组长:取消时能整组清理(见 stopChild),
|
|
473
509
|
// 且宿主进程崩溃后安装任务不会被连带误杀
|
|
@@ -522,7 +558,7 @@ function spawnMutation(options: {
|
|
|
522
558
|
if (task && task.status === 'running' && task.progress < 85) task.progress += 1
|
|
523
559
|
}, 500)
|
|
524
560
|
const collect = (kind: 'stdout' | 'stderr', chunk: Buffer): void => {
|
|
525
|
-
const text = chunk.
|
|
561
|
+
const text = kind === 'stdout' ? stdoutDecoder.push(chunk) : stderrDecoder.push(chunk)
|
|
526
562
|
if (kind === 'stdout') stdout = (stdout + text).slice(-CAPTURE_LIMIT_BYTES)
|
|
527
563
|
else stderr = (stderr + text).slice(-CAPTURE_LIMIT_BYTES)
|
|
528
564
|
if (task) {
|
|
@@ -578,7 +614,21 @@ function spawnMutation(options: {
|
|
|
578
614
|
// 超时复核用基线:本次任务开始前目标是否已在 profile 里。已安装的话,超时后核对无法区分
|
|
579
615
|
// 「旧版本还在」与「刚装上」,那种情形(更新超时)不冒认成功。
|
|
580
616
|
const wasInstalled = action !== 'remove' && !isGlobalNpm && verifyInstalledEntry(profile, target).name !== null
|
|
581
|
-
|
|
617
|
+
// 候选源:卸载不拉包、git 源装的是仓库快照,都不受 npm 镜像影响,不参与换源
|
|
618
|
+
const candidates = action === 'remove' || githubRepoOf(target) !== null ? [] : (registries ?? [])
|
|
619
|
+
let activeRegistry = candidates[0]
|
|
620
|
+
let result = await spawnOnce(args, activeRegistry)
|
|
621
|
+
// 传输层失败(超时 / 连接重置 / DNS / 5xx)才换下一个公共镜像;404、版本不存在、鉴权错误
|
|
622
|
+
// 保持原样,否则会掩盖作者侧问题或装到滞后版本。整段超时也不再换源,避免耗时翻倍。
|
|
623
|
+
for (const candidate of candidates.slice(1)) {
|
|
624
|
+
if (task?.status === 'cancelled' || result.exitCode === 0 || result.timedOut) break
|
|
625
|
+
if (!isTransportFailure(`${result.stderr}\n${result.stdout}`)) break
|
|
626
|
+
if (task) {
|
|
627
|
+
pushLine(task, `[registry-fallback] ${registryHost(activeRegistry ?? '')} unreachable — retrying with ${registryHost(candidate)}`)
|
|
628
|
+
}
|
|
629
|
+
activeRegistry = candidate
|
|
630
|
+
result = await spawnOnce(args, candidate)
|
|
631
|
+
}
|
|
582
632
|
// npm 11 的 arborist peer-set bug(`Cannot read properties of null (reading 'children')`)
|
|
583
633
|
// 会让 `npm install -g` 在解析阶段直接崩溃。首次失败后自动带 `--legacy-peer-deps` 重试一次:
|
|
584
634
|
// 跳过 peer 自动解析即可绕过该 bug;二次尝试仍失败则保留其错误信息,不掩盖真正的原因。
|
|
@@ -587,7 +637,7 @@ function spawnMutation(options: {
|
|
|
587
637
|
const retryExecuted = `npm install -g --legacy-peer-deps ${(globalNpm ?? []).join(' ')}`
|
|
588
638
|
if (task && !task.attempts.includes(retryExecuted)) task.attempts.push(retryExecuted)
|
|
589
639
|
if (task) pushLine(task, '[npm] peer-set resolution crashed npm (known npm bug) — retrying with --legacy-peer-deps')
|
|
590
|
-
result = await spawnOnce(retryArgs)
|
|
640
|
+
result = await spawnOnce(retryArgs, activeRegistry)
|
|
591
641
|
}
|
|
592
642
|
// 超时被强杀 ≠ 没装上:命中的安装可能已经落盘(日志里 pnpm 已打印 Done,只是 dsh 进程迟迟不退出
|
|
593
643
|
// 而被我们超时掐掉)。判失败之前先复核 profile —— 目标已在依赖里、且声明分发的入口文件都在,
|
|
@@ -644,7 +694,9 @@ function spawnMutation(options: {
|
|
|
644
694
|
addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install')
|
|
645
695
|
}
|
|
646
696
|
}
|
|
647
|
-
|
|
697
|
+
// 子进程一个字节都没吐时在退出行上标注:日志面板与失败上报都能看出「没有输出可排查」
|
|
698
|
+
const printedNothing = `${result.stdout}${result.stderr}`.trim() === ''
|
|
699
|
+
pushLine(task, result.timedOut ? '[timed out]' : `[exit ${result.exitCode ?? '?'}]${printedNothing ? ' (no output captured)' : ''}`)
|
|
648
700
|
}
|
|
649
701
|
}
|
|
650
702
|
return result
|
|
@@ -740,6 +792,26 @@ async function annotateRegistryMissing(result: InstallResult, options: {
|
|
|
740
792
|
return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line }
|
|
741
793
|
}
|
|
742
794
|
|
|
795
|
+
/** 安装失败且插件声明的最低 DSH 版本高于当前宿主 → 注入 [dsh-too-old] 标记。
|
|
796
|
+
* 只在失败路径上发生:peer 不满足本身通常只是警告,装成功时不该冒这个提示。
|
|
797
|
+
* 超时也算失败 —— 旧宿主上插件可能卡在半兼容状态,这个信息同样有用。 */
|
|
798
|
+
async function annotateDshTooOld(result: InstallResult, options: {
|
|
799
|
+
action: 'add' | 'remove' | 'update'
|
|
800
|
+
target: string
|
|
801
|
+
task?: InstallTask
|
|
802
|
+
globalNpm?: string[]
|
|
803
|
+
registries?: string[]
|
|
804
|
+
}): Promise<InstallResult> {
|
|
805
|
+
if (options.action === 'remove' || result.exitCode === 0) return result
|
|
806
|
+
// 全局 npm 装的是本机 CLI 工具,不进 profile,没有宿主版本要求
|
|
807
|
+
if ((options.globalNpm ?? []).length > 0) return result
|
|
808
|
+
// 用安装实际使用的源读元数据:私有源/镜像里也能读到同一个包的声明
|
|
809
|
+
const line = await dshTooOldMarker(options.target, { registry: options.registries?.[0] })
|
|
810
|
+
if (line === null) return result
|
|
811
|
+
if (options.task && options.task.status !== 'cancelled') pushLine(options.task, line)
|
|
812
|
+
return { ...result, stderr: `${result.stderr}\n${line}`, error: result.error ? `${result.error}\n${line}` : line }
|
|
813
|
+
}
|
|
814
|
+
|
|
743
815
|
/**
|
|
744
816
|
* 宿主自身 CLI 调不起来时的 ASCII 标记行:spawn 我们解析出的宿主入口报 ENOENT 时返回
|
|
745
817
|
* `[host-spawn]`,供前端单独归因、不引导提 Issue。PATH 上的 dsh/npm 各有自己的标记,返回 null。
|
|
@@ -771,9 +843,14 @@ export async function runPluginMutation(options: {
|
|
|
771
843
|
loader?: LoaderHandle
|
|
772
844
|
/** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
|
|
773
845
|
globalNpm?: string[]
|
|
846
|
+
/** npm 源候选(首选在前):传输层失败时按顺序换源重试 */
|
|
847
|
+
registries?: string[]
|
|
848
|
+
/** 精确安装 spec(如 `dsh-plugin@2.0.5`):见 startPluginMutation 同名说明 */
|
|
849
|
+
installSpec?: string
|
|
774
850
|
}): Promise<InstallResult> {
|
|
775
851
|
// operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
|
|
776
|
-
const
|
|
852
|
+
const profileDir = profileDirectory(options.profile)
|
|
853
|
+
const logTap = createOperationLogTap(profileDir)
|
|
777
854
|
let result = await spawnMutation(options)
|
|
778
855
|
// allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
|
|
779
856
|
// 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
|
|
@@ -834,22 +911,33 @@ export async function runPluginMutation(options: {
|
|
|
834
911
|
// 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
|
|
835
912
|
// 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
|
|
836
913
|
// 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
|
|
837
|
-
|
|
914
|
+
// 超时同样回捞:被强杀前 pnpm 往往已经把真实原因写进 operation 日志。
|
|
915
|
+
if ((result.exitCode !== 0 || result.timedOut) && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
838
916
|
const output = `${result.stderr}\n${result.stdout}`
|
|
839
|
-
const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output)
|
|
917
|
+
const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output, profileDir)
|
|
840
918
|
if (backfilled !== null) {
|
|
841
919
|
if (options.task && options.task.status !== 'cancelled') {
|
|
842
920
|
pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
|
|
843
921
|
for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
|
|
844
922
|
}
|
|
845
923
|
result = { ...result, stderr: `${result.stderr}\n${backfilled}` }
|
|
846
|
-
} else if (options.task && options.task.status !== 'cancelled'
|
|
847
|
-
|
|
848
|
-
|
|
924
|
+
} else if (options.task && options.task.status !== 'cancelled') {
|
|
925
|
+
if (operationLogPathOf(output) !== null) {
|
|
926
|
+
// 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
|
|
927
|
+
pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
|
|
928
|
+
} else if (!hasSubstantiveOutput(output)) {
|
|
929
|
+
// 宿主连 diagnostics 路径都没回显:显式留痕,避免上报正文只剩一个退出码
|
|
930
|
+
const what = result.timedOut
|
|
931
|
+
? 'printed nothing before the timeout'
|
|
932
|
+
: `exited with code ${result.exitCode ?? '?'} without printing anything`
|
|
933
|
+
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`)
|
|
934
|
+
}
|
|
849
935
|
}
|
|
850
936
|
}
|
|
851
937
|
// 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
|
|
852
938
|
result = await annotateRegistryMissing(result, options)
|
|
939
|
+
// 插件声明的最低 DSH 版本高于当前宿主 → 追加 [dsh-too-old] 标记,前端提示先升级 DSH
|
|
940
|
+
result = await annotateDshTooOld(result, options)
|
|
853
941
|
// npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
|
|
854
942
|
return annotateNpmTooLow(result, options)
|
|
855
943
|
}
|
|
@@ -15,8 +15,10 @@ export interface HubSettings {
|
|
|
15
15
|
checkUpdatesOnStart: boolean
|
|
16
16
|
/** HTTP(S) 代理地址,用于 npm / git / 目录请求(空串 = 直连) */
|
|
17
17
|
proxy: string
|
|
18
|
-
/** npm 镜像源 registry 地址(空串 = 未配置,跟随用户本机 npm
|
|
18
|
+
/** npm 镜像源 registry 地址(空串 = 未配置,跟随用户本机 npm 配置) */
|
|
19
19
|
npmRegistry: string
|
|
20
|
+
/** 公共源传输失败时自动换下一个镜像重试(自定义/内网源不切换) */
|
|
21
|
+
registryFallback: boolean
|
|
20
22
|
/** 启用命令行安装 NPM 包(默认开;关掉后输入 npm 包名安装被拦截) */
|
|
21
23
|
enableNpmInstall: boolean
|
|
22
24
|
/** 日志存放位置覆盖(空串 = 默认 ~/.dsh/profiles/<profile>/hub.log;可填目录或 .log 文件路径) */
|
|
@@ -27,6 +29,7 @@ export const DEFAULT_SETTINGS: HubSettings = {
|
|
|
27
29
|
checkUpdatesOnStart: true,
|
|
28
30
|
proxy: '',
|
|
29
31
|
npmRegistry: '',
|
|
32
|
+
registryFallback: true,
|
|
30
33
|
enableNpmInstall: true,
|
|
31
34
|
logPath: '',
|
|
32
35
|
}
|