From e6ac19da5b48a81cd673af5c1fdeac2998908a54 Mon Sep 17 00:00:00 2001 From: Ansu <230804140223@stu.nepu.edu.cn> Date: Tue, 13 Oct 2026 15:42:25 +0800 Subject: [PATCH 1/3] fix(install): require only the distribution files a manifest declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bundle that mounts a skill directory declares `dsh.bundle.patch` and no `main` / `exports` at all. The entry check defaulted such a manifest to `index.js` and then demanded that file, so the git channel rejected a complete distribution with "[packaging] … the entry file index.js declared in package.json is not present in the git distribution" and the feedback dialog filed that as a bug on the author's repository (QingYunA/answer-me-with-html#209). A host loads a bundle by reading its manifest and the patch that manifest names, never by importing the package (dsh's bundleManifest is documented as "Read bundle metadata without loading its JavaScript"), and the npm channel already accepts the same shape by design. Both channels now check the files the manifest declares: the entry it names, when it names one, and the bundle patch it declares. --- src/server/http/install.ts | 6 +-- src/server/services/install/package-entry.ts | 52 ++++++++++++++++---- src/server/services/install/preflight.ts | 52 ++++++++++++-------- src/server/services/install/task-queue.ts | 37 +++++++------- tests/package-entry.test.ts | 38 +++++++++++--- tests/preflight.test.ts | 15 +++++- 6 files changed, 141 insertions(+), 59 deletions(-) diff --git a/src/server/http/install.ts b/src/server/http/install.ts index bb74d62..696c2d6 100644 --- a/src/server/http/install.ts +++ b/src/server/http/install.ts @@ -268,17 +268,17 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri return } } - // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。 + // 安装前预检:仅新安装走预检(github 源分发缺 package.json 声明的文件时直接拦截)。 // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。 // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀, // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue if (already === null) { const preflight = await preflightTarget(target) if (!preflight.ok) { - // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式, + // 预检拦截(git 分发缺作者声明的文件):同步 400 返回,附上已尝试的安装方式, // 客户端失败弹窗提 Issue 时一并贴给作者 sendJson(response, 400, { - error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`, + error: `[packaging] ${target}: plugin distribution is incomplete — the file ${preflight.missing ?? '?'} declared in package.json (main / exports / dsh.bundle.patch) is not present in the git distribution (build output not committed). Report to the author.`, attempts, }) return diff --git a/src/server/services/install/package-entry.ts b/src/server/services/install/package-entry.ts index 27109ef..dc3d9d8 100644 --- a/src/server/services/install/package-entry.ts +++ b/src/server/services/install/package-entry.ts @@ -1,25 +1,38 @@ /** - * DSH-Plugin Hub — the community plugin marketplace for DeepSeek Harness. + * DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness. * Website: https://dsh-plugin.org * GitHub: https://github.com/dshplugin/dsh-plugin-hub * - * 包入口解析:从 package.json 的 exports["."] / main 推出用于校验的入口文件。 - * git 通道(tar 预检)与 npm 通道(装后 statSync 校验)共用,保证两条通道 - * 对同一个包判定一致。 + * 分发文件解析:从 package.json 推出装完后必须真实存在的文件 —— 作者声明的入口 + * (exports["."] / main)与 bundle 声明的补丁(dsh.bundle.patch)。git 通道(tar 预检) + * 与 npm 通道(装后 statSync 校验)共用,保证两条通道对同一个包判定一致。 + * + * 这里不替作者补一个 index.js:宿主加载一个 bundle 读的是它的 package.json 和它声明的 + * 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its + * JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的 + * 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。 */ /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */ const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'] as const +/** 解析所需的 manifest 字段;其余字段不读。 */ +export interface PackageManifest { + main?: unknown + exports?: unknown + dsh?: unknown +} + /** - * 解析包的入口文件(相对包根,不带 `./` 前缀)。 + * 解析作者声明的入口文件(相对包根,不带 `./` 前缀)。manifest 未声明入口时返回 null: + * 没有声明就没有可缺失的入口,是否可 import 交给真实安装与宿主加载去暴露。 * * Node 的 exports 规范要求字符串 target 以 `./` 开头(如 `"./lib/index.js"`), * 而 tar 成员名与磁盘路径都不带该前缀,因此统一剥掉;条件对象 * (`{ "import": ..., "require": ... }`)按 default → import → require 取第一 - * 个字符串值。无 exports 时回退 main,再回退 index.js。 + * 个字符串值。无 exports 时回退 main;两者都没有则没有入口。 */ -export function resolvePackageEntry(meta: { main?: unknown; exports?: unknown }): string { +export function resolvePackageEntry(meta: PackageManifest): string | null { const dot = (meta.exports as Record | undefined)?.['.'] const fromExports = typeof dot === 'string' ? dot @@ -28,8 +41,27 @@ export function resolvePackageEntry(meta: { main?: unknown; exports?: unknown }) : undefined const candidate = typeof fromExports === 'string' ? fromExports : typeof meta.main === 'string' ? meta.main - : 'index.js' - return candidate.replace(/^\.\//u, '') + : null + return candidate === null ? null : stripDotSlash(candidate) +} + +/** + * 解析 `dsh.bundle.patch` 声明的补丁文件(相对包根,不带 `./` 前缀)。 + * 形状不合规范(既不是字符串也不是字符串数组)在这里不判定:那是 dsh 启动时 + * bundlePatchFiles 的报错职责,预检只负责「声明了就要在包里存在」。 + */ +export function resolveBundlePatchFiles(meta: PackageManifest): string[] { + const patch = (meta.dsh as { bundle?: { patch?: unknown } } | undefined)?.bundle?.patch + const declared = typeof patch === 'string' ? [patch] : Array.isArray(patch) ? patch : [] + return declared + .filter((file): file is string => typeof file === 'string' && file !== '') + .map(stripDotSlash) +} + +/** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。 */ +export function requiredDistributionFiles(meta: PackageManifest): string[] { + const entry = resolvePackageEntry(meta) + return [...(entry === null ? [] : [entry]), ...resolveBundlePatchFiles(meta)] } function firstStringOf(conditions: Record): string | undefined { @@ -39,3 +71,5 @@ function firstStringOf(conditions: Record): string | undefined } return undefined } + +const stripDotSlash = (file: string): string => file.replace(/^\.\//u, '') diff --git a/src/server/services/install/preflight.ts b/src/server/services/install/preflight.ts index 5fa4ca3..82ba04e 100644 --- a/src/server/services/install/preflight.ts +++ b/src/server/services/install/preflight.ts @@ -3,10 +3,10 @@ * Website: https://dsh-plugin.org * GitHub: https://github.com/dshplugin/dsh-plugin-hub * - * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否 - * 真的包含 package.json 声明的入口文件(main / exports["."].default)。 - * git 分发常不提交构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 - * ERR_MODULE_NOT_FOUND 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。 + * 安装前预检:对 GitHub 源的插件,检查其分发(codeload tarball)里是否真的包含 + * package.json 声明的文件(main / exports["."] / dsh.bundle.patch)。git 分发常不提交 + * 构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时 ERR_MODULE_NOT_FOUND + * 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。 * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后 * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。 */ @@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises' import { promisify } from 'node:util' import { githubRefOf, githubRepoOf } from '../profile/profile.ts' import { githubReleaseTarget } from './release-target.ts' -import { resolvePackageEntry } from './package-entry.ts' +import { requiredDistributionFiles } from './package-entry.ts' const execFileAsync = promisify(execFile) @@ -27,7 +27,8 @@ const execFileAsync = promisify(execFile) * 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */ const PREFLIGHT_TIMEOUT_MS = 15_000 -/** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径; +/** 预检结果:ok=false 表示分发改 package.json 声明的文件(入口或 bundle 补丁), + * missing 为该文件在包内的相对路径; * name 为 git 分发包 package.json 声明的包名(非 git 目标/无法确定时为 null), * 供安装路由做「包名冲突」检测 —— 同名包名已被其他来源占用时,pnpm 装前必然撞车, * 需要把晦涩的 CLI 报错转成明确的拦截。 */ @@ -57,9 +58,11 @@ export async function preflightTarget(target: string): Promise const rootPrefix = rootPackage.slice(0, -'package.json'.length) const meta = await readTarJson(tar, rootPackage) if (meta === null) return { ok: true, missing: null, name: null } - const entry = resolvePackageEntry(meta) - const inTar = await hasTarEntry(tar, `${rootPrefix}${entry}`) - return { ok: inTar, missing: inTar ? null : entry, name: typeof meta.name === 'string' ? meta.name : null } + // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口, + // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定。 + const required = requiredDistributionFiles(meta).map((file) => `${rootPrefix}${file}`) + const missing = missingDistributionFileOfTarList(await tarListing(tar), required) + return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null } } catch { return { ok: true, missing: null, name: null } } finally { @@ -125,10 +128,29 @@ export function rootPackageEntryOfTarList(listing: string): string | null { return candidates.length === 1 ? candidates[0] : null } +/** + * 清单里第一个缺失的必需文件(成员名带包根前缀,由调用方拼好),全部都在则为 null。 + * 清单不可读(tar 缺失/解压失败)时按第一个必需文件缺失处理:与逐文件查询时 + * 「查不到就是缺」的判定一致,宁可拦下一次可疑安装,也不放一个空包过去。 + * Exported for a small parser regression test. + */ +export function missingDistributionFileOfTarList(listing: string | null, required: string[]): string | null { + if (required.length === 0) return null + if (listing === null) return required[0] + const present = new Set(listing.split(/\r?\n/)) + return required.find((file) => !present.has(file)) ?? null +} + async function rootPackageEntry(tar: string): Promise { + const listing = await tarListing(tar) + return listing === null ? null : rootPackageEntryOfTarList(listing) +} + +/** tar 成员清单;`tar` 执行失败时返回 null(调用方按无法判定处理)。 */ +async function tarListing(tar: string): Promise { try { const { stdout } = await execFileAsync('tar', ['-tzf', tar], { maxBuffer: 16 * 1024 * 1024 }) - return rootPackageEntryOfTarList(stdout) + return stdout } catch { return null } @@ -143,13 +165,3 @@ async function readTarJson(tar: string, entry: string): Promise { - try { - const { stdout } = await execFileAsync('tar', ['-tzf', tar, entry], { maxBuffer: 1024 * 1024 }) - return stdout.split(/\r?\n/).some((line) => line === entry) - } catch { - return false - } -} diff --git a/src/server/services/install/task-queue.ts b/src/server/services/install/task-queue.ts index 7395398..5cf6d42 100644 --- a/src/server/services/install/task-queue.ts +++ b/src/server/services/install/task-queue.ts @@ -8,7 +8,7 @@ * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。 */ import { spawn, spawnSync } from 'node:child_process' -import { resolvePackageEntry } from './package-entry.ts' +import { requiredDistributionFiles } from './package-entry.ts' import { existsSync, readFileSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts' @@ -625,10 +625,12 @@ function spawnMutation(options: { } /** - * 安装后校验已装包的入口文件(package.json 的 main / exports["."].default)是否真实存在。 - * git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有报错,但宿主重启加载插件树时会 - * ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在登记「待重启」前就把这类残缺包拦下。 - * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失的入口路径。 + * 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与 + * dsh.bundle.patch)是否真实存在。git: 分发常不提交构建产物(lib/ 等),pnpm 装完没有 + * 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在 + * 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的 + * bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。 + * 返回 { name, missing }:name 为空表示无法定位目标包(跳过校验);missing 为缺失文件的路径。 */ export function verifyInstalledEntry(profile: string, target: string): { name: string | null; missing: string | null } { let profilePkg: Record | null = null @@ -645,21 +647,20 @@ export function verifyInstalledEntry(profile: string, target: string): { name: s : deps[target] !== undefined ? target : Object.keys(deps).find((k) => deps[k] === target) if (!name) return { name: null, missing: null } const pkgDir = join(profileDirectory(profile), 'node_modules', name) - let entry: string | null = null + let required: string[] = [] try { - const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as { - main?: unknown - exports?: unknown - } - entry = resolvePackageEntry(meta) + required = requiredDistributionFiles(JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as Record) } catch { return { name, missing: null } } - try { - return { name, missing: statSync(join(pkgDir, entry)).isFile() ? null : entry } - } catch { - return { name, missing: entry } + for (const file of required) { + try { + if (!statSync(join(pkgDir, file)).isFile()) return { name, missing: file } + } catch { + return { name, missing: file } + } } + return { name, missing: null } } /** 从 pnpm `Ignored build scripts:` 提示行解析被拦截的构建脚本 key(name@version,逗号分隔)。 */ @@ -749,21 +750,21 @@ export async function runPluginMutation(options: { } } } - // pnpm 安装本身成功,但目标包入口文件缺失(git 分发缺构建产物)→ 判为失败: + // pnpm 安装本身成功,但目标包声明分发的文件缺失(git 分发缺构建产物 / 缺声明的补丁)→ 判为失败: // 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。 // 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验 if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) { const verified = verifyInstalledEntry(options.profile, options.target) if (verified.name !== null && verified.missing !== null) { clearPendingRestart(options.displayTarget ?? options.target) - const detail = `[packaging] ${verified.name}: entry file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)` + const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (git distribution lacks build output — install the npm version or report to the author)` if (options.task && options.task.status !== 'cancelled') { options.task.status = 'failed' options.task.exitCode = null options.task.progress = 0 pushLine(options.task, detail) } - return { exitCode: 1, timedOut: false, error: `entry file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` } + return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` } } } // npm arborist edgesOut 崩溃 + 本机 npm 版本过低 → 追加 [npm-too-low] 标记,前端据此给准确原因 diff --git a/tests/package-entry.test.ts b/tests/package-entry.test.ts index 18917ef..f951fb3 100644 --- a/tests/package-entry.test.ts +++ b/tests/package-entry.test.ts @@ -3,11 +3,12 @@ * Website: https://dsh-plugin.org * GitHub: https://github.com/dshplugin/dsh-plugin-hub * - * 回归测试:包入口解析(exports `./` 前缀、条件对象、main 回退)。 + * 回归测试:分发文件解析(exports `./` 前缀、条件对象、main 回退、未声明入口, + * 以及 dsh.bundle.patch 的字符串/数组两种写法)。 */ import { test } from 'node:test' import assert from 'node:assert/strict' -import { resolvePackageEntry } from '../src/server/services/install/package-entry.ts' +import { requiredDistributionFiles, resolveBundlePatchFiles, resolvePackageEntry } from '../src/server/services/install/package-entry.ts' test('resolvePackageEntry: strips the mandatory ./ prefix from exports string targets', () => { assert.equal(resolvePackageEntry({ exports: { '.': './lib/index.js' } }), 'lib/index.js') @@ -20,14 +21,35 @@ test('resolvePackageEntry: reads condition objects via default/import/require', assert.equal(resolvePackageEntry({ exports: { '.': { default: './lib/default.js', import: './lib/mjs.js' } } }), 'lib/default.js') }) -test('resolvePackageEntry: falls back to main then index.js', () => { +test('resolvePackageEntry: falls back to main when exports declares no root target', () => { assert.equal(resolvePackageEntry({ main: './lib/main.js' }), 'lib/main.js') assert.equal(resolvePackageEntry({ main: 'lib/main.js' }), 'lib/main.js') - assert.equal(resolvePackageEntry({ exports: { './sub': './sub.js' } }), 'index.js') - assert.equal(resolvePackageEntry({}), 'index.js') }) -test('resolvePackageEntry: non-string exports/main values are ignored', () => { - assert.equal(resolvePackageEntry({ exports: { '.': { node: { import: './x.js' } } }, main: 42 }), 'index.js') - assert.equal(resolvePackageEntry({ exports: ['./*'], main: null }), 'index.js') +test('resolvePackageEntry: a manifest that declares no entry has none', () => { + assert.equal(resolvePackageEntry({}), null) + assert.equal(resolvePackageEntry({ exports: { './sub': './sub.js' } }), null) + assert.equal(resolvePackageEntry({ exports: { '.': { node: { import: './x.js' } } }, main: 42 }), null) + assert.equal(resolvePackageEntry({ exports: ['./*'], main: null }), null) +}) + +test('resolveBundlePatchFiles: reads the string and the list form of dsh.bundle.patch', () => { + assert.deepEqual(resolveBundlePatchFiles({ dsh: { bundle: { patch: './cordis.patch.yml' } } }), ['cordis.patch.yml']) + assert.deepEqual(resolveBundlePatchFiles({ dsh: { bundle: { patch: ['./a.patch.yml', 'b.patch.yml'] } } }), ['a.patch.yml', 'b.patch.yml']) + assert.deepEqual(resolveBundlePatchFiles({}), []) + assert.deepEqual(resolveBundlePatchFiles({ dsh: {} }), []) +}) + +test('resolveBundlePatchFiles: a patch declaration that is not a path is left to dsh to reject', () => { + assert.deepEqual(resolveBundlePatchFiles({ dsh: { bundle: { patch: 42 } } }), []) + assert.deepEqual(resolveBundlePatchFiles({ dsh: { bundle: { patch: [{}, './a.yml', ''] } } }), ['a.yml']) +}) + +test('requiredDistributionFiles: the declared entry first, then the declared bundle patches', () => { + assert.deepEqual(requiredDistributionFiles({}), []) + assert.deepEqual(requiredDistributionFiles({ dsh: { bundle: { patch: './cordis.patch.yml' } } }), ['cordis.patch.yml']) + assert.deepEqual( + requiredDistributionFiles({ main: './lib/index.js', dsh: { bundle: { patch: './cordis.patch.yml' } } }), + ['lib/index.js', 'cordis.patch.yml'], + ) }) diff --git a/tests/preflight.test.ts b/tests/preflight.test.ts index 9a802f0..21d3966 100644 --- a/tests/preflight.test.ts +++ b/tests/preflight.test.ts @@ -5,7 +5,7 @@ */ import { test } from 'node:test' import assert from 'node:assert/strict' -import { rootPackageEntryOfTarList } from '../src/server/services/install/preflight.ts' +import { missingDistributionFileOfTarList, rootPackageEntryOfTarList } from '../src/server/services/install/preflight.ts' test('rootPackageEntryOfTarList: finds the root manifest in GitHub codeload layout', () => { const listing = [ @@ -21,3 +21,16 @@ test('rootPackageEntryOfTarList: rejects ambiguous or missing archive roots', () assert.equal(rootPackageEntryOfTarList('repo-a/package.json\nrepo-b/package.json\n'), null) assert.equal(rootPackageEntryOfTarList('repo-a/packages/helper/package.json\n'), null) }) + +test('missingDistributionFileOfTarList: reports the first declared file the distribution lacks', () => { + const listing = ['repo-deadbeef/', 'repo-deadbeef/package.json', 'repo-deadbeef/cordis.patch.yml'].join('\n') + assert.equal(missingDistributionFileOfTarList(listing, []), null) + assert.equal(missingDistributionFileOfTarList(listing, ['repo-deadbeef/package.json']), null) + assert.equal(missingDistributionFileOfTarList(listing, ['repo-deadbeef/cordis.patch.yml', 'repo-deadbeef/lib/index.js']), 'repo-deadbeef/lib/index.js') + assert.equal(missingDistributionFileOfTarList(listing, ['repo-deadbeef/index.js']), 'repo-deadbeef/index.js') +}) + +test('missingDistributionFileOfTarList: an unreadable listing is treated as a missing file', () => { + assert.equal(missingDistributionFileOfTarList(null, ['repo-deadbeef/lib/index.js']), 'repo-deadbeef/lib/index.js') + assert.equal(missingDistributionFileOfTarList(null, []), null) +}) From 810bcd680114d45fbb77857b9046fc56c6c3c272 Mon Sep 17 00:00:00 2001 From: Ansu <230804140223@stu.nepu.edu.cn> Date: Tue, 13 Oct 2026 15:54:30 +0800 Subject: [PATCH 2/3] fix(client): name the file a packaging failure is about The guard can now trip on a declared bundle patch as well as on a declared entry, so the failure copy and the architecture notes say "a file declared in package.json (main / exports / dsh.bundle.patch)" instead of calling every miss an entry file. --- docs/architecture.md | 9 +++++---- docs/development.md | 2 +- src/client/locales.ts | 8 ++++---- src/client/logic/urls.ts | 6 +++--- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9c1705d..04a49a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,10 +22,10 @@ services/install/ install.ts 对外统一出口(纯 re-export,聚合下面的模块) install-types.ts 公共类型与常量(InstallTask / InstallResult / 上限值) task-queue.ts 后台任务:内存注册表 + FIFO 串行 worker + 子进程生命周期 - preflight.ts 装前预检:拉 GitHub tarball 核对入口文件是否真的在分发里 + preflight.ts 装前预检:拉 GitHub tarball 核对 package.json 声明的文件(入口 / bundle 补丁)是否真的在分发里 npm-resolve.ts 按 repository 反查官方 npm 包(git 分发残缺时切 npm 通道) npm-check.ts 识别「本机 npm 版本过低」导致的失败并打标记 - package-entry.ts 从 package.json 推入口文件路径(预检与装后校验共用口径) + package-entry.ts 从 package.json 推分发必须包含的文件(入口 + dsh.bundle.patch;预检与装后校验共用口径) release-target.ts 目录下发的 release 直链解析与安全校验 services/profile/ profile.ts profile 解析、目标语法校验、pnpm allowBuilds 白名单写入 @@ -70,7 +70,8 @@ services/settings.ts 设置持久化:profiles/

