fix(repository-plugin): make GitHub source preparation self-contained

This commit is contained in:
Tianyi Cui
2026-08-08 17:46:56 +08:00
parent da2179dd2b
commit b91b1fdefe
20 changed files with 454 additions and 64 deletions

View File

@@ -36,14 +36,14 @@ describe('RepositoryCache', () => {
calls.push(directory)
await fakePackage(directory)
}
const cache = new RepositoryCache(root, install)
const cache = new RepositoryCache(root, { install })
const specifier = 'github:owner/repository#0123456789abcdef'
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
expect(concurrent).toBe(first)
expect(calls).toHaveLength(1)
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
const reopened = new RepositoryCache(root, { install: async () => { throw new Error('cache miss') } })
expect(await reopened.resolve(specifier)).toBe(first)
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
@@ -68,8 +68,8 @@ describe('RepositoryCache', () => {
const specifier = 'github:owner/repository#race'
const [first, second] = await Promise.all([
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
new RepositoryCache(root, { install }).resolve(specifier),
])
expect(second).toBe(first)
@@ -80,11 +80,11 @@ describe('RepositoryCache', () => {
it('removes a failed staging tree and permits an exact retry', async () => {
const root = await temporaryRoot('repository-retry')
let attempts = 0
const cache = new RepositoryCache(root, async (directory) => {
const cache = new RepositoryCache(root, { install: async (directory) => {
attempts += 1
if (attempts === 1) throw new Error('install failed')
await fakePackage(directory)
})
} })
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
expect(await readdir(root)).toEqual([])
@@ -94,7 +94,7 @@ describe('RepositoryCache', () => {
it('rejects empty or padded specifiers before touching the cache', async () => {
const root = await temporaryRoot('repository-input')
const cache = new RepositoryCache(root, fakePackage)
const cache = new RepositoryCache(root, { install: fakePackage })
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
await expect(readdir(root)).resolves.toEqual([])
@@ -107,7 +107,7 @@ describe('RepositoryCache', () => {
const entry = join(root, key)
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
const cache = new RepositoryCache(root, { install: async () => { throw new Error('must not reinstall') } })
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
})
@@ -115,6 +115,22 @@ describe('RepositoryCache', () => {
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { 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, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
@@ -125,17 +141,9 @@ describe('RepositoryCache', () => {
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepare: 'node prepare.mjs' },
scripts: { prepack: 'dsh-plugin-prepare' },
dsh: { skills: ['../skills'] },
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'))
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
await execFileAsync('git', ['add', '.'], { cwd: repository })
await execFileAsync('git', [
@@ -148,7 +156,9 @@ describe('RepositoryCache', () => {
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
const installed = await new RepositoryCache(join(root, 'cache'), {
executableDirectories: [executableDirectory],
}).resolve(specifier)
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')
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))

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: 33cd763d7dbe21b72f9e604b7b2e313081cf656f
README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b
README.md: e0b45dc5fd40b5d1005598ae100d23ca7d0b6ec9
README.zh.md: 30f200d2b7188d88e4a5e4e566cf3394f799933a

View File

@@ -14,10 +14,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory:
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "dsh-plugin-prepare"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
"prepack": "dsh-plugin-prepare"
},
"dsh": {
"skills": ["../skills"],
@@ -26,7 +23,7 @@ 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.
`scripts.prepack` must be exactly `dsh-plugin-prepare`. DSH supplies that command from its own installed runtime while preparing Git source, so the repository package needs no DSH or npm dependency. `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
@@ -47,7 +44,7 @@ Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A v
## 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.
During exact Git installation, DSH places a temporary host-owned `dsh-plugin-prepare` command on the isolated package lifecycle `PATH`; the command is not fetched from npm. The required `prepack` lifecycle runs after the Git package's dependency installation and before its selected subdirectory is packed, including when `.dsh-plugin` sits inside another package-manager workspace. The command validates `package.json#dsh`, verifies skill-root types, parses the MCP file, copies assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained the exact `prepack` declaration. 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. Failure to run or complete preparation fails installation 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).
The containing package manager still runs the configured repository package's lifecycle scripts. This restriction defines the supported DSH contribution surface; it is not a security boundary for a repository that the user chose to install as executable package-manager source.

View File

@@ -14,10 +14,7 @@
"version": "0.0.0",
"private": true,
"scripts": {
"prepare": "dsh-plugin-prepare"
},
"devDependencies": {
"@deepseek-ai/dsh-repository-plugin": "^0.0.1"
"prepack": "dsh-plugin-prepare"
},
"dsh": {
"skills": ["../skills"],
@@ -26,7 +23,7 @@
}
```
`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
`scripts.prepack` 必须精确设为 `dsh-plugin-prepare`。DSH 会在准备 Git 源时由已安装的运行时提供该命令,因此仓库包无需添加 DSH 或 NPM 依赖。`dsh.skills` 是可选的本地 skill 根数组。`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;两者至少声明一个。路径相对于 `.dsh-plugin`,必须留在其父级源码目录下,因此可以引用 `../skills` 等仓库现有资源。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。
## 独立应用配置
@@ -47,7 +44,7 @@
## 准备阶段
`dsh-plugin-prepare` 校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。包装模块只包含规范化后的静态 manifest元数据清单以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript运行时也不会导入仓库的其他入口。
安装精确指定的 Git 源时DSH 会把一个临时的宿主自有 `dsh-plugin-prepare` 命令放入隔离的包生命周期 `PATH`;该命令不从 NPM 获取。必需的 `prepack` 生命周期在 Git 包完成依赖安装后、选定子目录打包前运行,即使 `.dsh-plugin` 位于另一个包管理器工作区内也不例外。该命令校验 `package.json#dsh`、确认 skill 根类型、解析 MCP 文件、把资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`导入该包装模块前DSH 会重新校验已安装包是否仍保留精确的 `prepack` 声明。包装模块只包含规范化后的静态 manifest元数据清单以及查找 `dsh-repository-plugin` Loader builtin 的固定代码;它不会发现或编译仓库 JavaScript运行时也不会导入仓库的其他入口。准备阶段未运行或未完成时,安装会在发布缓存 generation 前失败。设计依据见[宿主自有 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-host-owned-git-repository-plugin-preparation.md)。
外层包管理器仍会运行已配置仓库包的生命周期脚本。这里的限制只定义 DSH 所支持的贡献表面;对于用户选择以可执行包管理器源安装的仓库,它并不是安全边界。

View File

@@ -14,6 +14,8 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
/** Loader builtin used by every generated import-free wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
/** Exact host-owned command required by the repository package `prepack` lifecycle. */
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
const sourceMetadataSchema = z.object({
skills: z.array(z.string().min(1)).default([]),
@@ -23,6 +25,9 @@ const sourceMetadataSchema = z.object({
})
const sourcePackageSchema = z.looseObject({
name: z.string().min(1),
scripts: z.looseObject({
prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND),
}),
dsh: sourceMetadataSchema,
})
const preparedManifestSchema = z.object({
@@ -148,7 +153,7 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
}
const parsed = sourcePackageSchema.safeParse(packageValue)
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error)
const sourceRoot = await realpath(dirname(pluginDirectory))
const skillSources: string[] = []

View File

@@ -20,6 +20,7 @@ import {
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -29,6 +30,7 @@ export {
PREPARED_ASSET_DIRECTORY,
PREPARED_ENTRY_FILENAME,
REPOSITORY_PLUGIN_BUILTIN,
REPOSITORY_PLUGIN_PREPARE_COMMAND,
prepareDshPlugin,
type PreparedPluginManifest,
} from './format.ts'
@@ -129,17 +131,24 @@ 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 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)
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)
}
}
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
} finally {
await prepareCommand?.dispose()
}
}

View File

@@ -3,12 +3,18 @@
* @module
*/
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fileURLToPath, 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'
import { PREPARED_ENTRY_FILENAME } from './format.ts'
import { z } from 'zod'
import {
PREPARED_ENTRY_FILENAME,
REPOSITORY_PLUGIN_PREPARE_COMMAND,
} from './format.ts'
// Value mirror: Cordis's const enum has no runtime object to import. Keep
// aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts`.
@@ -17,11 +23,66 @@ 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
// resolvable point').
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
const installedPackageSchema = z.looseObject({
scripts: z.looseObject({
prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND),
}),
})
function validPluginPath(path: string): boolean {
const segments = path.split('/').slice(1)
@@ -57,6 +118,19 @@ export function resolveRepositoryCacheDirectory(configured: string | undefined):
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
}
async function assertInstalledPackageMetadata(directory: string): Promise<void> {
let value: unknown
try {
value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown
} catch (cause) {
throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause })
}
const result = installedPackageSchema.safeParse(value)
if (!result.success) {
throw new Error(`installed DSH plugin package must declare scripts.prepack as ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`)
}
}
/**
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
* @param ctx - repository runtime context that owns the child.
@@ -73,6 +147,7 @@ export async function loadPreparedRepository(
const directory = await cache.resolve(specifier)
const filename = join(directory, PREPARED_ENTRY_FILENAME)
try {
await assertInstalledPackageMetadata(directory)
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
const fiber = ctx.plugin(plugin)
await fiber

View File

@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -14,6 +14,7 @@ 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,
@@ -30,7 +31,12 @@ async function temporaryDirectory(name: string): Promise<string> {
async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): 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', dsh }, undefined, 2)}\n`)
await writeFile(join(directory, 'package.json'), `${JSON.stringify({
name,
version: '0.0.0',
scripts: { prepack: RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND },
dsh,
}, undefined, 2)}\n`)
return directory
}
@@ -102,6 +108,16 @@ describe('dsh-plugin-prepare', () => {
await writeFile(join(malformed, 'package.json'), '{')
await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata')
const lifecycleRoot = await temporaryDirectory('wrong-lifecycle')
const lifecycle = join(lifecycleRoot, '.dsh-plugin')
await mkdir(lifecycle)
await writeFile(join(lifecycle, 'package.json'), JSON.stringify({
name: 'wrong-lifecycle',
scripts: { prepare: 'dsh-plugin-prepare' },
dsh: { skills: ['../skills'] },
}))
await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack')
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 or mcpServers file')
@@ -272,6 +288,17 @@ 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)
@@ -439,12 +466,43 @@ describe('configured GitHub repository sources', () => {
it('labels a missing prepared wrapper with its exact source and path', async () => {
const root = await temporaryDirectory('missing-wrapper')
const directory = await writePlugin(root, 'missing-wrapper', { skills: ['../skills'] })
const ctx = new Context()
const specifier = 'github:owner/repository#missing&path:/.dsh-plugin'
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, specifier))
.rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`)
await ctx.fiber.dispose()
})
it('rejects installed source with the obsolete prepare lifecycle', async () => {
const root = await temporaryDirectory('installed-lifecycle')
await writeFile(join(root, 'package.json'), JSON.stringify({
name: 'installed-lifecycle',
scripts: { prepare: 'dsh-plugin-prepare' },
}))
const ctx = new Context()
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin'))
.rejects.toMatchObject({
cause: expect.objectContaining({
message: expect.stringContaining('must declare scripts.prepack') 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()
const specifier = 'github:owner/repository#damaged&path:/.dsh-plugin'
await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier))
.rejects.toMatchObject({
message: expect.stringContaining(JSON.stringify(specifier)) as string,
cause: expect.objectContaining({
message: expect.stringContaining('failed to read installed DSH plugin package metadata') as string,
}) as Error,
})
await ctx.fiber.dispose()
})
})
describe('repository plugin invariant companion', () => {