feat: configure repository plugins from DSH home
This commit is contained in:
@@ -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/cordis/repository-plugin/README.md
|
||||
README.md: 80744eb489d1714f59ba6e53207476a8ce222e24
|
||||
README.zh.md: d297b44e4a065fa99865e3a42d2c823c7b7c5848
|
||||
README.md: 0ba1ce86d99a12e0f94e7a39fd3ae44dc29889a7
|
||||
README.zh.md: 2d9544166eafbb1066b65031969925890f2b9797
|
||||
|
||||
@@ -28,6 +28,23 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
|
||||
|
||||
`dsh.skills` is an optional array of local skill roots. `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one field is required. Paths are relative to `.dsh-plugin`, must stay under its parent source directory, and may therefore refer to existing repository assets such as `../skills`. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory.
|
||||
|
||||
## Standalone app configuration
|
||||
|
||||
The shipped `dsh` TUI, Web, and headless trees contain an empty `repository-plugins` row. A standalone user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`):
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
Each source must use `github:owner/repository#<ref>`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root.
|
||||
|
||||
The TUI and Web watch `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless runs consume the file only at startup. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md).
|
||||
|
||||
## Preparation
|
||||
|
||||
`dsh-plugin-prepare` validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. The wrapper contains only the normalized static manifest and fixed code that looks up the `dsh-repository-plugin` Loader builtin. It neither discovers nor compiles repository JavaScript, and the runtime never imports another repository entry point.
|
||||
|
||||
@@ -28,6 +28,23 @@
|
||||
|
||||
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` package。
|
||||
|
||||
## 独立应用配置
|
||||
|
||||
已交付的 `dsh` TUI、Web 和无头配置树包含一个空的 `repository-plugins` 配置项。独立用户只需在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,即可启用精确指定的 GitHub generation:
|
||||
|
||||
```yaml
|
||||
- id: repository-plugins
|
||||
name: '@deepseek-ai/dsh-repository-plugin'
|
||||
config:
|
||||
repositories:
|
||||
- 'github:PolyArch/humanize#<commit>'
|
||||
- 'github:owner/repository#<ref>&path:/plugins/one/.dsh-plugin'
|
||||
```
|
||||
|
||||
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为显式配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。
|
||||
|
||||
TUI 和 Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套仓库插件 generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头运行只在启动时使用该文件。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入仓库插件的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。
|
||||
|
||||
## 准备阶段
|
||||
|
||||
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest(元数据清单),以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript,运行时也不会导入仓库的其他入口。
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-mcp-client": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill-local": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -44,6 +45,7 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
@@ -8,8 +8,10 @@ import { dirname, isAbsolute, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
isOutside,
|
||||
@@ -17,6 +19,11 @@ import {
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
import {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from './source.ts'
|
||||
|
||||
export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
@@ -31,6 +38,19 @@ export const name = 'repository-plugin'
|
||||
/** Loader service required to register the fixed prepared-wrapper builtin. */
|
||||
export const inject = ['loader']
|
||||
|
||||
/** Repository Plugin runtime and source-list configuration. */
|
||||
export interface Config {
|
||||
/** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
|
||||
repositories?: string[]
|
||||
/** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
|
||||
cacheDir?: string
|
||||
}
|
||||
|
||||
export const Config = z.object({
|
||||
repositories: z.array(z.string().min(1)).default([]),
|
||||
cacheDir: z.string().min(1).optional(),
|
||||
}).strict().default({ repositories: [] })
|
||||
|
||||
function preparedPath(baseUrl: string, configured: string): string {
|
||||
if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
|
||||
const directory = dirname(fileURLToPath(baseUrl))
|
||||
@@ -101,16 +121,25 @@ const preparedRuntime = {
|
||||
* Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
|
||||
* @param ctx - plugin context carrying the Loader service.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
|
||||
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
|
||||
}
|
||||
ctx.effect(function* () {
|
||||
const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
|
||||
if (new Set(repositories).size !== repositories.length) {
|
||||
throw new Error('repository sources must resolve to unique exact specifiers')
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}, 'repository-plugin Loader builtin')
|
||||
for (const repository of repositories) {
|
||||
const plugin = await loadPreparedRepository(ctx, cache, repository)
|
||||
yield plugin.dispose
|
||||
}
|
||||
}, 'repository-plugin runtime and sources')
|
||||
}
|
||||
|
||||
74
packages/cordis/repository-plugin/src/source.ts
Normal file
74
packages/cordis/repository-plugin/src/source.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* GitHub repository source validation and prepared-wrapper loading.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Context, Fiber, Plugin } from 'cordis'
|
||||
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { PREPARED_ENTRY_FILENAME } from './format.ts'
|
||||
|
||||
/** Directory under the Harness home containing immutable repository generations. */
|
||||
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
|
||||
|
||||
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s&]+)(?:&path:(\/[^\s&]+))?$/
|
||||
|
||||
function validPluginPath(path: string): boolean {
|
||||
const segments = path.split('/').slice(1)
|
||||
return segments.length > 0
|
||||
&& segments.at(-1) === '.dsh-plugin'
|
||||
&& segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one user-facing GitHub source to the exact pnpm dependency specifier.
|
||||
* @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`.
|
||||
* @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted.
|
||||
* @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid.
|
||||
*/
|
||||
export function resolveRepositorySpecifier(configured: string): string {
|
||||
const match = GITHUB_SOURCE_PATTERN.exec(configured)
|
||||
if (match === null) {
|
||||
throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const path = match[4]
|
||||
if (path !== undefined && !validPluginPath(path)) {
|
||||
throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`)
|
||||
}
|
||||
return path === undefined ? `${configured}&path:/.dsh-plugin` : configured
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persistent repository cache root.
|
||||
* @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`.
|
||||
* @returns an absolute cache directory.
|
||||
*/
|
||||
export function resolveRepositoryCacheDirectory(configured: string | undefined): string {
|
||||
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
|
||||
* @param ctx - repository runtime context that owns the child.
|
||||
* @param cache - package-manager-native immutable repository cache.
|
||||
* @param specifier - normalized exact pnpm dependency specifier.
|
||||
* @returns the settled prepared-wrapper fiber.
|
||||
* @throws when installation, wrapper import, manifest validation, or child registration fails.
|
||||
*/
|
||||
export async function loadPreparedRepository(
|
||||
ctx: Context,
|
||||
cache: Pick<RepositoryCache, 'resolve'>,
|
||||
specifier: string,
|
||||
): Promise<Fiber> {
|
||||
const directory = await cache.resolve(specifier)
|
||||
const filename = join(directory, PREPARED_ENTRY_FILENAME)
|
||||
try {
|
||||
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
|
||||
const fiber = ctx.plugin(plugin)
|
||||
return await fiber
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ 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'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -12,6 +13,11 @@ import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
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 {
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
} from '../src/source.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -35,6 +41,8 @@ async function writeSkill(root: string, name: string): Promise<void> {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
@@ -253,7 +261,7 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
await ctx.plugin(Loader)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered')
|
||||
await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered')
|
||||
|
||||
const replacement = { name: 'replacement', apply() {} }
|
||||
ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement
|
||||
@@ -263,6 +271,113 @@ describe('prepared repository plugin Loader composition', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('configured GitHub repository sources', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
it('accepts an empty direct-apply config', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await RepositoryPlugin.apply(ctx, {})
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('adds the root plugin subpath and preserves an explicit nested plugin subpath', () => {
|
||||
expect(resolveRepositorySpecifier('github:PolyArch/humanize#v1.0.0'))
|
||||
.toBe('github:PolyArch/humanize#v1.0.0&path:/.dsh-plugin')
|
||||
expect(resolveRepositorySpecifier('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin'))
|
||||
.toBe('github:owner/repository#feature/ref&path:/plugins/one/.dsh-plugin')
|
||||
})
|
||||
|
||||
it('rejects absent refs and invalid plugin subpaths', () => {
|
||||
for (const source of [
|
||||
'github:owner/repository',
|
||||
'github:owner/repository#',
|
||||
'https://github.com/owner/repository#ref',
|
||||
'github:owner/repository#ref&path:relative/.dsh-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#<ref>')
|
||||
}
|
||||
for (const path of [
|
||||
'/plugins//.dsh-plugin',
|
||||
'/plugins/../.dsh-plugin',
|
||||
'/plugins/./.dsh-plugin',
|
||||
'/plugins/not-a-plugin',
|
||||
]) {
|
||||
expect(() => resolveRepositorySpecifier(`github:owner/repository#ref&path:${path}`))
|
||||
.toThrow('path must be an absolute repository subpath')
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the default cache under DSH_HOME and an explicit cache absolutely', async () => {
|
||||
const root = await temporaryDirectory('cache-root')
|
||||
vi.stubEnv('DSH_HOME', root)
|
||||
expect(resolveRepositoryCacheDirectory(undefined)).toBe(join(root, 'cache', 'repository-plugins'))
|
||||
expect(resolveRepositoryCacheDirectory(join(root, 'explicit'))).toBe(join(root, 'explicit'))
|
||||
})
|
||||
|
||||
it('loads a configured source through the immutable cache and removes its skill on teardown', async () => {
|
||||
const root = await temporaryDirectory('configured-source')
|
||||
await writeSkill(join(root, 'skills'), 'configured-repository-skill')
|
||||
const directory = await writePlugin(root, 'configured-source-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
const resolved: string[] = []
|
||||
const cacheDirectory = join(root, 'cache')
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async function (this: RepositoryCache, specifier) {
|
||||
expect(this.directory).toBe(cacheDirectory)
|
||||
resolved.push(specifier)
|
||||
return directory
|
||||
})
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin, {
|
||||
repositories: ['github:owner/repository#fixed-ref'],
|
||||
cacheDir: cacheDirectory,
|
||||
})
|
||||
await registrar
|
||||
expect(resolved).toEqual(['github:owner/repository#fixed-ref&path:/.dsh-plugin'])
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toMatchObject({
|
||||
provider: 'repository:configured-source-fixture',
|
||||
})
|
||||
|
||||
await registrar.dispose()
|
||||
await expect(ctx.skills.get('configured-repository-skill')).resolves.toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate generations and cleans the builtin after cache preparation fails', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: [
|
||||
'github:owner/repository#ref',
|
||||
'github:owner/repository#ref',
|
||||
],
|
||||
})).rejects.toThrow('must resolve to unique exact specifiers')
|
||||
|
||||
vi.spyOn(RepositoryCache.prototype, 'resolve').mockRejectedValue(new Error('prepare failed'))
|
||||
await expect(RepositoryPlugin.apply(ctx, {
|
||||
repositories: ['github:owner/repository#other'],
|
||||
})).rejects.toThrow('prepare failed')
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('labels a missing prepared wrapper with its exact source and path', async () => {
|
||||
const root = await temporaryDirectory('missing-wrapper')
|
||||
const ctx = new Context()
|
||||
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
|
||||
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
|
||||
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository plugin invariant companion', () => {
|
||||
it('registers its explained empty invariant', async () => {
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../mcp/mcp-client"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user