source.ts 4.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. /**
  2. * GitHub repository source validation and prepared-wrapper loading.
  3. * @module
  4. */
  5. import { join, resolve } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import type { Context, Fiber, FiberState, Plugin } from 'cordis'
  8. import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
  9. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  10. import { PREPARED_ENTRY_FILENAME } from './format.ts'
  11. // Value mirror: Cordis's const enum has no runtime object to import. Keep
  12. // aligned with `packages/cordis/tool-cordis/src/fiber-state.ts`.
  13. const FIBER_ACTIVE = 2 as FiberState.ACTIVE
  14. /** Directory under the Harness home containing immutable repository generations. */
  15. export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
  16. // The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
  17. // parser, with the syntax the error message promises — instead of inside the
  18. // cache's pnpm install ('misconfiguration fails loud at the earliest
  19. // resolvable point').
  20. const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
  21. function validPluginPath(path: string): boolean {
  22. const segments = path.split('/').slice(1)
  23. return segments.length > 0
  24. && segments.at(-1) === '.dsh-plugin'
  25. && segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..')
  26. }
  27. /**
  28. * Normalize one user-facing GitHub source to the exact pnpm dependency specifier.
  29. * @param configured - `github:owner/repo#ref` with an optional `&path:/.../.dsh-plugin`.
  30. * @returns the exact specifier, with the root `.dsh-plugin` subpath added when omitted.
  31. * @throws when the GitHub owner, repository, explicit ref, or plugin subpath is invalid.
  32. */
  33. export function resolveRepositorySpecifier(configured: string): string {
  34. const match = GITHUB_SOURCE_PATTERN.exec(configured)
  35. if (match === null) {
  36. throw new Error(`repository source must use github:owner/repo#<ref> with an optional &path:/.../.dsh-plugin: ${JSON.stringify(configured)}`)
  37. }
  38. const path = match[4]
  39. if (path !== undefined && !validPluginPath(path)) {
  40. throw new Error(`repository source path must be an absolute repository subpath ending in .dsh-plugin without empty, . or .. segments: ${JSON.stringify(path)}`)
  41. }
  42. return path === undefined ? `${configured}&path:/.dsh-plugin` : configured
  43. }
  44. /**
  45. * Resolve the persistent repository cache root.
  46. * @param configured - explicit cache directory, or undefined for `$DSH_HOME/cache/repository-plugins`.
  47. * @returns an absolute cache directory.
  48. */
  49. export function resolveRepositoryCacheDirectory(configured: string | undefined): string {
  50. return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
  51. }
  52. /**
  53. * Load one exact repository generation's generated wrapper as a child Cordis fiber.
  54. * @param ctx - repository runtime context that owns the child.
  55. * @param cache - package-manager-native immutable repository cache.
  56. * @param specifier - normalized exact pnpm dependency specifier.
  57. * @returns the settled prepared-wrapper fiber.
  58. * @throws when installation, wrapper import, manifest validation, or child registration fails.
  59. */
  60. export async function loadPreparedRepository(
  61. ctx: Context,
  62. cache: Pick<RepositoryCache, 'resolve'>,
  63. specifier: string,
  64. ): Promise<Fiber> {
  65. const directory = await cache.resolve(specifier)
  66. const filename = join(directory, PREPARED_ENTRY_FILENAME)
  67. try {
  68. const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
  69. const fiber = ctx.plugin(plugin)
  70. await fiber
  71. // Awaiting a service-gated fiber returns while it is still PENDING (the
  72. // generated wrapper injects `skills`/`tools` per its manifest). This
  73. // runtime commits the repository configuration transactionally, so a
  74. // composition that never provides a required service must reject the
  75. // transaction here — not settle ACTIVE with a silently pending child.
  76. if (fiber.state !== FIBER_ACTIVE) {
  77. const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
  78. /* v8 ignore next 2 -- the 'unknown' arm needs a service to appear after the state read; not deterministically stageable. */
  79. const detail = missing.join(', ') || 'unknown'
  80. throw new Error(`prepared wrapper did not activate (waiting for services: ${detail})`)
  81. }
  82. return await fiber
  83. } catch (cause) {
  84. throw new Error(`failed to load prepared repository Plugin ${JSON.stringify(specifier)} from ${filename}`, { cause })
  85. }
  86. }