fix(repository-plugin): enforce published prepare dependency
This commit is contained in:
@@ -209,7 +209,7 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(other.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
})
|
||||
|
||||
it('logs error and registers no tools when connect fails; dispose is a no-op', async () => {
|
||||
it('logs error and registers no tools when connect fails; dispose closes the client', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/self-modification/repository-plugin/README.md
|
||||
README.md: 0df0680cef873d9f7078cb63d1c803e5c323f07c
|
||||
README.zh.md: 04e5f8f81ba1b3e788775e9deaff6bb0b6dd1a48
|
||||
README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7
|
||||
README.zh.md: 62f467dd9ccac904ea2a216242f5475c29734a86
|
||||
|
||||
@@ -60,7 +60,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v
|
||||
|
||||
## Preparation
|
||||
|
||||
During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared `@deepseek-ai/dsh-repository-plugin` dependency. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md).
|
||||
During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared direct development dependency on `@deepseek-ai/dsh-repository-plugin`. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained both the direct development dependency and a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md).
|
||||
|
||||
## Runtime composition
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
|
||||
|
||||
## 准备阶段
|
||||
|
||||
安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从已声明的 `@deepseek-ai/dsh-repository-plugin` 依赖取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。
|
||||
安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍同时保留该直接开发依赖,以及包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。
|
||||
|
||||
## 运行时组合
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
|
||||
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
|
||||
/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
|
||||
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
|
||||
/** Published package whose direct development dependency supplies the prepare command. */
|
||||
export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
/**
|
||||
* Whether a package lifecycle declaration names the preparation dependency's helper.
|
||||
@@ -40,6 +42,9 @@ const sourceMetadataSchema = z.object({
|
||||
})
|
||||
const sourcePackageSchema = z.looseObject({
|
||||
name: z.string().min(1),
|
||||
devDependencies: z.looseObject({
|
||||
[REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1),
|
||||
}),
|
||||
scripts: z.looseObject({
|
||||
prepack: prepackSchema,
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
REPOSITORY_PLUGIN_PACKAGE_NAME,
|
||||
REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_PACKAGE_NAME,
|
||||
REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
hasRepositoryPrepareCommand,
|
||||
} from './format.ts'
|
||||
@@ -29,6 +30,9 @@ export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
|
||||
// resolvable point').
|
||||
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
|
||||
const installedPackageSchema = z.looseObject({
|
||||
devDependencies: z.looseObject({
|
||||
[REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1),
|
||||
}),
|
||||
scripts: z.looseObject({
|
||||
prepack: z.string().min(1).refine(
|
||||
hasRepositoryPrepareCommand,
|
||||
@@ -81,7 +85,7 @@ async function assertInstalledPackageMetadata(directory: string): Promise<void>
|
||||
const result = installedPackageSchema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error([
|
||||
`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:`,
|
||||
`installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}, and declare ${JSON.stringify(REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies:`,
|
||||
z.prettifyError(result.error),
|
||||
'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.',
|
||||
].join('\n'))
|
||||
|
||||
@@ -32,12 +32,16 @@ async function writePlugin(
|
||||
name: string,
|
||||
dsh: Record<string, unknown>,
|
||||
prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
devDependencies: Record<string, string> = {
|
||||
[RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1',
|
||||
},
|
||||
): Promise<string> {
|
||||
const directory = join(root, '.dsh-plugin')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), `${JSON.stringify({
|
||||
name,
|
||||
version: '0.0.0',
|
||||
devDependencies,
|
||||
scripts: { prepack },
|
||||
dsh,
|
||||
}, undefined, 2)}\n`)
|
||||
@@ -149,6 +153,17 @@ describe('dsh-plugin-prepare', () => {
|
||||
)
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare')
|
||||
|
||||
const undeclaredPrepareRoot = await temporaryDirectory('undeclared-prepare-dependency')
|
||||
const undeclaredPrepare = await writePlugin(
|
||||
undeclaredPrepareRoot,
|
||||
'undeclared-prepare-dependency',
|
||||
{ skills: ['../skills'] },
|
||||
RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
{},
|
||||
)
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(undeclaredPrepare))
|
||||
.rejects.toThrow(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)
|
||||
|
||||
const emptyRoot = await temporaryDirectory('empty-metadata')
|
||||
const empty = await writePlugin(emptyRoot, 'empty', {})
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root, mcpServers file, or compiled entry')
|
||||
@@ -584,6 +599,7 @@ describe('configured GitHub repository sources', () => {
|
||||
const root = await temporaryDirectory('installed-lifecycle')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'installed-lifecycle',
|
||||
devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
|
||||
scripts: { prepare: 'dsh-plugin-prepare' },
|
||||
}))
|
||||
const ctx = new Context()
|
||||
@@ -606,6 +622,7 @@ describe('configured GitHub repository sources', () => {
|
||||
const root = await temporaryDirectory('installed-skipped-prepare')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'installed-skipped-prepare',
|
||||
devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
|
||||
scripts: { prepack: 'npm run build' },
|
||||
}))
|
||||
const ctx = new Context()
|
||||
@@ -618,6 +635,22 @@ describe('configured GitHub repository sources', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects installed source without the declared prepare dependency', async () => {
|
||||
const root = await temporaryDirectory('installed-missing-prepare-dependency')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'installed-missing-prepare-dependency',
|
||||
scripts: { prepack: 'dsh-plugin-prepare' },
|
||||
}))
|
||||
const ctx = new Context()
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#ambient-helper&path:/.dsh-plugin'))
|
||||
.rejects.toMatchObject({
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining(`${JSON.stringify(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies`) as string,
|
||||
}) as Error,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('labels missing installed package metadata with its source', async () => {
|
||||
const root = await temporaryDirectory('missing-installed-metadata')
|
||||
const ctx = new Context()
|
||||
|
||||
Reference in New Issue
Block a user