dsh-plugin 1.6.6 → 1.6.8
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 +299 -294
- package/client/client.js.map +1 -1
- package/lib/http/install.js +10 -11
- package/lib/services/install/operation-log.js +49 -0
- package/lib/services/install/package-entry.js +59 -8
- package/lib/services/install/preflight.js +47 -17
- package/lib/services/install/task-queue.js +47 -15
- package/lib/types/services/install/operation-log.d.ts +11 -0
- package/lib/types/services/install/package-entry.d.ts +42 -10
- package/lib/types/services/install/preflight.d.ts +9 -1
- package/lib/types/services/install/task-queue.d.ts +6 -4
- package/package.json +1 -1
- package/src/client/components/modals/NotificationsModal.tsx +6 -0
- package/src/client/components/modals/modals.tsx +5 -0
- package/src/client/locales.ts +8 -4
- package/src/client/logic/failures.ts +7 -1
- package/src/client/logic/urls.ts +4 -3
- package/src/server/http/install.ts +9 -11
- package/src/server/services/install/operation-log.ts +46 -0
- package/src/server/services/install/package-entry.ts +72 -9
- package/src/server/services/install/preflight.ts +47 -18
- package/src/server/services/install/task-queue.ts +43 -18
|
@@ -151,7 +151,7 @@ export function removeNotification(id: number): NotificationRecord[] {
|
|
|
151
151
|
return next
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hub'
|
|
154
|
+
export type FailureKind = 'npmTooOld' | 'dshMissing' | 'gitMissing' | 'pnpmMissing' | 'npmMissing' | 'pnpmStore' | 'pnpmWorkspace' | 'originRejected' | 'pnpmPolicy' | 'pnpmUnusedPatch' | 'pnpmMissingDep' | 'registryVersionStale' | 'pnpmLinkedDirMissing' | 'fileLocked' | 'accessDenied' | 'fsUnavailable' | 'pnpmIgnoredBuild' | 'pluginPrepare' | 'network' | 'hubState' | 'hub'
|
|
155
155
|
|
|
156
156
|
/** Match the pnpm error code, not a generic mention of checksum or integrity. */
|
|
157
157
|
function missingTarballIntegrity(message: string): boolean {
|
|
@@ -238,6 +238,12 @@ export function classifyFailure(message: string): FailureKind {
|
|
|
238
238
|
// 构建脚本真的跑挂了才算插件问题。Command failed 区分大小写:只匹配 pnpm 的包装,
|
|
239
239
|
// 不匹配宿主那句小写 dsh: plugin command failed
|
|
240
240
|
if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|prepare-guard/i.test(message) || /Command failed/.test(message)) return 'pluginPrepare'
|
|
241
|
+
// 安装器自身产生的状态类消息(未安装 / 不在此处卸载 / 已在队列中 / 已安装):这几句由本服务的
|
|
242
|
+
// 守卫发出,不是 CLI 输出、也不是插件问题,而是插件中心的已安装列表与实读快照不同步。
|
|
243
|
+
// 单独归类,让前端给「刷新后重试」这类可行动提示,而不是当作未知失败引导去提 Issue。
|
|
244
|
+
// already installed 只认「整行就是它」的短形态(旧版本守卫留下的历史记录);带「——」说明的
|
|
245
|
+
// 包名冲突另有处置,不能按状态不同步提示。
|
|
246
|
+
if (/plugin is not installed|plugin cannot be uninstalled here|already queued:|^already installed: \S+$/im.test(message)) return 'hubState'
|
|
241
247
|
// 其余未知失败:引导到插件中心仓库排查
|
|
242
248
|
return 'hub'
|
|
243
249
|
}
|
package/src/client/logic/urls.ts
CHANGED
|
@@ -63,6 +63,7 @@ function reasonTitleOf(kind: FailureKind): string {
|
|
|
63
63
|
case 'pnpmIgnoredBuild': return 'build scripts blocked by pnpm allowlist'
|
|
64
64
|
case 'pluginPrepare': return 'plugin distribution incomplete'
|
|
65
65
|
case 'network': return 'network failure on the user side'
|
|
66
|
+
case 'hubState': return 'stale install state in the plugin hub'
|
|
66
67
|
case 'hub': return 'install failed (cause unclassified)'
|
|
67
68
|
default: return 'plugin install failed'
|
|
68
69
|
}
|
|
@@ -80,13 +81,13 @@ function reasonTitleOf(kind: FailureKind): string {
|
|
|
80
81
|
export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
|
|
81
82
|
// 原因判定:按失败类型归类,便于作者判断问题是否出在插件侧。
|
|
82
83
|
// [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整
|
|
83
|
-
// (package.json
|
|
84
|
+
// (package.json 声明的文件在发布物里缺失),作者照此补齐即可。
|
|
84
85
|
const kind = classifyFailure(message)
|
|
85
86
|
const executed = executedCommandOf(command, attempts)
|
|
86
87
|
// 标题 = 错误原因(非动作词),让 issue 列表一眼可分流
|
|
87
88
|
const title = `[dsh-plugin.org | dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}`
|
|
88
89
|
const reason = /\[packaging\]/i.test(message)
|
|
89
|
-
? 'plugin distribution is incomplete — the entry
|
|
90
|
+
? '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 package (github tarball or npm package); please commit build output or publish a complete package'
|
|
90
91
|
: kind === 'network'
|
|
91
92
|
? 'network connectivity issue on the user side (DNS, proxy, firewall, or a blocked connection)'
|
|
92
93
|
: kind === 'pluginPrepare'
|
|
@@ -132,7 +133,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
|
|
|
132
133
|
? [
|
|
133
134
|
'',
|
|
134
135
|
'## 安装方式说明',
|
|
135
|
-
'DSH 插件支持三种官方安装通道:`dsh plugin add <npm-package>`(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download/<tag>/<asset>.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare
|
|
136
|
+
'DSH 插件支持三种官方安装通道:`dsh plugin add <npm-package>`(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download/<tag>/<asset>.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare`,请勿依赖它生成产物。当前插件的分发物缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。',
|
|
136
137
|
]
|
|
137
138
|
: []),
|
|
138
139
|
'',
|
|
@@ -206,16 +206,14 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
|
|
|
206
206
|
return
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
209
|
+
// 命中已安装目标时一律按「更新」语义执行(pnpm add 对已存在依赖本就是原位覆盖重装),
|
|
210
|
+
// 不再抛 409:客户端的已装列表与服务端实读可能不同步(如页面列表滞后),此时用户点
|
|
211
|
+
// 「安装」会撞上重复安装守卫,直接覆盖重装比让用户看到一句看不懂的 already installed
|
|
212
|
+
// 报错友好,最终状态也一致(都是已安装)。命中判定双通道:npm 包名按 dependencies
|
|
213
|
+
// 键直接命中,GitHub 目标按仓库身份匹配。
|
|
213
214
|
const installed = readInstalled(profile)
|
|
214
215
|
const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null)
|
|
215
|
-
if (already !== null
|
|
216
|
-
sendJson(response, 409, { error: `already installed: ${already}` })
|
|
217
|
-
return
|
|
218
|
-
}
|
|
216
|
+
if (already !== null) mode = 'update'
|
|
219
217
|
// 队列级去重:同一目标已在排队/执行中时拒绝,避免客户端防重竞态导致重复入队
|
|
220
218
|
if (hasQueuedTarget(target)) {
|
|
221
219
|
sendJson(response, 409, { error: `already queued: ${target}` })
|
|
@@ -268,17 +266,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri
|
|
|
268
266
|
return
|
|
269
267
|
}
|
|
270
268
|
}
|
|
271
|
-
// 安装前预检:仅新安装走预检(github
|
|
269
|
+
// 安装前预检:仅新安装走预检(github 源分发缺 package.json 声明的文件时直接拦截)。
|
|
272
270
|
// 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
|
|
273
271
|
// npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
|
|
274
272
|
// 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
|
|
275
273
|
if (already === null) {
|
|
276
274
|
const preflight = await preflightTarget(target)
|
|
277
275
|
if (!preflight.ok) {
|
|
278
|
-
// 预检拦截(git
|
|
276
|
+
// 预检拦截(git 分发缺作者声明的文件):同步 400 返回,附上已尝试的安装方式,
|
|
279
277
|
// 客户端失败弹窗提 Issue 时一并贴给作者
|
|
280
278
|
sendJson(response, 400, {
|
|
281
|
-
error: `[packaging] ${target}: plugin distribution is incomplete — the
|
|
279
|
+
error: `[packaging] ${target}: plugin distribution is incomplete — the file ${preflight.missing ?? '?'} the package needs (its declared entry, an entry a bundled patch mounts, or the patch itself) is not present in the git distribution (build output not committed). Report to the author.`,
|
|
282
280
|
attempts,
|
|
283
281
|
})
|
|
284
282
|
return
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
|
|
3
|
+
* Website: https://dsh-plugin.org
|
|
4
|
+
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
|
+
*
|
|
6
|
+
* 失败回捞:DSH 有时只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)
|
|
7
|
+
* 回显到控制台,真正的 pnpm 报错写进该路径的 operation 日志。这里从输出里解析出该路径
|
|
8
|
+
* 并读取其尾部,补进失败信息 —— 否则前端拿不到任何错误特征,只能把失败归为「未知」。
|
|
9
|
+
* 只允许读 DSH 自己的 operation 日志(路径含 .plugin-manager 且以 pnpm.log 结尾),
|
|
10
|
+
* 防止插件把任意路径打印进日志造成越权读取;任何异常都静默降级(返回 null),
|
|
11
|
+
* 不改变原有失败处理。
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { CAPTURE_LIMIT_BYTES } from './install-types.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从 DSH 输出里解析 operation 日志路径(`diagnostics: <path>`)。
|
|
18
|
+
* 只认 DSH 的 operation 日志:路径含 `.plugin-manager` 且以 `pnpm.log` 结尾;
|
|
19
|
+
* 不是这种路径(或压根没有该行)返回 null,调用方据此跳过回捞。
|
|
20
|
+
*/
|
|
21
|
+
export function operationLogPathOf(output: string): string | null {
|
|
22
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
23
|
+
const m = /diagnostics:\s*"?(.+?)"?\s*$/i.exec(raw.trim())
|
|
24
|
+
if (m === null) continue
|
|
25
|
+
const path = m[1].trim()
|
|
26
|
+
if (!path.includes('.plugin-manager') || !path.toLowerCase().endsWith('pnpm.log')) continue
|
|
27
|
+
return path
|
|
28
|
+
}
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 回捞 operation 日志尾部,作为失败详情返回;拿不到(无路径 / 文件不存在 / 读失败 /
|
|
34
|
+
* 内容为空)返回 null。尾部截到与子进程输出相同的上限,避免超大日志撑爆内存与消息。
|
|
35
|
+
*/
|
|
36
|
+
export function backfillOperationLog(output: string): string | null {
|
|
37
|
+
const path = operationLogPathOf(output)
|
|
38
|
+
if (path === null) return null
|
|
39
|
+
try {
|
|
40
|
+
const text = readFileSync(path, 'utf8').trim()
|
|
41
|
+
if (text === '') return null
|
|
42
|
+
return text.length > CAPTURE_LIMIT_BYTES ? text.slice(-CAPTURE_LIMIT_BYTES) : text
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DSH
|
|
2
|
+
* DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口
|
|
7
|
+
* (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检)
|
|
8
|
+
* 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。
|
|
9
|
+
*
|
|
10
|
+
* 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的
|
|
11
|
+
* 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its
|
|
12
|
+
* JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的
|
|
13
|
+
* 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。
|
|
14
|
+
*
|
|
15
|
+
* 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
|
|
16
|
+
* `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
|
|
17
|
+
* 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
20
|
/** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
|
|
12
21
|
const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'] as const
|
|
13
22
|
|
|
23
|
+
/** 解析所需的 manifest 字段;其余字段不读。 */
|
|
24
|
+
export interface PackageManifest {
|
|
25
|
+
name?: unknown
|
|
26
|
+
main?: unknown
|
|
27
|
+
exports?: unknown
|
|
28
|
+
dsh?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
14
31
|
/**
|
|
15
|
-
*
|
|
32
|
+
* 解析作者声明的入口文件(相对包根,不带 `./` 前缀)。manifest 未声明入口时返回 null:
|
|
33
|
+
* 没有声明就没有可缺失的入口,是否可 import 交给真实安装与宿主加载去暴露。
|
|
16
34
|
*
|
|
17
35
|
* Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`),
|
|
18
36
|
* 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象
|
|
19
37
|
* (`{ "import": ..., "require": ... }`)按 default → import → require 取第一
|
|
20
|
-
* 个字符串值。无 exports 时回退 main
|
|
38
|
+
* 个字符串值。无 exports 时回退 main;两者都没有则没有入口。
|
|
21
39
|
*/
|
|
22
|
-
export function resolvePackageEntry(meta:
|
|
40
|
+
export function resolvePackageEntry(meta: PackageManifest): string | null {
|
|
23
41
|
const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
|
|
24
42
|
const fromExports = typeof dot === 'string'
|
|
25
43
|
? dot
|
|
@@ -28,8 +46,51 @@ export function resolvePackageEntry(meta: { main?: unknown; exports?: unknown })
|
|
|
28
46
|
: undefined
|
|
29
47
|
const candidate = typeof fromExports === 'string' ? fromExports
|
|
30
48
|
: typeof meta.main === 'string' ? meta.main
|
|
31
|
-
:
|
|
32
|
-
return candidate
|
|
49
|
+
: null
|
|
50
|
+
return candidate === null ? null : stripDotSlash(candidate)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。
|
|
55
|
+
* 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时
|
|
56
|
+
* bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。
|
|
57
|
+
*/
|
|
58
|
+
export function resolveBundlePatchFiles(meta: PackageManifest): string[] {
|
|
59
|
+
const patch = (meta.dsh as { bundle?: { patch?: unknown } } | undefined)?.bundle?.patch
|
|
60
|
+
const declared = typeof patch === 'string' ? [patch] : Array.isArray(patch) ? patch : []
|
|
61
|
+
return declared
|
|
62
|
+
.filter((file): file is string => typeof file === 'string' && file !== '')
|
|
63
|
+
.map(stripDotSlash)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。
|
|
67
|
+
* manifest 没声明入口时才看补丁:补丁按本包名 mount 自己(下面 mountsItself),
|
|
68
|
+
* 宿主就会 import 本包,Node 的旧式解析落到 index.js —— 那个入口同样必须有。 */
|
|
69
|
+
export function requiredDistributionFiles(meta: PackageManifest, patchTexts: string[] = []): string[] {
|
|
70
|
+
const declared = resolvePackageEntry(meta)
|
|
71
|
+
const entry = declared ?? (mountsItself(meta, patchTexts) ? 'index.js' : null)
|
|
72
|
+
return [...(entry === null ? [] : [entry]), ...resolveBundlePatchFiles(meta)]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 补丁文本里插件行的 `name:` 值(DSH 补丁按行书写:`- name: <pkg>` / ` name: <pkg>`)。
|
|
77
|
+
* 刻意不做 YAML 解析:只认这一种行形状,认不出来就不判定 —— 漏判只是少一条要求,
|
|
78
|
+
* 误判会拦下能装的包,两边的代价不对称。
|
|
79
|
+
*/
|
|
80
|
+
export function patchRowNamesOf(text: string): string[] {
|
|
81
|
+
return text
|
|
82
|
+
.split(/\r?\n/)
|
|
83
|
+
.map((line) => /^\s*(?:-\s*)?name:\s*(.+?)\s*$/u.exec(line)?.[1])
|
|
84
|
+
.filter((value): value is string => value !== undefined)
|
|
85
|
+
.map((value) => value.replace(/^(['"])(.*)\1$/u, '$2'))
|
|
86
|
+
.filter((value) => value !== '')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 补丁是否把本包自己 mount 进树(dsh 文档里组合包的常规写法),是则宿主会 import 本包。 */
|
|
90
|
+
export function mountsItself(meta: PackageManifest, patchTexts: string[]): boolean {
|
|
91
|
+
const name = meta.name
|
|
92
|
+
if (typeof name !== 'string' || name === '') return false
|
|
93
|
+
return patchTexts.some((text) => patchRowNamesOf(text).includes(name))
|
|
33
94
|
}
|
|
34
95
|
|
|
35
96
|
function firstStringOf(conditions: Record<string, unknown>): string | undefined {
|
|
@@ -39,3 +100,5 @@ function firstStringOf(conditions: Record<string, unknown>): string | undefined
|
|
|
39
100
|
}
|
|
40
101
|
return undefined
|
|
41
102
|
}
|
|
103
|
+
|
|
104
|
+
const stripDotSlash = (file: string): string => file.replace(/^\.\//u, '')
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
* 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含
|
|
7
|
+
* package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交
|
|
8
|
+
* 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND
|
|
9
|
+
* 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
|
|
10
10
|
* npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
|
|
11
11
|
* 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
|
|
12
12
|
*/
|
|
@@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises'
|
|
|
19
19
|
import { promisify } from 'node:util'
|
|
20
20
|
import { githubRefOf, githubRepoOf } from '../profile/profile.ts'
|
|
21
21
|
import { githubReleaseTarget } from './release-target.ts'
|
|
22
|
-
import {
|
|
22
|
+
import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.ts'
|
|
23
23
|
|
|
24
24
|
const execFileAsync = promisify(execFile)
|
|
25
25
|
|
|
@@ -27,7 +27,8 @@ const execFileAsync = promisify(execFile)
|
|
|
27
27
|
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
|
|
28
28
|
const PREFLIGHT_TIMEOUT_MS = 15_000
|
|
29
29
|
|
|
30
|
-
/** 预检结果:ok=false
|
|
30
|
+
/** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁),
|
|
31
|
+
* missing 为该文件在包内的相对路径;
|
|
31
32
|
* name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null),
|
|
32
33
|
* 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车,
|
|
33
34
|
* 需要把晦涩的 CLI 报错转成明确的拦截。 */
|
|
@@ -57,9 +58,17 @@ export async function preflightTarget(target: string): Promise<PreflightResult>
|
|
|
57
58
|
const rootPrefix = rootPackage.slice(0, -'package.json'.length)
|
|
58
59
|
const meta = await readTarJson(tar, rootPackage)
|
|
59
60
|
if (meta === null) return { ok: true, missing: null, name: null }
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口,
|
|
62
|
+
// 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己,
|
|
63
|
+
// 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。
|
|
64
|
+
const patchTexts: string[] = []
|
|
65
|
+
for (const file of resolveBundlePatchFiles(meta)) {
|
|
66
|
+
const text = await readTarText(tar, `${rootPrefix}${file}`)
|
|
67
|
+
if (text !== null) patchTexts.push(text)
|
|
68
|
+
}
|
|
69
|
+
const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`)
|
|
70
|
+
const missing = missingDistributionFileOfTarList(await tarListing(tar), required)
|
|
71
|
+
return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null }
|
|
63
72
|
} catch {
|
|
64
73
|
return { ok: true, missing: null, name: null }
|
|
65
74
|
} finally {
|
|
@@ -125,31 +134,51 @@ export function rootPackageEntryOfTarList(listing: string): string | null {
|
|
|
125
134
|
return candidates.length === 1 ? candidates[0] : null
|
|
126
135
|
}
|
|
127
136
|
|
|
137
|
+
/**
|
|
138
|
+
* 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。
|
|
139
|
+
* 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时
|
|
140
|
+
* 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。
|
|
141
|
+
* Exported for a small parser regression test.
|
|
142
|
+
*/
|
|
143
|
+
export function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null {
|
|
144
|
+
if (required.length === 0) return null
|
|
145
|
+
if (listing === null) return required[0]
|
|
146
|
+
const present = new Set(listing.split(/\r?\n/))
|
|
147
|
+
return required.find((file) => !present.has(file)) ?? null
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
async function rootPackageEntry(tar: string): Promise<string | null> {
|
|
151
|
+
const listing = await tarListing(tar)
|
|
152
|
+
return listing === null ? null : rootPackageEntryOfTarList(listing)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */
|
|
156
|
+
async function tarListing(tar: string): Promise<string | null> {
|
|
129
157
|
try {
|
|
130
158
|
const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 })
|
|
131
|
-
return
|
|
159
|
+
return stdout
|
|
132
160
|
} catch {
|
|
133
161
|
return null
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
|
|
137
|
-
/** 从 tarball
|
|
138
|
-
async function
|
|
165
|
+
/** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */
|
|
166
|
+
async function readTarText(tar: string, entry: string): Promise<string | null> {
|
|
139
167
|
try {
|
|
140
168
|
const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 })
|
|
141
|
-
return
|
|
169
|
+
return stdout
|
|
142
170
|
} catch {
|
|
143
171
|
return null
|
|
144
172
|
}
|
|
145
173
|
}
|
|
146
174
|
|
|
147
|
-
/**
|
|
148
|
-
async function
|
|
175
|
+
/** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
|
|
176
|
+
async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
|
|
177
|
+
const text = await readTarText(tar, entry)
|
|
178
|
+
if (text === null) return null
|
|
149
179
|
try {
|
|
150
|
-
|
|
151
|
-
return stdout.split(/\r?\n/).some((line) => line === entry)
|
|
180
|
+
return JSON.parse(text) as Record<string, unknown>
|
|
152
181
|
} catch {
|
|
153
|
-
return
|
|
182
|
+
return null
|
|
154
183
|
}
|
|
155
184
|
}
|
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process'
|
|
11
|
-
import {
|
|
11
|
+
import { requiredDistributionFiles, resolveBundlePatchFiles, type PackageManifest } from './package-entry.ts'
|
|
12
12
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
13
13
|
import { dirname, join, resolve } from 'node:path'
|
|
14
14
|
import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts'
|
|
15
15
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
|
|
16
16
|
import { npmTooLowMarker } from './npm-check.ts'
|
|
17
|
+
import { backfillOperationLog } from './operation-log.ts'
|
|
17
18
|
import { cleanLine, estimateProgress } from '../profile/progress.ts'
|
|
18
19
|
import type { LoaderHandle } from '../loader.ts'
|
|
19
20
|
import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
|
|
@@ -625,10 +626,12 @@ function spawnMutation(options: {
|
|
|
625
626
|
}
|
|
626
627
|
|
|
627
628
|
/**
|
|
628
|
-
*
|
|
629
|
-
* git: 分发常不提交构建产物(lib/ 等),pnpm
|
|
630
|
-
* ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 ——
|
|
631
|
-
*
|
|
629
|
+
* 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
|
|
630
|
+
* dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有
|
|
631
|
+
* 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
|
|
632
|
+
* 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
|
|
633
|
+
* bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
|
|
634
|
+
* 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失文件的路径。
|
|
632
635
|
*/
|
|
633
636
|
export function verifyInstalledEntry(profile: string, target: string): { name: string | null; missing: string | null } {
|
|
634
637
|
let profilePkg: Record<string, unknown> | null = null
|
|
@@ -645,21 +648,29 @@ export function verifyInstalledEntry(profile: string, target: string): { name: s
|
|
|
645
648
|
: deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target)
|
|
646
649
|
if (!name) return { name: null, missing: null }
|
|
647
650
|
const pkgDir = join(profileDirectory(profile), 'node_modules', name)
|
|
648
|
-
let
|
|
651
|
+
let required: string[] = []
|
|
649
652
|
try {
|
|
650
|
-
const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
653
|
+
const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as PackageManifest
|
|
654
|
+
// 补丁按本包名 mount 自己时入口也是必须的,所以先读已装包里的补丁(读不到就当没有)。
|
|
655
|
+
const patchTexts = resolveBundlePatchFiles(meta).flatMap((file) => {
|
|
656
|
+
try {
|
|
657
|
+
return [readFileSync(join(pkgDir, file), 'utf8')]
|
|
658
|
+
} catch {
|
|
659
|
+
return []
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
required = requiredDistributionFiles(meta, patchTexts)
|
|
655
663
|
} catch {
|
|
656
664
|
return { name, missing: null }
|
|
657
665
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
666
|
+
for (const file of required) {
|
|
667
|
+
try {
|
|
668
|
+
if (!statSync(join(pkgDir, file)).isFile()) return { name, missing: file }
|
|
669
|
+
} catch {
|
|
670
|
+
return { name, missing: file }
|
|
671
|
+
}
|
|
662
672
|
}
|
|
673
|
+
return { name, missing: null }
|
|
663
674
|
}
|
|
664
675
|
|
|
665
676
|
/** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */
|
|
@@ -749,21 +760,35 @@ export async function runPluginMutation(options: {
|
|
|
749
760
|
}
|
|
750
761
|
}
|
|
751
762
|
}
|
|
752
|
-
// pnpm
|
|
763
|
+
// pnpm 安装本身成功,但目标包声明分发的文件缺失(git 分发缺构建产物 / 缺声明的补丁)→ 判为失败:
|
|
753
764
|
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
|
|
754
765
|
// 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
|
|
755
766
|
if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
756
767
|
const verified = verifyInstalledEntry(options.profile, options.target)
|
|
757
768
|
if (verified.name !== null && verified.missing !== null) {
|
|
758
769
|
clearPendingRestart(options.displayTarget ?? options.target)
|
|
759
|
-
const detail = `[packaging] ${verified.name}:
|
|
770
|
+
const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)`
|
|
760
771
|
if (options.task && options.task.status !== 'cancelled') {
|
|
761
772
|
options.task.status = 'failed'
|
|
762
773
|
options.task.exitCode = null
|
|
763
774
|
options.task.progress = 0
|
|
764
775
|
pushLine(options.task, detail)
|
|
765
776
|
}
|
|
766
|
-
return { exitCode: 1, timedOut: false, error: `
|
|
777
|
+
return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// DSH 只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台时,
|
|
781
|
+
// 控制台里没有任何错误特征,前端只能把失败归为「未知」(issue #192)。这里按该路径回捞
|
|
782
|
+
// operation 日志尾部补进失败信息:用户直接看到真实报错,自动归类也有据可依。
|
|
783
|
+
// 只在本就不含任何错误码时回捞 —— pnpm 已把报错回显出来时无需多读一次文件。
|
|
784
|
+
if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
785
|
+
const backfilled = backfillOperationLog(`${result.stderr}\n${result.stdout}`)
|
|
786
|
+
if (backfilled !== null) {
|
|
787
|
+
if (options.task && options.task.status !== 'cancelled') {
|
|
788
|
+
pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
|
|
789
|
+
for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
|
|
790
|
+
}
|
|
791
|
+
result = { ...result, stderr: `${result.stderr}\n${backfilled}` }
|
|
767
792
|
}
|
|
768
793
|
}
|
|
769
794
|
// npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因
|