refactor(packages): dissolve ui/ and rename sdk/ to scaffold/

git mv per the regrouping RFC: the five human-collaboration seams and
tui join packages/interaction/, app-boot becomes packages/boot/, and
jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half
beside client/protocol/create-sdk/helper/scripts/telemetry, whose
folders drop the legacy sdk- prefix. Three new group README triplets
replace the ui/ and sdk/ ones; tsconfig references/paths/globs,
knip keys, vitest globs, gate scripts, catalogs, docs, and the
lockfile follow. Adds the four settled FIXME rename markers
(dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts).

The scaffold folders diverge from their npm names until those renames
land, so tsconfig.base.json maps the three affected names explicitly
beside the group wildcard. Also repairs two pre-existing stale-path
classes the strengthened sweep surfaced: docs/web-styling.md's retired
web-ui host package and type-model spec fixture-literal joins.

app-boot's three Loader-composition specs time out at the default 5s
under full-suite parallel load on this filesystem (pre-existing;
pass isolated with --testTimeout=30000); interaction/scaffold/boot
suites otherwise green (687 passed).
This commit is contained in:
Tianyi Cui
2026-07-30 03:13:49 +08:00
parent 7e445c3a67
commit 3fc35c91ff
351 changed files with 368 additions and 311 deletions

View File

@@ -0,0 +1,28 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
const execFileAsync = promisify(execFile)
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const createBin = join(repoRoot, 'packages/scaffold/create-sdk/lib/bin.js')
const scriptsBin = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
describe.skipIf(!existsSync(createBin) || !existsSync(scriptsBin))(
'SDK built artifacts',
() => {
it('runs the published dsh-sdk bin help path under plain Node', async () => {
const result = await execFileAsync(process.execPath, [scriptsBin, '--help'], { encoding: 'utf8' })
expect(result.stdout).toContain('Usage: dsh-sdk <command>')
expect(result.stderr).toBe('')
})
it('runs the published create-sdk bin help path under plain Node', async () => {
const result = await execFileAsync(process.execPath, [createBin, '--help'], { encoding: 'utf8' })
expect(result.stdout).toContain('Usage: create-sdk [directory]')
expect(result.stderr).toBe('')
})
},
)

View File

