create-wizard.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. /**
  2. * Declarative create questions with dynamic feature and plugin orchestration.
  3. *
  4. * @module @deepseek-ai/create-sdk/create-wizard
  5. */
  6. import { resolve } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import {
  9. FeatureConfigurator,
  10. ConfirmQuestion,
  11. LocalPluginBlueprint,
  12. NpmPackageManager,
  13. SelectQuestion,
  14. featureId,
  15. createBuiltinRegistry,
  16. createPackageManager,
  17. inferPackageManagerName,
  18. probePackageManagerVersion,
  19. requireAnswer,
  20. type FeatureRegistry,
  21. type FeatureSelection,
  22. type LocalPluginKind,
  23. type PackageManager,
  24. type PackageManagerName,
  25. type PackageManagerVersionProbe,
  26. type ProjectCreationRequest,
  27. type ProjectProfile,
  28. type PromptPort,
  29. } from '@deepseek-ai/dsh-helper'
  30. import type { CreateArgs } from './args.ts'
  31. import { collectProjectAnswers, type ProjectAnswers } from './create-questions.ts'
  32. import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
  33. /** Fully resolved initializer request and post-create choice. */
  34. export interface ResolvedCreateRequest {
  35. directory: string
  36. request: ProjectCreationRequest
  37. install: boolean
  38. }
  39. /** Create-specific orchestration around declarative questions and dynamic selections. */
  40. export class CreateWizard {
  41. private readonly args: CreateArgs
  42. private readonly port: PromptPort
  43. private readonly cwd: string
  44. private readonly releaseVersion: string
  45. private readonly versionProbe: PackageManagerVersionProbe
  46. private readonly userAgent: string
  47. private readonly linkWorkspaceRoot: string | undefined
  48. private readonly featurePlan: readonly FeatureSelection[] | undefined
  49. /** Bind parsed args and infrastructure to one wizard run. */
  50. constructor(options: {
  51. args: CreateArgs
  52. port: PromptPort
  53. cwd?: string
  54. releaseVersion: string
  55. versionProbe?: PackageManagerVersionProbe
  56. userAgent?: string
  57. features?: readonly FeatureSelection[]
  58. }) {
  59. this.args = options.args
  60. this.port = options.port
  61. this.cwd = resolve(options.cwd ?? process.cwd())
  62. this.releaseVersion = options.releaseVersion
  63. this.versionProbe = options.versionProbe ?? probePackageManagerVersion
  64. /* v8 ignore next -- pnpm supplies npm_config_user_agent while direct invocations may omit it */
  65. this.userAgent = options.userAgent ?? process.env.npm_config_user_agent ?? ''
  66. this.linkWorkspaceRoot = options.args.linkWorkspace
  67. ? fileURLToPath(new URL('../../../../', import.meta.url))
  68. : undefined
  69. this.featurePlan = options.features
  70. }
  71. /** Collect all answers before constructing any project files. */
  72. async run(): Promise<ResolvedCreateRequest> {
  73. const answers = await this.collectProjectAnswers()
  74. const profile = this.provisionalProfile(answers)
  75. const registry = createBuiltinRegistry(profile)
  76. const features = await this.collectFeatures(profile, registry, answers)
  77. const localPlugins = await this.collectPlugins()
  78. const { manager, install } = await this.collectPackageManager()
  79. return {
  80. directory: answers.directory,
  81. install,
  82. request: {
  83. name: answers.name,
  84. description: answers.description,
  85. runtime: { model: answers.model },
  86. packageManager: manager,
  87. releaseVersion: this.releaseVersion,
  88. ...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
  89. features,
  90. localPlugins,
  91. },
  92. }
  93. }
  94. private async collectProjectAnswers(): Promise<ProjectAnswers> {
  95. return collectProjectAnswers(this.port, this.args, this.cwd)
  96. }
  97. private provisionalProfile(answers: ProjectAnswers): ProjectProfile {
  98. return {
  99. name: answers.name,
  100. description: answers.description,
  101. runtime: { model: answers.model },
  102. runInterface: answers.runInterface,
  103. packageManager: new NpmPackageManager('10.0.0'),
  104. releaseVersion: this.releaseVersion,
  105. ...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
  106. }
  107. }
  108. private async collectFeatures(
  109. profile: ProjectProfile,
  110. registry: FeatureRegistry,
  111. answers: ProjectAnswers,
  112. ): Promise<FeatureSelection[]> {
  113. const configurator = new FeatureConfigurator(this.port)
  114. const selections: FeatureSelection[] = [
  115. {
  116. id: featureId('provider'),
  117. options: [answers.provider],
  118. ...answers.baseURL ? { values: { baseURL: answers.baseURL } } : {},
  119. secrets: { apiKey: answers.apiKey },
  120. },
  121. { id: featureId('spine'), options: ['default'] },
  122. { id: featureId('app'), options: [answers.runInterface] },
  123. ]
  124. const configurable = registry.all().filter(feature => feature.id === 'bash'
  125. || feature.id === 'persistence'
  126. || (!feature.required && feature.isApplicable(profile)))
  127. const selected = this.featurePlan
  128. ? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
  129. : [...requireAnswer(await this.port.nestedMultiselect({
  130. message: 'Select features',
  131. options: configurable.map((feature) => {
  132. const nested = feature.mode !== 'single'
  133. const defaults = new Set(feature.defaultOptions(profile))
  134. return {
  135. value: feature.id,
  136. label: feature.summary,
  137. required: feature.required,
  138. default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
  139. || feature.id === 'skill',
  140. ...nested ? {
  141. choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
  142. choices: feature.options.map(option => ({
  143. value: option.id,
  144. label: option.label,
  145. default: defaults.has(option.id),
  146. })),
  147. } : {},
  148. }
  149. }),
  150. }))]
  151. if (!this.featurePlan) {
  152. for (const { value: id } of [...selected]) {
  153. const feature = registry.get(id)
  154. for (const suggestedId of feature.suggests) {
  155. if (selected.some(item => item.value === suggestedId)) continue
  156. const suggested = registry.get(suggestedId)
  157. const add = requireAnswer(await new ConfirmQuestion({
  158. id: `${feature.id}.${suggested.id}`,
  159. message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
  160. initialValue: true,
  161. }).resolve(this.port))
  162. if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
  163. }
  164. }
  165. }
  166. const fixed = new Set(selections.map(selection => selection.id))
  167. const choices = new Map<FeatureSelection['id'], readonly string[] | undefined>()
  168. for (const feature of registry.all()) {
  169. if (feature.required && feature.isApplicable(profile) && !fixed.has(feature.id)) {
  170. choices.set(feature.id, feature.defaultOptions(profile))
  171. }
  172. }
  173. for (const choice of selected) {
  174. choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
  175. }
  176. const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
  177. for (const [id, options] of choices) {
  178. const planned = plannedById.get(id)
  179. selections.push(await configurator.configure(
  180. registry.get(id),
  181. profile,
  182. undefined,
  183. options,
  184. planned?.secrets ?? {},
  185. planned?.values ?? {},
  186. ))
  187. }
  188. return selections
  189. }
  190. private async collectPlugins(): Promise<LocalPluginBlueprint[]> {
  191. const kind = requireAnswer(await new SelectQuestion<LocalPluginKind | 'none'>({
  192. id: 'plugins.kind',
  193. message: 'Local plugin',
  194. options: [
  195. { value: 'none', label: 'No local plugin' },
  196. { value: 'plugin', label: 'Cordis plugin' },
  197. { value: 'tool', label: 'Model-facing tool' },
  198. ],
  199. initialValue: 'none',
  200. }).resolve(this.port))
  201. return kind === 'none' ? [] : [new LocalPluginBlueprint(kind, kind)]
  202. }
  203. private async collectPackageManager(): Promise<{ manager: PackageManager; install: boolean }> {
  204. const inferred = inferPackageManagerName(this.args.packageManager, this.userAgent)
  205. const name = requireAnswer(await new SelectQuestion<PackageManagerName>({
  206. id: 'packageManager',
  207. message: 'Package manager',
  208. options: [
  209. { value: 'npm', label: 'npm' },
  210. { value: 'pnpm', label: 'pnpm' },
  211. { value: 'yarn', label: 'Yarn' },
  212. ],
  213. initialValue: inferred ?? 'npm',
  214. }).resolve(this.port, inferred))
  215. const manager = createPackageManager(name, await this.versionProbe(name, this.cwd))
  216. const install = requireAnswer(await new ConfirmQuestion({
  217. id: 'install',
  218. message: CREATE_TEMPLATES.installQuestion.render(packageManagerTemplateModel(manager)).trimEnd(),
  219. initialValue: true,
  220. }).resolve(this.port, this.args.install))
  221. return { manager, install }
  222. }
  223. }