|
|
@@ -27,10 +27,24 @@ async function temporaryDirectory(name: string): Promise<string> {
|
|
|
return directory
|
|
|
}
|
|
|
|
|
|
-async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): Promise<string> {
|
|
|
+async function writePlugin(
|
|
|
+ root: string,
|
|
|
+ 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', dsh }, undefined, 2)}\n`)
|
|
|
+ await writeFile(join(directory, 'package.json'), `${JSON.stringify({
|
|
|
+ name,
|
|
|
+ version: '0.0.0',
|
|
|
+ devDependencies,
|
|
|
+ scripts: { prepack },
|
|
|
+ dsh,
|
|
|
+ }, undefined, 2)}\n`)
|
|
|
return directory
|
|
|
}
|
|
|
|
|
|
@@ -76,6 +90,24 @@ describe('dsh-plugin-prepare', () => {
|
|
|
.resolves.toContain('mcp.expo.dev')
|
|
|
})
|
|
|
|
|
|
+ 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',
|
|
|
+ }, 'npm run build && dsh-plugin-prepare')
|
|
|
+ await mkdir(join(directory, 'lib'))
|
|
|
+ await writeFile(join(directory, 'lib/plugin.mjs'), 'export default { name: "compiled-entry" }\n')
|
|
|
+
|
|
|
+ await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({
|
|
|
+ name: 'compiled-entry-fixture',
|
|
|
+ skills: [],
|
|
|
+ entry: './lib/plugin.mjs',
|
|
|
+ })
|
|
|
+ const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')
|
|
|
+ expect(wrapper).toContain('await import(manifest.entry)')
|
|
|
+ expect(wrapper).toContain('"entry":"./lib/plugin.mjs"')
|
|
|
+ })
|
|
|
+
|
|
|
it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => {
|
|
|
const root = await temporaryDirectory('oauth')
|
|
|
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
|
|
@@ -102,9 +134,39 @@ 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 skippedPrepareRoot = await temporaryDirectory('skipped-prepare')
|
|
|
+ const skippedPrepare = await writePlugin(
|
|
|
+ skippedPrepareRoot,
|
|
|
+ 'skipped-prepare',
|
|
|
+ { skills: ['../skills'] },
|
|
|
+ 'npm run build',
|
|
|
+ )
|
|
|
+ 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 or mcpServers file')
|
|
|
+ await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root, mcpServers file, or compiled entry')
|
|
|
|
|
|
const missingRoot = await temporaryDirectory('missing-asset')
|
|
|
const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] })
|
|
|
@@ -133,16 +195,21 @@ describe('dsh-plugin-prepare', () => {
|
|
|
await writeSkill(outside, 'outside-skill')
|
|
|
const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] })
|
|
|
await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root')
|
|
|
+
|
|
|
+ const escapedEntryRoot = await temporaryDirectory('escaped-entry')
|
|
|
+ await writeFile(join(escapedEntryRoot, 'outside.mjs'), 'export default {}\n')
|
|
|
+ const escapedEntry = await writePlugin(escapedEntryRoot, 'escaped-entry', { entry: '../outside.mjs' })
|
|
|
+ await expect(RepositoryPlugin.prepareDshPlugin(escapedEntry)).rejects.toThrow('escapes its plugin source root')
|
|
|
})
|
|
|
|
|
|
- it('validates prepared wrapper configs with and without MCP assets', () => {
|
|
|
+ it('validates prepared wrapper configs with optional MCP assets and code entries', () => {
|
|
|
expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin')
|
|
|
expect(parsePreparedPluginConfig({
|
|
|
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
|
|
- manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
|
|
+ manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' },
|
|
|
})).toEqual({
|
|
|
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
|
|
- manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
|
|
+ manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' },
|
|
|
})
|
|
|
})
|
|
|
})
|
|
|
@@ -179,12 +246,70 @@ describe('prepared repository plugin Loader composition', () => {
|
|
|
await ctx.fiber.dispose()
|
|
|
})
|
|
|
|
|
|
- it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
|
|
- const root = await temporaryDirectory('mcp-loader')
|
|
|
+ it('mounts and removes the repository package code entry through the real Loader', async () => {
|
|
|
+ const root = await temporaryDirectory('code-loader')
|
|
|
+ const directory = await writePlugin(root, 'code-loader-fixture', { entry: './lib/plugin.mjs' })
|
|
|
+ await mkdir(join(directory, 'lib'))
|
|
|
+ await writeFile(join(directory, 'lib/plugin.mjs'), [
|
|
|
+ "export const name = 'repository-code-proof'",
|
|
|
+ 'export function apply(ctx) {',
|
|
|
+ " ctx.provide('repositoryCodeProof', { source: 'compiled-entry' })",
|
|
|
+ '}',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ await RepositoryPlugin.prepareDshPlugin(directory)
|
|
|
+
|
|
|
+ const ctx = new Context()
|
|
|
+ ctx.baseUrl = pathToFileURL(directory).href + '/'
|
|
|
+ await ctx.plugin(Loader)
|
|
|
+ await ctx.plugin(RepositoryPlugin)
|
|
|
+ const id = await ctx.loader.create({
|
|
|
+ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
|
|
+ })
|
|
|
+ await ctx.loader.await()
|
|
|
+ const getService = (name: string): unknown => (ctx as unknown as { get(name: string): unknown }).get(name)
|
|
|
+ expect(getService('repositoryCodeProof')).toEqual({ source: 'compiled-entry' })
|
|
|
+
|
|
|
+ await ctx.loader.remove(id)
|
|
|
+ expect(getService('repositoryCodeProof')).toBeUndefined()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('mounts and removes tools discovered from a repository MCP server', async () => {
|
|
|
+ const root = await temporaryDirectory('mcp-loader-success')
|
|
|
+ const server = join(root, 'mcp-server.mjs')
|
|
|
+ await writeFile(server, [
|
|
|
+ "import { createInterface } from 'node:readline'",
|
|
|
+ 'const lines = createInterface({ input: process.stdin })',
|
|
|
+ 'for await (const line of lines) {',
|
|
|
+ ' const request = JSON.parse(line)',
|
|
|
+ " if (!('id' in request)) continue",
|
|
|
+ ' let result',
|
|
|
+ " if (request.method === 'initialize') {",
|
|
|
+ ' result = {',
|
|
|
+ ' protocolVersion: request.params.protocolVersion,',
|
|
|
+ ' capabilities: { tools: {} },',
|
|
|
+ " serverInfo: { name: 'repository-fixture', version: '0.0.0' },",
|
|
|
+ ' }',
|
|
|
+ " } else if (request.method === 'tools/list') {",
|
|
|
+ ' result = {',
|
|
|
+ ' tools: [{',
|
|
|
+ " name: 'proof',",
|
|
|
+ " description: 'Repository MCP proof.',",
|
|
|
+ " inputSchema: { type: 'object', properties: {} },",
|
|
|
+ ' }],',
|
|
|
+ ' }',
|
|
|
+ ' } else {',
|
|
|
+ ' result = {}',
|
|
|
+ ' }',
|
|
|
+ " process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`)",
|
|
|
+ '}',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
|
|
- mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
|
|
+ mcpServers: { online: { command: process.execPath, args: [server] } },
|
|
|
}))
|
|
|
- const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' })
|
|
|
+ const directory = await writePlugin(root, 'mcp-loader-success-fixture', { mcpServers: '../.mcp.json' })
|
|
|
await RepositoryPlugin.prepareDshPlugin(directory)
|
|
|
|
|
|
const ctx = new Context()
|
|
|
@@ -197,8 +322,31 @@ describe('prepared repository plugin Loader composition', () => {
|
|
|
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
|
|
})
|
|
|
await ctx.loader.await()
|
|
|
- expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
|
|
+ expect(ctx.tools.get('mcp__online__proof')).toBeDefined()
|
|
|
+
|
|
|
await ctx.loader.remove(id)
|
|
|
+ expect(ctx.tools.get('mcp__online__proof')).toBeUndefined()
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('fails an MCP repository plugin load when its declared server cannot connect', async () => {
|
|
|
+ const root = await temporaryDirectory('mcp-loader')
|
|
|
+ await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
|
|
+ mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
|
|
+ }))
|
|
|
+ const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' })
|
|
|
+ await RepositoryPlugin.prepareDshPlugin(directory)
|
|
|
+
|
|
|
+ const ctx = new Context()
|
|
|
+ ctx.baseUrl = pathToFileURL(directory).href + '/'
|
|
|
+ await ctx.plugin(Loader)
|
|
|
+ await ctx.plugin(SystemPrompt)
|
|
|
+ await ctx.plugin(ToolRegistry)
|
|
|
+ await ctx.plugin(RepositoryPlugin)
|
|
|
+ await expect(ctx.loader.create({
|
|
|
+ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
|
|
+ })).rejects.toThrow('initial connection or tool synchronization failed')
|
|
|
+ expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
|
|
await ctx.fiber.dispose()
|
|
|
})
|
|
|
|
|
|
@@ -439,12 +587,83 @@ 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',
|
|
|
+ devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
|
|
|
+ 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 a non-empty scripts.prepack') as string,
|
|
|
+ }) as Error,
|
|
|
+ })
|
|
|
+ await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin'))
|
|
|
+ .rejects.toMatchObject({
|
|
|
+ cause: expect.objectContaining({
|
|
|
+ message: expect.stringContaining('Clear the matching repository cache generation') as string,
|
|
|
+ }) as Error,
|
|
|
+ })
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ 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',
|
|
|
+ devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' },
|
|
|
+ scripts: { prepack: 'npm run build' },
|
|
|
+ }))
|
|
|
+ const ctx = new Context()
|
|
|
+ await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#unprepared&path:/.dsh-plugin'))
|
|
|
+ .rejects.toMatchObject({
|
|
|
+ cause: expect.objectContaining({
|
|
|
+ message: expect.stringContaining('must invoke dsh-plugin-prepare') as string,
|
|
|
+ }) as Error,
|
|
|
+ })
|
|
|
+ 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()
|
|
|
+ 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', () => {
|