index.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. /**
  2. * Trusted repository-package runtime for code, skills, and common MCP definitions.
  3. * @module @deepseek-ai/dsh-repository-plugin
  4. */
  5. import { readFile, stat } from 'node:fs/promises'
  6. import { dirname, isAbsolute, resolve } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import type { Context } from 'cordis'
  9. import type {} from '@cordisjs/plugin-loader'
  10. import { RepositoryCache } from '@cordisjs/plugin-loader/repository'
  11. import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
  12. import * as McpClient from '@deepseek-ai/dsh-mcp-client'
  13. import { z } from 'zod'
  14. import {
  15. REPOSITORY_PLUGIN_BUILTIN,
  16. isOutside,
  17. parsePreparedPluginConfig,
  18. type PreparedPluginConfig,
  19. } from './format.ts'
  20. import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
  21. import {
  22. loadPreparedRepository,
  23. resolveRepositoryCacheDirectory,
  24. resolveRepositorySpecifier,
  25. } from './source.ts'
  26. export {
  27. PREPARED_ASSET_DIRECTORY,
  28. PREPARED_ENTRY_FILENAME,
  29. REPOSITORY_PLUGIN_BUILTIN,
  30. REPOSITORY_PLUGIN_PACKAGE_NAME,
  31. REPOSITORY_PLUGIN_PREPARE_COMMAND,
  32. prepareDshPlugin,
  33. type PreparedPluginManifest,
  34. } from './format.ts'
  35. /** Cordis plugin name used by Loader diagnostics. */
  36. export const name = 'repository-plugin'
  37. /** Loader service required to register the fixed prepared-wrapper builtin. */
  38. export const inject = ['loader']
  39. /** Repository Plugin runtime and source-list configuration. */
  40. export interface Config {
  41. /** GitHub repository sources with explicit refs and optional `.dsh-plugin` subpaths. */
  42. repositories?: string[]
  43. /** Persistent generation cache; defaults to `$DSH_HOME/cache/repository-plugins`. */
  44. cacheDir?: string
  45. }
  46. export const Config = z.object({
  47. repositories: z.array(z.string().min(1)).default([]),
  48. cacheDir: z.string().min(1).optional(),
  49. }).strict().default({ repositories: [] })
  50. function preparedPath(baseUrl: string, configured: string): string {
  51. if (isAbsolute(configured)) throw new Error(`prepared DSH plugin path must be relative: ${JSON.stringify(configured)}`)
  52. const directory = dirname(fileURLToPath(baseUrl))
  53. const path = resolve(directory, configured)
  54. if (isOutside(directory, path)) {
  55. throw new Error(`prepared DSH plugin path escapes its package: ${JSON.stringify(configured)}`)
  56. }
  57. return path
  58. }
  59. async function preparedDirectory(baseUrl: string, configured: string): Promise<string> {
  60. const path = preparedPath(baseUrl, configured)
  61. // A manifest-declared skill root missing from the installed package (files/
  62. // .npmignore dropping generated outputs, a damaged cache entry) must fail
  63. // the plugin load: the skill provider treats an absent root as legitimately
  64. // empty, which would silently mount a skill-less plugin.
  65. let info
  66. try {
  67. info = await stat(path)
  68. } catch (cause) {
  69. throw new Error(`prepared DSH plugin skill root is missing from the installed package: ${JSON.stringify(configured)}`, { cause })
  70. }
  71. if (!info.isDirectory()) {
  72. throw new Error(`prepared DSH plugin skill root is not a directory: ${JSON.stringify(configured)}`)
  73. }
  74. return path
  75. }
  76. async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise<void> {
  77. const config = parsePreparedPluginConfig(value)
  78. const directory = dirname(fileURLToPath(config.baseUrl))
  79. const skillDirectories = await Promise.all(config.manifest.skills.map(path => preparedDirectory(config.baseUrl, path)))
  80. const mcpConfigs = config.manifest.mcpServers === undefined
  81. ? []
  82. : resolveMcpServers(
  83. parseMcpDocument(await readFile(preparedPath(config.baseUrl, config.manifest.mcpServers), 'utf8')),
  84. process.env,
  85. directory,
  86. // Schemastery call signatures collapse the parameter to `never` under
  87. // NodeNext; ResolvedMcpServer is shaped for the Config union by design.
  88. ).map(input => McpClient.Config(input as never))
  89. await ctx.effect(async function* () {
  90. if (skillDirectories.length > 0) {
  91. const skills = ctx.plugin(SkillLocal, {
  92. providerName: `repository:${config.manifest.name}`,
  93. includeDefaultRoots: false,
  94. customSkillDirs: skillDirectories,
  95. watch: false,
  96. })
  97. await skills
  98. yield skills.dispose
  99. }
  100. for (const mcpConfig of mcpConfigs) {
  101. const mcp = ctx.plugin(McpClient, mcpConfig)
  102. await mcp
  103. yield mcp.dispose
  104. }
  105. }, `repository-plugin(${config.manifest.name})`)
  106. }
  107. const preparedRuntime = {
  108. name: 'repository-plugin-runtime',
  109. apply: applyPrepared,
  110. }
  111. /**
  112. * Register the DSH-owned runtime as the Loader builtin used by fixed prepared wrappers.
  113. * @param ctx - plugin context carrying the Loader service.
  114. */
  115. export async function apply(ctx: Context, config: Config = {}): Promise<void> {
  116. if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] !== undefined) {
  117. throw new Error(`Loader builtin ${REPOSITORY_PLUGIN_BUILTIN} is already registered`)
  118. }
  119. const repositories = (config.repositories ?? []).map(resolveRepositorySpecifier)
  120. if (new Set(repositories).size !== repositories.length) {
  121. throw new Error('repository sources must resolve to unique exact specifiers')
  122. }
  123. const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
  124. await ctx.effect(async function* () {
  125. ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
  126. yield () => {
  127. if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
  128. Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
  129. }
  130. }
  131. for (const repository of repositories) {
  132. const plugin = await loadPreparedRepository(ctx, cache, repository)
  133. yield plugin.dispose
  134. }
  135. }, 'repository-plugin runtime and sources')
  136. }