runtime.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. /**
  2. * Shared start/dev runtime and project-local module resolution.
  3. *
  4. * @module @deepseek-ai/dsh-scripts/runtime
  5. */
  6. import { register as registerHook } from 'node:module'
  7. import { access, readFile, readdir } from 'node:fs/promises'
  8. import { dirname, resolve } from 'node:path'
  9. import { fileURLToPath, pathToFileURL } from 'node:url'
  10. import type { Context } from 'cordis'
  11. import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
  12. import { parseSdkBootArgs } from './args.ts'
  13. /** Options that distinguish dev boot from production boot. */
  14. interface BootProjectOptions {
  15. cwd?: string
  16. dev?: boolean
  17. argv?: readonly string[]
  18. }
  19. /** Startup context passed to a generated project's exported `main()`. */
  20. export interface SdkBootContext {
  21. /** Developer arguments forwarded after the launcher's `--` separator. */
  22. readonly argv: readonly string[]
  23. /** SDK-recognized structured arguments parsed from {@link argv}. */
  24. readonly args: Record<string, string | boolean | undefined>
  25. /** Absolute project working directory selected by the launcher. */
  26. readonly cwd: string
  27. /** Whether the launcher is running the built or TypeScript development entry. */
  28. readonly mode: 'start' | 'dev'
  29. }
  30. async function localPluginMappings(cwd: string): Promise<Record<string, string>> {
  31. const mappings: Record<string, string> = {}
  32. let directories
  33. try {
  34. directories = await readdir(resolve(cwd, 'plugins'), { withFileTypes: true })
  35. } catch (error) {
  36. /* v8 ignore else -- the other arm requires a filesystem permission/IO fault from readdir */
  37. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return mappings
  38. /* v8 ignore next -- paired with the ignored defensive readdir-error arm above */
  39. throw error
  40. }
  41. for (const directory of directories) {
  42. if (!directory.isDirectory()) continue
  43. const root = resolve(cwd, 'plugins', directory.name)
  44. let manifest: { name?: unknown }
  45. try {
  46. manifest = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8')) as { name?: unknown }
  47. await access(resolve(root, 'src/index.ts'))
  48. } catch (error) {
  49. throw new Error(`cannot load local plugin metadata from ${root}: ${String(error)}`)
  50. }
  51. if (typeof manifest.name !== 'string' || manifest.name.length === 0) {
  52. throw new Error(`local plugin package has no name: ${root}`)
  53. }
  54. if (mappings[manifest.name]) throw new Error(`duplicate local plugin package name: ${manifest.name}`)
  55. mappings[manifest.name] = pathToFileURL(resolve(root, 'src/index.ts')).href
  56. }
  57. return mappings
  58. }
  59. /** Register tsx and exact local-plugin source mappings for the current process. */
  60. async function registerDevRuntime(cwd: string = process.cwd()): Promise<void> {
  61. let registerTsx: typeof import('tsx/esm/api')['register']
  62. try {
  63. ({ register: registerTsx } = await import('tsx/esm/api'))
  64. } catch (error) {
  65. /* v8 ignore next -- tsx is a declared project NPM dependency; missing-package behavior is defensive */
  66. throw new Error(`dsh-sdk dev requires the project's tsx NPM dependency: ${String(error)}`)
  67. }
  68. registerTsx()
  69. const mappings = await localPluginMappings(resolve(cwd))
  70. const hook = new URL(
  71. /* v8 ignore next -- the .js arm is exercised by the built-bin smoke rather than source coverage */
  72. import.meta.url.endsWith('.ts')
  73. ? './local-plugin-loader-hooks.ts'
  74. : './local-plugin-loader-hooks.js', import.meta.url)
  75. registerHook(hook, { data: { mappings } })
  76. }
  77. /**
  78. * Boot one cordis.yml after loading its sibling .env.
  79. * @param source - file path or file URL to cordis.yml.
  80. * @param options - working directory and development-runtime options.
  81. * @returns live Cordis context.
  82. */
  83. export async function startSDK(
  84. source: string | URL = './cordis.yml',
  85. options: BootProjectOptions = {},
  86. ): Promise<Context> {
  87. const cwd = resolve(options.cwd ?? process.cwd())
  88. if (options.dev) await registerDevRuntime(cwd)
  89. if (source instanceof URL && source.protocol !== 'file:') {
  90. throw new Error(`cordis.yml URL must use file:, got ${source.protocol}`)
  91. }
  92. const requested = source instanceof URL ? fileURLToPath(source) : source
  93. const absolute = resolveConfigPath(requested, undefined, cwd)
  94. loadEnv('dsh-sdk', dirname(absolute))
  95. installFailLoud('dsh-sdk')
  96. return boot('dsh-sdk', absolute)
  97. }
  98. /**
  99. * Import and invoke a module target's main(), or directly boot cordis.yml.
  100. * @param target - module path relative to the project, or absent for cordis.yml.
  101. * @param options - working directory and development-runtime options.
  102. * @returns target main result or live Cordis context.
  103. */
  104. export async function runSDK(
  105. target?: string,
  106. options: BootProjectOptions = {},
  107. ): Promise<unknown> {
  108. /* v8 ignore next -- the bin always supplies cwd; direct consumers normally accept process.cwd() */
  109. const cwd = resolve(options.cwd ?? process.cwd())
  110. if (options.dev) await registerDevRuntime(cwd)
  111. if (!target) return startSDK('./cordis.yml', { cwd })
  112. const absolute = resolve(cwd, target)
  113. try {
  114. await access(absolute)
  115. } catch (error) {
  116. const hint = options.dev ? '' : ' Run dsh-sdk build first if this is a TypeScript project.'
  117. throw new Error(`cannot start missing target ${target}.${hint} ${String(error)}`)
  118. }
  119. const module = await import(pathToFileURL(absolute).href) as { main?: (context: SdkBootContext) => unknown }
  120. if (typeof module.main !== 'function') {
  121. throw new Error(`dsh-sdk target ${target} must export function main()`)
  122. }
  123. const argv = [...options.argv ?? []]
  124. return module.main({
  125. argv,
  126. args: parseSdkBootArgs(argv),
  127. cwd,
  128. mode: options.dev ? 'dev' : 'start',
  129. })
  130. }