fix(repository-plugin): require published prepare dependency

This commit is contained in:
Tianyi Cui
2026-08-09 00:16:14 +08:00
parent e91ff6d499
commit 29b3e3fa84
26 changed files with 319 additions and 243 deletions

View File

@@ -115,24 +115,9 @@ describe('RepositoryCache', () => {
it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
const executableDirectory = join(root, 'bin')
await mkdir(executableDirectory)
await writeFile(join(executableDirectory, 'dsh-plugin-prepare'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(executableDirectory, 'dsh-plugin-prepare.cmd'), [
'@echo off',
'node "%~dp0\\dsh-plugin-prepare" %*',
'',
].join('\r\n'))
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'build-helper'), { recursive: true })
await mkdir(join(repository, 'prepare-helper'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
@@ -160,12 +145,29 @@ describe('RepositoryCache', () => {
"require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'prepare-helper', 'package.json'), `${JSON.stringify({
name: 'repository-prepare-helper',
version: '1.0.0',
bin: { 'dsh-plugin-prepare': 'index.js' },
})}\n`)
await writeFile(join(repository, 'prepare-helper', 'index.js'), [
'#!/usr/bin/env node',
"const { cpSync, mkdirSync, writeFileSync } = require('node:fs')",
"mkdirSync('dsh-plugin-assets/skills', { recursive: true })",
"cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')",
"writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'), { mode: 0o700 })
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepack: 'repository-build-helper && dsh-plugin-prepare' },
devDependencies: { 'repository-build-helper': 'file:../build-helper' },
devDependencies: {
'repository-build-helper': 'file:../build-helper',
'repository-prepare-helper': 'file:../prepare-helper',
},
dsh: { skills: ['../skills'] },
})}\n`)
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
@@ -180,9 +182,7 @@ describe('RepositoryCache', () => {
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache'), {
executableDirectories: [executableDirectory],
}).resolve(specifier)
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n')
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')

View File

@@ -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: e10907408a98cd470ae935b327ae44322f2e54a7
README.zh.md: b8ba7dccd929574cc5aa1b7a3d68bf4f1e2f80e4
README.md: 3a6478d5cca05684e80ce0854cb6e0b5d2f88bd4
README.zh.md: 274d11c96cb61d4d9fa4b837433994248c94bff4

View File

@@ -27,12 +27,13 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. DSH supplies only that helper command from its installed runtime: the package declares and runs its own compiler, runtime dependencies, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry.
`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry.
`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
@@ -59,7 +60,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v
## Preparation
During exact Git installation, DSH places temporary host-owned `pnpm` and `dsh-plugin-prepare` commands on the isolated package lifecycle `PATH`; neither command is fetched from the repository. The pnpm command reinvokes DSH's 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. 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 install, build, or prepare fails before a cache generation is published. Rationale: [host-owned Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-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 `@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).
## Runtime composition

View File

@@ -27,12 +27,13 @@
"@modelcontextprotocol/sdk": "1.29.0"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1",
"typescript": "6.0.3"
}
}
```
`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。DSH 已安装的运行时只提供该辅助命令:包自行声明并运行编译器、运行时依赖其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace因此必须声明所需的每项依赖不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript也不推断包入口。
`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace因此必须声明所需的每项依赖不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript也不推断包入口。
`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export并自行拥有常规的 `name``inject``Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
@@ -59,7 +60,7 @@ Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有
## 准备阶段
安装精确指定的 Git 源时DSH 会把临时的宿主自有 `pnpm``dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;两个命令都不从 repository 获取。该 pnpm 命令会以 `--ignore-workspace` 重新调用 DSH 锁定的 pnpm因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前DSH 会重新校验已安装包是否仍保留包含该辅助命令的 `prepack` 声明。安装、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-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`。该包把 CordisDSH 运行时对等依赖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)。
## 运行时组合

View File

@@ -36,6 +36,26 @@
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@cordisjs/plugin-loader": {
"optional": true
},
"@deepseek-ai/dsh-invariants": {
"optional": true
},
"@deepseek-ai/dsh-mcp-client": {
"optional": true
},
"@deepseek-ai/dsh-paths": {
"optional": true
},
"@deepseek-ai/dsh-skill-local": {
"optional": true
},
"cordis": {
"optional": true
}
},
"dependencies": {
"zod": "^4.4.3"
},

View File

@@ -14,11 +14,11 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
/** Loader builtin used by every generated repository wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
/** Host-owned command that repository package `prepack` lifecycles must invoke. */
/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
/**
* Whether a package lifecycle declaration names the host preparation helper.
* Whether a package lifecycle declaration names the preparation dependency's helper.
* @param script - package-authored lifecycle command.
* @returns true when the required helper command is present.
*/

View File

@@ -20,7 +20,6 @@ import {
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -131,24 +130,17 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
if (new Set(repositories).size !== repositories.length) {
throw new Error('repository sources must resolve to unique exact specifiers')
}
const prepareCommand = repositories.length === 0 ? undefined : await createRepositoryPrepareCommand()
try {
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir), {
executableDirectories: prepareCommand === undefined ? [] : [prepareCommand.directory],
})
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
} finally {
await prepareCommand?.dispose()
}
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
}

View File

@@ -3,10 +3,9 @@
* @module
*/
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { pathToFileURL } from 'node:url'
import type { Context, Fiber, FiberState, Plugin } from 'cordis'
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -24,56 +23,6 @@ const FIBER_ACTIVE = 2 as FiberState.ACTIVE
/** Directory under the Harness home containing immutable repository generations. */
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
/** Temporary host command supplied to repository package lifecycle scripts. */
export interface RepositoryPrepareCommand {
/** Absolute directory to prepend to the isolated install's executable search path. */
directory: string
/** Remove the temporary command directory. */
dispose(): Promise<void>
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function batchQuote(value: string): string {
return `"${value.replaceAll('%', '%%')}"`
}
/**
* Materialize the DSH-owned prepare executable used only while pnpm packs Git source.
* @returns a command directory and its idempotent cleanup operation.
*/
export async function createRepositoryPrepareCommand(): Promise<RepositoryPrepareCommand> {
const directory = await mkdtemp(join(tmpdir(), 'dsh-repository-plugin-bin-'))
const target = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
try {
await Promise.all([
writeFile(join(directory, REPOSITORY_PLUGIN_PREPARE_COMMAND), [
'#!/bin/sh',
`exec ${shellQuote(process.execPath)} ${shellQuote(target)} "$@"`,
'',
].join('\n'), { mode: 0o700 }),
writeFile(join(directory, `${REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), [
'@echo off',
`${batchQuote(process.execPath)} ${batchQuote(target)} %*`,
'',
].join('\r\n'), { mode: 0o700 }),
])
} catch (cause) {
/* v8 ignore next -- requires a host filesystem failure after mkdtemp; cleanup semantics are the contract under test. */
await rm(directory, { recursive: true, force: true })
/* v8 ignore next -- preserves that unstageable host failure after best-effort cleanup. */
throw cause
}
return {
directory,
async dispose() {
await rm(directory, { recursive: true, force: true })
},
}
}
// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
// parser, with the syntax the error message promises — instead of inside the
// cache's pnpm install ('misconfiguration fails loud at the earliest

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -14,7 +14,6 @@ import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant'
import { parsePreparedPluginConfig } from '../src/format.ts'
import {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -87,7 +86,7 @@ describe('dsh-plugin-prepare', () => {
.resolves.toContain('mcp.expo.dev')
})
it('preserves a compiled package entry and accepts a build before the host prepare command', async () => {
it('preserves a compiled package entry and accepts a build before the package prepare command', async () => {
const root = await temporaryDirectory('compiled-entry')
const directory = await writePlugin(root, 'compiled-entry-fixture', {
entry: './lib/plugin.mjs',
@@ -406,17 +405,6 @@ describe('prepared repository plugin Loader composition', () => {
})
describe('configured GitHub repository sources', () => {
it('creates host-owned prepare commands and removes them idempotently', async () => {
const command = await createRepositoryPrepareCommand()
expect(await readFile(join(command.directory, RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND), 'utf8'))
.toContain(process.execPath)
expect(await readFile(join(command.directory, `${RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), 'utf8'))
.toContain(process.execPath)
await command.dispose()
await command.dispose()
await expect(stat(command.directory)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('defaults an omitted source list and rejects unknown configuration fields', () => {
expect(RepositoryPlugin.Config.parse(undefined)).toEqual({ repositories: [] })
expect(RepositoryPlugin.Config.safeParse({ repositories: [], unexpected: true }).success).toBe(false)
@@ -608,7 +596,7 @@ describe('configured GitHub repository sources', () => {
await ctx.fiber.dispose()
})
it('rejects an installed source whose prepack omits the host prepare command', async () => {
it('rejects an installed source whose prepack omits the package prepare command', async () => {
const root = await temporaryDirectory('installed-skipped-prepare')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-skipped-prepare',