create.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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. expect(index).not.toContain('AgentId')
  244. const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
  245. const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
  246. expect(tsconfig.compilerOptions.types).toEqual(['node'])
  247. expect(manifest.scripts).toEqual({
  248. dev: 'dsh-sdk dev index.ts',
  249. build: 'dsh-sdk build',
  250. typecheck: 'tsc -b',
  251. start: 'dsh-sdk start index.js',
  252. config: 'dsh-sdk config',
  253. })
  254. expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
  255. expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
  256. expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
  257. const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
  258. expect(cordis).toMatch(/^- id:/)
  259. expect(cordis).not.toMatch(/^\[/)
  260. const occupied = join(root, 'occupied')
  261. await mkdir(occupied)
  262. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  263. await writeFile(join(occupied, 'keep'), 'x')
  264. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  265. })
  266. it('installs workflow requirements before validating the next feature', async () => {
  267. const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
  268. temporary.push(cwd)
  269. const port = new ScriptedPort([
  270. 'workflow-agent',
  271. [
  272. { value: featureId('persistence'), choices: ['jsonl'] },
  273. { value: featureId('workflow'), choices: [] },
  274. ],
  275. 'none',
  276. ])
  277. const resolved = await new CreateWizard({
  278. args: parseCreateArgs([
  279. 'workflow-agent', '--description=test', '--provider=deepseek', '--api-key=key',
  280. '--interface=embed', '--pm=npm', '--no-install',
  281. ]),
  282. port,
  283. cwd,
  284. releaseVersion: '0.0.1',
  285. versionProbe: async () => '10.0.0',
  286. }).run()
  287. const result = await scaffoldProject(resolved.directory, resolved.request)
  288. expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
  289. expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
  290. })
  291. it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
  292. const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
  293. temporary.push(cwd)
  294. const port = new ScriptedPort([
  295. 'empty-key-agent',
  296. '',
  297. true,
  298. [{ value: featureId('persistence'), choices: ['jsonl'] }],
  299. 'none',
  300. ])
  301. const resolved = await new CreateWizard({
  302. args: parseCreateArgs([
  303. 'empty-key-agent', '--description=test', '--provider=deepseek',
  304. '--interface=embed', '--pm=npm', '--no-install',
  305. ]),
  306. port,
  307. cwd,
  308. releaseVersion: '0.0.1',
  309. versionProbe: async () => '10.0.0',
  310. }).run()
  311. await scaffoldProject(resolved.directory, resolved.request)
  312. expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
  313. '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
  314. )
  315. expect(port.requests).toContain('Keep the API key empty and fill .env later?')
  316. })
  317. it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
  318. const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
  319. temporary.push(cwd)
  320. const port = new ScriptedPort([
  321. 'custom-agent',
  322. 'test custom provider',
  323. 'custom',
  324. 'https://provider.example/v1',
  325. '', false, 'custom-key',
  326. 'embed',
  327. [
  328. { value: featureId('persistence'), choices: ['jsonl'] },
  329. { value: featureId('web'), choices: ['deepseek'] },
  330. ],
  331. true,
  332. 'none',
  333. 'npm',
  334. false,
  335. ])
  336. const resolved = await new CreateWizard({
  337. args: parseCreateArgs(['custom-agent']),
  338. port,
  339. cwd,
  340. releaseVersion: '0.0.1',
  341. versionProbe: async () => '10.0.0',
  342. userAgent: '',
  343. }).run()
  344. expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
  345. options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
  346. })
  347. expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
  348. })
  349. it('does not re-suggest an already selected feature', async () => {
  350. const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
  351. temporary.push(cwd)
  352. const port = new ScriptedPort([
  353. 'agent',
  354. [
  355. { value: featureId('persistence'), choices: ['jsonl'] },
  356. { value: featureId('web'), choices: ['deepseek'] },
  357. { value: featureId('timeout-policy'), choices: ['default'] },
  358. ],
  359. 'none',
  360. ])
  361. const resolved = await new CreateWizard({
  362. args: parseCreateArgs([
  363. 'agent', '--description=test', '--provider=deepseek', '--api-key=key',
  364. '--interface=embed', '--pm=npm', '--no-install',
  365. ]),
  366. port,
  367. cwd,
  368. releaseVersion: '0.0.1',
  369. versionProbe: async () => '10.0.0',
  370. }).run()
  371. expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
  372. })
  373. it('uses process defaults when constructor infrastructure is omitted', async () => {
  374. const name = `default-infra-${String(process.pid)}`
  375. const port = new ScriptedPort([
  376. name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  377. ])
  378. const resolved = await new CreateWizard({
  379. args: parseCreateArgs([
  380. name, '--description=test', '--provider=deepseek', '--api-key=key',
  381. '--interface=embed', '--pm=npm', '--no-install',
  382. ]),
  383. port,
  384. releaseVersion: '0.0.1',
  385. }).run()
  386. expect(resolved.request.packageManager.name).toBe('npm')
  387. })
  388. it('reads the release batch from the initializer package', async () => {
  389. await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
  390. })
  391. })
  392. describe('create command composition', () => {
  393. const argv = (directory: string, install: boolean): string[] => [
  394. directory, '--description=test', '--provider=deepseek', '--api-key=key',
  395. '--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
  396. ]
  397. it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
  398. const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
  399. temporary.push(root)
  400. const context = commandContext(root)
  401. context.stdin.isTTY = false
  402. context.stdout.isTTY = false
  403. await expect(createProject(['--help'], context)).resolves.toBeUndefined()
  404. expect(context.readStdout()).toContain('Usage: create-sdk')
  405. expect(context.readStdout()).not.toContain('--link-workspace')
  406. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  407. context.stdin.isTTY = true
  408. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  409. })
  410. it('creates through an injected prompt port and delegates optional setup', async () => {
  411. const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
  412. temporary.push(root)
  413. const port = new ScriptedPort([
  414. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  415. ])
  416. let setupDirectory = ''
  417. const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
  418. const result = await createProject(argv('agent', true), context)
  419. expect(result?.project.root).toBe(join(root, 'agent'))
  420. expect(setupDirectory).toBe(join(root, 'agent'))
  421. expect(context.readStdout()).toContain('Created agent')
  422. expect(context.readStdout()).toContain('Next: cd')
  423. const noInstall = commandContext(root, new ScriptedPort([
  424. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  425. ]))
  426. await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
  427. expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
  428. })
  429. it('uses the package manager setup path when no setup override is supplied', async () => {
  430. const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
  431. temporary.push(root)
  432. const port = new ScriptedPort([
  433. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  434. ])
  435. const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
  436. const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
  437. const context = commandContext(root, port)
  438. delete context.releaseVersion
  439. delete context.versionProbe
  440. await createProject(argv('agent', true), context)
  441. expect(install).toHaveBeenCalledOnce()
  442. expect(build).toHaveBeenCalledOnce()
  443. install.mockRestore()
  444. build.mockRestore()
  445. })
  446. it('reports setup failures after preserving generated files', async () => {
  447. const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
  448. temporary.push(root)
  449. const port = new ScriptedPort([
  450. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  451. ])
  452. const context = commandContext(root, port, async () => { throw new Error('offline') })
  453. await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
  454. expect(context.readStderr()).toContain('Project files are ready, but setup failed')
  455. expect(context.readStderr()).toContain('npm install && npm run build')
  456. const stringFailure = commandContext(root, new ScriptedPort([
  457. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  458. ]), async () => { throw 'offline-string' })
  459. await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
  460. expect(stringFailure.readStderr()).toContain('offline-string')
  461. })
  462. it('maps cancellation and ordinary errors to command exit codes', async () => {
  463. const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
  464. temporary.push(root)
  465. const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
  466. await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
  467. expect(cancelled.readStderr()).toContain('cancelled')
  468. const invalid = commandContext(root)
  469. await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
  470. expect(invalid.readStderr()).toContain('unknown option')
  471. const help = commandContext(root)
  472. await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
  473. })
  474. })