@@ -0,0 +1,391 @@
import { describe, expect, it } from 'vitest'
import {
featureId,
createPackageManager,
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 { CreateWizard } from '../src/create-wizard.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from '../src/templates/create-templates.ts'
class RecordingPort implements PromptPort {
readonly transcript: unknown[] = []
readonly #answers: unknown[]
constructor(answers: unknown[]) { this.#answers = [...answers] }
answer<T>(record: unknown): Promise<PromptOutcome<T>> {
this.transcript.push(record)
return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
}
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
return this.answer({
kind: 'text',
message: request.message,
defaultValue: request.defaultValue,
initialValue: request.initialValue,
})
}
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return this.answer({ kind: 'secret', message: request.message })
}
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
return this.answer({
kind: 'select', message: request.message, options: request.options.map(option => option.label),
initialValue: request.initialValue,
})
}
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return this.answer({
kind: 'multiselect', message: request.message, options: request.options.map(option => option.label),
initialValues: request.initialValues,
})
}
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue })
}
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return this.answer({
kind: 'nested-multiselect',
message: request.message,
options: request.options.map(option => ({
label: option.label,
required: option.required,
default: option.default,
choices: option.choices?.map(choice => choice.label),
})),
})
}
}
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
it('renders package-manager-specific setup commands', () => {
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')
expect(CREATE_TEMPLATES.setupFailure.render({
directory: '/workspace/agent',
error: 'offline',
...model,
})).toContain('yarn install && yarn build')
})
it('pins the full unresolved question order and completion messages', async () => {
const port = new RecordingPort([
'my-agent',
'my-agent',
'Snapshot agent',
'deepseek-official',
'secret-key',
'acp',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('hmr'), choices: [] },
{ value: featureId('web'), choices: ['exa'] },
{ value: featureId('workflow'), choices: [] },
],
true,
'exa-key',
'none',
'npm',
false,
])
const resolved = await new CreateWizard({
args: parseCreateArgs([]),
port,
cwd: '/workspace',
releaseVersion: '0.0.1',
userAgent: '',
versionProbe: async () => '10.0.0',
}).run()
expect({
prompts: port.transcript,
result: {
directory: resolved.directory,
name: resolved.request.name,
manager: resolved.request.packageManager.name,
install: resolved.install,
features: resolved.request.features.map(item => ({ id: item.id, options: item.options })),
},
messages: {
created: CREATE_TEMPLATES.created.render({
name: resolved.request.name,
directory: resolved.directory,
}),
next: CREATE_TEMPLATES.nextSteps.render({
directory: resolved.directory,
setupRequired: false,
...packageManagerTemplateModel(resolved.request.packageManager),
}),
failure: CREATE_TEMPLATES.setupFailure.render({
directory: resolved.directory,
error: String(new Error('offline')),
...packageManagerTemplateModel(resolved.request.packageManager),
}),
},
}).toMatchInlineSnapshot(`
{
"messages": {
"created": "Created my-agent in /workspace/my-agent
",
"failure": "Project files are ready, but setup failed: Error: offline
Retry: cd /workspace/my-agent && npm install && npm run build
",
"next": "Next: cd /workspace/my-agent && npm start
",
},
"prompts": [
{
"defaultValue": "my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Where should the project be created?",
},
{
"defaultValue": "my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Package name",
},
{
"defaultValue": "A DeepSeek Harness agent named my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Project description",
},
{
"initialValue": "deepseek-official",
"kind": "select",
"message": "Model provider",
"options": [
"DeepSeek",
"Custom endpoint (pi-ai)",
],
},
{
"kind": "secret",
"message": "DeepSeek API key",
},
{
"initialValue": "acp",
"kind": "select",
"message": "Run interface",
"options": [
"ACP automation server",
"Embedded context",
],
},
{
"kind": "nested-multiselect",
"message": "Select features",
"options": [
{
"choices": [
"Local executor",
"Sandboxed executor",
],
"default": true,
"label": "Command execution",
"required": true,
},
{
"choices": [
"JSONL files",
"SQLite database",
],
"default": true,
"label": "Durable session storage",
"required": true,
},
{
"choices": undefined,
"default": true,
"label": "Hot-module reload",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Read, write, and edit local files",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Model-facing task tracking",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Local skill discovery",
"required": false,
},
{
"choices": [
"DeepSeek search",
"Exa search",
"Perplexity search",
"Fetch only",
],
"default": false,
"label": "Web search and fetch tools",
"required": false,
},
{
"choices": [
"Fresh child agent",
"Fork parent history",
],
"default": false,
"label": "Delegate work to child agents",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Scripted multi-agent workflows",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Automatic context compaction",
"required": false,
},
{
"choices": [
"Claude Code hooks",
"Codex hooks",
],
"default": false,
"label": "Run Claude Code or Codex hooks",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Loop-hygiene reminders",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Tool timeout policy",
"required": false,
},
],
},
{
"initialValue": true,
"kind": "confirm",
"message": "Add the recommended tool timeout policy for web search and fetch tools?",
},
{
"kind": "secret",
"message": "Exa API key",
},
{
"initialValue": "none",
"kind": "select",
"message": "Local plugin",
"options": [
"No local plugin",
"Cordis plugin",
"Model-facing tool",
],
},
{
"initialValue": "npm",
"kind": "select",
"message": "Package manager",
"options": [
"npm",
"pnpm",
"Yarn",
],
},
{
"initialValue": true,
"kind": "confirm",
"message": "Run npm install and then build the project?",
},
],
"result": {
"directory": "/workspace/my-agent",
"features": [
{
"id": "provider",
"options": [
"deepseek-official",
],
},
{
"id": "spine",
"options": [
"default",
],
},
{
"id": "app",
"options": [
"acp",
],
},
{
"id": "bash",
"options": [
"local",
],
},
{
"id": "persistence",
"options": [
"jsonl",
],
},
{
"id": "hmr",
"options": [
"default",
],
},
{
"id": "web",
"options": [
"exa",
],
},
{
"id": "workflow",
"options": [
"workerthread",
],
},
{
"id": "timeout-policy",
"options": [
"default",
],
},
],
"install": false,
"manager": "npm",
"name": "my-agent",
},
}
`)
})
})

