Merge branch 'master' into worktree-windows-runtime

This commit is contained in:
Tianyi Cui
2026-07-20 20:39:13 +08:00
1064 changed files with 22568 additions and 10875 deletions

View File

@@ -6,13 +6,13 @@ The supported package surface is the `create-sdk` bin. The package root exports
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY.
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
## Model Experience
Indirectly, through the generated project composition and its selected runtime plugins.
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events.
#### KV Cache effect
@@ -20,4 +20,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project.
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.

View File

@@ -19,6 +19,9 @@ export interface CreateArgs {
packageManager?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help: boolean
}
@@ -32,6 +35,9 @@ interface CommanderCreateOptions {
pm?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help?: boolean
}
@@ -60,6 +66,9 @@ function createProgram(): Command {
.addOption(new Option('--install').default(undefined))
.addOption(new Option('--no-install').default(undefined))
.option('--link-workspace')
.option('--config <path>')
.option('--config-json <json>')
.addOption(new Option('--json').default(undefined))
}
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
@@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs {
...options.pm === undefined ? {} : { packageManager: options.pm },
...options.install === undefined ? {} : { install: options.install },
...options.linkWorkspace ? { linkWorkspace: true } : {},
...options.config === undefined ? {} : { config: options.config },
...options.configJson === undefined ? {} : { configJson: options.configJson },
...options.json === undefined ? {} : { json: options.json },
help: options.help ?? false,
}
}

View File

