dsh-plugin 2.0.0 → 2.0.2
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 +12 -12
- package/README.md +12 -12
- package/client/client.js +469 -313
- package/client/client.js.map +1 -1
- package/lib/services/install/operation-log.js +53 -1
- package/lib/services/install/task-queue.js +6 -2
- package/lib/types/services/install/operation-log.d.ts +9 -0
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +9 -0
- package/src/client/components/modals/NotificationsModal.tsx +39 -8
- package/src/client/components/modals/modals.tsx +40 -11
- package/src/client/locales.ts +112 -0
- package/src/client/logic/failures.ts +5 -2
- package/src/client/logic/urls.ts +4 -1
- package/src/client/styles/Modal.module.css +42 -1
- package/src/server/services/install/operation-log.ts +47 -1
- package/src/server/services/install/task-queue.ts +6 -2
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
|
|
11
11
|
* 不改变原有失败处理。
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
14
15
|
import { CAPTURE_LIMIT_BYTES } from './install-types.js';
|
|
15
16
|
/**
|
|
16
17
|
* 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
|
|
@@ -47,3 +48,54 @@ export function backfillOperationLog(output) {
|
|
|
47
48
|
return null;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
53
|
+
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
54
|
+
* `.plugin-manager/logs/<operation>/pnpm.log`,用抓到的内容兜底。
|
|
55
|
+
* 只认本次运行产生的新文件(mtime 晚于启动基线),避免复述上一次安装的日志。
|
|
56
|
+
*/
|
|
57
|
+
export function createOperationLogTap(profileDir) {
|
|
58
|
+
const logsDir = join(profileDir, '.plugin-manager', 'logs');
|
|
59
|
+
const newestLog = () => {
|
|
60
|
+
let newest = null;
|
|
61
|
+
for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
|
|
62
|
+
if (!entry.isDirectory())
|
|
63
|
+
continue;
|
|
64
|
+
const file = join(logsDir, entry.name, 'pnpm.log');
|
|
65
|
+
try {
|
|
66
|
+
const mtimeMs = statSync(file).mtimeMs;
|
|
67
|
+
if (newest === null || mtimeMs > newest.mtimeMs)
|
|
68
|
+
newest = { file, mtimeMs };
|
|
69
|
+
}
|
|
70
|
+
catch { /* 该 operation 还没有 pnpm.log */ }
|
|
71
|
+
}
|
|
72
|
+
return newest;
|
|
73
|
+
};
|
|
74
|
+
let baselineMs = 0;
|
|
75
|
+
try {
|
|
76
|
+
baselineMs = newestLog()?.mtimeMs ?? 0;
|
|
77
|
+
}
|
|
78
|
+
catch { /* logs 目录还不存在 */ }
|
|
79
|
+
let captured = '';
|
|
80
|
+
const tick = () => {
|
|
81
|
+
try {
|
|
82
|
+
const newest = newestLog();
|
|
83
|
+
if (newest === null || newest.mtimeMs <= baselineMs)
|
|
84
|
+
return;
|
|
85
|
+
const text = readFileSync(newest.file, 'utf8');
|
|
86
|
+
if (text.length > captured.length)
|
|
87
|
+
captured = text;
|
|
88
|
+
}
|
|
89
|
+
catch { /* 读不到就放弃这一轮 */ }
|
|
90
|
+
};
|
|
91
|
+
const timer = setInterval(tick, 700);
|
|
92
|
+
timer.unref();
|
|
93
|
+
tick();
|
|
94
|
+
return {
|
|
95
|
+
stop: () => {
|
|
96
|
+
clearInterval(timer);
|
|
97
|
+
tick();
|
|
98
|
+
return captured.length > CAPTURE_LIMIT_BYTES ? captured.slice(-CAPTURE_LIMIT_BYTES) : captured;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -14,7 +14,7 @@ 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, operationLogPathOf } from './operation-log.js';
|
|
17
|
+
import { backfillOperationLog, createOperationLogTap, 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';
|
|
@@ -722,6 +722,8 @@ export function hostSpawnMarker(invocation, error) {
|
|
|
722
722
|
* 其它失败一律落到单次 spawn 的结果。
|
|
723
723
|
*/
|
|
724
724
|
export async function runPluginMutation(options) {
|
|
725
|
+
// operation 日志只在运行期存在(宿主结束后可能清掉),先开抓取,退出后按路径读作兜底
|
|
726
|
+
const logTap = createOperationLogTap(profileDirectory(options.profile));
|
|
725
727
|
let result = await spawnMutation(options);
|
|
726
728
|
// allowBuilds 放行 / 装后入口校验都是 profile 专属(pnpm 装进 profile 的场景);
|
|
727
729
|
// 全局 npm 安装直接走 npm,无 pnpm allowBuilds 拦截、也不进 profile,跳过这两类恢复
|
|
@@ -762,6 +764,8 @@ export async function runPluginMutation(options) {
|
|
|
762
764
|
}
|
|
763
765
|
}
|
|
764
766
|
}
|
|
767
|
+
// 重试会产生新的 operation 日志,运行期抓取到此为止
|
|
768
|
+
const tappedLog = logTap.stop();
|
|
765
769
|
// pnpm 安装本身成功,但目标包声明分发的文件缺失(分发物缺构建产物 / 缺声明的补丁)→ 判为失败:
|
|
766
770
|
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
|
|
767
771
|
// 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
|
|
@@ -784,7 +788,7 @@ export async function runPluginMutation(options) {
|
|
|
784
788
|
// 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
|
|
785
789
|
if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
786
790
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
787
|
-
const backfilled = backfillOperationLog(output);
|
|
791
|
+
const backfilled = tappedLog !== '' ? tappedLog : backfillOperationLog(output);
|
|
788
792
|
if (backfilled !== null) {
|
|
789
793
|
if (options.task && options.task.status !== 'cancelled') {
|
|
790
794
|
pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk');
|
|
@@ -9,3 +9,12 @@ export declare function operationLogPathOf(output: string): string | null;
|
|
|
9
9
|
* 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
|
|
10
10
|
*/
|
|
11
11
|
export declare function backfillOperationLog(output: string): string | null;
|
|
12
|
+
/**
|
|
13
|
+
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
14
|
+
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
15
|
+
* `.plugin-manager/logs/<operation>/pnpm.log`,用抓到的内容兜底。
|
|
16
|
+
* 只认本次运行产生的新文件(mtime 晚于启动基线),避免复述上一次安装的日志。
|
|
17
|
+
*/
|
|
18
|
+
export declare function createOperationLogTap(profileDir: string): {
|
|
19
|
+
stop: () => string;
|
|
20
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -168,6 +168,15 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
|
|
|
168
168
|
setNotifications(addSuccess({ kind: 'uninstall', repo: repo ?? '' }))
|
|
169
169
|
},
|
|
170
170
|
onError: (message, repo, kind, command, attempts, update) => {
|
|
171
|
+
// 失败只由错误弹窗呈现:先关掉发起本次操作的确认/进度弹窗
|
|
172
|
+
setInstallDone(false)
|
|
173
|
+
setUninstallDone(false)
|
|
174
|
+
setConfirmPlugin(null)
|
|
175
|
+
setConfirmCustomTarget(null)
|
|
176
|
+
setConfirmCustomChannel(null)
|
|
177
|
+
setConfirmGlobalNpm(null)
|
|
178
|
+
setUninstallPlugin(null)
|
|
179
|
+
setUninstallItem(null)
|
|
171
180
|
// 提 Issue 目标先反查仓库身份:npm 包名能在已安装表/目录里反查到 owner/repo 才保留,
|
|
172
181
|
// 反查不到置 null —— 错误弹窗据此不显示「一键提 Issue」按钮(防提到错误仓库)
|
|
173
182
|
const issueRepo = issueRepoOf(repo ?? '', catalog.installedItems, catalog.plugins)
|
|
@@ -202,14 +202,19 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
202
202
|
? (isUpdate
|
|
203
203
|
? h('span', { className: styles.failRepo, title: r.repo }, r.repo)
|
|
204
204
|
: shownRepo
|
|
205
|
-
?
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
205
|
+
? (() => {
|
|
206
|
+
const url = pluginSiteUrl(shownRepo)
|
|
207
|
+
return h('a', {
|
|
208
|
+
className: styles.failRepo,
|
|
209
|
+
// 跳官网「插件详情」页;文字里直接给出目标地址(去掉协议)
|
|
210
|
+
href: url,
|
|
211
|
+
target: '_blank',
|
|
212
|
+
rel: 'noopener noreferrer',
|
|
213
|
+
title: url,
|
|
214
|
+
},
|
|
215
|
+
h('span', { className: styles.failRepoLabel }, `${t('detailTitle')} · `),
|
|
216
|
+
h('span', { className: styles.failRepoUrl }, url.replace(/^https?:\/\//, '').split('?')[0]))
|
|
217
|
+
})()
|
|
213
218
|
// 反查不到仓库身份(如命令行安装的裸 npm 包名):回退显示记录里的原始值,
|
|
214
219
|
// 保证通知永不只剩一句「安装/更新成功」没有名字 —— 只是不给官网外链(查不到不敢乱跳)
|
|
215
220
|
: h('span', { className: styles.failRepo, title: r.repo }, r.repo))
|
|
@@ -247,6 +252,16 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
247
252
|
onClick: (e: MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); setConfirmRemoveId(r.id) },
|
|
248
253
|
}, h(CloseIcon)),
|
|
249
254
|
),
|
|
255
|
+
// 统一失败卡片头部:报错类型 + 一句话原因(具体指引在下面的分支里)
|
|
256
|
+
!r.ok && (() => {
|
|
257
|
+
const k = classifyFailure(r.message)
|
|
258
|
+
return h('div', null, [
|
|
259
|
+
h('div', { className: styles.failKindTitle }, t(`failKind_${k}`, k === 'hostIncompatible'
|
|
260
|
+
? { version: /incompatible with dsh\s+([^\s:;,]+)/i.exec(r.message ?? '')?.[1] ?? '' }
|
|
261
|
+
: undefined)),
|
|
262
|
+
k === 'hostIncompatible' ? null : h('div', { className: styles.failReason }, t(`failReason_${k}`)),
|
|
263
|
+
])
|
|
264
|
+
})(),
|
|
250
265
|
// 每条失败记录是一个独立卡片:头部(状态/仓库/时间/复制完整日志)→ 修复或提 Issue 动作。
|
|
251
266
|
// 卡片不展示错误日志预览,完整日志靠「复制完整日志」按钮带走,避免每条卡片被日志撑高
|
|
252
267
|
!r.ok && (() => {
|
|
@@ -330,6 +345,22 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
|
|
|
330
345
|
}, t('failIssueBig')) : null,
|
|
331
346
|
])
|
|
332
347
|
}
|
|
348
|
+
if (kind === 'hostIncompatible') {
|
|
349
|
+
// 宿主版本门禁:插件声明的兼容范围不含当前 dsh 版本,安装前就被拒绝。
|
|
350
|
+
// 作者能修(把该版本加进兼容清单),故引导到作者仓库;同时说明本地可临时授权
|
|
351
|
+
const version = /incompatible with dsh\s+([^\s:;,]+)/i.exec(r.message ?? '')?.[1] ?? ''
|
|
352
|
+
return h('div', null, [
|
|
353
|
+
h('div', { className: styles.failReason }, t('failHostIncompatibleReason', { version })),
|
|
354
|
+
h('div', { className: styles.failPrepareHint }, t('failHostIncompatibleHint')),
|
|
355
|
+
r.repo && shownRepo ? h('a', {
|
|
356
|
+
className: styles.failBigIssue,
|
|
357
|
+
href: pluginIssueUrl(shownRepo, r.message, env, r.command, r.attempts, action),
|
|
358
|
+
target: '_blank',
|
|
359
|
+
rel: 'noopener noreferrer',
|
|
360
|
+
title: t('failIssueHint'),
|
|
361
|
+
}, t('failIssueBig')) : null,
|
|
362
|
+
])
|
|
363
|
+
}
|
|
333
364
|
if (kind === 'registryVersionStale') {
|
|
334
365
|
// 要装的版本在「实际执行安装的那个源」里查不到(ERR_PNPM_NO_MATCHING_VERSION / ETARGET):
|
|
335
366
|
// 常见于镜像滞后 —— 实际安装沿用本机 ~/.npmrc 的源;
|
|
@@ -385,22 +385,32 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
|
|
|
385
385
|
// 与失败记录一致的布局:类型徽标 + 仓库超链接 + 顶部隐蔽复制按钮
|
|
386
386
|
h('div', { className: styles.failRow },
|
|
387
387
|
h('div', { className: styles.failHead },
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
388
|
+
// 跳官网「插件详情」页;文字里直接给出目标地址(去掉协议),点前就知道会去哪
|
|
389
|
+
repo ? (() => {
|
|
390
|
+
const url = pluginSiteUrl(repo)
|
|
391
|
+
return h('a', {
|
|
392
|
+
className: styles.failRepo,
|
|
393
|
+
href: url,
|
|
394
|
+
target: '_blank',
|
|
395
|
+
rel: 'noopener noreferrer',
|
|
396
|
+
title: url,
|
|
397
|
+
},
|
|
398
|
+
h('span', { className: styles.failRepoLabel }, `${t('detailTitle')} · `),
|
|
399
|
+
h('span', { className: styles.failRepoUrl }, url.replace(/^https?:\/\//, '').split('?')[0]),
|
|
400
|
+
)
|
|
401
|
+
})() : null,
|
|
399
402
|
// 复制按钮放最上面、浅灰隐蔽;报错正文不允许鼠标选择复制,只能点这里
|
|
400
403
|
h('button', { className: styles.errorCopySoft, onClick: () => onCopy(copyText) }, t('errorCopy')),
|
|
401
404
|
),
|
|
402
405
|
// 报错信息完整展示(可滚动),比失败记录的预览看得更多
|
|
403
406
|
h('pre', { className: styles.errorBox }, message),
|
|
407
|
+
// 统一失败卡片头部:报错类型 + 一句话原因(各类型的具体指引仍在下面的分支里)
|
|
408
|
+
h('div', { className: styles.failKindTitle }, t(`failKind_${failureKind}`, failureKind === 'hostIncompatible'
|
|
409
|
+
? { version: /incompatible with dsh\s+([^\s:;,]+)/i.exec(message)?.[1] ?? '' }
|
|
410
|
+
: undefined)),
|
|
411
|
+
failureKind === 'hostIncompatible'
|
|
412
|
+
? null
|
|
413
|
+
: h('div', { className: styles.failReason }, t(`failReason_${failureKind}`)),
|
|
404
414
|
// npm 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题 ——
|
|
405
415
|
// 直接给升级指引,不引导提 Issue(提了也是 npm 的问题,插件作者无法修复)
|
|
406
416
|
failureKind === 'npmTooOld'
|
|
@@ -465,6 +475,25 @@ export function ErrorModal({ message, repo, action, command, attempts, t, env, o
|
|
|
465
475
|
title: t('failIssueHint'),
|
|
466
476
|
}, t('failIssueBig')) : null,
|
|
467
477
|
])
|
|
478
|
+
: failureKind === 'hostIncompatible'
|
|
479
|
+
// 宿主版本门禁:插件声明的兼容范围不含当前 dsh 版本,安装前就被宿主拒绝。
|
|
480
|
+
// 这是作者能修的(把该版本加进兼容清单),故直接引导到作者仓库;同时说明本地可临时授权
|
|
481
|
+
? h('div', null, [
|
|
482
|
+
(() => {
|
|
483
|
+
const version = /incompatible with dsh\s+([^\s:;,]+)/i.exec(message)?.[1] ?? ''
|
|
484
|
+
return h('div', null, [
|
|
485
|
+
h('div', { className: styles.failReason }, t('failHostIncompatibleReason', { version })),
|
|
486
|
+
h('div', { className: styles.failPrepareHint }, t('failHostIncompatibleHint')),
|
|
487
|
+
])
|
|
488
|
+
})(),
|
|
489
|
+
repo ? h('a', {
|
|
490
|
+
className: styles.failBigIssue,
|
|
491
|
+
href: pluginIssueUrl(repo, message, env, command, attempts, action),
|
|
492
|
+
target: '_blank',
|
|
493
|
+
rel: 'noopener noreferrer',
|
|
494
|
+
title: t('failIssueHint'),
|
|
495
|
+
}, t('failIssueBig')) : null,
|
|
496
|
+
])
|
|
468
497
|
: failureKind === 'registryVersionStale'
|
|
469
498
|
// 要装的版本在「实际执行安装的那个源」里查不到(ERR_PNPM_NO_MATCHING_VERSION / ETARGET):
|
|
470
499
|
// 常见于镜像滞后 —— 实际安装沿用本机 ~/.npmrc 的源,
|
package/src/client/locales.ts
CHANGED
|
@@ -350,6 +350,62 @@ export const zh = {
|
|
|
350
350
|
failRegistryMirrorLagHint: '安装器调用 pnpm 时报「包不存在」(ERR_PNPM_FETCH_404):该包在官方 npm 源上是有的,但你本机配置的 npm/pnpm 源(registry 或镜像)没有同步到它。请把 npm/pnpm 的 registry 指回官方源 https://registry.npmjs.org(或换一个已同步该包的镜像)后重试。这不是插件本身的问题。',
|
|
351
351
|
failPluginMissingDepTarget: '{pkg} 在官方 npm 源上不存在',
|
|
352
352
|
failPluginMissingDepHint: '该插件依赖的包在官方 npm 源上并不存在(已删除或从未发布):这是插件作者侧的依赖问题,不是你环境的问题。请向插件作者仓库反馈,请作者补上或修正该依赖,或改为随插件一起分发。',
|
|
353
|
+
failHostIncompatibleKind: '插件不支持当前的 DSH 版本',
|
|
354
|
+
failHostIncompatibleReason: '当前 DSH {version} 不在该插件的兼容清单里,宿主在安装前就拒绝了 —— 什么都没装。',
|
|
355
|
+
failHostIncompatibleHint: '① 请作者把该版本加入兼容清单后发新版(下方按钮直达作者仓库)\n② 在桌面应用的插件管理器里为这个精确版本授权(宿主会提示崩溃/数据风险)',
|
|
356
|
+
// 统一失败卡片头部:报错类型标题 + 一句话原因(每类各自的详细指引见 fail*Hint)
|
|
357
|
+
failKind_npmTooOld: '本机 npm 版本过低',
|
|
358
|
+
failReason_npmTooOld: '当前 npm 版本处理不了这个包的依赖树,安装被中断。',
|
|
359
|
+
failKind_dshMissing: '本机缺少 dsh',
|
|
360
|
+
failReason_dshMissing: '安装器需要调用 dsh,但在你的机器上找不到它。',
|
|
361
|
+
failKind_gitMissing: '本机缺少 git',
|
|
362
|
+
failReason_gitMissing: '这个包的依赖需要 git,但机器上找不到 git。',
|
|
363
|
+
failKind_pnpmMissing: '本机缺少 pnpm',
|
|
364
|
+
failReason_pnpmMissing: '安装器通过 pnpm 安装插件,但机器上找不到 pnpm。',
|
|
365
|
+
failKind_npmMissing: '本机缺少 npm',
|
|
366
|
+
failReason_npmMissing: '安装流程需要 npm,但机器上找不到它。',
|
|
367
|
+
failKind_pnpmStore: 'pnpm store 版本不匹配',
|
|
368
|
+
failReason_pnpmStore: 'profile 里的依赖由另一大版本的 pnpm 生成,当前 pnpm 不认。',
|
|
369
|
+
failKind_pnpmWorkspace: 'workspace 根校验阻止了安装',
|
|
370
|
+
failReason_pnpmWorkspace: 'profile 被当作 workspace 根,但没有声明根操作,pnpm 中止了安装。',
|
|
371
|
+
failKind_originRejected: '本地服务的来源校验未通过',
|
|
372
|
+
failReason_originRejected: '请求根本没进安装流程:本地服务拒绝了它的来源。',
|
|
373
|
+
failKind_pnpmPolicy: '被 pnpm 供应链策略拦截',
|
|
374
|
+
failReason_pnpmPolicy: '版本未满最小发布年龄,或缺完整性校验 / 来源不可信。',
|
|
375
|
+
failKind_pnpmUnusedPatch: 'profile 残留旧补丁声明',
|
|
376
|
+
failReason_pnpmUnusedPatch: 'profile 里留着指向旧版本 dsh-plugin 的补丁,pnpm 出于安全中止了安装。',
|
|
377
|
+
failKind_pnpmMissingDep: '依赖包在 registry 上不存在',
|
|
378
|
+
failReason_pnpmMissingDep: 'profile 里有一条指向未发布包的依赖,pnpm 每次解析都卡在它上面。',
|
|
379
|
+
failKind_pluginMissingDep: '插件依赖未发布',
|
|
380
|
+
failReason_pluginMissingDep: '插件声明依赖的包在官方 npm 源上不存在(已删除或从未发布)。',
|
|
381
|
+
failKind_registryVersionStale: '该版本在所用源上不存在',
|
|
382
|
+
failReason_registryVersionStale: '实际安装用的源还没同步到这个版本,或版本号写错。',
|
|
383
|
+
failKind_pnpmLinkedDirMissing: 'profile 里有失效的本地依赖',
|
|
384
|
+
failReason_pnpmLinkedDirMissing: '有一条指向本机不存在目录的 link/file 依赖,pnpm 每次解析都卡住。',
|
|
385
|
+
failKind_fileLocked: '文件被其他程序占用',
|
|
386
|
+
failReason_fileLocked: 'pnpm 无法替换 profile 里的文件:有程序正持有它。',
|
|
387
|
+
failKind_accessDenied: '没有写入权限',
|
|
388
|
+
failReason_accessDenied: '系统拒绝 pnpm 写入 profile 里的文件。',
|
|
389
|
+
failKind_fsUnavailable: '磁盘不可写',
|
|
390
|
+
failReason_fsUnavailable: '磁盘已满、处于只读,或文件句柄耗尽。',
|
|
391
|
+
failKind_pnpmIgnoredBuild: '原生模块构建被拦截',
|
|
392
|
+
failReason_pnpmIgnoredBuild: '插件依赖需要在你的机器上现场编译,pnpm 默认拦住构建脚本。',
|
|
393
|
+
failKind_pluginPrepare: '插件分发不完整',
|
|
394
|
+
failReason_pluginPrepare: '作者发布的包里缺文件,或构建脚本执行失败。',
|
|
395
|
+
failKind_hostIncompatible: '插件不支持当前的 DSH 版本({version})',
|
|
396
|
+
failReason_hostIncompatible: '插件声明的兼容清单里没有你正在运行的 DSH 版本。',
|
|
397
|
+
failKind_network: '网络连接失败',
|
|
398
|
+
failReason_network: '安装过程中连不上 npm 源或代理,请求没有完成。',
|
|
399
|
+
failKind_hostCliSpawn: '无法启动 DSH 宿主',
|
|
400
|
+
failReason_hostCliSpawn: '解析到的宿主入口起不来,安装命令没有真正执行。',
|
|
401
|
+
failKind_hubState: '插件中心状态不同步',
|
|
402
|
+
failReason_hubState: '本地记录的安装状态与实际不一致,这次操作没有生效。',
|
|
403
|
+
failKind_timeout: '操作超时',
|
|
404
|
+
failReason_timeout: '安装耗时超过上限仍未完成,已经中止。',
|
|
405
|
+
failKind_profileLock: 'profile 被其他进程占用',
|
|
406
|
+
failReason_profileLock: '另一个 dsh 进程正在使用这个 profile。',
|
|
407
|
+
failKind_hub: '出现未知错误',
|
|
408
|
+
failReason_hub: '我们还没能识别这条报错,你可以一键反馈给我们。',
|
|
353
409
|
failRegistryVersionStaleHint: '安装器调用 pnpm 时报「找不到匹配的版本」(ERR_PNPM_NO_MATCHING_VERSION):要装的这个版本在实际执行安装的 npm 源里查不到。常见原因是你设置的 npmRegistry 与本机 ~/.npmrc 用的不是同一个源——官方源刚发布、镜像还没同步上来(快则几分钟、慢则几小时),也可能只是版本号写错了。这不是插件本身的问题。可在插件市场设置里把 npmRegistry 改成与本机 ~/.npmrc 一致(或反过来),或那一次显式走官方源(https://registry.npmjs.org)、改用离线包后重试;若该版本刚发布不久,等镜像同步后重试即可。',
|
|
354
410
|
failPnpmLinkedDirMissingHint: '安装器调用 pnpm 时报「要安装的目录不存在」(ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND):你的 profile 里留着一条指向本机不存在目录的本地依赖(link: / file: 形式,例如用 dsh plugin add <本地路径> 装过开发中的插件,之后那个目录被删除或移动了)。pnpm 每次解析 profile 依赖都会先卡在它上面,因此任何插件都装不进来。这不是插件本身的问题——请在 profile 目录(~/.dsh/profiles/<profile>)的 package.json 里删除该本地依赖条目(同时删掉对应的 pnpm-lock.yaml 条目或整个锁文件),或把该目录恢复回原路径后重试。',
|
|
355
411
|
failFileLockedHint: '安装过程中 pnpm 无法替换 profile 里的文件:有程序正占用着它(Windows 常见报错 os error 32「另一个程序正在使用此文件」)。通常是 DeepSeek Harness 自身或杀毒软件的实时扫描持有文件句柄。这不是插件本身的问题——请完全退出 DeepSeek Harness(Windows 可在任务管理器确认进程已结束,必要时临时关闭杀毒软件实时防护)后重试。',
|
|
@@ -751,6 +807,62 @@ export const en = {
|
|
|
751
807
|
failRegistryMirrorLagHint: 'pnpm reported that a package does not exist on the npm registry (ERR_PNPM_FETCH_404): that package does exist on the official npm registry, but the npm/pnpm registry (or mirror) configured on your machine has not synced it. Point the npm/pnpm registry back at the official one (https://registry.npmjs.org), or switch to a mirror that has the package, then retry. This is not a problem with the plugin itself.',
|
|
752
808
|
failPluginMissingDepTarget: '{pkg} does not exist on the official npm registry',
|
|
753
809
|
failPluginMissingDepHint: 'A package this plugin depends on does not exist on the official npm registry (removed, or never published). This is a dependency problem on the plugin author side, not a problem with your environment. Please report it to the plugin repository so the author can fix that dependency or ship it together with the plugin.',
|
|
810
|
+
failHostIncompatibleKind: 'Plugin does not support the DSH version in use',
|
|
811
|
+
failHostIncompatibleReason: 'The current DSH ({version}) is not in this plugin\'s compatibility list, so the host refused before installing anything.',
|
|
812
|
+
failHostIncompatibleHint: '① Ask the author to extend the supported dsh versions and publish a release (button below).\n② Grant an exact-version exemption in the plugin manager of the desktop app (the host warns about crash / data-loss risk).',
|
|
813
|
+
// Unified failure card header: kind label + one-line reason (per-kind details stay in fail*Hint)
|
|
814
|
+
failKind_npmTooOld: 'npm on this machine is too old',
|
|
815
|
+
failReason_npmTooOld: 'The installed npm cannot resolve the dependency tree of this package, so it aborted.',
|
|
816
|
+
failKind_dshMissing: 'dsh is not installed on this machine',
|
|
817
|
+
failReason_dshMissing: 'The installer needs to call dsh, but it cannot be found on your machine.',
|
|
818
|
+
failKind_gitMissing: 'git is not installed on this machine',
|
|
819
|
+
failReason_gitMissing: 'A dependency of this package needs git, but git cannot be found.',
|
|
820
|
+
failKind_pnpmMissing: 'pnpm is not installed on this machine',
|
|
821
|
+
failReason_pnpmMissing: 'The installer uses pnpm to add plugins, but pnpm cannot be found.',
|
|
822
|
+
failKind_npmMissing: 'npm is not installed on this machine',
|
|
823
|
+
failReason_npmMissing: 'The install flow needs npm, but it cannot be found.',
|
|
824
|
+
failKind_pnpmStore: 'pnpm store version mismatch',
|
|
825
|
+
failReason_pnpmStore: 'The profile dependencies were written by a different pnpm major, which the current pnpm does not accept.',
|
|
826
|
+
failKind_pnpmWorkspace: 'workspace-root check blocked the install',
|
|
827
|
+
failReason_pnpmWorkspace: 'The profile is treated as a workspace root without a root operation declared, so pnpm aborted.',
|
|
828
|
+
failKind_originRejected: 'request origin rejected by the local service',
|
|
829
|
+
failReason_originRejected: 'The request never reached the install flow: the local service rejected its origin.',
|
|
830
|
+
failKind_pnpmPolicy: 'blocked by the pnpm supply-chain policy',
|
|
831
|
+
failReason_pnpmPolicy: 'The version is younger than the minimum release age, or an integrity / trust check failed.',
|
|
832
|
+
failKind_pnpmUnusedPatch: 'stale patch entry in the profile',
|
|
833
|
+
failReason_pnpmUnusedPatch: 'The profile keeps a patch for an older dsh-plugin version, so pnpm aborted for safety.',
|
|
834
|
+
failKind_pnpmMissingDep: 'a dependency does not exist on the registry',
|
|
835
|
+
failReason_pnpmMissingDep: 'The profile keeps a dependency on an unpublished package, which pnpm hits on every resolve.',
|
|
836
|
+
failKind_pluginMissingDep: 'plugin dependency is not published',
|
|
837
|
+
failReason_pluginMissingDep: 'A package the plugin depends on does not exist on the official npm registry.',
|
|
838
|
+
failKind_registryVersionStale: 'this version is not available on the registry in use',
|
|
839
|
+
failReason_registryVersionStale: 'The registry that actually performs the install has not synced this version yet, or the version is mistyped.',
|
|
840
|
+
failKind_pnpmLinkedDirMissing: 'dangling local dependency in the profile',
|
|
841
|
+
failReason_pnpmLinkedDirMissing: 'A link/file dependency points at a directory that no longer exists, so pnpm stalls on it.',
|
|
842
|
+
failKind_fileLocked: 'a file is held by another program',
|
|
843
|
+
failReason_fileLocked: 'pnpm could not replace a file inside the profile because another program is holding it.',
|
|
844
|
+
failKind_accessDenied: 'write access denied',
|
|
845
|
+
failReason_accessDenied: 'The system denied pnpm write access to files inside the profile.',
|
|
846
|
+
failKind_fsUnavailable: 'the disk is not writable',
|
|
847
|
+
failReason_fsUnavailable: 'The disk is full or read-only, or file handles are exhausted.',
|
|
848
|
+
failKind_pnpmIgnoredBuild: 'native build scripts blocked',
|
|
849
|
+
failReason_pnpmIgnoredBuild: 'A plugin dependency has to be compiled on your machine, and pnpm blocks build scripts by default.',
|
|
850
|
+
failKind_pluginPrepare: 'plugin distribution is incomplete',
|
|
851
|
+
failReason_pluginPrepare: 'The published package is missing files, or its build script failed.',
|
|
852
|
+
failKind_hostIncompatible: 'plugin does not support the DSH version in use ({version})',
|
|
853
|
+
failReason_hostIncompatible: 'The plugin\'s declared compatibility list does not include the DSH version you are running.',
|
|
854
|
+
failKind_network: 'network connection failed',
|
|
855
|
+
failReason_network: 'The install could not reach the npm registry or the proxy, so the request never completed.',
|
|
856
|
+
failKind_hostCliSpawn: 'the DSH host could not be launched',
|
|
857
|
+
failReason_hostCliSpawn: 'The resolved host entry point would not start, so the install command never ran.',
|
|
858
|
+
failKind_hubState: 'plugin hub state is out of sync',
|
|
859
|
+
failReason_hubState: 'The locally recorded install state does not match reality, so this operation had no effect.',
|
|
860
|
+
failKind_timeout: 'operation timed out',
|
|
861
|
+
failReason_timeout: 'The install exceeded the time limit and was aborted.',
|
|
862
|
+
failKind_profileLock: 'profile is locked by another process',
|
|
863
|
+
failReason_profileLock: 'Another dsh process is using this profile right now.',
|
|
864
|
+
failKind_hub: 'unknown error',
|
|
865
|
+
failReason_hub: 'We could not identify this error yet — you can report it to us in one click.',
|
|
754
866
|
failRegistryVersionStaleHint: 'pnpm reported that no matching version was found (ERR_PNPM_NO_MATCHING_VERSION): the version being installed does not exist in the npm registry that actually performs the install. This usually means the npmRegistry configured in the hub differs from the source in your local ~/.npmrc — the official registry has published it but the mirror has not synced yet (it can take minutes to hours) — or the version number was simply mistyped. This is not a problem with the plugin itself. Align the hub\'s npmRegistry with your local ~/.npmrc (or the other way round), or explicitly use the official registry (https://registry.npmjs.org) for that one install, or use an offline package, then retry; if the version was published only recently, waiting for the mirror to sync is enough.',
|
|
755
867
|
failPnpmLinkedDirMissingHint: 'pnpm reported that a directory it needs to install from does not exist (ERR_PNPM_LINKED_PKG_DIR_NOT_FOUND): your profile keeps a local dependency (a link: / file: entry, e.g. from `dsh plugin add <local path>`) whose target directory has since been deleted or moved. pnpm hits it every time it resolves profile dependencies, so no plugin can be installed into that profile. This is not a problem with the plugin itself. Remove that local dependency from package.json inside the profile directory (~/.dsh/profiles/<profile>) — along with its entry in pnpm-lock.yaml, or the lockfile itself — or restore the directory to its original path, then retry.',
|
|
756
868
|
failFileLockedHint: 'During install, pnpm could not replace a file inside the profile because another program is holding it (on Windows this shows as os error 32, "the process cannot access the file because it is being used by another process"). Usually DeepSeek Harness itself or a real-time antivirus scan holds the handle. This is not a problem with the plugin itself — fully quit DeepSeek Harness (on Windows, confirm in Task Manager that the process has ended, and temporarily disable real-time antivirus protection if needed), then retry.',
|
|
@@ -148,7 +148,7 @@ export function removeNotification(id: number): NotificationRecord[] {
|
|
|
148
148
|
return next
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hostCliSpawn' | 'hubState' | 'timeout' | 'profileLock' | 'hub'
|
|
151
|
+
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'pluginMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'hostIncompatible' | 'network' | 'hostCliSpawn' | 'hubState' | 'timeout' | 'profileLock' | 'hub'
|
|
152
152
|
|
|
153
153
|
/** 失败动作口径:标题、上报正文与提示文案按安装/更新/卸载取词,不写死「安装」。 */
|
|
154
154
|
export type FailureAction = 'install' | 'update' | 'uninstall'
|
|
@@ -208,6 +208,9 @@ export function pnpmPolicyHintParams(message: string): Record<string, string> {
|
|
|
208
208
|
* (只影响这个插件 → 才引导提 Issue)。各分支判定顺序有依赖,见函数内注释。
|
|
209
209
|
*/
|
|
210
210
|
export function classifyFailure(message: string): FailureKind {
|
|
211
|
+
// 宿主版本门禁:插件声明的兼容范围不含当前 dsh 版本,宿主在安装前直接拒绝。
|
|
212
|
+
// 必须最先判 —— 这是宿主自己给的结论,不是 pnpm 的安装失败
|
|
213
|
+
if (/installation rejected:.*incompatible with dsh/i.test(message)) return 'hostIncompatible'
|
|
211
214
|
// npm 自身崩溃(edgesOut)或服务端标记的本机 npm 版本过低。必须最先判,否则会被外层
|
|
212
215
|
// ERR_PNPM_PREPARE_PACKAGE 吞成「插件打包问题」
|
|
213
216
|
if (/\[npm-too-low\]|edgesOut/i.test(message)) return 'npmTooOld'
|
|
@@ -288,7 +291,7 @@ export function npmTooLowVersion(message: string): string | null {
|
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
/** 核心行特征:错误代码 / 生命周期脚本失败 / prepare 失败 / 描述性报错(子模块缺失、找不到等)/ 退出与宿主提示信息。 */
|
|
291
|
-
const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed to prepare|exit code|\bprepare\b|pnpm failed in profile|git-hosted plugins build|submodule|not found|cannot find|no such|unable to|fatal|missing|error/i
|
|
294
|
+
const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed to prepare|exit code|\bprepare\b|pnpm failed in profile|git-hosted plugins build|incompatible with dsh|submodule|not found|cannot find|no such|unable to|fatal|missing|error/i
|
|
292
295
|
/** pnpm 的 peer 依赖告警行(`missing peer …` / `Issues with peer dependencies found` /
|
|
293
296
|
* `Peer dependencies that should be installed`):宿主提供的 peer(@deepseek-ai/*、react、
|
|
294
297
|
* dsh-client-* 等,DSH profile 用 autoInstallPeers:false 不自动装)缺失是无害噪音,与插件本身
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -61,6 +61,7 @@ function reasonTitleOf(kind: FailureKind, action: FailureAction): string {
|
|
|
61
61
|
case 'fsUnavailable': return 'local file system unavailable on the user machine'
|
|
62
62
|
case 'pnpmIgnoredBuild': return 'build scripts blocked by pnpm allowlist'
|
|
63
63
|
case 'pluginPrepare': return 'plugin distribution incomplete'
|
|
64
|
+
case 'hostIncompatible': return 'plugin does not support the DSH version in use'
|
|
64
65
|
case 'network': return 'network failure on the user side'
|
|
65
66
|
case 'hostCliSpawn': return 'the DSH host could not be launched on the user machine'
|
|
66
67
|
case 'hubState': return `stale ${word} state in the plugin hub`
|
|
@@ -91,7 +92,9 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
91
92
|
const zhWord = actionWordZh(action)
|
|
92
93
|
// 标题 = 错误原因(非动作词),让 issue 列表一眼可分流
|
|
93
94
|
const title = `[dsh-plugin.org | dsh-plugin-hub] ${reasonTitleOf(kind, action)}: ${repo}`
|
|
94
|
-
const reason =
|
|
95
|
+
const reason = kind === 'hostIncompatible'
|
|
96
|
+
? 'the plugin declares a dsh compatibility range that does not include the runtime in use, so the host rejects it before installing anything; please extend the supported dsh versions or publish a compatible release'
|
|
97
|
+
: /\[packaging\]/i.test(message)
|
|
95
98
|
? 'plugin distribution is incomplete — a file the package needs (its entry from main / exports, an entry a bundled patch mounts, or the patch declared by dsh.bundle.patch) is missing from the published npm package; please publish a complete package'
|
|
96
99
|
: /\[registry-missing\]/i.test(message)
|
|
97
100
|
// 服务端用官方 registry 复核过:缺的包在官方源上也不存在,且不是用户 profile 的残留依赖
|
|
@@ -559,7 +559,7 @@
|
|
|
559
559
|
/* 错误信息正文:等宽字体 + 可滚动 + 保留换行,完整展示服务端报错 */
|
|
560
560
|
.errorBox {
|
|
561
561
|
margin: 0;
|
|
562
|
-
max-height:
|
|
562
|
+
max-height: 72px;
|
|
563
563
|
overflow: auto;
|
|
564
564
|
padding: 10px 12px;
|
|
565
565
|
border: 1px solid var(--hub-danger-border);
|
|
@@ -1139,6 +1139,7 @@
|
|
|
1139
1139
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1140
1140
|
font-size: 12px;
|
|
1141
1141
|
line-height: 18px;
|
|
1142
|
+
/* 来源参数 ?ref=hub 只保留在 href 里,不显示 */
|
|
1142
1143
|
color: var(--hub-brand);
|
|
1143
1144
|
text-decoration: none;
|
|
1144
1145
|
cursor: pointer;
|
|
@@ -1146,6 +1147,21 @@
|
|
|
1146
1147
|
}
|
|
1147
1148
|
|
|
1148
1149
|
.failRepo:hover {
|
|
1150
|
+
text-decoration: none;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/* 只有真正的超链接用蓝色(同类名也用于纯文本的仓库名/更新记录) */
|
|
1154
|
+
a.failRepo {
|
|
1155
|
+
color: #2f6feb;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/* 链接里的「插件详情」前缀保持正文色,只有后面的网址是蓝色 */
|
|
1159
|
+
.failRepoLabel {
|
|
1160
|
+
color: var(--hub-text-secondary);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/* 下划线只加在网址上,前缀不跟着变成链接样式 */
|
|
1164
|
+
a.failRepo:hover .failRepoUrl {
|
|
1149
1165
|
text-decoration: underline;
|
|
1150
1166
|
}
|
|
1151
1167
|
|
|
@@ -1226,12 +1242,37 @@
|
|
|
1226
1242
|
box-shadow: 0 3px 10px rgba(209, 36, 47, 0.35);
|
|
1227
1243
|
}
|
|
1228
1244
|
|
|
1245
|
+
/* 失败主因分三层:标题(报错类型)→ 一句话原因 → 操作指引,视觉上逐层递减 */
|
|
1246
|
+
.failKindTitle {
|
|
1247
|
+
margin-top: 10px;
|
|
1248
|
+
font-size: 15px;
|
|
1249
|
+
font-weight: 700;
|
|
1250
|
+
line-height: 22px;
|
|
1251
|
+
text-align: left;
|
|
1252
|
+
color: var(--hub-danger-text);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
.failReason {
|
|
1256
|
+
margin-top: 2px;
|
|
1257
|
+
font-size: 12.5px;
|
|
1258
|
+
font-weight: 400;
|
|
1259
|
+
line-height: 19px;
|
|
1260
|
+
white-space: pre-line;
|
|
1261
|
+
text-align: left;
|
|
1262
|
+
color: var(--hub-text-secondary);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1229
1265
|
/* 插件 prepare 构建脚本失败说明:位于提 Issue 按钮上方的补充说明,同琥珀警示样式 */
|
|
1230
1266
|
.failPrepareHint {
|
|
1231
1267
|
margin-top: 6px;
|
|
1268
|
+
/* 与下方的报错框 / 红色按钮留出呼吸空间 */
|
|
1269
|
+
margin-bottom: 12px;
|
|
1232
1270
|
padding: 8px 12px;
|
|
1233
1271
|
font-size: 12px;
|
|
1234
1272
|
line-height: 18px;
|
|
1273
|
+
/* 提示里的 \n 按行渲染:多步骤指引一条一行,读起来不用在一段长文里找 */
|
|
1274
|
+
white-space: pre-line;
|
|
1275
|
+
text-align: left;
|
|
1235
1276
|
color: var(--hub-warning);
|
|
1236
1277
|
background: var(--hub-warning-tint);
|
|
1237
1278
|
border: 1px solid var(--hub-warning-border);
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
|
|
11
11
|
* 不改变原有失败处理。
|
|
12
12
|
*/
|
|
13
|
-
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
14
15
|
import { CAPTURE_LIMIT_BYTES } from './install-types.ts'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -44,3 +45,48 @@ export function backfillOperationLog(output: string): string | null {
|
|
|
44
45
|
return null
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 运行期抓取:宿主的 operation 日志只在命令运行期间存在,结束后可能被清理,
|
|
51
|
+
* 退出后再按路径读往往已经拿不到。这里在安装过程中轮询 profile 下最新的
|
|
52
|
+
* `.plugin-manager/logs/<operation>/pnpm.log`,用抓到的内容兜底。
|
|
53
|
+
* 只认本次运行产生的新文件(mtime 晚于启动基线),避免复述上一次安装的日志。
|
|
54
|
+
*/
|
|
55
|
+
export function createOperationLogTap(profileDir: string): { stop: () => string } {
|
|
56
|
+
const logsDir = join(profileDir, '.plugin-manager', 'logs')
|
|
57
|
+
const newestLog = (): { file: string; mtimeMs: number } | null => {
|
|
58
|
+
let newest: { file: string; mtimeMs: number } | null = null
|
|
59
|
+
for (const entry of readdirSync(logsDir, { withFileTypes: true })) {
|
|
60
|
+
if (!entry.isDirectory()) continue
|
|
61
|
+
const file = join(logsDir, entry.name, 'pnpm.log')
|
|
62
|
+
try {
|
|
63
|
+
const mtimeMs = statSync(file).mtimeMs
|
|
64
|
+
if (newest === null || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs }
|
|
65
|
+
} catch { /* 该 operation 还没有 pnpm.log */ }
|
|
66
|
+
}
|
|
67
|
+
return newest
|
|
68
|
+
}
|
|
69
|
+
let baselineMs = 0
|
|
70
|
+
try {
|
|
71
|
+
baselineMs = newestLog()?.mtimeMs ?? 0
|
|
72
|
+
} catch { /* logs 目录还不存在 */ }
|
|
73
|
+
let captured = ''
|
|
74
|
+
const tick = (): void => {
|
|
75
|
+
try {
|
|
76
|
+
const newest = newestLog()
|
|
77
|
+
if (newest === null || newest.mtimeMs <= baselineMs) return
|
|
78
|
+
const text = readFileSync(newest.file, 'utf8')
|
|
79
|
+
if (text.length > captured.length) captured = text
|
|
80
|
+
} catch { /* 读不到就放弃这一轮 */ }
|
|
81
|
+
}
|
|
82
|
+
const timer = setInterval(tick, 700)
|
|
83
|
+
timer.unref()
|
|
84
|
+
tick()
|
|
85
|
+
return {
|
|
86
|
+
stop: (): string => {
|
|
87
|
+
clearInterval(timer)
|
|
88
|
+
tick()
|
|
89
|
+
return captured.length > CAPTURE_LIMIT_BYTES ? captured.slice(-CAPTURE_LIMIT_BYTES) : captured
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|