Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 核对插件需要的文件(声明的入口 / bundle 补丁,以及补丁 mount 的入口)是否真的在分发里
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 白名单写入
Expand Down Expand Up @@ -70,7 +70,8 @@ services/settings.ts 设置持久化:profiles/<p>/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 通道安装。
Expand All @@ -80,7 +81,7 @@ services/settings.ts 设置持久化:profiles/<p>/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 条目,最后判定「是否需要刷新 / 是否需要重启」(见下)。

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 安装目标语法解析 |
Expand Down
8 changes: 4 additions & 4 deletions src/client/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tag>/<asset>.tgz`),或发布完整的 npm 版。',
// 安装前预检 / 装后校验拦截([packaging]):git 分发缺插件需要的文件(入口 / 补丁 mount 的入口 / bundle 补丁)= 插件未适配官方默认安装方式
failPackagingHint: '该插件不支持官方默认安装方式:其 git 分发缺少它需要的文件(`main` / `exports` 声明的入口、补丁按本包名 mount 的入口,或 `dsh.bundle.patch` 声明的补丁),说明作者未适配官方安装流程。请到作者仓库提交 Issue 反馈,请其提交构建产物、改用预构建 release `.tgz` 直链(`releases/download/<tag>/<asset>.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 即可
Expand Down Expand Up @@ -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/<tag>/<asset>.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/<tag>/<asset>.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',
Expand Down
6 changes: 3 additions & 3 deletions src/client/logic/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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'
Expand Down Expand Up @@ -132,7 +132,7 @@ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | nu
? [
'',
'## 安装方式说明',
'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`,请勿依赖它生成产物。当前插件的分发物缺少 package.json 声明的入口文件,请提交构建产物、改用 release `.tgz` 直链,或发布完整的 npm 包。',
'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 包。',
]
: []),
'',
Expand Down
6 changes: 3 additions & 3 deletions src/server/http/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '?'} 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
Expand Down
81 changes: 72 additions & 9 deletions src/server/services/install/package-entry.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,43 @@
/**
* 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)。
*
* 例外是补丁自己 mount 了本包:dsh 的《打包与安装插件》里组合包的常规写法就是
* `- insert: - name: <本包名>`,宿主随后按包名 import 本包,入口那时必须存在。
* 所以只有补丁真的写了自己的包名,才把这个入口算进分发要求。
*/

/** exports 条件对象里按优先级尝试的键;Node 语义下 default 最宽,import/require 次之。 */
const EXPORT_CONDITION_KEYS = ['default', 'import', 'require'] as const

/** 解析所需的 manifest 字段;其余字段不读。 */
export interface PackageManifest {
name?: unknown
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<string, unknown> | undefined)?.['.']
const fromExports = typeof dot === 'string'
? dot
Expand All @@ -28,8 +46,51 @@ 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 声明的补丁,入口在前。
* 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: <pkg>` / ` name: <pkg>`)。
* 刻意不做 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, unknown>): string | undefined {
Expand All @@ -39,3 +100,5 @@ function firstStringOf(conditions: Record<string, unknown>): string | undefined
}
return undefined
}

const stripDotSlash = (file: string): string => file.replace(/^\.\//u, '')
Loading