675 lines
28 KiB
TypeScript
675 lines
28 KiB
TypeScript
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', '--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',
|
|
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, tui, 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',
|
|
'--api-key=deepseek-key',
|
|
'--model=deepseek-v4-flash',
|
|
'--interface=tui',
|
|
'--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', '--api-key=deepseek-key',
|
|
'--model=deepseek-v4-flash', '--interface=tui', '--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', '--api-key=k',
|
|
'--model=m', '--interface=tui', '--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'], 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', '--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',
|
|
'--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'] },
|
|
],
|
|
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'] },
|
|
{ value: featureId('timeout-policy'), choices: ['default'] },
|
|
],
|
|
'none',
|
|
])
|
|
const resolved = await new CreateWizard({
|
|
args: parseCreateArgs([
|
|
'agent', '--description=test', '--provider=deepseek', '--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', '--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', '--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', 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', 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', 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()
|
|
})
|
|
})
|