/hub-settings.json 1. **校验与解析**(`routes.ts` → `profile.ts`):仓库/包名格式、profile 名、 同目标去重(`hasQueuedTarget`)、包名冲突检测。 2. **装前预检**(`preflight.ts`):GitHub 源先 `git ls-remote` 定位 commit、 - 下载 `codeload` tarball,核对 `package.json` 声明的入口文件是否真在分发里 —— + 下载 `codeload` tarball,核对 `package.json` 声明的文件(入口,以及 bundle + 声明的补丁)是否真在分发里 —— git 分发经常不提交构建产物,这类残缺包在动手安装前就拦下。 3. **npm 反查**(`npm-resolve.ts`):预检不通过时按 `repository` 字段反查该仓库的 官方 npm 包,命中就切到 npm 通道安装。 @@ -80,7 +81,7 @@ services/settings.ts 设置持久化:profiles/

/hub-settings.json (`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` / `ERR_PNPM_IGNORED_BUILDS`)时写入 profile 的 `pnpm-workspace.yaml` 后重试一次;全局 npm 安装撞上 npm arborist peer-set 崩溃时带 `--legacy-peer-deps` 重试一次。 -5. **装后校验与收尾**(`task-queue.ts` + `loader.ts`):校验入口文件真实存在 +5. **装后校验与收尾**(`task-queue.ts` + `loader.ts`):校验声明的分发文件真实存在 (缺失即判失败并撤销待重启,避免重启时加载残缺包直接崩溃),再尝试热挂载 / 停用 loader 条目,最后判定「是否需要刷新 / 是否需要重启」(见下)。 diff --git a/docs/development.md b/docs/development.md index 88cf252..39811e5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -63,7 +63,7 @@ npm run test:watch # 监听模式 | 文件 | 覆盖 | | --- | --- | | `progress.test.ts` | 进度估算与输出清洗 | -| `preflight.test.ts` · `package-entry.test.ts` | 装前预检与入口路径推导 | +| `preflight.test.ts` · `package-entry.test.ts` | 装前预检与分发文件推导 | | `npm-resolve.test.ts` | npm 反查与元数据校验 | | `release-target.test.ts` | release 直链解析与安全校验 | | `install-target.test.ts` | 安装目标语法解析 | diff --git a/src/client/locales.ts b/src/client/locales.ts index 122f8b9..c4c30eb 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -317,8 +317,8 @@ export const zh = { failUnclassifiedHint: '安装失败,原因未能自动识别。这通常不是插件自身的问题(可能与本机环境或插件中心有关),请将错误信息反馈给插件中心,由维护者排查。', // prepare 构建脚本实际执行失败(git 分发缺子模块/构建产物):插件打包分发问题,应提 Issue failPrepareHint: '该插件安装时的构建脚本执行失败(git 分发常缺失子模块或构建产物),属插件打包分发问题。请向作者仓库提交 Issue 反馈。', - // 安装前预检 / 装后校验拦截([packaging]):git 分发缺构建产物、入口文件不存在 = 插件未适配官方默认安装方式 - failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少构建产物(package.json 声明的入口文件在仓库中不存在),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download//.tgz`),或发布完整的 npm 版。', + // 安装前预检 / 装后校验拦截([packaging]):git 分发缺 package.json 声明的文件(入口 / bundle 补丁)= 插件未适配官方默认安装方式 + failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少 package.json 声明的文件(`main` / `exports` 声明的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download//.tgz`),或发布完整的 npm 版。', // 原生依赖构建脚本被 pnpm 默认拦截(如 node-pty 的 ERR_PNPM_IGNORED_BUILDS):不单独算插件问题,引导到插件中心仓库排查 failIgnoredBuild: '该插件或其依赖需要执行构建脚本,被 pnpm 的安全白名单(allowBuilds)默认拦截导致安装失败(如原生模块 node-pty、或 git 安装时插件的 prepare 脚本)。这不一定是插件自身的问题(也可能是插件中心反查失败后回退到 git 安装所致),请先反馈给插件中心排查;若确认为插件依赖问题,可建议作者改用预编译版本(如 node-pty-prebuilt-multiarch)或移除安装期构建。', // npm arborist 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题,升级 npm 即可 @@ -715,8 +715,8 @@ export const en = { failUnclassifiedHint: 'The install failed and the cause could not be identified automatically. This is usually not the plugin itself (it may relate to your local environment or the Plugin Hub). Please report the error to the Plugin Hub so the maintainers can triage it.', // prepare script actually failed (git tarball misses submodules/build output): a packaging issue of the plugin itself — file an issue failPrepareHint: 'This plugin failed while running its build scripts during install (git tarballs often miss submodules or build output). This is a packaging issue of the plugin itself — please report it to the author repository.', - // Pre-install / post-install guard hit ([packaging]): git distribution lacks build output, entry file missing = not adapted to the official default install method - failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks the build output (the entry file declared in package.json is not in the repository), so it has not been adapted to the official install flow. Please file an Issue on the author\'s repo asking for committed build output, a prebuilt release `.tgz` (`releases/download//.tgz`), or a complete npm release.', + // Pre-install / post-install guard hit ([packaging]): git distribution lacks a file declared in package.json (entry / bundle patch) = not adapted to the official default install method + failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks a file declared in package.json (the entry from `main` / `exports`, or the patch declared by `dsh.bundle.patch`), so it has not been adapted to the official install flow. Please file an Issue on the author\'s repo asking for committed build output, a prebuilt release `.tgz` (`releases/download//.tgz`), or a complete npm release.', // native-module build scripts ignored by pnpm by default (e.g. node-pty, ERR_PNPM_IGNORED_BUILDS): not a plugin-side issue on its own, route to the hub repo for triage failIgnoredBuild: 'This plugin (or its dependencies) needs to run build scripts, which pnpm blocks by default via its allowBuilds allowlist, so the install fails (e.g. native modules like node-pty, or the plugin\'s own prepare script on git installs). This is not necessarily the plugin itself (it may be caused by the hub falling back to a git install after npm lookup failed). Please report it to the Plugin Hub first; if it turns out to be a plugin dependency issue, the author can ship a prebuilt variant (e.g. node-pty-prebuilt-multiarch) or drop install-time builds.', failNpmTooLow: 'Your local npm may be too old: npm crashed internally while resolving the plugin\'s dependencies (a known npm bug — not a plugin issue). Please upgrade npm and retry: npm install -g npm@latest', diff --git a/src/client/logic/urls.ts b/src/client/logic/urls.ts index c117c91..5af8809 100644 --- a/src/client/logic/urls.ts +++ b/src/client/logic/urls.ts @@ -80,13 +80,13 @@ function reasonTitleOf(kind: FailureKind): string { export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string { // 原因判定:按失败类型归类,便于作者判断问题是否出在插件侧。 // [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整 - // (package.json 声明的入口文件在发布物里缺失),作者照此补齐即可。 + // (package.json 声明的文件在发布物里缺失),作者照此补齐即可。 const kind = classifyFailure(message) const executed = executedCommandOf(command, attempts) // 标题 = 错误原因(非动作词),让 issue 列表一眼可分流 const title = `[dsh-plugin.org | dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}` const reason = /\[packaging\]/i.test(message) - ? 'plugin distribution is incomplete — the entry file declared in package.json is missing from the published package (github tarball or npm package); please commit build output or publish a complete package' + ? 'plugin distribution is incomplete — a file declared in package.json (main / exports / dsh.bundle.patch) is missing from the published package (github tarball or npm package); please commit build output or publish a complete package' : kind === 'network' ? 'network connectivity issue on the user side (DNS, proxy, firewall, or a blocked connection)' : kind === 'pluginPrepare' @@ -132,7 +132,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu ? [ '', '## 安装方式说明', - 'DSH 插件支持三种官方安装通道:`dsh plugin add `(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download//.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare`,请勿依赖它生成产物。当前插件的分发物缺少 package.json 声明的入口文件,请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。', + 'DSH 插件支持三种官方安装通道:`dsh plugin add `(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download//.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare`,请勿依赖它生成产物。当前插件的分发物缺少 package.json 声明的文件(`main` / `exports` 声明的入口,或 `dsh.bundle.patch` 声明的补丁),请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。', ] : []), '', From dacafe62bff6ef1251ba75a96c80c13fcc2c4c3b Mon Sep 17 00:00:00 2001 From: Ansu <230804140223@stu.nepu.edu.cn> Date: Tue, 13 Oct 2026 16:59:06 +0800 Subject: [PATCH 3/3] fix(install): keep the entry requirement for a bundle that mounts itself A bundle's patch usually names the package it belongs to, and the host imports that package when it does; a skill-only bundle names a provider instead and needs no entry of its own. The guard now reads the declared patches and asks for the entry only in the first case, so neither shape is judged by the other's rules. --- docs/architecture.md | 2 +- src/client/locales.ts | 8 ++-- src/client/logic/urls.ts | 4 +- src/server/http/install.ts | 2 +- src/server/services/install/package-entry.ts | 35 ++++++++++++++-- src/server/services/install/preflight.ts | 27 +++++++++--- src/server/services/install/task-queue.ts | 13 +++++- tests/package-entry.test.ts | 44 +++++++++++++++++++- 8 files changed, 116 insertions(+), 19 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 04a49a5..637ac90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,7 +22,7 @@ services/install/ install.ts 对外统一出口(纯 re-export,聚合下面的模块) install-types.ts 公共类型与常量(InstallTask / InstallResult / 上限值) task-queue.ts 后台任务:内存注册表 + FIFO 串行 worker + 子进程生命周期 - preflight.ts 装前预检:拉 GitHub tarball 核对 package.json 声明的文件(入口 / bundle 补丁)是否真的在分发里 + preflight.ts 装前预检:拉 GitHub tarball 核对插件需要的文件(声明的入口 / bundle 补丁,以及补丁 mount 的入口)是否真的在分发里 npm-resolve.ts 按 repository 反查官方 npm 包(git 分发残缺时切 npm 通道) npm-check.ts 识别「本机 npm 版本过低」导致的失败并打标记 package-entry.ts 从 package.json 推分发必须包含的文件(入口 + dsh.bundle.patch;预检与装后校验共用口径) diff --git a/src/client/locales.ts b/src/client/locales.ts index c4c30eb..eee7e9d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -317,8 +317,8 @@ export const zh = { failUnclassifiedHint: '安装失败,原因未能自动识别。这通常不是插件自身的问题(可能与本机环境或插件中心有关),请将错误信息反馈给插件中心,由维护者排查。', // prepare 构建脚本实际执行失败(git 分发缺子模块/构建产物):插件打包分发问题,应提 Issue failPrepareHint: '该插件安装时的构建脚本执行失败(git 分发常缺失子模块或构建产物),属插件打包分发问题。请向作者仓库提交 Issue 反馈。', - // 安装前预检 / 装后校验拦截([packaging]):git 分发缺 package.json 声明的文件(入口 / bundle 补丁)= 插件未适配官方默认安装方式 - failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少 package.json 声明的文件(`main` / `exports` 声明的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download//.tgz`),或发布完整的 npm 版。', + // 安装前预检 / 装后校验拦截([packaging]):git 分发缺插件需要的文件(入口 / 补丁 mount 的入口 / bundle 补丁)= 插件未适配官方默认安装方式 + failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download//.tgz`),或发布完整的 npm 版。', // 原生依赖构建脚本被 pnpm 默认拦截(如 node-pty 的 ERR_PNPM_IGNORED_BUILDS):不单独算插件问题,引导到插件中心仓库排查 failIgnoredBuild: '该插件或其依赖需要执行构建脚本,被 pnpm 的安全白名单(allowBuilds)默认拦截导致安装失败(如原生模块 node-pty、或 git 安装时插件的 prepare 脚本)。这不一定是插件自身的问题(也可能是插件中心反查失败后回退到 git 安装所致),请先反馈给插件中心排查;若确认为插件依赖问题,可建议作者改用预编译版本(如 node-pty-prebuilt-multiarch)或移除安装期构建。', // npm arborist 内部崩溃(edgesOut)+ 本机 npm 版本过低:npm 自身已知缺陷,不是插件问题,升级 npm 即可 @@ -715,8 +715,8 @@ export const en = { failUnclassifiedHint: 'The install failed and the cause could not be identified automatically. This is usually not the plugin itself (it may relate to your local environment or the Plugin Hub). Please report the error to the Plugin Hub so the maintainers can triage it.', // prepare script actually failed (git tarball misses submodules/build output): a packaging issue of the plugin itself — file an issue failPrepareHint: 'This plugin failed while running its build scripts during install (git tarballs often miss submodules or build output). This is a packaging issue of the plugin itself — please report it to the author repository.', - // Pre-install / post-install guard hit ([packaging]): git distribution lacks a file declared in package.json (entry / bundle patch) = not adapted to the official default install method - failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks a file declared in package.json (the entry from `main` / `exports`, or the patch declared by `dsh.bundle.patch`), so it has not been adapted to the official install flow. Please file an Issue on the author\'s repo asking for committed build output, a prebuilt release `.tgz` (`releases/download//.tgz`), or a complete npm release.', + // Pre-install / post-install guard hit ([packaging]): git distribution lacks a file the package needs (entry / entry a patch mounts / bundle patch) = not adapted to the official default install method + failPackagingHint: 'This plugin does not support the official default install method: its git distribution lacks a file the package needs (the entry from `main` / `exports`, an entry its own patch mounts by name, or the patch declared by `dsh.bundle.patch`), so it has not been adapted to the official install flow. Please file an Issue on the author\'s repo asking for committed build output, a prebuilt release `.tgz` (`releases/download//.tgz`), or a complete npm release.', // native-module build scripts ignored by pnpm by default (e.g. node-pty, ERR_PNPM_IGNORED_BUILDS): not a plugin-side issue on its own, route to the hub repo for triage failIgnoredBuild: 'This plugin (or its dependencies) needs to run build scripts, which pnpm blocks by default via its allowBuilds allowlist, so the install fails (e.g. native modules like node-pty, or the plugin\'s own prepare script on git installs). This is not necessarily the plugin itself (it may be caused by the hub falling back to a git install after npm lookup failed). Please report it to the Plugin Hub first; if it turns out to be a plugin dependency issue, the author can ship a prebuilt variant (e.g. node-pty-prebuilt-multiarch) or drop install-time builds.', failNpmTooLow: 'Your local npm may be too old: npm crashed internally while resolving the plugin\'s dependencies (a known npm bug — not a plugin issue). Please upgrade npm and retry: npm install -g npm@latest', diff --git a/src/client/logic/urls.ts b/src/client/logic/urls.ts index 5af8809..ea90be5 100644 --- a/src/client/logic/urls.ts +++ b/src/client/logic/urls.ts @@ -86,7 +86,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu // 标题 = 错误原因(非动作词),让 issue 列表一眼可分流 const title = `[dsh-plugin.org | dsh-plugin-hub] ${reasonTitleOf(kind)}: ${repo}` const reason = /\[packaging\]/i.test(message) - ? 'plugin distribution is incomplete — a file declared in package.json (main / exports / dsh.bundle.patch) is missing from the published package (github tarball or npm package); please commit build output or publish a complete package' + ? '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' : kind === 'network' ? 'network connectivity issue on the user side (DNS, proxy, firewall, or a blocked connection)' : kind === 'pluginPrepare' @@ -132,7 +132,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu ? [ '', '## 安装方式说明', - 'DSH 插件支持三种官方安装通道:`dsh plugin add `(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download//.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare`,请勿依赖它生成产物。当前插件的分发物缺少 package.json 声明的文件(`main` / `exports` 声明的入口,或 `dsh.bundle.patch` 声明的补丁),请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。', + 'DSH 插件支持三种官方安装通道:`dsh plugin add `(npm 分发,需发布完整构建产物)、`dsh plugin add git+https://github.com/owner/repo.git`(Git 直装,仓库需提交构建产物),以及预构建 release 包 `dsh plugin add https://github.com/owner/repo/releases/download//.tgz`(仓库不便提交构建产物时的推荐方式)。注意:pnpm ≥10 默认拦截依赖构建脚本,Git 直装不会执行包内 `prepare`,请勿依赖它生成产物。当前插件的分发物缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。', ] : []), '', diff --git a/src/server/http/install.ts b/src/server/http/install.ts index 696c2d6..b0887c0 100644 --- a/src/server/http/install.ts +++ b/src/server/http/install.ts @@ -278,7 +278,7 @@ export function registerInstallRoutes(webServer: WebServerService, profile: stri // 预检拦截(git 分发缺作者声明的文件):同步 400 返回,附上已尝试的安装方式, // 客户端失败弹窗提 Issue 时一并贴给作者 sendJson(response, 400, { - error: `[packaging] ${target}: plugin distribution is incomplete — the file ${preflight.missing ?? '?'} declared in package.json (main / exports / dsh.bundle.patch) is not present in the git distribution (build output not committed). Report to the author.`, + 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.`, attempts, }) return diff --git a/src/server/services/install/package-entry.ts b/src/server/services/install/package-entry.ts index dc3d9d8..cf0205a 100644 --- a/src/server/services/install/package-entry.ts +++ b/src/server/services/install/package-entry.ts @@ -11,6 +11,10 @@ * 补丁文件(官方 bundleManifest 的注释即「Read bundle metadata without loading its * JavaScript」),从不 import 包本身。凭空要求一个作者没声明的入口,只会把完整的 * 技能包判成分发残缺,并把一条假缺陷推到作者仓库(见 QingYunA/answer-me-with-html#209)。 + * + * 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是 + * `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。 + * 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。 */ /** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */ @@ -18,6 +22,7 @@ const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'] as const /** 解析所需的 manifest 字段;其余字段不读。 */ export interface PackageManifest { + name?: unknown main?: unknown exports?: unknown dsh?: unknown @@ -58,12 +63,36 @@ export function resolveBundlePatchFiles(meta: PackageManifest): string[] { .map(stripDotSlash) } -/** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。 */ -export function requiredDistributionFiles(meta: PackageManifest): string[] { - const entry = resolvePackageEntry(meta) +/** 分发里必须存在的全部文件:作者声明的入口,加上 bundle 声明的补丁,入口在前。 + * manifest 没声明入口时才看补丁:补丁按本包名 mount 自己(下面 mountsItself), + * 宿主就会 import 本包,Node 的旧式解析落到 index.js —— 那个入口同样必须有。 */ +export function requiredDistributionFiles(meta: PackageManifest, patchTexts: string[] = []): string[] { + const declared = resolvePackageEntry(meta) + const entry = declared ?? (mountsItself(meta, patchTexts) ? 'index.js' : null) return [...(entry === null ? [] : [entry]), ...resolveBundlePatchFiles(meta)] } +/** + * 补丁文本里插件行的 `name:` 值(DSH 补丁按行书写:`- name: ` / ` name: `)。 + * 刻意不做 YAML 解析:只认这一种行形状,认不出来就不判定 —— 漏判只是少一条要求, + * 误判会拦下能装的包,两边的代价不对称。 + */ +export function patchRowNamesOf(text: string): string[] { + return text + .split(/\r?\n/) + .map((line) => /^\s*(?:-\s*)?name:\s*(.+?)\s*$/u.exec(line)?.[1]) + .filter((value): value is string => value !== undefined) + .map((value) => value.replace(/^(['"])(.*)\1$/u, '$2')) + .filter((value) => value !== '') +} + +/** 补丁是否把本包自己 mount 进树(dsh 文档里组合包的常规写法),是则宿主会 import 本包。 */ +export function mountsItself(meta: PackageManifest, patchTexts: string[]): boolean { + const name = meta.name + if (typeof name !== 'string' || name === '') return false + return patchTexts.some((text) => patchRowNamesOf(text).includes(name)) +} + function firstStringOf(conditions: Record): string | undefined { for (const key of EXPORT_CONDITION_KEYS) { const value = conditions[key] diff --git a/src/server/services/install/preflight.ts b/src/server/services/install/preflight.ts index 82ba04e..69318a9 100644 --- a/src/server/services/install/preflight.ts +++ b/src/server/services/install/preflight.ts @@ -19,7 +19,7 @@ import { pipeline } from 'node:stream/promises' import { promisify } from 'node:util' import { githubRefOf, githubRepoOf } from '../profile/profile.ts' import { githubReleaseTarget } from './release-target.ts' -import { requiredDistributionFiles } from './package-entry.ts' +import { requiredDistributionFiles, resolveBundlePatchFiles } from './package-entry.ts' const execFileAsync = promisify(execFile) @@ -59,8 +59,14 @@ export async function preflightTarget(target: string): Promise const meta = await readTarJson(tar, rootPackage) if (meta === null) return { ok: true, missing: null, name: null } // 只校验作者声明过的文件:没声明入口的包(只挂技能的 bundle)没有可缺失的入口, - // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定。 - const required = requiredDistributionFiles(meta).map((file) => `${rootPrefix}${file}`) + // 它的分发是否完整由 dsh.bundle.patch 是否在包里决定;补丁若按本包名 mount 自己, + // 入口又变成必须的(宿主会 import 本包)—— 所以判入口前先把补丁读出来。 + const patchTexts: string[] = [] + for (const file of resolveBundlePatchFiles(meta)) { + const text = await readTarText(tar, `${rootPrefix}${file}`) + if (text !== null) patchTexts.push(text) + } + const required = requiredDistributionFiles(meta, patchTexts).map((file) => `${rootPrefix}${file}`) const missing = missingDistributionFileOfTarList(await tarListing(tar), required) return { ok: missing === null, missing, name: typeof meta.name === 'string' ? meta.name : null } } catch { @@ -156,11 +162,22 @@ async function tarListing(tar: string): Promise { } } +/** 从 tarball 提取一个文件的内容;文件不存在或提取失败返回 null。 */ +async function readTarText(tar: string, entry: string): Promise { + try { + const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 }) + return stdout + } catch { + return null + } +} + /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */ async function readTarJson(tar: string, entry: string): Promise | null> { + const text = await readTarText(tar, entry) + if (text === null) return null try { - const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 }) - return JSON.parse(stdout) as Record + return JSON.parse(text) as Record } catch { return null } diff --git a/src/server/services/install/task-queue.ts b/src/server/services/install/task-queue.ts index 5cf6d42..c299607 100644 --- a/src/server/services/install/task-queue.ts +++ b/src/server/services/install/task-queue.ts @@ -8,7 +8,7 @@ * 输出流式收集到任务上;卸载成功时尝试从运行中 loader 即时停用(见 loader.ts)。 */ import { spawn, spawnSync } from 'node:child_process' -import { requiredDistributionFiles } from './package-entry.ts' +import { requiredDistributionFiles, resolveBundlePatchFiles, type PackageManifest } from './package-entry.ts' import { existsSync, readFileSync, statSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import type { InstallResult, InstallTask, Invocation, QueueItem } from './install-types.ts' @@ -649,7 +649,16 @@ export function verifyInstalledEntry(profile: string, target: string): { name: s const pkgDir = join(profileDirectory(profile), 'node_modules', name) let required: string[] = [] try { - required = requiredDistributionFiles(JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as Record) + const meta = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')) as PackageManifest + // 补丁按本包名 mount 自己时入口也是必须的,所以先读已装包里的补丁(读不到就当没有)。 + const patchTexts = resolveBundlePatchFiles(meta).flatMap((file) => { + try { + return [readFileSync(join(pkgDir, file), 'utf8')] + } catch { + return [] + } + }) + required = requiredDistributionFiles(meta, patchTexts) } catch { return { name, missing: null } } diff --git a/tests/package-entry.test.ts b/tests/package-entry.test.ts index f951fb3..8bf192a 100644 --- a/tests/package-entry.test.ts +++ b/tests/package-entry.test.ts @@ -8,7 +8,7 @@ */ import { test } from 'node:test' import assert from 'node:assert/strict' -import { requiredDistributionFiles, resolveBundlePatchFiles, resolvePackageEntry } from '../src/server/services/install/package-entry.ts' +import { mountsItself, patchRowNamesOf, requiredDistributionFiles, resolveBundlePatchFiles, resolvePackageEntry } from '../src/server/services/install/package-entry.ts' test('resolvePackageEntry: strips the mandatory ./ prefix from exports string targets', () => { assert.equal(resolvePackageEntry({ exports: { '.': './lib/index.js' } }), 'lib/index.js') @@ -53,3 +53,45 @@ test('requiredDistributionFiles: the declared entry first, then the declared bun ['lib/index.js', 'cordis.patch.yml'], ) }) + +// The patch below is the one the market reports against (QingYunA/answer-me-with-html): it mounts a +// dsh-internal provider and only mentions its own name as a config value, so it mounts no module of +// its own and its package needs no entry. +const SKILL_BUNDLE_PATCH = [ + '- insert:', + ' - id: answer-me-with-html-skill', + " name: '@deepseek-ai/dsh-skill-filesystem'", + ' config:', + ' providerName: answer-me-with-html', + ' includeDefaultRoots: false', + " bundledSkillDir: !!js process.getBuiltinModule('node:path').join(root, 'answer-me-with-html/package.json')", +].join('\n') + +// The pattern dsh's "Packaging and installing a plugin" guide shows: the row names the package itself. +const SELF_MOUNTING_PATCH = ['- insert:', ' - id: hello', ' name: dsh-hello-plugin'].join('\n') + +test('patchRowNamesOf: reads the row names it can recognise, and nothing else', () => { + assert.deepEqual(patchRowNamesOf(SKILL_BUNDLE_PATCH), ['@deepseek-ai/dsh-skill-filesystem']) + assert.deepEqual(patchRowNamesOf(SELF_MOUNTING_PATCH), ['dsh-hello-plugin']) + assert.deepEqual(patchRowNamesOf(['- id: tool', " name: '@deepseek-ai/dsh-tool-present'"].join('\n')), ['@deepseek-ai/dsh-tool-present']) + assert.deepEqual(patchRowNamesOf(''), []) +}) + +test('mountsItself: only a row naming this package counts', () => { + const manifest = { name: 'answer-me-with-html', dsh: { bundle: { patch: './cordis.patch.yml' } } } + assert.equal(mountsItself(manifest, [SKILL_BUNDLE_PATCH]), false) + assert.equal(mountsItself(manifest, []), false) + assert.equal(mountsItself({}, [SELF_MOUNTING_PATCH]), false) + assert.equal(mountsItself({ name: 'dsh-hello-plugin' }, [SELF_MOUNTING_PATCH]), true) +}) + +test('requiredDistributionFiles: a patch that mounts the package itself brings the entry back', () => { + const manifest = { name: 'dsh-hello-plugin', dsh: { bundle: { patch: './cordis.patch.yml' } } } + assert.deepEqual(requiredDistributionFiles(manifest), ['cordis.patch.yml']) + assert.deepEqual(requiredDistributionFiles(manifest, [SKILL_BUNDLE_PATCH]), ['cordis.patch.yml']) + assert.deepEqual(requiredDistributionFiles(manifest, [SELF_MOUNTING_PATCH]), ['index.js', 'cordis.patch.yml']) + assert.deepEqual( + requiredDistributionFiles({ name: 'dsh-hello-plugin', main: './lib/index.js', dsh: { bundle: { patch: './p.yml' } } }, [SELF_MOUNTING_PATCH]), + ['lib/index.js', 'p.yml'], + ) +})