create-plugin.ts 3.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. /**
  2. * dsh-sdk create command: add an external Cordis plugin (github or npm) as a
  3. * native package-manager dependency and mount it in cordis.yml.
  4. *
  5. * @module @deepseek-ai/dsh-scripts/create-plugin
  6. */
  7. import { readFile } from 'node:fs/promises'
  8. import { join } from 'node:path'
  9. import {
  10. ClackPromptPort,
  11. ConfirmQuestion,
  12. SdkProject,
  13. createBuiltinRegistry,
  14. requireAnswer,
  15. type PackageManager,
  16. type ProjectCommitResult,
  17. type PromptPort,
  18. } from '@deepseek-ai/dsh-helper'
  19. /** Process and interaction slice required by dsh-sdk create. */
  20. export interface CreatePluginContext {
  21. cwd: string
  22. stdin: NodeJS.ReadStream
  23. stdout: NodeJS.WriteStream
  24. port?: PromptPort
  25. add?: (manager: PackageManager, spec: string, cwd: string) => Promise<void>
  26. }
  27. /** Result of a create run; `undefined` when the confirmation was declined. */
  28. export type CreatePluginResult = ProjectCommitResult<SdkProject> | undefined
  29. /** Derive a stable cordis entry id from a package name's last path segment. */
  30. function pluginId(packageName: string): string {
  31. const base = packageName.startsWith('@') ? packageName.slice(packageName.indexOf('/') + 1) : packageName
  32. const id = base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
  33. /* v8 ignore next -- a valid npm package name always yields a non-empty id */
  34. if (!id) throw new Error(`cannot derive a plugin id from package name: ${packageName}`)
  35. return id
  36. }
  37. /** Read the direct dependency names declared in a project's package.json. */
  38. async function dependencyNames(cwd: string): Promise<Set<string>> {
  39. const manifest = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as {
  40. dependencies?: Record<string, unknown>
  41. }
  42. /* v8 ignore next -- generated projects always declare a dependencies map */
  43. return new Set(Object.keys(manifest.dependencies ?? {}))
  44. }
  45. /**
  46. * Add one external plugin dependency to the current project and mount it.
  47. * @param source - a package-manager-native source (`pkg@version` or `github:owner/repo#ref`).
  48. * @param context - process, interaction, and dependency-add boundaries.
  49. * @returns the commit result, or `undefined` when the confirmation was declined.
  50. */
  51. export async function runCreatePluginCommand(
  52. source: string,
  53. context: CreatePluginContext,
  54. ): Promise<CreatePluginResult> {
  55. const spec = source.trim()
  56. if (!spec) throw new Error('dsh-sdk create requires a plugin source (pkg@version or github:owner/repo#ref)')
  57. if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
  58. throw new Error('dsh-sdk create requires an interactive TTY')
  59. }
  60. const project = await SdkProject.open(context.cwd)
  61. /* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
  62. const port = context.port ?? new ClackPromptPort(context.stdin, context.stdout)
  63. const confirmed = requireAnswer(await new ConfirmQuestion({
  64. id: 'create.confirm',
  65. message: `Add plugin '${spec}' as a dependency and mount it in cordis.yml?`,
  66. initialValue: true,
  67. }).resolve(port))
  68. if (!confirmed) return undefined
  69. const before = await dependencyNames(context.cwd)
  70. /* v8 ignore next -- production package-manager wiring is exercised by the built-bin smoke */
  71. const add = context.add ?? ((manager, source, cwd) => manager.add(source, cwd))
  72. await add(project.profile.packageManager, spec, context.cwd)
  73. const after = await dependencyNames(context.cwd)
  74. const added = [...after].filter(name => !before.has(name))
  75. if (added.length === 0) throw new Error(`dsh-sdk create: '${spec}' added no new dependency`)
  76. const reopened = await SdkProject.open(context.cwd)
  77. const registry = createBuiltinRegistry(reopened.profile)
  78. const edit = reopened.edit(registry)
  79. for (const packageName of added) edit.addExternalPlugin(pluginId(packageName), packageName)
  80. const commit = await edit.commit()
  81. context.stdout.write(`Mounted ${added.join(', ')} in cordis.yml.\n`)
  82. return commit
  83. }