@@ -7,12 +7,16 @@
import { readFile } from 'node:fs/promises'
import {
ClackPromptPort,
HeadlessPromptError,
HeadlessPromptPort,
NodeCommandRunner,
PromptCancelledError,
type PackageManagerVersionProbe,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import { parseCreateArgs } from './args.ts'
import { parseCreateArgs, type CreateArgs } from './args.ts'
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
import { resolveHeadless } from './headless.ts'
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
@@ -42,24 +46,29 @@ export async function createProject(
context: CreateCommandContext,
): Promise<ScaffoldResult | undefined> {
const args = parseCreateArgs(argv)
// Under --json, stdout carries only NDJSON events: human-readable progress
// and package-manager child output move to stderr.
const progress = args.json === true ? context.stderr : context.stdout
if (args.help) {
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
return undefined
}
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY')
const headless = await resolveHeadless(args)
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
}
const wizard = new CreateWizard({
args,
args: headless ? headless.args : args,
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
cwd: context.cwd,
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
...headless?.features ? { features: headless.features } : {},
})
const resolved = await wizard.run()
const result = await scaffoldProject(resolved.directory, resolved.request)
context.stdout.write(CREATE_TEMPLATES.created.render({
progress.write(CREATE_TEMPLATES.created.render({
name: resolved.request.name,
directory: resolved.directory,
}))
@@ -67,8 +76,9 @@ export async function createProject(
try {
if (context.setup) await context.setup(resolved)
else {
await resolved.request.packageManager.install(resolved.directory)
await resolved.request.packageManager.build(resolved.directory)
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
await resolved.request.packageManager.install(resolved.directory, runner)
await resolved.request.packageManager.build(resolved.directory, runner)
}
} catch (error) {
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
@@ -79,7 +89,7 @@ export async function createProject(
throw error
}
}
context.stdout.write(CREATE_TEMPLATES.nextSteps.render({
progress.write(CREATE_TEMPLATES.nextSteps.render({
directory: resolved.directory,
setupRequired: !resolved.install,
...packageManagerTemplateModel(resolved.request.packageManager),
@@ -87,6 +97,17 @@ export async function createProject(
return result
}
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
function wantsJsonEvents(argv: readonly string[]): boolean {
let parsed: CreateArgs
try {
parsed = parseCreateArgs(argv)
} catch {
return false
}
return parsed.json === true
}
/** Run the create command with process defaults and convert cancellation to a clean exit. */
export async function runCreateCommand(
argv: readonly string[] = process.argv.slice(2),
@@ -97,15 +118,27 @@ export async function runCreateCommand(
stderr: process.stderr,
},
): Promise<number> {
const json = wantsJsonEvents(argv)
const emit = (event: Record<string, unknown>): void => {
context.stdout.write(`${JSON.stringify(event)}\n`)
}
try {
await createProject(argv, context)
if (json) emit({ type: 'done' })
return 0
} catch (error) {
if (error instanceof PromptCancelledError) {
context.stderr.write('create-sdk: cancelled\n')
if (json) emit({ type: 'error', reason: 'cancelled' })
else context.stderr.write('create-sdk: cancelled\n')
return 1
}
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
if (json && error instanceof HeadlessPromptError) {
emit({ type: 'action-required', prompt: error.prompt })
return 1
}
const message = error instanceof Error ? error.message : String(error)
if (json) emit({ type: 'error', message })
else context.stderr.write(`create-sdk: ${message}\n`)
return 1
}
}

View File

@@ -48,6 +48,7 @@ export class CreateWizard {
private readonly versionProbe: PackageManagerVersionProbe
private readonly userAgent: string
private readonly linkWorkspaceRoot: string | undefined
private readonly featurePlan: readonly FeatureSelection[] | undefined
/** Bind parsed args and infrastructure to one wizard run. */
constructor(options: {
@@ -57,6 +58,7 @@ export class CreateWizard {
releaseVersion: string
versionProbe?: PackageManagerVersionProbe
userAgent?: string
features?: readonly FeatureSelection[]
}) {
this.args = options.args
this.port = options.port
@@ -68,6 +70,7 @@ export class CreateWizard {
this.linkWorkspaceRoot = options.args.linkWorkspace
? fileURLToPath(new URL('../../../../', import.meta.url))
: undefined
this.featurePlan = options.features
}
/** Collect all answers before constructing any project files. */
@@ -129,39 +132,43 @@ export class CreateWizard {
const configurable = registry.all().filter(feature => feature.id === 'bash'
|| feature.id === 'persistence'
|| (!feature.required && feature.isApplicable(profile)))
const selected = [...requireAnswer(await this.port.nestedMultiselect({
message: 'Select features',
options: configurable.map((feature) => {
const nested = feature.mode !== 'single'
const defaults = new Set(feature.defaultOptions(profile))
return {
value: feature.id,
label: feature.summary,
required: feature.required,
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|| feature.id === 'skill',
...nested ? {
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: defaults.has(option.id),
})),
} : {},
const selected = this.featurePlan
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
: [...requireAnswer(await this.port.nestedMultiselect({
message: 'Select features',
options: configurable.map((feature) => {
const nested = feature.mode !== 'single'
const defaults = new Set(feature.defaultOptions(profile))
return {
value: feature.id,
label: feature.summary,
required: feature.required,
default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
|| feature.id === 'skill',
...nested ? {
choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
choices: feature.options.map(option => ({
value: option.id,
label: option.label,
default: defaults.has(option.id),
})),
} : {},
}
}),
}))]
if (!this.featurePlan) {
for (const { value: id } of [...selected]) {
const feature = registry.get(id)
for (const suggestedId of feature.suggests) {
if (selected.some(item => item.value === suggestedId)) continue
const suggested = registry.get(suggestedId)
const add = requireAnswer(await new ConfirmQuestion({
id: `${feature.id}.${suggested.id}`,
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
initialValue: true,
}).resolve(this.port))
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
}
}),
}))]
for (const { value: id } of [...selected]) {
const feature = registry.get(id)
for (const suggestedId of feature.suggests) {
if (selected.some(item => item.value === suggestedId)) continue
const suggested = registry.get(suggestedId)
const add = requireAnswer(await new ConfirmQuestion({
id: `${feature.id}.${suggested.id}`,
message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
initialValue: true,
}).resolve(this.port))
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
}
}
const fixed = new Set(selections.map(selection => selection.id))
@@ -174,12 +181,16 @@ export class CreateWizard {
for (const choice of selected) {
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
}
const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
for (const [id, options] of choices) {
const planned = plannedById.get(id)
selections.push(await configurator.configure(
registry.get(id),
profile,
undefined,
options,
planned?.secrets ?? {},
planned?.values ?? {},
))
}
return selections

View File

@@ -0,0 +1,98 @@
/**
* Headless create input: a structured project spec supplied by an agent or CI
* instead of interactive prompts.
*
* @module @deepseek-ai/create-sdk/headless
*/
import { readFile } from 'node:fs/promises'
import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
import type { CreateArgs } from './args.ts'
/**
* Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs}
* project answers; `features` is the headless feature plan handed to `CreateWizard`
* (the interactive tree/suggests prompts are skipped). Absent required answers make
* the run fail loud through `HeadlessPromptPort` rather than blocking.
*/
interface HeadlessCreateSpec {
directory?: string
description?: string
provider?: 'deepseek' | 'custom'
baseURL?: string
apiKey?: string
model?: string
interface?: RunInterface
pm?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
features?: readonly FeatureSelection[]
}
/** Resolved headless input: the args the wizard reads plus the feature plan. */
export interface ResolvedHeadless {
args: CreateArgs
features: readonly FeatureSelection[] | undefined
}
function asRecord(value: unknown, source: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${source}: expected a JSON object`)
}
return value as Record<string, unknown>
}
/** Parse and shallow-validate a headless spec from JSON text. */
function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (error) {
/* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
}
const record = asRecord(parsed, source)
if (record.features !== undefined && !Array.isArray(record.features)) {
throw new Error(`${source}: "features" must be an array`)
}
return record
}
/**
* Load a headless spec from `--config-json` (inline) or `--config` (a JSON file),
* returning `undefined` when neither is supplied.
* @param args - parsed create args.
* @param readFileText - file reader seam for tests.
* @returns the resolved args + feature plan, or `undefined` for interactive runs.
*/
export async function resolveHeadless(
args: CreateArgs,
readFileText: (path: string) => Promise<string> = path => readFile(path, 'utf8'),
): Promise<ResolvedHeadless | undefined> {
let text: string
let source: string
if (args.configJson !== undefined) {
text = args.configJson
source = '--config-json'
} else if (args.config !== undefined) {
source = args.config
text = await readFileText(args.config)
} else {
return undefined
}
const spec = parseHeadlessSpec(text, source)
const resolvedArgs: CreateArgs = {
...spec.directory === undefined ? {} : { directory: spec.directory },
...spec.description === undefined ? {} : { description: spec.description },
...spec.provider === undefined ? {} : { provider: spec.provider },
...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL },
...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey },
...spec.model === undefined ? {} : { model: spec.model },
...spec.interface === undefined ? {} : { runInterface: spec.interface },
...spec.pm === undefined ? {} : { packageManager: spec.pm },
...spec.install === undefined ? {} : { install: spec.install },
...spec.linkWorkspace ? { linkWorkspace: true } : {},
help: false,
}
return { args: resolvedArgs, features: spec.features }
}

View File

@@ -9,3 +9,6 @@ Options:
--interface <acp|stdio|embed>
--pm <npm|pnpm|yarn>
--install / --no-install
--config <path>
--config-json <json>
--json

View File

@@ -5,9 +5,12 @@ 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'
@@ -28,6 +31,7 @@ import {
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 {
@@ -233,6 +237,54 @@ describe('CreateWizard and scaffolder', () => {
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=stdio', '--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=stdio', '--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)
@@ -427,12 +479,65 @@ describe('create command composition', () => {
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)
@@ -467,6 +572,17 @@ describe('create command composition', () => {
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()
})
@@ -501,3 +617,58 @@ describe('create command composition', () => {
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()
})
})