feat: add static repository plugin format
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/** Command-line entry that prepares the current `.dsh-plugin` package. @module */
|
||||
|
||||
import { prepareDshPlugin } from './format.ts'
|
||||
|
||||
try {
|
||||
await prepareDshPlugin()
|
||||
} catch (error) {
|
||||
process.stderr.write(`dsh-plugin-prepare: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Static repository-plugin preparation and prepared-manifest validation.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { cp, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { parseMcpDocument } from './mcp.ts'
|
||||
|
||||
/** Fixed module filename loaded from an installed prepared plugin package. */
|
||||
export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
|
||||
/** Fixed directory containing copied static plugin assets. */
|
||||
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'
|
||||
|
||||
const sourceMetadataSchema = z.object({
|
||||
skills: z.array(z.string().min(1)).default([]),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined, {
|
||||
message: 'declare at least one skill root or mcpServers file',
|
||||
})
|
||||
const sourcePackageSchema = z.looseObject({
|
||||
name: z.string().min(1),
|
||||
dsh: sourceMetadataSchema,
|
||||
})
|
||||
const preparedManifestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
skills: z.array(z.string().min(1)),
|
||||
mcpServers: z.string().min(1).optional(),
|
||||
}).strict()
|
||||
const preparedConfigSchema = z.object({
|
||||
baseUrl: z.url(),
|
||||
manifest: preparedManifestSchema,
|
||||
}).strict()
|
||||
|
||||
/** Static manifest embedded in the generated wrapper. */
|
||||
export interface PreparedPluginManifest {
|
||||
name: string
|
||||
skills: string[]
|
||||
mcpServers?: string
|
||||
}
|
||||
|
||||
/** Untrusted generated-wrapper config accepted by the DSH-owned runtime builtin. */
|
||||
export interface PreparedPluginConfig {
|
||||
baseUrl: string
|
||||
manifest: PreparedPluginManifest
|
||||
}
|
||||
|
||||
function formatZodError(label: string, error: z.ZodError): Error {
|
||||
return new Error(`${label}:\n${z.prettifyError(error)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the config passed by an installed prepared wrapper.
|
||||
* @param value - wrapper-provided value crossing the file/module boundary.
|
||||
* @returns a detached typed config.
|
||||
*/
|
||||
export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig {
|
||||
const result = preparedConfigSchema.safeParse(value)
|
||||
if (!result.success) throw formatZodError('invalid prepared DSH plugin', result.error)
|
||||
return {
|
||||
baseUrl: result.data.baseUrl,
|
||||
manifest: {
|
||||
name: result.data.manifest.name,
|
||||
skills: result.data.manifest.skills,
|
||||
...result.data.manifest.mcpServers === undefined ? {} : { mcpServers: result.data.manifest.mcpServers },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isOutside(root: string, candidate: string): boolean {
|
||||
const path = relative(root, candidate)
|
||||
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
||||
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
||||
}
|
||||
|
||||
async function sourcePath(pluginDirectory: string, sourceRoot: string, configured: string, kind: 'directory' | 'file'): Promise<string> {
|
||||
if (isAbsolute(configured)) throw new Error(`DSH plugin asset path must be relative: ${JSON.stringify(configured)}`)
|
||||
let path: string
|
||||
try {
|
||||
path = await realpath(resolve(pluginDirectory, configured))
|
||||
} catch (cause) {
|
||||
throw new Error(`DSH plugin asset does not exist: ${JSON.stringify(configured)}`, { cause })
|
||||
}
|
||||
if (isOutside(sourceRoot, path)) {
|
||||
throw new Error(`DSH plugin asset escapes its plugin source root: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
const info = await stat(path)
|
||||
if (kind === 'directory' ? !info.isDirectory() : !info.isFile()) {
|
||||
throw new Error(`DSH plugin asset is not a ${kind}: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function wrapperSource(manifest: PreparedPluginManifest): string {
|
||||
return [
|
||||
'// Generated by dsh-plugin-prepare. Do not edit.',
|
||||
`const manifest = ${JSON.stringify(manifest)}`,
|
||||
`export const name = ${JSON.stringify(manifest.name)}`,
|
||||
"export const inject = ['loader']",
|
||||
'export async function apply(ctx) {',
|
||||
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
|
||||
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
|
||||
' await ctx.plugin(runtime, { baseUrl: import.meta.url, manifest })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
||||
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
|
||||
* @returns the generated static manifest.
|
||||
*/
|
||||
export async function prepareDshPlugin(directory: string = process.cwd()): Promise<PreparedPluginManifest> {
|
||||
const pluginDirectory = await realpath(resolve(directory))
|
||||
let packageValue: unknown
|
||||
try {
|
||||
packageValue = JSON.parse(await readFile(join(pluginDirectory, 'package.json'), 'utf8')) as unknown
|
||||
} catch (cause) {
|
||||
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)
|
||||
|
||||
const sourceRoot = await realpath(dirname(pluginDirectory))
|
||||
const skillSources: string[] = []
|
||||
for (const configured of parsed.data.dsh.skills) {
|
||||
const source = await sourcePath(pluginDirectory, sourceRoot, configured, 'directory')
|
||||
if (!isOutside(source, pluginDirectory)) {
|
||||
throw new Error(`DSH skill root cannot contain the .dsh-plugin package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
skillSources.push(source)
|
||||
}
|
||||
let mcpSource: string | undefined
|
||||
if (parsed.data.dsh.mcpServers !== undefined) {
|
||||
mcpSource = await sourcePath(pluginDirectory, sourceRoot, parsed.data.dsh.mcpServers, 'file')
|
||||
parseMcpDocument(await readFile(mcpSource, 'utf8'))
|
||||
}
|
||||
|
||||
const manifest: PreparedPluginManifest = {
|
||||
name: parsed.data.name,
|
||||
skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`),
|
||||
...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` },
|
||||
}
|
||||
const staging = await mkdtemp(join(pluginDirectory, '.dsh-plugin-prepare-'))
|
||||
try {
|
||||
const stagedAssets = join(staging, PREPARED_ASSET_DIRECTORY)
|
||||
await mkdir(join(stagedAssets, 'skills'), { recursive: true })
|
||||
await Promise.all(skillSources.map((source, index) => cp(source, join(stagedAssets, 'skills', String(index)), {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
})))
|
||||
if (mcpSource !== undefined) await copyFile(mcpSource, join(stagedAssets, '.mcp.json'))
|
||||
await writeFile(join(staging, PREPARED_ENTRY_FILENAME), wrapperSource(manifest))
|
||||
|
||||
await rm(join(pluginDirectory, PREPARED_ASSET_DIRECTORY), { recursive: true, force: true })
|
||||
await rm(join(pluginDirectory, PREPARED_ENTRY_FILENAME), { force: true })
|
||||
await rename(stagedAssets, join(pluginDirectory, PREPARED_ASSET_DIRECTORY))
|
||||
await rename(join(staging, PREPARED_ENTRY_FILENAME), join(pluginDirectory, PREPARED_ENTRY_FILENAME))
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Restricted repository-plugin runtime for static skills and common MCP definitions.
|
||||
* @module @deepseek-ai/dsh-repository-plugin
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
||||
import {
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
parsePreparedPluginConfig,
|
||||
type PreparedPluginConfig,
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
|
||||
export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
} from './format.ts'
|
||||
|
||||
/** Cordis plugin name used by Loader diagnostics. */
|
||||
export const name = 'repository-plugin'
|
||||
/** Loader service required to register the fixed prepared-wrapper builtin. */
|
||||
export const inject = ['loader']
|
||||
|
||||
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))
|
||||
const path = resolve(directory, configured)
|
||||
const rel = relative(directory, path)
|
||||
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
||||
throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
|
||||
const config = parsePreparedPluginConfig(value)
|
||||
const directory = dirname(fileURLToPath(config.baseUrl))
|
||||
const skillDirectories = config.manifest.skills.map(path => preparedPath(config.baseUrl, path))
|
||||
const mcpConfigs = config.manifest.mcpServers === undefined
|
||||
? []
|
||||
: resolveMcpServers(
|
||||
parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
|
||||
process.env,
|
||||
directory,
|
||||
).map(input => McpClient.Config(input as never))
|
||||
|
||||
await ctx.effect(async function* () {
|
||||
if (skillDirectories.length > 0) {
|
||||
const skills = ctx.plugin(SkillLocal, {
|
||||
providerName: `repository:${config.manifest.name}`,
|
||||
includeDefaultRoots: false,
|
||||
customSkillDirs: skillDirectories,
|
||||
watch: false,
|
||||
})
|
||||
await skills
|
||||
yield skills.dispose
|
||||
}
|
||||
for (const mcpConfig of mcpConfigs) {
|
||||
const mcp = ctx.plugin(McpClient, mcpConfig)
|
||||
await mcp
|
||||
yield mcp.dispose
|
||||
}
|
||||
}, `repository-plugin(${config.manifest.name})`)
|
||||
}
|
||||
|
||||
const preparedRuntime = {
|
||||
name: 'repository-plugin-runtime',
|
||||
apply: applyPrepared,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
|
||||
throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
|
||||
}
|
||||
ctx.effect(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')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`.
|
||||
* @module @deepseek-ai/dsh-repository-plugin/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'repository-plugin-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the package owns no service state; Loader fibers and the existing skill
|
||||
* and MCP owners expose the authoritative lifecycle relationships for its composed children.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Parser for the common `.mcp.json` file consumed by prepared repository plugins.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
|
||||
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
const PLACEHOLDER_PATTERN = /\$\{([^}]*)\}/g
|
||||
|
||||
const stringMap = z.record(z.string(), z.string())
|
||||
const stdioServerSchema = z.object({
|
||||
type: z.literal('stdio').optional(),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: stringMap.optional(),
|
||||
}).strict()
|
||||
const httpServerSchema = z.object({
|
||||
type: z.literal('http'),
|
||||
url: z.string().min(1),
|
||||
headers: stringMap.optional(),
|
||||
}).strict()
|
||||
const documentSchema = z.object({
|
||||
mcpServers: z.record(z.string(), z.union([stdioServerSchema, httpServerSchema])),
|
||||
}).strict()
|
||||
|
||||
/** One supported server entry from the common `.mcp.json` format. */
|
||||
export type McpServerDefinition = z.infer<typeof stdioServerSchema> | z.infer<typeof httpServerSchema>
|
||||
|
||||
/** Parsed common MCP document before process-environment expansion. */
|
||||
export interface McpDocument {
|
||||
mcpServers: Record<string, McpServerDefinition>
|
||||
}
|
||||
|
||||
/** Resolved input handed to the existing `dsh-mcp-client` Config schema. */
|
||||
export type ResolvedMcpServer =
|
||||
| {
|
||||
transport: 'stdio'
|
||||
serverName: string
|
||||
command: string
|
||||
args: string[]
|
||||
env: Record<string, string>
|
||||
cwd: string
|
||||
}
|
||||
| {
|
||||
transport: 'streamable-http'
|
||||
serverName: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
function assertTemplate(value: string, location: string): void {
|
||||
for (const match of value.matchAll(PLACEHOLDER_PATTERN)) {
|
||||
const name = match[1] as string
|
||||
if (!ENVIRONMENT_NAME_PATTERN.test(name)) {
|
||||
throw new Error(`${location} contains an unsupported environment placeholder ${JSON.stringify(match[0])}`)
|
||||
}
|
||||
}
|
||||
if (value.replace(PLACEHOLDER_PATTERN, '').includes('${')) {
|
||||
throw new Error(`${location} contains an unterminated environment placeholder`)
|
||||
}
|
||||
}
|
||||
|
||||
function visitStrings(serverName: string, definition: McpServerDefinition, visit: (value: string, location: string) => void): void {
|
||||
if ('command' in definition) {
|
||||
visit(definition.command, `mcpServers.${serverName}.command`)
|
||||
definition.args?.forEach((value, index) => { visit(value, `mcpServers.${serverName}.args[${index}]`) })
|
||||
Object.entries(definition.env ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.env.${name}`) })
|
||||
return
|
||||
}
|
||||
visit(definition.url, `mcpServers.${serverName}.url`)
|
||||
Object.entries(definition.headers ?? {}).forEach(([name, value]) => { visit(value, `mcpServers.${serverName}.headers.${name}`) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one common `.mcp.json` document without resolving environment values.
|
||||
* @param content - UTF-8 JSON document.
|
||||
* @returns the supported stdio and Streamable HTTP server definitions.
|
||||
*/
|
||||
export function parseMcpDocument(content: string): McpDocument {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(content) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error('invalid .mcp.json: expected JSON', { cause })
|
||||
}
|
||||
const result = documentSchema.safeParse(value)
|
||||
if (!result.success) throw new Error(`invalid .mcp.json:\n${z.prettifyError(result.error)}`)
|
||||
for (const [serverName, definition] of Object.entries(result.data.mcpServers)) {
|
||||
if (!SERVER_NAME_PATTERN.test(serverName)) {
|
||||
throw new Error(`invalid .mcp.json: server name ${JSON.stringify(serverName)} must match [A-Za-z0-9_-]{1,32}`)
|
||||
}
|
||||
visitStrings(serverName, definition, assertTemplate)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
function expand(value: string, environment: NodeJS.ProcessEnv, location: string): string {
|
||||
return value.replace(PLACEHOLDER_PATTERN, (_placeholder, name: string) => {
|
||||
const replacement = environment[name]
|
||||
if (replacement === undefined) throw new Error(`${location} requires missing environment variable ${name}`)
|
||||
return replacement
|
||||
})
|
||||
}
|
||||
|
||||
function expandMap(values: Record<string, string> | undefined, environment: NodeJS.ProcessEnv, location: string): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(values ?? {}).map(([name, value]) => [
|
||||
name,
|
||||
expand(value, environment, `${location}.${name}`),
|
||||
]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve supported MCP definitions to inputs for the existing MCP client.
|
||||
* @param document - validated common MCP document.
|
||||
* @param environment - process environment used for exact `${NAME}` expansion.
|
||||
* @param cwd - prepared plugin directory used for stdio child processes.
|
||||
* @returns one existing-client config input per declared server.
|
||||
*/
|
||||
export function resolveMcpServers(document: McpDocument, environment: NodeJS.ProcessEnv, cwd: string): ResolvedMcpServer[] {
|
||||
return Object.entries(document.mcpServers).map(([serverName, definition]) => {
|
||||
if ('command' in definition) {
|
||||
return {
|
||||
transport: 'stdio',
|
||||
serverName,
|
||||
command: expand(definition.command, environment, `mcpServers.${serverName}.command`),
|
||||
args: (definition.args ?? []).map((value, index) => expand(value, environment, `mcpServers.${serverName}.args[${index}]`)),
|
||||
env: expandMap(definition.env, environment, `mcpServers.${serverName}.env`),
|
||||
cwd,
|
||||
}
|
||||
}
|
||||
const url = expand(definition.url, environment, `mcpServers.${serverName}.url`)
|
||||
const protocol = new URL(url).protocol
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`mcpServers.${serverName}.url must use http or https`)
|
||||
}
|
||||
return {
|
||||
transport: 'streamable-http',
|
||||
serverName,
|
||||
url,
|
||||
headers: expandMap(definition.headers, environment, `mcpServers.${serverName}.headers`),
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user