create.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { PassThrough, Writable } from 'node:stream'
  5. import { fileURLToPath } from 'node:url'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import {
  8. LocalPluginBlueprint,
  9. featureId,
  10. NpmPackageManager,
  11. type NestedMultiSelectValue,
  12. type PromptPort,
  13. } from '@deepseek-ai/dsh-helper'
  14. import type {
  15. ConfirmPromptRequest,
  16. MultiSelectPromptRequest,
  17. NestedMultiSelectRequest,
  18. PromptOutcome,
  19. SecretPromptRequest,
  20. SelectPromptRequest,
  21. TextPromptRequest,
  22. } from '../../helper/src/questions/prompt-port.ts'
  23. import { parseCreateArgs } from '../src/args.ts'
  24. import {
  25. createProject,
  26. readCreateSdkVersion,
  27. runCreateCommand,
  28. type CreateCommandContext,
  29. } from '../src/command.ts'
  30. import { CreateWizard } from '../src/create-wizard.ts'
  31. import { scaffoldProject } from '../src/project-scaffolder.ts'
  32. class ScriptedPort implements PromptPort {
  33. readonly requests: string[] = []
  34. readonly #answers: unknown[]
  35. constructor(answers: unknown[]) {
  36. this.#answers = [...answers]
  37. }
  38. answer<T>(message: string): Promise<PromptOutcome<T>> {
  39. this.requests.push(message)
  40. const value = this.#answers.shift()
  41. return Promise.resolve(value === ScriptedPort.cancel
  42. ? { status: 'cancelled' }
  43. : { status: 'answered', value: value as T })
  44. }
  45. async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
  46. const outcome = await this.answer<string>(request.message)
  47. if (outcome.status === 'cancelled') return outcome
  48. const value = outcome.value || request.defaultValue || ''
  49. const diagnostic = request.validate?.(value)
  50. if (diagnostic) throw new Error(diagnostic)
  51. return { status: 'answered', value }
  52. }
  53. secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
  54. select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
  55. multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
  56. return this.answer(request.message)
  57. }
  58. confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
  59. nestedMultiselect<TValue, TChoice>(
  60. request: NestedMultiSelectRequest<TValue, TChoice>,
  61. ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
  62. return this.answer(request.message)
  63. }
  64. static readonly cancel = Symbol('cancel')
  65. }
  66. const temporary: string[] = []
  67. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  68. interface GeneratedPackageManifest {
  69. scripts?: Record<string, string>
  70. dependencies?: Record<string, string>
  71. devDependencies?: Record<string, string>
  72. }
  73. interface GeneratedTsConfig {
  74. compilerOptions: {
  75. types?: readonly string[]
  76. }
  77. }
  78. function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
  79. return JSON.parse(text) as GeneratedPackageManifest
  80. }
  81. function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
  82. return JSON.parse(text) as GeneratedTsConfig
  83. }
  84. function commandContext(
  85. cwd: string,
  86. port?: PromptPort,
  87. setup?: CreateCommandContext['setup'],
  88. ): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
  89. let stdout = ''
  90. let stderr = ''
  91. const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
  92. const output = Object.assign(new Writable({
  93. write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
  94. }), { isTTY: true }) as unknown as NodeJS.WriteStream
  95. const error = new Writable({
  96. write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
  97. }) as unknown as NodeJS.WriteStream
  98. return {
  99. cwd,
  100. stdin: input,
  101. stdout: output,
  102. stderr: error,
  103. releaseVersion: '0.0.1',
  104. versionProbe: async () => '10.0.0',
  105. ...port ? { port } : {},
  106. ...setup ? { setup } : {},
  107. readStdout: () => stdout,
  108. readStderr: () => stderr,
  109. }
  110. }
  111. afterEach(async () => {
  112. await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
  113. })
  114. describe('create arguments', () => {
  115. it('parses public options and the private repository link mode', () => {
  116. expect(parseCreateArgs([
  117. 'agent', '--description=demo', '--provider', 'deepseek', '--base-url=https://api.example',
  118. '--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
  119. '--link-workspace',
  120. ])).toEqual({
  121. directory: 'agent',
  122. description: 'demo',
  123. provider: 'deepseek',
  124. baseURL: 'https://api.example',
  125. apiKey: 'key',
  126. model: 'm',
  127. runInterface: 'acp',
  128. packageManager: 'pnpm',
  129. install: false,
  130. linkWorkspace: true,
  131. help: false,
  132. })
  133. expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
  134. expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
  135. expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
  136. expect(parseCreateArgs(['--help']).help).toBe(true)
  137. expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, stdio, embed')
  138. expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
  139. expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
  140. })
  141. it('validates empty directories and package names', async () => {
  142. const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
  143. temporary.push(root)
  144. await expect(new CreateWizard({
  145. args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
  146. releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
  147. }).run()).rejects.toThrow('A value is required')
  148. await expect(new CreateWizard({
  149. args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
  150. releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
  151. }).run()).rejects.toThrow('lowercase npm package name')
  152. })
  153. it('rejects an existing target before asking project questions', async () => {
  154. const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
  155. temporary.push(cwd)
  156. await mkdir(join(cwd, 'taken'))
  157. const port = new ScriptedPort([])
  158. const wizard = new CreateWizard({
  159. args: parseCreateArgs(['taken']),
  160. port,
  161. cwd,
  162. releaseVersion: '0.0.1',
  163. versionProbe: async () => '10.0.0',
  164. })
  165. await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
  166. expect(port.requests).toEqual([])
  167. })
  168. })
  169. describe('CreateWizard and scaffolder', () => {
  170. it('asks only unresolved questions in requirement-safe order', async () => {
  171. const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
  172. temporary.push(cwd)
  173. const port = new ScriptedPort([
  174. 'my-agent',
  175. [
  176. { value: featureId('persistence'), choices: ['sqlite'] },
  177. { value: featureId('hmr'), choices: [] },
  178. { value: featureId('fs'), choices: [] },
  179. { value: featureId('web'), choices: ['exa'] },
  180. ],
  181. false,
  182. 'exa-key',
  183. 'tool',
  184. ])
  185. const args = parseCreateArgs([
  186. 'my-agent',
  187. '--description=demo',
  188. '--provider=deepseek',
  189. '--api-key=deepseek-key',
  190. '--model=deepseek-v4-flash',
  191. '--interface=stdio',
  192. '--pm=npm',
  193. '--no-install',
  194. '--link-workspace',
  195. ])
  196. const resolved = await new CreateWizard({
  197. args,
  198. port,
  199. cwd,
  200. releaseVersion: '0.0.1',
  201. versionProbe: async () => '10.0.0',
  202. }).run()
  203. expect(port.requests).toEqual([
  204. 'Package name',
  205. 'Select features',
  206. 'Add the recommended tool timeout policy for web search and fetch tools?',
  207. 'Exa API key',
  208. 'Local plugin',
  209. ])
  210. expect(resolved.install).toBe(false)
  211. expect(resolved.request.packageManager.name).toBe('npm')
  212. expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
  213. expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
  214. expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
  215. options: ['exa'], secrets: { apiKey: 'exa-key' },
  216. })
  217. expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
  218. })
  219. it('writes the project once and refuses every existing target', async () => {
  220. const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
  221. temporary.push(root)
  222. const request = {
  223. name: 'agent',
  224. description: 'demo',
  225. runtime: { model: 'deepseek-v4-flash' },
  226. packageManager: new NpmPackageManager('10.0.0'),
  227. releaseVersion: '0.0.1',
  228. features: [
  229. { id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'key' } },
  230. { id: featureId('bash'), options: ['local'] },
  231. { id: featureId('app'), options: ['embed'] },
  232. { id: featureId('persistence'), options: ['jsonl'] },
  233. ],
  234. localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
  235. }
  236. const target = join(root, 'project')
  237. const result = await scaffoldProject(target, request)
  238. expect(result.changes.changedFiles).toContain('README.md')
  239. const index = await readFile(join(target, 'index.ts'), 'utf8')
  240. expect(index).toContain('SdkBootContext')
  241. expect(index).toContain('ctx.agents.create')
  242. expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
  243. const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
  244. const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
  245. expect(tsconfig.compilerOptions.types).toEqual(['node'])
  246. expect(manifest.scripts).toEqual({
  247. dev: 'dsh-sdk dev index.ts',
  248. build: 'dsh-sdk build',
  249. typecheck: 'tsc -b',
  250. start: 'dsh-sdk start index.js',
  251. config: 'dsh-sdk config',
  252. })
  253. expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
  254. expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
  255. expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
  256. const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
  257. expect(cordis).toMatch(/^- id:/)
  258. expect(cordis).not.toMatch(/^\[/)
  259. const occupied = join(root, 'occupied')
  260. await mkdir(occupied)
  261. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  262. await writeFile(join(occupied, 'keep'), 'x')
  263. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  264. })
  265. it('installs workflow requirements before validating the next feature', async () => {
  266. const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
  267. temporary.push(cwd)
  268. const port = new ScriptedPort([
  269. 'workflow-agent',
  270. [
  271. { value: featureId('persistence'), choices: ['jsonl'] },
  272. { value: featureId('workflow'), choices: [] },
  273. ],
  274. 'none',
  275. ])
  276. const resolved = await new CreateWizard({
  277. args: parseCreateArgs([
  278. 'workflow-agent', '--description=test', '--provider=deepseek', '--api-key=key',
  279. '--interface=embed', '--pm=npm', '--no-install',
  280. ]),
  281. port,
  282. cwd,
  283. releaseVersion: '0.0.1',
  284. versionProbe: async () => '10.0.0',
  285. }).run()
  286. const result = await scaffoldProject(resolved.directory, resolved.request)
  287. expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
  288. expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
  289. })
  290. it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
  291. const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
  292. temporary.push(cwd)
  293. const port = new ScriptedPort([
  294. 'empty-key-agent',
  295. '',
  296. true,
  297. [{ value: featureId('persistence'), choices: ['jsonl'] }],
  298. 'none',
  299. ])
  300. const resolved = await new CreateWizard({
  301. args: parseCreateArgs([
  302. 'empty-key-agent', '--description=test', '--provider=deepseek',
  303. '--interface=embed', '--pm=npm', '--no-install',
  304. ]),
  305. port,
  306. cwd,
  307. releaseVersion: '0.0.1',
  308. versionProbe: async () => '10.0.0',
  309. }).run()
  310. await scaffoldProject(resolved.directory, resolved.request)
  311. expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
  312. '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
  313. )
  314. expect(port.requests).toContain('Keep the API key empty and fill .env later?')
  315. })
  316. it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
  317. const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
  318. temporary.push(cwd)
  319. const port = new ScriptedPort([
  320. 'custom-agent',
  321. 'test custom provider',
  322. 'custom',
  323. 'https://provider.example/v1',
  324. '', false, 'custom-key',
  325. 'embed',
  326. [
  327. { value: featureId('persistence'), choices: ['jsonl'] },
  328. { value: featureId('web'), choices: ['deepseek'] },
  329. ],
  330. true,
  331. 'none',
  332. 'npm',
  333. false,
  334. ])
  335. const resolved = await new CreateWizard({
  336. args: parseCreateArgs(['custom-agent']),
  337. port,
  338. cwd,
  339. releaseVersion: '0.0.1',
  340. versionProbe: async () => '10.0.0',
  341. userAgent: '',
  342. }).run()
  343. expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
  344. options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
  345. })
  346. expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
  347. })
  348. it('does not re-suggest an already selected feature', async () => {
  349. const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
  350. temporary.push(cwd)
  351. const port = new ScriptedPort([
  352. 'agent',
  353. [
  354. { value: featureId('persistence'), choices: ['jsonl'] },
  355. { value: featureId('web'), choices: ['deepseek'] },
  356. { value: featureId('timeout-policy'), choices: ['default'] },
  357. ],
  358. 'none',
  359. ])
  360. const resolved = await new CreateWizard({
  361. args: parseCreateArgs([
  362. 'agent', '--description=test', '--provider=deepseek', '--api-key=key',
  363. '--interface=embed', '--pm=npm', '--no-install',
  364. ]),
  365. port,
  366. cwd,
  367. releaseVersion: '0.0.1',
  368. versionProbe: async () => '10.0.0',
  369. }).run()
  370. expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
  371. })
  372. it('uses process defaults when constructor infrastructure is omitted', async () => {
  373. const name = `default-infra-${String(process.pid)}`
  374. const port = new ScriptedPort([
  375. name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  376. ])
  377. const resolved = await new CreateWizard({
  378. args: parseCreateArgs([
  379. name, '--description=test', '--provider=deepseek', '--api-key=key',
  380. '--interface=embed', '--pm=npm', '--no-install',
  381. ]),
  382. port,
  383. releaseVersion: '0.0.1',
  384. }).run()
  385. expect(resolved.request.packageManager.name).toBe('npm')
  386. })
  387. it('reads the release batch from the initializer package', async () => {
  388. await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
  389. })
  390. })
  391. describe('create command composition', () => {
  392. const argv = (directory: string, install: boolean): string[] => [
  393. directory, '--description=test', '--provider=deepseek', '--api-key=key',
  394. '--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
  395. ]
  396. it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
  397. const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
  398. temporary.push(root)
  399. const context = commandContext(root)
  400. context.stdin.isTTY = false
  401. context.stdout.isTTY = false
  402. await expect(createProject(['--help'], context)).resolves.toBeUndefined()
  403. expect(context.readStdout()).toContain('Usage: create-sdk')
  404. expect(context.readStdout()).not.toContain('--link-workspace')
  405. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  406. context.stdin.isTTY = true
  407. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  408. })
  409. it('creates through an injected prompt port and delegates optional setup', async () => {
  410. const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
  411. temporary.push(root)
  412. const port = new ScriptedPort([
  413. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  414. ])
  415. let setupDirectory = ''
  416. const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
  417. const result = await createProject(argv('agent', true), context)
  418. expect(result?.project.root).toBe(join(root, 'agent'))
  419. expect(setupDirectory).toBe(join(root, 'agent'))
  420. expect(context.readStdout()).toContain('Created agent')
  421. expect(context.readStdout()).toContain('Next: cd')
  422. const noInstall = commandContext(root, new ScriptedPort([
  423. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  424. ]))
  425. await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
  426. expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
  427. })
  428. it('uses the package manager setup path when no setup override is supplied', async () => {
  429. const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
  430. temporary.push(root)
  431. const port = new ScriptedPort([
  432. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  433. ])
  434. const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
  435. const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
  436. const context = commandContext(root, port)
  437. delete context.releaseVersion
  438. delete context.versionProbe
  439. await createProject(argv('agent', true), context)
  440. expect(install).toHaveBeenCalledOnce()
  441. expect(build).toHaveBeenCalledOnce()
  442. install.mockRestore()
  443. build.mockRestore()
  444. })
  445. it('reports setup failures after preserving generated files', async () => {
  446. const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
  447. temporary.push(root)
  448. const port = new ScriptedPort([
  449. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  450. ])
  451. const context = commandContext(root, port, async () => { throw new Error('offline') })
  452. await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
  453. expect(context.readStderr()).toContain('Project files are ready, but setup failed')
  454. expect(context.readStderr()).toContain('npm install && npm run build')
  455. const stringFailure = commandContext(root, new ScriptedPort([
  456. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  457. ]), async () => { throw 'offline-string' })
  458. await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
  459. expect(stringFailure.readStderr()).toContain('offline-string')
  460. })
  461. it('maps cancellation and ordinary errors to command exit codes', async () => {
  462. const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
  463. temporary.push(root)
  464. const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
  465. await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
  466. expect(cancelled.readStderr()).toContain('cancelled')
  467. const invalid = commandContext(root)
  468. await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
  469. expect(invalid.readStderr()).toContain('unknown option')
  470. const help = commandContext(root)
  471. await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
  472. })
  473. })