create.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  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. HeadlessPromptPort,
  9. LocalPluginBlueprint,
  10. featureId,
  11. NodeCommandRunner,
  12. NpmPackageManager,
  13. type FeatureSelection,
  14. type NestedMultiSelectValue,
  15. type PromptPort,
  16. } from '@deepseek-ai/dsh-helper'
  17. import type {
  18. ConfirmPromptRequest,
  19. MultiSelectPromptRequest,
  20. NestedMultiSelectRequest,
  21. PromptOutcome,
  22. SecretPromptRequest,
  23. SelectPromptRequest,
  24. TextPromptRequest,
  25. } from '../../helper/src/questions/prompt-port.ts'
  26. import { parseCreateArgs } from '../src/args.ts'
  27. import {
  28. createProject,
  29. readCreateSdkVersion,
  30. runCreateCommand,
  31. type CreateCommandContext,
  32. } from '../src/command.ts'
  33. import { CreateWizard } from '../src/create-wizard.ts'
  34. import { resolveHeadless } from '../src/headless.ts'
  35. import { scaffoldProject } from '../src/project-scaffolder.ts'
  36. class ScriptedPort implements PromptPort {
  37. readonly requests: string[] = []
  38. readonly #answers: unknown[]
  39. constructor(answers: unknown[]) {
  40. this.#answers = [...answers]
  41. }
  42. answer<T>(message: string): Promise<PromptOutcome<T>> {
  43. this.requests.push(message)
  44. const value = this.#answers.shift()
  45. return Promise.resolve(value === ScriptedPort.cancel
  46. ? { status: 'cancelled' }
  47. : { status: 'answered', value: value as T })
  48. }
  49. async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
  50. const outcome = await this.answer<string>(request.message)
  51. if (outcome.status === 'cancelled') return outcome
  52. const value = outcome.value || request.defaultValue || ''
  53. const diagnostic = request.validate?.(value)
  54. if (diagnostic) throw new Error(diagnostic)
  55. return { status: 'answered', value }
  56. }
  57. secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
  58. select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
  59. multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
  60. return this.answer(request.message)
  61. }
  62. confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
  63. nestedMultiselect<TValue, TChoice>(
  64. request: NestedMultiSelectRequest<TValue, TChoice>,
  65. ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
  66. return this.answer(request.message)
  67. }
  68. static readonly cancel = Symbol('cancel')
  69. }
  70. const temporary: string[] = []
  71. const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
  72. interface GeneratedPackageManifest {
  73. scripts?: Record<string, string>
  74. dependencies?: Record<string, string>
  75. devDependencies?: Record<string, string>
  76. }
  77. interface GeneratedTsConfig {
  78. compilerOptions: {
  79. types?: readonly string[]
  80. }
  81. }
  82. function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
  83. return JSON.parse(text) as GeneratedPackageManifest
  84. }
  85. function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
  86. return JSON.parse(text) as GeneratedTsConfig
  87. }
  88. function commandContext(
  89. cwd: string,
  90. port?: PromptPort,
  91. setup?: CreateCommandContext['setup'],
  92. ): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
  93. let stdout = ''
  94. let stderr = ''
  95. const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
  96. const output = Object.assign(new Writable({
  97. write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
  98. }), { isTTY: true }) as unknown as NodeJS.WriteStream
  99. const error = new Writable({
  100. write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
  101. }) as unknown as NodeJS.WriteStream
  102. return {
  103. cwd,
  104. stdin: input,
  105. stdout: output,
  106. stderr: error,
  107. releaseVersion: '0.0.1',
  108. versionProbe: async () => '10.0.0',
  109. ...port ? { port } : {},
  110. ...setup ? { setup } : {},
  111. readStdout: () => stdout,
  112. readStderr: () => stderr,
  113. }
  114. }
  115. afterEach(async () => {
  116. await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
  117. })
  118. describe('create arguments', () => {
  119. it('parses public options and the private repository link mode', () => {
  120. expect(parseCreateArgs([
  121. 'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example',
  122. '--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
  123. '--link-workspace',
  124. ])).toEqual({
  125. directory: 'agent',
  126. description: 'demo',
  127. provider: 'deepseek-official',
  128. baseURL: 'https://api.example',
  129. apiKey: 'key',
  130. model: 'm',
  131. runInterface: 'acp',
  132. packageManager: 'pnpm',
  133. install: false,
  134. linkWorkspace: true,
  135. help: false,
  136. })
  137. expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
  138. expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
  139. expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
  140. expect(parseCreateArgs(['--help']).help).toBe(true)
  141. expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
  142. expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
  143. expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
  144. })
  145. it('validates empty directories and package names', async () => {
  146. const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
  147. temporary.push(root)
  148. await expect(new CreateWizard({
  149. args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
  150. releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
  151. }).run()).rejects.toThrow('A value is required')
  152. await expect(new CreateWizard({
  153. args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
  154. releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
  155. }).run()).rejects.toThrow('lowercase npm package name')
  156. })
  157. it('rejects an existing target before asking project questions', async () => {
  158. const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
  159. temporary.push(cwd)
  160. await mkdir(join(cwd, 'taken'))
  161. const port = new ScriptedPort([])
  162. const wizard = new CreateWizard({
  163. args: parseCreateArgs(['taken']),
  164. port,
  165. cwd,
  166. releaseVersion: '0.0.1',
  167. versionProbe: async () => '10.0.0',
  168. })
  169. await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
  170. expect(port.requests).toEqual([])
  171. })
  172. })
  173. describe('CreateWizard and scaffolder', () => {
  174. it('asks only unresolved questions in requirement-safe order', async () => {
  175. const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
  176. temporary.push(cwd)
  177. const port = new ScriptedPort([
  178. 'my-agent',
  179. [
  180. { value: featureId('persistence'), choices: ['sqlite'] },
  181. { value: featureId('hmr'), choices: [] },
  182. { value: featureId('fs'), choices: [] },
  183. { value: featureId('web'), choices: ['exa'] },
  184. ],
  185. false,
  186. 'exa-key',
  187. 'tool',
  188. ])
  189. const args = parseCreateArgs([
  190. 'my-agent',
  191. '--description=demo',
  192. '--provider=deepseek-official',
  193. '--api-key=deepseek-key',
  194. '--model=deepseek-v4-flash',
  195. '--interface=acp',
  196. '--pm=npm',
  197. '--no-install',
  198. '--link-workspace',
  199. ])
  200. const resolved = await new CreateWizard({
  201. args,
  202. port,
  203. cwd,
  204. releaseVersion: '0.0.1',
  205. versionProbe: async () => '10.0.0',
  206. }).run()
  207. expect(port.requests).toEqual([
  208. 'Package name',
  209. 'Select features',
  210. 'Add the recommended tool timeout policy for web search and fetch tools?',
  211. 'Exa API key',
  212. 'Local plugin',
  213. ])
  214. expect(resolved.install).toBe(false)
  215. expect(resolved.request.packageManager.name).toBe('npm')
  216. expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
  217. expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
  218. expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
  219. options: ['exa'], secrets: { apiKey: 'exa-key' },
  220. })
  221. expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
  222. })
  223. it('runs headlessly from a feature plan without reaching the terminal', async () => {
  224. const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
  225. temporary.push(cwd)
  226. const features: FeatureSelection[] = [
  227. { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
  228. { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
  229. ]
  230. const resolved = await new CreateWizard({
  231. args: parseCreateArgs([
  232. 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key',
  233. '--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install',
  234. ]),
  235. port: new HeadlessPromptPort(),
  236. cwd,
  237. releaseVersion: '0.0.1',
  238. versionProbe: async () => '10.0.0',
  239. features,
  240. }).run()
  241. expect(resolved.install).toBe(false)
  242. expect(resolved.request.localPlugins).toEqual([])
  243. expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
  244. options: ['exa'], secrets: { apiKey: 'exa-key' },
  245. })
  246. expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
  247. expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
  248. secrets: { apiKey: 'deepseek-key' },
  249. })
  250. })
  251. it('rejects a non-string feature value in a headless plan', async () => {
  252. const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
  253. temporary.push(cwd)
  254. const features = [
  255. { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
  256. ] as unknown as FeatureSelection[]
  257. await expect(new CreateWizard({
  258. args: parseCreateArgs([
  259. 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k',
  260. '--model=m', '--interface=acp', '--pm=npm', '--no-install',
  261. ]),
  262. port: new HeadlessPromptPort(),
  263. cwd,
  264. releaseVersion: '0.0.1',
  265. versionProbe: async () => '10.0.0',
  266. features,
  267. }).run()).rejects.toThrow('must be a string')
  268. })
  269. it('writes the project once and refuses every existing target', async () => {
  270. const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
  271. temporary.push(root)
  272. const request = {
  273. name: 'agent',
  274. description: 'demo',
  275. runtime: { model: 'deepseek-v4-flash' },
  276. packageManager: new NpmPackageManager('10.0.0'),
  277. releaseVersion: '0.0.1',
  278. features: [
  279. { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
  280. { id: featureId('bash'), options: ['local'] },
  281. { id: featureId('app'), options: ['embed'] },
  282. { id: featureId('persistence'), options: ['jsonl'] },
  283. ],
  284. localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
  285. }
  286. const target = join(root, 'project')
  287. const result = await scaffoldProject(target, request)
  288. expect(result.changes.changedFiles).toContain('README.md')
  289. const index = await readFile(join(target, 'index.ts'), 'utf8')
  290. expect(index).toContain('SdkBootContext')
  291. expect(index).toContain('ctx.agents.create')
  292. expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
  293. expect(index).not.toContain('AgentId')
  294. const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
  295. const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
  296. expect(tsconfig.compilerOptions.types).toEqual(['node'])
  297. expect(manifest.scripts).toEqual({
  298. dev: 'dsh-sdk dev index.ts',
  299. build: 'dsh-sdk build',
  300. typecheck: 'tsc -b',
  301. start: 'dsh-sdk start index.js',
  302. config: 'dsh-sdk config',
  303. })
  304. expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
  305. expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
  306. expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
  307. const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
  308. expect(cordis).toMatch(/^- id:/)
  309. expect(cordis).not.toMatch(/^\[/)
  310. const occupied = join(root, 'occupied')
  311. await mkdir(occupied)
  312. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  313. await writeFile(join(occupied, 'keep'), 'x')
  314. await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
  315. })
  316. it('installs workflow requirements before validating the next feature', async () => {
  317. const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
  318. temporary.push(cwd)
  319. const port = new ScriptedPort([
  320. 'workflow-agent',
  321. [
  322. { value: featureId('persistence'), choices: ['jsonl'] },
  323. { value: featureId('workflow'), choices: [] },
  324. ],
  325. 'none',
  326. ])
  327. const resolved = await new CreateWizard({
  328. args: parseCreateArgs([
  329. 'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
  330. '--interface=embed', '--pm=npm', '--no-install',
  331. ]),
  332. port,
  333. cwd,
  334. releaseVersion: '0.0.1',
  335. versionProbe: async () => '10.0.0',
  336. }).run()
  337. const result = await scaffoldProject(resolved.directory, resolved.request)
  338. expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
  339. expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
  340. })
  341. it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
  342. const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
  343. temporary.push(cwd)
  344. const port = new ScriptedPort([
  345. 'empty-key-agent',
  346. '',
  347. true,
  348. [{ value: featureId('persistence'), choices: ['jsonl'] }],
  349. 'none',
  350. ])
  351. const resolved = await new CreateWizard({
  352. args: parseCreateArgs([
  353. 'empty-key-agent', '--description=test', '--provider=deepseek-official',
  354. '--interface=embed', '--pm=npm', '--no-install',
  355. ]),
  356. port,
  357. cwd,
  358. releaseVersion: '0.0.1',
  359. versionProbe: async () => '10.0.0',
  360. }).run()
  361. await scaffoldProject(resolved.directory, resolved.request)
  362. expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
  363. '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
  364. )
  365. expect(port.requests).toContain('Keep the API key empty and fill .env later?')
  366. })
  367. it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
  368. const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
  369. temporary.push(cwd)
  370. const port = new ScriptedPort([
  371. 'custom-agent',
  372. 'test custom provider',
  373. 'custom',
  374. 'https://provider.example/v1',
  375. '', false, 'custom-key',
  376. 'embed',
  377. [
  378. { value: featureId('persistence'), choices: ['jsonl'] },
  379. { value: featureId('web'), choices: ['deepseek-official'] },
  380. ],
  381. true,
  382. 'none',
  383. 'npm',
  384. false,
  385. ])
  386. const resolved = await new CreateWizard({
  387. args: parseCreateArgs(['custom-agent']),
  388. port,
  389. cwd,
  390. releaseVersion: '0.0.1',
  391. versionProbe: async () => '10.0.0',
  392. userAgent: '',
  393. }).run()
  394. expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
  395. options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
  396. })
  397. expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
  398. })
  399. it('does not re-suggest an already selected feature', async () => {
  400. const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
  401. temporary.push(cwd)
  402. const port = new ScriptedPort([
  403. 'agent',
  404. [
  405. { value: featureId('persistence'), choices: ['jsonl'] },
  406. { value: featureId('web'), choices: ['deepseek-official'] },
  407. { value: featureId('timeout-policy'), choices: ['default'] },
  408. ],
  409. 'none',
  410. ])
  411. const resolved = await new CreateWizard({
  412. args: parseCreateArgs([
  413. 'agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
  414. '--interface=embed', '--pm=npm', '--no-install',
  415. ]),
  416. port,
  417. cwd,
  418. releaseVersion: '0.0.1',
  419. versionProbe: async () => '10.0.0',
  420. }).run()
  421. expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
  422. })
  423. it('uses process defaults when constructor infrastructure is omitted', async () => {
  424. const name = `default-infra-${String(process.pid)}`
  425. const port = new ScriptedPort([
  426. name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  427. ])
  428. const resolved = await new CreateWizard({
  429. args: parseCreateArgs([
  430. name, '--description=test', '--provider=deepseek-official', '--api-key=key',
  431. '--interface=embed', '--pm=npm', '--no-install',
  432. ]),
  433. port,
  434. releaseVersion: '0.0.1',
  435. }).run()
  436. expect(resolved.request.packageManager.name).toBe('npm')
  437. })
  438. it('reads the release batch from the initializer package', async () => {
  439. await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
  440. })
  441. })
  442. describe('create command composition', () => {
  443. const argv = (directory: string, install: boolean): string[] => [
  444. directory, '--description=test', '--provider=deepseek-official', '--api-key=key',
  445. '--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
  446. ]
  447. it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
  448. const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
  449. temporary.push(root)
  450. const context = commandContext(root)
  451. context.stdin.isTTY = false
  452. context.stdout.isTTY = false
  453. await expect(createProject(['--help'], context)).resolves.toBeUndefined()
  454. expect(context.readStdout()).toContain('Usage: create-sdk')
  455. expect(context.readStdout()).toContain('--config-json <json>')
  456. expect(context.readStdout()).not.toContain('--link-workspace')
  457. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  458. context.stdin.isTTY = true
  459. await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
  460. })
  461. it('creates headlessly from --config-json with no TTY', async () => {
  462. const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
  463. temporary.push(root)
  464. const spec = JSON.stringify({
  465. directory: 'agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
  466. model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
  467. features: [{ id: 'persistence', options: ['jsonl'] }],
  468. })
  469. const context = commandContext(root)
  470. context.stdin.isTTY = false
  471. context.stdout.isTTY = false
  472. const result = await createProject(['--config-json', spec], context)
  473. expect(result?.project.root).toBe(join(root, 'agent'))
  474. })
  475. it('emits NDJSON lifecycle events under --json', async () => {
  476. const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
  477. temporary.push(root)
  478. const base = {
  479. description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
  480. }
  481. const ok = commandContext(root)
  482. ok.stdin.isTTY = false
  483. ok.stdout.isTTY = false
  484. const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek-official', apiKey: 'key', features: [] })
  485. await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
  486. expect(ok.readStdout()).toContain('{"type":"done"}')
  487. // stdout stays pure NDJSON: every line parses, human progress goes to stderr
  488. for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
  489. expect(() => { JSON.parse(line) }).not.toThrow()
  490. }
  491. expect(ok.readStderr()).toContain('Created done-agent')
  492. expect(ok.readStderr()).toContain('Next: cd')
  493. const missing = commandContext(root)
  494. missing.stdin.isTTY = false
  495. missing.stdout.isTTY = false
  496. const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
  497. await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
  498. expect(missing.readStdout()).toContain('"type":"action-required"')
  499. const broken = commandContext(root)
  500. broken.stdin.isTTY = false
  501. broken.stdout.isTTY = false
  502. await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
  503. expect(broken.readStdout()).toContain('"type":"error"')
  504. const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
  505. await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
  506. expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
  507. })
  508. it('creates through an injected prompt port and delegates optional setup', async () => {
  509. const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
  510. temporary.push(root)
  511. const port = new ScriptedPort([
  512. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  513. ])
  514. let setupDirectory = ''
  515. const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
  516. const result = await createProject(argv('agent', true), context)
  517. expect(result?.project.root).toBe(join(root, 'agent'))
  518. expect(setupDirectory).toBe(join(root, 'agent'))
  519. expect(context.readStdout()).toContain('Created agent')
  520. expect(context.readStdout()).toContain('Next: cd')
  521. const noInstall = commandContext(root, new ScriptedPort([
  522. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  523. ]))
  524. await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
  525. expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
  526. })
  527. it('uses the package manager setup path when no setup override is supplied', async () => {
  528. const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
  529. temporary.push(root)
  530. const port = new ScriptedPort([
  531. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  532. ])
  533. const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
  534. const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
  535. const context = commandContext(root, port)
  536. delete context.releaseVersion
  537. delete context.versionProbe
  538. await createProject(argv('agent', true), context)
  539. expect(install).toHaveBeenCalledOnce()
  540. expect(build).toHaveBeenCalledOnce()
  541. const spec = JSON.stringify({
  542. directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
  543. model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
  544. })
  545. const json = commandContext(root)
  546. json.stdin.isTTY = false
  547. json.stdout.isTTY = false
  548. await createProject(['--config-json', spec, '--json'], json)
  549. // json mode hands install/build a runner that redirects child output to stderr
  550. expect(install).toHaveBeenCalledTimes(2)
  551. expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
  552. install.mockRestore()
  553. build.mockRestore()
  554. })
  555. it('reports setup failures after preserving generated files', async () => {
  556. const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
  557. temporary.push(root)
  558. const port = new ScriptedPort([
  559. 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  560. ])
  561. const context = commandContext(root, port, async () => { throw new Error('offline') })
  562. await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
  563. expect(context.readStderr()).toContain('Project files are ready, but setup failed')
  564. expect(context.readStderr()).toContain('npm install && npm run build')
  565. const stringFailure = commandContext(root, new ScriptedPort([
  566. 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
  567. ]), async () => { throw 'offline-string' })
  568. await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
  569. expect(stringFailure.readStderr()).toContain('offline-string')
  570. })
  571. it('maps cancellation and ordinary errors to command exit codes', async () => {
  572. const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
  573. temporary.push(root)
  574. const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
  575. await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
  576. expect(cancelled.readStderr()).toContain('cancelled')
  577. const invalid = commandContext(root)
  578. await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
  579. expect(invalid.readStderr()).toContain('unknown option')
  580. const help = commandContext(root)
  581. await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
  582. })
  583. })
  584. describe('resolveHeadless', () => {
  585. it('returns undefined without a config source', async () => {
  586. expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
  587. })
  588. it('maps every inline --config-json field into args plus the feature plan', async () => {
  589. const spec = JSON.stringify({
  590. directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
  591. model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
  592. features: [{ id: 'todo', options: ['default'] }],
  593. })
  594. const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
  595. expect(resolved?.args).toMatchObject({
  596. directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
  597. model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
  598. })
  599. expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
  600. })
  601. it('reads --config from a file via the injected reader and omits absent fields', async () => {
  602. const resolved = await resolveHeadless(
  603. parseCreateArgs(['--config', '/spec.json']),
  604. async () => JSON.stringify({ description: 'from-file' }),
  605. )
  606. expect(resolved?.args.description).toBe('from-file')
  607. expect(resolved?.args.directory).toBeUndefined()
  608. expect(resolved?.args.linkWorkspace).toBeUndefined()
  609. expect(resolved?.features).toBeUndefined()
  610. })
  611. it('reads --config from disk with the default reader', async () => {
  612. const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
  613. temporary.push(dir)
  614. const file = join(dir, 'spec.json')
  615. await writeFile(file, JSON.stringify({ description: 'on-disk' }))
  616. const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
  617. expect(resolved?.args.description).toBe('on-disk')
  618. })
  619. it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
  620. await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
  621. await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
  622. await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
  623. await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
  624. await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
  625. })
  626. it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
  627. const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
  628. expect(resolved?.args.directory).toBe('x')
  629. expect(resolved?.args.description).toBeUndefined()
  630. expect(resolved?.features).toBeUndefined()
  631. })
  632. })