| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { PassThrough, Writable } from 'node:stream'
- import { fileURLToPath } from 'node:url'
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import {
- HeadlessPromptPort,
- LocalPluginBlueprint,
- featureId,
- NodeCommandRunner,
- NpmPackageManager,
- type FeatureSelection,
- type NestedMultiSelectValue,
- type PromptPort,
- } from '@deepseek-ai/dsh-helper'
- import type {
- ConfirmPromptRequest,
- MultiSelectPromptRequest,
- NestedMultiSelectRequest,
- PromptOutcome,
- SecretPromptRequest,
- SelectPromptRequest,
- TextPromptRequest,
- } from '../../helper/src/questions/prompt-port.ts'
- import { parseCreateArgs } from '../src/args.ts'
- import {
- createProject,
- readCreateSdkVersion,
- runCreateCommand,
- type CreateCommandContext,
- } from '../src/command.ts'
- import { CreateWizard } from '../src/create-wizard.ts'
- import { resolveHeadless } from '../src/headless.ts'
- import { scaffoldProject } from '../src/project-scaffolder.ts'
- class ScriptedPort implements PromptPort {
- readonly requests: string[] = []
- readonly #answers: unknown[]
- constructor(answers: unknown[]) {
- this.#answers = [...answers]
- }
- answer<T>(message: string): Promise<PromptOutcome<T>> {
- this.requests.push(message)
- const value = this.#answers.shift()
- return Promise.resolve(value === ScriptedPort.cancel
- ? { status: 'cancelled' }
- : { status: 'answered', value: value as T })
- }
- async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
- const outcome = await this.answer<string>(request.message)
- if (outcome.status === 'cancelled') return outcome
- const value = outcome.value || request.defaultValue || ''
- const diagnostic = request.validate?.(value)
- if (diagnostic) throw new Error(diagnostic)
- return { status: 'answered', value }
- }
- secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
- select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
- multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
- return this.answer(request.message)
- }
- confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
- nestedMultiselect<TValue, TChoice>(
- request: NestedMultiSelectRequest<TValue, TChoice>,
- ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
- return this.answer(request.message)
- }
- static readonly cancel = Symbol('cancel')
- }
- const temporary: string[] = []
- const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
- interface GeneratedPackageManifest {
- scripts?: Record<string, string>
- dependencies?: Record<string, string>
- devDependencies?: Record<string, string>
- }
- interface GeneratedTsConfig {
- compilerOptions: {
- types?: readonly string[]
- }
- }
- function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
- return JSON.parse(text) as GeneratedPackageManifest
- }
- function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
- return JSON.parse(text) as GeneratedTsConfig
- }
- function commandContext(
- cwd: string,
- port?: PromptPort,
- setup?: CreateCommandContext['setup'],
- ): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
- let stdout = ''
- let stderr = ''
- const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
- const output = Object.assign(new Writable({
- write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
- }), { isTTY: true }) as unknown as NodeJS.WriteStream
- const error = new Writable({
- write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
- }) as unknown as NodeJS.WriteStream
- return {
- cwd,
- stdin: input,
- stdout: output,
- stderr: error,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- ...port ? { port } : {},
- ...setup ? { setup } : {},
- readStdout: () => stdout,
- readStderr: () => stderr,
- }
- }
- afterEach(async () => {
- await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
- })
- describe('create arguments', () => {
- it('parses public options and the private repository link mode', () => {
- expect(parseCreateArgs([
- 'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example',
- '--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
- '--link-workspace',
- ])).toEqual({
- directory: 'agent',
- description: 'demo',
- provider: 'deepseek-official',
- baseURL: 'https://api.example',
- apiKey: 'key',
- model: 'm',
- runInterface: 'acp',
- packageManager: 'pnpm',
- install: false,
- linkWorkspace: true,
- help: false,
- })
- expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
- expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
- expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
- expect(parseCreateArgs(['--help']).help).toBe(true)
- expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
- expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
- expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
- })
- it('validates empty directories and package names', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
- temporary.push(root)
- await expect(new CreateWizard({
- args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
- releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
- }).run()).rejects.toThrow('A value is required')
- await expect(new CreateWizard({
- args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
- releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
- }).run()).rejects.toThrow('lowercase npm package name')
- })
- it('rejects an existing target before asking project questions', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
- temporary.push(cwd)
- await mkdir(join(cwd, 'taken'))
- const port = new ScriptedPort([])
- const wizard = new CreateWizard({
- args: parseCreateArgs(['taken']),
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- })
- await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
- expect(port.requests).toEqual([])
- })
- })
- describe('CreateWizard and scaffolder', () => {
- it('asks only unresolved questions in requirement-safe order', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
- temporary.push(cwd)
- const port = new ScriptedPort([
- 'my-agent',
- [
- { value: featureId('persistence'), choices: ['sqlite'] },
- { value: featureId('hmr'), choices: [] },
- { value: featureId('fs'), choices: [] },
- { value: featureId('web'), choices: ['exa'] },
- ],
- false,
- 'exa-key',
- 'tool',
- ])
- const args = parseCreateArgs([
- 'my-agent',
- '--description=demo',
- '--provider=deepseek-official',
- '--api-key=deepseek-key',
- '--model=deepseek-v4-flash',
- '--interface=acp',
- '--pm=npm',
- '--no-install',
- '--link-workspace',
- ])
- const resolved = await new CreateWizard({
- args,
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- }).run()
- expect(port.requests).toEqual([
- 'Package name',
- 'Select features',
- 'Add the recommended tool timeout policy for web search and fetch tools?',
- 'Exa API key',
- 'Local plugin',
- ])
- expect(resolved.install).toBe(false)
- expect(resolved.request.packageManager.name).toBe('npm')
- expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
- expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
- expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
- options: ['exa'], secrets: { apiKey: 'exa-key' },
- })
- expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
- })
- it('runs headlessly from a feature plan without reaching the terminal', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
- temporary.push(cwd)
- const features: FeatureSelection[] = [
- { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
- { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
- ]
- const resolved = await new CreateWizard({
- args: parseCreateArgs([
- 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=deepseek-key',
- '--model=deepseek-v4-flash', '--interface=acp', '--pm=npm', '--no-install',
- ]),
- port: new HeadlessPromptPort(),
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- features,
- }).run()
- expect(resolved.install).toBe(false)
- expect(resolved.request.localPlugins).toEqual([])
- expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
- options: ['exa'], secrets: { apiKey: 'exa-key' },
- })
- expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
- expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
- secrets: { apiKey: 'deepseek-key' },
- })
- })
- it('rejects a non-string feature value in a headless plan', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
- temporary.push(cwd)
- const features = [
- { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
- ] as unknown as FeatureSelection[]
- await expect(new CreateWizard({
- args: parseCreateArgs([
- 'my-agent', '--description=demo', '--provider=deepseek-official', '--api-key=k',
- '--model=m', '--interface=acp', '--pm=npm', '--no-install',
- ]),
- port: new HeadlessPromptPort(),
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- features,
- }).run()).rejects.toThrow('must be a string')
- })
- it('writes the project once and refuses every existing target', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
- temporary.push(root)
- const request = {
- name: 'agent',
- description: 'demo',
- runtime: { model: 'deepseek-v4-flash' },
- packageManager: new NpmPackageManager('10.0.0'),
- releaseVersion: '0.0.1',
- features: [
- { id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
- { id: featureId('bash'), options: ['local'] },
- { id: featureId('app'), options: ['embed'] },
- { id: featureId('persistence'), options: ['jsonl'] },
- ],
- localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
- }
- const target = join(root, 'project')
- const result = await scaffoldProject(target, request)
- expect(result.changes.changedFiles).toContain('README.md')
- const index = await readFile(join(target, 'index.ts'), 'utf8')
- expect(index).toContain('SdkBootContext')
- expect(index).toContain('ctx.agents.create')
- expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
- expect(index).not.toContain('AgentId')
- const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
- const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
- expect(tsconfig.compilerOptions.types).toEqual(['node'])
- expect(manifest.scripts).toEqual({
- dev: 'dsh-sdk dev index.ts',
- build: 'dsh-sdk build',
- typecheck: 'tsc -b',
- start: 'dsh-sdk start index.js',
- config: 'dsh-sdk config',
- })
- expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
- expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
- expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
- const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
- expect(cordis).toMatch(/^- id:/)
- expect(cordis).not.toMatch(/^\[/)
- const occupied = join(root, 'occupied')
- await mkdir(occupied)
- await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
- await writeFile(join(occupied, 'keep'), 'x')
- await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
- })
- it('installs workflow requirements before validating the next feature', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
- temporary.push(cwd)
- const port = new ScriptedPort([
- 'workflow-agent',
- [
- { value: featureId('persistence'), choices: ['jsonl'] },
- { value: featureId('workflow'), choices: [] },
- ],
- 'none',
- ])
- const resolved = await new CreateWizard({
- args: parseCreateArgs([
- 'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
- '--interface=embed', '--pm=npm', '--no-install',
- ]),
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- }).run()
- const result = await scaffoldProject(resolved.directory, resolved.request)
- expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
- expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
- })
- it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
- temporary.push(cwd)
- const port = new ScriptedPort([
- 'empty-key-agent',
- '',
- true,
- [{ value: featureId('persistence'), choices: ['jsonl'] }],
- 'none',
- ])
- const resolved = await new CreateWizard({
- args: parseCreateArgs([
- 'empty-key-agent', '--description=test', '--provider=deepseek-official',
- '--interface=embed', '--pm=npm', '--no-install',
- ]),
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- }).run()
- await scaffoldProject(resolved.directory, resolved.request)
- expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
- '# Required before start; an empty value makes provider startup fail.\nDEEPSEEK_API_KEY=\n',
- )
- expect(port.requests).toContain('Keep the API key empty and fill .env later?')
- })
- it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
- temporary.push(cwd)
- const port = new ScriptedPort([
- 'custom-agent',
- 'test custom provider',
- 'custom',
- 'https://provider.example/v1',
- '', false, 'custom-key',
- 'embed',
- [
- { value: featureId('persistence'), choices: ['jsonl'] },
- { value: featureId('web'), choices: ['deepseek-official'] },
- ],
- true,
- 'none',
- 'npm',
- false,
- ])
- const resolved = await new CreateWizard({
- args: parseCreateArgs(['custom-agent']),
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- userAgent: '',
- }).run()
- expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
- options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
- })
- expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
- })
- it('does not re-suggest an already selected feature', async () => {
- const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
- temporary.push(cwd)
- const port = new ScriptedPort([
- 'agent',
- [
- { value: featureId('persistence'), choices: ['jsonl'] },
- { value: featureId('web'), choices: ['deepseek-official'] },
- { value: featureId('timeout-policy'), choices: ['default'] },
- ],
- 'none',
- ])
- const resolved = await new CreateWizard({
- args: parseCreateArgs([
- 'agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
- '--interface=embed', '--pm=npm', '--no-install',
- ]),
- port,
- cwd,
- releaseVersion: '0.0.1',
- versionProbe: async () => '10.0.0',
- }).run()
- expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
- })
- it('uses process defaults when constructor infrastructure is omitted', async () => {
- const name = `default-infra-${String(process.pid)}`
- const port = new ScriptedPort([
- name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ])
- const resolved = await new CreateWizard({
- args: parseCreateArgs([
- name, '--description=test', '--provider=deepseek-official', '--api-key=key',
- '--interface=embed', '--pm=npm', '--no-install',
- ]),
- port,
- releaseVersion: '0.0.1',
- }).run()
- expect(resolved.request.packageManager.name).toBe('npm')
- })
- it('reads the release batch from the initializer package', async () => {
- await expect(readCreateSdkVersion()).resolves.toBe('0.0.1')
- })
- })
- describe('create command composition', () => {
- const argv = (directory: string, install: boolean): string[] => [
- directory, '--description=test', '--provider=deepseek-official', '--api-key=key',
- '--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
- ]
- it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
- temporary.push(root)
- const context = commandContext(root)
- context.stdin.isTTY = false
- context.stdout.isTTY = false
- await expect(createProject(['--help'], context)).resolves.toBeUndefined()
- expect(context.readStdout()).toContain('Usage: create-sdk')
- expect(context.readStdout()).toContain('--config-json <json>')
- expect(context.readStdout()).not.toContain('--link-workspace')
- await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
- context.stdin.isTTY = true
- await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
- })
- it('creates headlessly from --config-json with no TTY', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
- temporary.push(root)
- const spec = JSON.stringify({
- directory: 'agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
- model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
- features: [{ id: 'persistence', options: ['jsonl'] }],
- })
- const context = commandContext(root)
- context.stdin.isTTY = false
- context.stdout.isTTY = false
- const result = await createProject(['--config-json', spec], context)
- expect(result?.project.root).toBe(join(root, 'agent'))
- })
- it('emits NDJSON lifecycle events under --json', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
- temporary.push(root)
- const base = {
- description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
- }
- const ok = commandContext(root)
- ok.stdin.isTTY = false
- ok.stdout.isTTY = false
- const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek-official', apiKey: 'key', features: [] })
- await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
- expect(ok.readStdout()).toContain('{"type":"done"}')
- // stdout stays pure NDJSON: every line parses, human progress goes to stderr
- for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
- expect(() => { JSON.parse(line) }).not.toThrow()
- }
- expect(ok.readStderr()).toContain('Created done-agent')
- expect(ok.readStderr()).toContain('Next: cd')
- const missing = commandContext(root)
- missing.stdin.isTTY = false
- missing.stdout.isTTY = false
- const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
- await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
- expect(missing.readStdout()).toContain('"type":"action-required"')
- const broken = commandContext(root)
- broken.stdin.isTTY = false
- broken.stdout.isTTY = false
- await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
- expect(broken.readStdout()).toContain('"type":"error"')
- const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
- await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
- expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
- })
- it('creates through an injected prompt port and delegates optional setup', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
- temporary.push(root)
- const port = new ScriptedPort([
- 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ])
- let setupDirectory = ''
- const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
- const result = await createProject(argv('agent', true), context)
- expect(result?.project.root).toBe(join(root, 'agent'))
- expect(setupDirectory).toBe(join(root, 'agent'))
- expect(context.readStdout()).toContain('Created agent')
- expect(context.readStdout()).toContain('Next: cd')
- const noInstall = commandContext(root, new ScriptedPort([
- 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ]))
- await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
- expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
- })
- it('uses the package manager setup path when no setup override is supplied', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
- temporary.push(root)
- const port = new ScriptedPort([
- 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ])
- const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
- const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
- const context = commandContext(root, port)
- delete context.releaseVersion
- delete context.versionProbe
- await createProject(argv('agent', true), context)
- expect(install).toHaveBeenCalledOnce()
- expect(build).toHaveBeenCalledOnce()
- const spec = JSON.stringify({
- directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
- model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
- })
- const json = commandContext(root)
- json.stdin.isTTY = false
- json.stdout.isTTY = false
- await createProject(['--config-json', spec, '--json'], json)
- // json mode hands install/build a runner that redirects child output to stderr
- expect(install).toHaveBeenCalledTimes(2)
- expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
- install.mockRestore()
- build.mockRestore()
- })
- it('reports setup failures after preserving generated files', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
- temporary.push(root)
- const port = new ScriptedPort([
- 'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ])
- const context = commandContext(root, port, async () => { throw new Error('offline') })
- await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
- expect(context.readStderr()).toContain('Project files are ready, but setup failed')
- expect(context.readStderr()).toContain('npm install && npm run build')
- const stringFailure = commandContext(root, new ScriptedPort([
- 'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
- ]), async () => { throw 'offline-string' })
- await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
- expect(stringFailure.readStderr()).toContain('offline-string')
- })
- it('maps cancellation and ordinary errors to command exit codes', async () => {
- const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
- temporary.push(root)
- const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
- await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
- expect(cancelled.readStderr()).toContain('cancelled')
- const invalid = commandContext(root)
- await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
- expect(invalid.readStderr()).toContain('unknown option')
- const help = commandContext(root)
- await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
- })
- })
- describe('resolveHeadless', () => {
- it('returns undefined without a config source', async () => {
- expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
- })
- it('maps every inline --config-json field into args plus the feature plan', async () => {
- const spec = JSON.stringify({
- directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
- model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
- features: [{ id: 'todo', options: ['default'] }],
- })
- const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
- expect(resolved?.args).toMatchObject({
- directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
- model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
- })
- expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
- })
- it('reads --config from a file via the injected reader and omits absent fields', async () => {
- const resolved = await resolveHeadless(
- parseCreateArgs(['--config', '/spec.json']),
- async () => JSON.stringify({ description: 'from-file' }),
- )
- expect(resolved?.args.description).toBe('from-file')
- expect(resolved?.args.directory).toBeUndefined()
- expect(resolved?.args.linkWorkspace).toBeUndefined()
- expect(resolved?.features).toBeUndefined()
- })
- it('reads --config from disk with the default reader', async () => {
- const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
- temporary.push(dir)
- const file = join(dir, 'spec.json')
- await writeFile(file, JSON.stringify({ description: 'on-disk' }))
- const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
- expect(resolved?.args.description).toBe('on-disk')
- })
- it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
- await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
- await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
- await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
- await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
- await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
- })
- it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
- const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
- expect(resolved?.args.directory).toBe('x')
- expect(resolved?.args.description).toBeUndefined()
- expect(resolved?.features).toBeUndefined()
- })
- })
|