View File

@@ -0,0 +1,674 @@
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 the first model request.\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()
})
})

View File

@@ -0,0 +1,126 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import {
LocalPluginBlueprint,
featureId,
createPackageManager,
type PackageManagerName,
} from '@deepseek-ai/dsh-helper'
import { scrubEnvironment } from '../../helper/src/package-managers/package-manager.ts'
import { scaffoldProject } from '../src/project-scaffolder.ts'
const execFileAsync = promisify(execFile)
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const builtScripts = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
const temporary: string[] = []
function resolveCorepackHome(): string {
return process.env.COREPACK_HOME ?? join(
process.env.XDG_CACHE_HOME
?? process.env.LOCALAPPDATA
?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'),
'node/corepack',
)
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
async function managerVersion(name: PackageManagerName): Promise<string | undefined> {
try {
return (await execFileAsync(name, ['--version'], { encoding: 'utf8' })).stdout.trim()
} catch {
// An unavailable optional manager skips only its own live-link case.
return undefined
}
}
const managers: PackageManagerName[] = ['npm', 'pnpm', 'yarn']
describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () => {
for (const name of managers) {
it(`${name}: installs the local closure and resolves plugin TypeScript in dev`, async (context) => {
const version = await managerVersion(name)
if (!version) {
context.skip()
return
}
const parent = await mkdtemp(join(tmpdir(), `dsh-link-${name}-`))
const root = join(parent, 'project')
temporary.push(parent)
const manager = createPackageManager(name, version)
await scaffoldProject(root, {
name: `linked-${name}`,
description: 'link e2e',
runtime: { model: 'deepseek-v4-flash' },
packageManager: manager,
releaseVersion: '0.0.1',
linkWorkspaceRoot: repoRoot,
features: [
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'test-key' } },
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('app'), options: ['embed'] },
{ id: featureId('persistence'), options: ['jsonl'] },
],
localPlugins: [new LocalPluginBlueprint('probe', 'plugin')],
})
await writeFile(join(root, 'plugins/probe/src/index.ts'), `
import { writeFileSync } from 'node:fs'
import type { Context } from 'cordis'
export const name = 'probe'
export function apply(_ctx: Context): void {
writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n')
}
`)
const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name)
const pnpmStore = name === 'pnpm'
? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim()
: undefined
const commandEnvironment = {
...scrubEnvironment(),
COREPACK_HOME: resolveCorepackHome(),
...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') },
XDG_DATA_HOME: join(cacheRoot, 'data'),
npm_config_cache: join(cacheRoot, 'npm'),
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
// A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
}
await execFileAsync(name, manager.installCommand(), {
cwd: root,
env: commandEnvironment,
encoding: 'utf8',
timeout: 120_000,
})
await execFileAsync(name, manager.buildCommand(), {
cwd: root,
env: commandEnvironment,
encoding: 'utf8',
timeout: 120_000,
})
expect(existsSync(join(root, 'index.js'))).toBe(true)
expect(existsSync(join(root, 'plugins/probe/lib/index.js'))).toBe(true)
const dshSdk = join(root, 'node_modules/@deepseek-ai/dsh-scripts/lib/bin.js')
const run = await execFileAsync(process.execPath, [dshSdk, 'dev', 'index.ts'], {
cwd: root,
env: { ...commandEnvironment, DEEPSEEK_API_KEY: 'test-key' },
encoding: 'utf8',
timeout: 30_000,
})
expect(run.stderr).not.toContain('without inject')
expect(await readFile(join(root, 'plugin-loaded'), 'utf8')).toBe('loaded\n')
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
}
expect(manifest.dependencies.cordis).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/)
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
}, 180_000)
}
})