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, 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' 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[] = [] async function temporaryDirectory(name: string): Promise { const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`)) roots.push(directory) return directory } async function writePlugin( root: string, name: string, dsh: Record, prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, devDependencies: Record = { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1', }, ): Promise { 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`) return directory } async function writeSkill(root: string, name: string): Promise { const directory = join(root, name) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`) } afterEach(async () => { vi.restoreAllMocks() vi.unstubAllEnvs() await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) describe('dsh-plugin-prepare', () => { it('copies declared static assets and emits the fixed import-free wrapper', async () => { const root = await temporaryDirectory('prepare') await writeSkill(join(root, 'skills'), 'repository-fixture') await writeFile(join(root, '.mcp.json'), JSON.stringify({ mcpServers: { expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' }, }, })) const directory = await writePlugin(root, 'fixture-plugin', { skills: ['../skills'], mcpServers: '../.mcp.json', }) await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ name: 'fixture-plugin', skills: ['dsh-plugin-assets/skills/0'], mcpServers: 'dsh-plugin-assets/.mcp.json', }) const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`) // Import-free means no static AND no dynamic imports; `import.meta.url` // (no whitespace, no call parenthesis) is the one allowed appearance. expect(wrapper).not.toMatch(/\b(?:import|from)\s|\bimport\s*\(/) await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8')) .resolves.toContain('Static instructions.') await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8')) .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({ mcpServers: { workiq: { type: 'http', url: 'https://workiq.microsoft.com/mcp', oauthClientId: 'client-id', oauthPublicClient: true, auth: { redirectPort: 3317 }, }, }, })) const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' }) await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json') await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) }) it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => { const malformedRoot = await temporaryDirectory('malformed-package') const malformed = join(malformedRoot, '.dsh-plugin') await mkdir(malformed) 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, mcpServers file, or compiled entry') const missingRoot = await temporaryDirectory('missing-asset') const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] }) await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist') const absoluteRoot = await temporaryDirectory('absolute-asset') const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] }) await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative') const wrongTypeRoot = await temporaryDirectory('wrong-type') await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text') const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] }) await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory') const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type') await mkdir(join(wrongMcpRoot, 'not-a-file')) const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' }) await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file') const containingRoot = await temporaryDirectory('containing-root') const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] }) await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package') const escapedRoot = await temporaryDirectory('escaped-root') const outside = await temporaryDirectory('outside-root') 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 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', entry: './lib/plugin.js' }, })).toEqual({ baseUrl: 'file:///plugin/dsh-plugin.mjs', manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, }) }) }) describe('prepared repository plugin Loader composition', () => { it('mounts and removes copied skills through the real Loader and skill-local provider', async () => { const root = await temporaryDirectory('loader') await writeSkill(join(root, 'skills'), 'loaded-from-repository') const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] }) await RepositoryPlugin.prepareDshPlugin(directory) const ctx = new Context() ctx.baseUrl = pathToFileURL(directory).href + '/' await ctx.plugin(Loader) await ctx.plugin(SkillService) const registrar = ctx.plugin(RepositoryPlugin) await registrar expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined() const id = await ctx.loader.create({ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, }) await ctx.loader.await() await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({ name: 'loaded-from-repository', provider: 'repository:loader-fixture', content: 'Static instructions.', }) await ctx.loader.remove(id) await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined() await registrar.dispose() expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined() await ctx.fiber.dispose() }) 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: { online: { command: process.execPath, args: [server] } }, })) const directory = await writePlugin(root, 'mcp-loader-success-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) const id = await ctx.loader.create({ name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, }) await ctx.loader.await() 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() }) it('rejects hostile prepared paths before mounting children', async () => { const root = await temporaryDirectory('prepared-paths') const ctx = new Context() ctx.baseUrl = pathToFileURL(root).href + '/' await ctx.plugin(Loader) await ctx.plugin(RepositoryPlugin) for (const [filename, skillPath] of [ ['absolute.mjs', resolve(root)], ['escaped.mjs', '../outside'], ] as const) { const wrapper = join(root, filename) await writeFile(wrapper, [ "export const inject = ['loader']", 'export async function apply(ctx) {', ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, ` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`, ' })', '}', '', ].join('\n')) await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path') } await ctx.fiber.dispose() }) it('fails the plugin load when a declared skill root is missing or not a directory', async () => { const root = await temporaryDirectory('missing-skill-root') await writeFile(join(root, 'not-a-directory'), 'text') const ctx = new Context() ctx.baseUrl = pathToFileURL(root).href + '/' await ctx.plugin(Loader) await ctx.plugin(SkillService) await ctx.plugin(RepositoryPlugin) for (const [filename, skillPath, message] of [ ['missing.mjs', 'dsh-plugin-assets/skills/0', 'skill root is missing from the installed package'], ['file.mjs', 'not-a-directory', 'skill root is not a directory'], ] as const) { const wrapper = join(root, filename) await writeFile(wrapper, [ "export const inject = ['loader']", 'export async function apply(ctx) {', ` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`, ` baseUrl: import.meta.url, manifest: { name: 'damaged', skills: [${JSON.stringify(skillPath)}] },`, ' })', '}', '', ].join('\n')) await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow(message) } await ctx.fiber.dispose() }) it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => { const ctx = new Context() await ctx.plugin(Loader) const registrar = ctx.plugin(RepositoryPlugin) await registrar await expect(RepositoryPlugin.apply(ctx)).rejects.toThrow('already registered') const replacement = { name: 'replacement', apply() {} } ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement await registrar.dispose() expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement) await ctx.fiber.dispose() }) }) 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#', 'github:owner/repository#a#b', 'https://github.com/owner/repository#ref', 'github:owner/repository#ref&path:relative/.dsh-plugin', ]) { expect(() => resolveRepositorySpecifier(source)).toThrow('must use github:owner/repo#') } 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('swaps generations on a live source-list update and rolls a failed candidate back', async () => { // The headline flow: a personal-config edit reaches this plugin as a // Loader entry.update, which restarts the row's fiber (old cleanup, then // new apply — so the 'already registered' builtin guard must not fire). const roots: Record = {} for (const generation of ['one', 'two'] as const) { const root = await temporaryDirectory(`live-${generation}`) await writeSkill(join(root, 'skills'), `live-skill-${generation}`) const directory = await writePlugin(root, `live-fixture-${generation}`, { skills: ['../skills'] }) await RepositoryPlugin.prepareDshPlugin(directory) roots[`github:owner/repository#${generation}&path:/.dsh-plugin`] = directory } vi.spyOn(RepositoryCache.prototype, 'resolve').mockImplementation(async (specifier) => { const directory = roots[specifier] if (directory === undefined) throw new Error(`unprepared generation ${specifier}`) return directory }) // Route the row through the Loader builtin table exactly as a config tree // would; the module itself is the row's plugin. const ctx2 = new Context() await ctx2.plugin(Loader) await ctx2.plugin(SkillService) ctx2.loader.builtins['repository-plugins'] = RepositoryPlugin const entryId = await ctx2.loader.create({ name: 'cordis:repository-plugins', config: { repositories: ['github:owner/repository#one'] }, }) await ctx2.loader.await() await expect(ctx2.skills.get('live-skill-one')).resolves.toMatchObject({ provider: 'repository:live-fixture-one' }) const entry = ctx2.loader.resolve(entryId) await entry.update({ config: { repositories: ['github:owner/repository#two'] } }) await ctx2.loader.await() await expect(ctx2.skills.get('live-skill-one')).resolves.toBeUndefined() await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) // A failed candidate (unprepared source) rejects the update and the // transactional Loader restores the previous generation. await expect(entry.update({ config: { repositories: ['github:owner/repository#missing'] } })) .rejects.toThrow('unprepared generation') await ctx2.loader.await() await expect(ctx2.skills.get('live-skill-two')).resolves.toMatchObject({ provider: 'repository:live-fixture-two' }) await ctx2.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('rejects a wrapper left pending by a composition without its required services', async () => { // A skills-declaring generation mounted where no skills service exists: // the wrapper fiber stays PENDING, and the transaction must fail loud // instead of committing an ACTIVE row over a silently inert child. const root = await temporaryDirectory('pending-services') await writeSkill(join(root, 'skills'), 'pending-service-skill') const directory = await writePlugin(root, 'pending-service-fixture', { skills: ['../skills'] }) await RepositoryPlugin.prepareDshPlugin(directory) const ctx = new Context() await ctx.plugin(Loader) // Deliberately NO SkillService. await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, 'github:owner/repository#pending&path:/.dsh-plugin')) .rejects.toMatchObject({ message: expect.stringContaining('failed to load prepared repository Plugin') as string, cause: expect.objectContaining({ message: expect.stringContaining('waiting for services: skills') as string, }) as Error, }) await ctx.fiber.dispose() }) 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 () => 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', () => { it('registers its explained empty invariant', async () => { const ctx = new Context() await ctx.plugin(InvariantService, { enabled: true }) await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined() await ctx.fiber.dispose() }) })