feat(sdk): headless PromptPort + create/config headless plan

Add HeadlessPromptPort (fail-loud non-interactive PromptPort), thread
prefilled feature values through FeatureConfigurator, and let CreateWizard
and ConfigWorkflow accept a headless feature plan that skips the interactive
tree/suggests prompts. Per-file 100% coverage on all changed files.
This commit is contained in:
imccyu
2026-07-17 14:11:07 +08:00
parent 923b04606e
commit dcd886798f
9 changed files with 415 additions and 76 deletions

View File

@@ -48,6 +48,7 @@ export class CreateWizard {
private readonly versionProbe: PackageManagerVersionProbe private readonly versionProbe: PackageManagerVersionProbe
private readonly userAgent: string private readonly userAgent: string
private readonly linkWorkspaceRoot: string | undefined private readonly linkWorkspaceRoot: string | undefined
private readonly featurePlan: readonly FeatureSelection[] | undefined
/** Bind parsed args and infrastructure to one wizard run. */ /** Bind parsed args and infrastructure to one wizard run. */
constructor(options: { constructor(options: {
@@ -57,6 +58,7 @@ export class CreateWizard {
releaseVersion: string releaseVersion: string
versionProbe?: PackageManagerVersionProbe versionProbe?: PackageManagerVersionProbe
userAgent?: string userAgent?: string
features?: readonly FeatureSelection[]
}) { }) {
this.args = options.args this.args = options.args
this.port = options.port this.port = options.port
@@ -68,6 +70,7 @@ export class CreateWizard {
this.linkWorkspaceRoot = options.args.linkWorkspace this.linkWorkspaceRoot = options.args.linkWorkspace
? fileURLToPath(new URL('../../../../', import.meta.url)) ? fileURLToPath(new URL('../../../../', import.meta.url))
: undefined : undefined
this.featurePlan = options.features
} }
/** Collect all answers before constructing any project files. */ /** Collect all answers before constructing any project files. */
@@ -129,7 +132,9 @@ export class CreateWizard {
const configurable = registry.all().filter(feature => feature.id === 'bash' const configurable = registry.all().filter(feature => feature.id === 'bash'
|| feature.id === 'persistence' || feature.id === 'persistence'
|| (!feature.required && feature.isApplicable(profile))) || (!feature.required && feature.isApplicable(profile)))
const selected = [...requireAnswer(await this.port.nestedMultiselect({ const selected = this.featurePlan
? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
: [...requireAnswer(await this.port.nestedMultiselect({
message: 'Select features', message: 'Select features',
options: configurable.map((feature) => { options: configurable.map((feature) => {
const nested = feature.mode !== 'single' const nested = feature.mode !== 'single'
@@ -151,6 +156,7 @@ export class CreateWizard {
} }
}), }),
}))] }))]
if (!this.featurePlan) {
for (const { value: id } of [...selected]) { for (const { value: id } of [...selected]) {
const feature = registry.get(id) const feature = registry.get(id)
for (const suggestedId of feature.suggests) { for (const suggestedId of feature.suggests) {
@@ -164,6 +170,7 @@ export class CreateWizard {
if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) }) if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
} }
} }
}
const fixed = new Set(selections.map(selection => selection.id)) const fixed = new Set(selections.map(selection => selection.id))
const choices = new Map<FeatureSelection['id'], readonly string[] | undefined>() const choices = new Map<FeatureSelection['id'], readonly string[] | undefined>()
for (const feature of registry.all()) { for (const feature of registry.all()) {
@@ -174,12 +181,16 @@ export class CreateWizard {
for (const choice of selected) { for (const choice of selected) {
choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined) 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) { for (const [id, options] of choices) {
const planned = plannedById.get(id)
selections.push(await configurator.configure( selections.push(await configurator.configure(
registry.get(id), registry.get(id),
profile, profile,
undefined, undefined,
options, options,
planned?.secrets ?? {},
planned?.values ?? {},
)) ))
} }
return selections return selections

View File

@@ -5,9 +5,11 @@ import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
HeadlessPromptPort,
LocalPluginBlueprint, LocalPluginBlueprint,
featureId, featureId,
NpmPackageManager, NpmPackageManager,
type FeatureSelection,
type NestedMultiSelectValue, type NestedMultiSelectValue,
type PromptPort, type PromptPort,
} from '@deepseek-ai/dsh-helper' } from '@deepseek-ai/dsh-helper'
@@ -233,6 +235,54 @@ describe('CreateWizard and scaffolder', () => {
expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] }) 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 () => { it('writes the project once and refuses every existing target', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-scaffold-')) const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
temporary.push(root) temporary.push(root)

View File

@@ -26,6 +26,7 @@ export class FeatureConfigurator {
* @param current - currently installed selection, when configuring. * @param current - currently installed selection, when configuring.
* @param prefilledOptions - options already chosen by a tree picker. * @param prefilledOptions - options already chosen by a tree picker.
* @param prefilledSecrets - non-interactive secret values supplied by creation. * @param prefilledSecrets - non-interactive secret values supplied by creation.
* @param prefilledValues - non-interactive value inputs supplied by a headless spec.
* @returns normalized selection with captured values and secrets. * @returns normalized selection with captured values and secrets.
*/ */
async configure( async configure(
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
current?: FeatureSelection, current?: FeatureSelection,
prefilledOptions?: readonly string[], prefilledOptions?: readonly string[],
prefilledSecrets: Readonly<Record<string, string>> = {}, prefilledSecrets: Readonly<Record<string, string>> = {},
prefilledValues: Readonly<Record<string, unknown>> = {},
): Promise<FeatureSelection> { ): Promise<FeatureSelection> {
let options: readonly string[] let options: readonly string[]
switch (feature.mode) { switch (feature.mode) {
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
id: feature.id, id: feature.id,
options, options,
} }
const coercedPrefilled: Record<string, string> = {}
for (const [key, value] of Object.entries(prefilledValues)) {
if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
coercedPrefilled[key] = value
}
const values: Record<string, string> = {} const values: Record<string, string> = {}
for (const input of feature.valueInputs(selected, profile)) { for (const input of feature.valueInputs(selected, profile)) {
const existing = current?.values?.[input.id] const existing = current?.values?.[input.id]
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
...existing === undefined ? {} : { initialValue: existing }, ...existing === undefined ? {} : { initialValue: existing },
validate: value => value.trim().length === 0 ? 'A value is required' : undefined, validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
}) })
values[input.id] = requireAnswer(await question.resolve(this.port)) values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
} }
const base: FeatureSelection = Object.keys(values).length === 0 const base: FeatureSelection = Object.keys(values).length === 0
? selected ? selected

View File

@@ -43,3 +43,4 @@ export {
} from './questions/question.ts' } from './questions/question.ts'
export type { Question } from './questions/question.ts' export type { Question } from './questions/question.ts'
export { ClackPromptPort } from './questions/clack-prompt-port.ts' export { ClackPromptPort } from './questions/clack-prompt-port.ts'
export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'

View File

@@ -0,0 +1,97 @@
/**
* Non-interactive prompt port for headless create/config and skill-driven runs.
*
* @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
*/
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
/**
* Raised when a headless run reaches a decision that was neither prefilled nor
* carries a usable default. The message names the unanswered prompt so an agent
* or CI caller can see exactly which input the spec must supply.
*/
export class HeadlessPromptError extends Error {
/** The unanswered prompt's user-facing message. */
readonly prompt: string
/** Build an error naming the unanswered prompt. */
constructor(prompt: string) {
super(`headless run needs an answer for: ${prompt}`)
this.name = 'HeadlessPromptError'
this.prompt = prompt
}
}
/** Resolve an answered outcome. */
function answered<T>(value: T): Promise<PromptOutcome<T>> {
return Promise.resolve({ status: 'answered', value })
}
/** Reject with a named unanswered-prompt error. */
function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
return Promise.reject(new HeadlessPromptError(message))
}
/**
* A {@link PromptPort} that never blocks on a terminal.
*
* Answers are expected to arrive as prefilled values through the `Question` /
* `FeatureConfigurator` layers, so in a fully specified run this port is never
* reached. When it *is* reached, it takes the prompt's own declared default
* (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
* {@link HeadlessPromptError}. Nested feature selection has no scalar default,
* so it always fails loud — headless callers must supply the feature set through
* the spec rather than the tree picker.
*/
export class HeadlessPromptPort implements PromptPort {
/** Answer visible text from its default, or fail loud. */
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const fallback = request.initialValue ?? request.defaultValue
if (fallback === undefined) return unanswered(request.message)
const diagnostic = request.validate?.(fallback)
if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
return answered(fallback)
}
/** A secret has no safe default: always fail loud. */
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return unanswered(request.message)
}
/** Answer a single choice from its initial value, or fail loud. */
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Answer a multi-choice from its initial values, or fail loud when required. */
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
const initial = request.initialValues ?? []
if (request.required && initial.length === 0) return unanswered(request.message)
return answered(initial)
}
/** Answer a confirmation from its initial value, or fail loud. */
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
if (request.initialValue === undefined) return unanswered(request.message)
return answered(request.initialValue)
}
/** Nested feature selection has no scalar default: always fail loud. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return unanswered(request.message)
}
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
/** Unwrap an answered outcome or fail the test. */
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
const outcome = await promise
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
return outcome.value
}
describe('HeadlessPromptError', () => {
it('names the unanswered prompt', () => {
const error = new HeadlessPromptError('DeepSeek API key')
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('HeadlessPromptError')
expect(error.prompt).toBe('DeepSeek API key')
expect(error.message).toContain('DeepSeek API key')
})
})
describe('HeadlessPromptPort', () => {
const port = new HeadlessPromptPort()
describe('text', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
})
it('falls back to the default value', async () => {
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
})
it('prefers the initial value over the default value', async () => {
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
})
it('fails loud when no default exists', async () => {
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
})
it('fails loud when the default is invalid', async () => {
await expect(port.text({
message: 'name',
defaultValue: '',
validate: value => value.length === 0 ? 'required' : undefined,
})).rejects.toThrow(/required/)
})
})
describe('secret', () => {
it('always fails loud', async () => {
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('select', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
})
it('fails loud without an initial value', async () => {
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
})
})
describe('multiselect', () => {
it('returns the initial values', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
})
it('returns an empty selection when none are supplied and none are required', async () => {
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
})
it('fails loud when required and nothing is preselected', async () => {
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
})
})
describe('confirm', () => {
it('takes the initial value when present', async () => {
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
})
it('fails loud without an initial value', async () => {
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
})
})
describe('nestedMultiselect', () => {
it('always fails loud', async () => {
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
})
})
})

View File

@@ -450,4 +450,35 @@ describe('feature configurator', () => {
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile)) await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
.rejects.toThrow('has no default option') .rejects.toThrow('has no default option')
}) })
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
const registry = createBuiltinRegistry(profile)
const port = new QueuePromptPort([])
const result = await new FeatureConfigurator(port).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'prefilled-key' },
{ baseURL: 'https://prefilled' },
)
expect(result).toMatchObject({
options: ['custom'],
values: { baseURL: 'https://prefilled' },
secrets: { apiKey: 'prefilled-key' },
})
expect(port.requests).toEqual([])
})
it('rejects a non-string prefilled feature value', async () => {
const registry = createBuiltinRegistry(profile)
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
registry.get(featureId('provider')),
profile,
undefined,
['custom'],
{ apiKey: 'k' },
{ baseURL: 123 },
)).rejects.toThrow('must be a string')
})
}) })

View File

@@ -28,6 +28,17 @@ export interface ConfigWorkflowResult {
installError?: Error installError?: Error
} }
/**
* Non-interactive desired end-state for a config run: the complete set of enabled
* features, with options and any secrets/values a newly installed feature needs.
* Features not listed are reconciled to disabled, exactly as an interactive tree
* selection would be. Custom (non-feature) cordis plugins keep their current state;
* toggling them headlessly is not yet supported.
*/
export interface ConfigPlan {
features: readonly FeatureSelection[]
}
function featureTarget(feature: Feature): string { function featureTarget(feature: Feature): string {
return `feature:${feature.id}` return `feature:${feature.id}`
} }
@@ -66,13 +77,23 @@ export class ConfigWorkflow {
} }
/** Select desired state, reconcile the working copy, review, and apply. */ /** Select desired state, reconcile the working copy, review, and apply. */
async run(project: SdkProject, registry: FeatureRegistry): Promise<ConfigWorkflowResult> { async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise<ConfigWorkflowResult> {
const edit = project.edit(registry) const edit = project.edit(registry)
const configurator = new FeatureConfigurator(this.port) const configurator = new FeatureConfigurator(this.port)
const features = registry.all().filter(feature => feature.isApplicable(project.profile)) const features = registry.all().filter(feature => feature.isApplicable(project.profile))
const inspections = new Map(edit.inspections().map(item => [item.id, item])) const inspections = new Map(edit.inspections().map(item => [item.id, item]))
const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile)) const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile))
const desired = requireAnswer(await this.port.nestedMultiselect<string, string>({ const desired = plan
? [
...plan.features.map(selection => ({
value: featureTarget(registry.get(selection.id)),
choices: selection.options,
})),
...custom
.filter(entry => !entry.disabled)
.map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })),
]
: requireAnswer(await this.port.nestedMultiselect<string, string>({
message: 'Configure the project', message: 'Configure the project',
showChanges: true, showChanges: true,
options: [ options: [
@@ -117,6 +138,9 @@ export class ConfigWorkflow {
if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature)) if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
} }
const plannedById = new Map<FeatureSelection['id'], FeatureSelection>(
(plan?.features ?? []).map(selection => [selection.id, selection]),
)
for (const feature of features) { for (const feature of features) {
const installation = inspections.get(feature.id) const installation = inspections.get(feature.id)
/* v8 ignore next -- inspections() is built from this exact feature registry */ /* v8 ignore next -- inspections() is built from this exact feature registry */
@@ -124,7 +148,7 @@ export class ConfigWorkflow {
if (installation.state === 'inconsistent') continue if (installation.state === 'inconsistent') continue
const choice = desiredByTarget.get(featureTarget(feature)) const choice = desiredByTarget.get(featureTarget(feature))
if (!choice && !feature.required) continue if (!choice && !feature.required) continue
await this.enableOrConfigure(feature, installation, choice, project, edit, configurator) await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id))
} }
for (const feature of [...features].reverse()) { for (const feature of [...features].reverse()) {
@@ -176,6 +200,7 @@ export class ConfigWorkflow {
project: SdkProject, project: SdkProject,
edit: ReturnType<SdkProject['edit']>, edit: ReturnType<SdkProject['edit']>,
configurator: FeatureConfigurator, configurator: FeatureConfigurator,
planned?: FeatureSelection,
): Promise<void> { ): Promise<void> {
const options = choice?.choices.length const options = choice?.choices.length
? choice.choices ? choice.choices
@@ -183,7 +208,9 @@ export class ConfigWorkflow {
? installation.options ? installation.options
: feature.defaultOptions(project.profile) : feature.defaultOptions(project.profile)
if (installation.state === 'absent') { if (installation.state === 'absent') {
const selection = await configurator.configure(feature, project.profile, undefined, options) const selection = await configurator.configure(
feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {},
)
edit.installFeature(feature, selection) edit.installFeature(feature, selection)
return return
} }
@@ -191,10 +218,7 @@ export class ConfigWorkflow {
if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`) if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`)
if (!sameOptions(installation.options, options)) { if (!sameOptions(installation.options, options)) {
const selection: FeatureSelection = await configurator.configure( const selection: FeatureSelection = await configurator.configure(
feature, feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {},
project.profile,
installation.selection,
options,
) )
edit.configureFeature(feature, selection) edit.configureFeature(feature, selection)
} }

View File

@@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url' import { fileURLToPath, pathToFileURL } from 'node:url'
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { import {
HeadlessPromptPort,
LocalPluginBlueprint, LocalPluginBlueprint,
NpmPackageManager, NpmPackageManager,
SdkProject, SdkProject,
@@ -29,7 +30,7 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts'
import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts' import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts' import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts'
import { runConfigCommand } from '../src/config.ts' import { runConfigCommand } from '../src/config.ts'
import { ConfigWorkflow } from '../src/config/config-workflow.ts' import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts'
import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts' import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts'
const temporary: string[] = [] const temporary: string[] = []
@@ -399,6 +400,28 @@ describe('ConfigWorkflow', () => {
expect(output.read()).toContain('Disable feature: todo') expect(output.read()).toContain('Disable feature: todo')
}) })
it('reconciles a headless plan without prompting and preserves custom plugins', async () => {
const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')])
const registry = createBuiltinRegistry(project.profile)
const output = outputBuffer()
let installs = 0
const plan: ConfigPlan = {
features: [
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('persistence'), options: ['jsonl'] },
{ id: featureId('todo'), options: ['default'] },
{ id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
],
}
const result = await new ConfigWorkflow(
new HeadlessPromptPort(), output.stream, async () => { installs += 1 },
).run(project, registry, plan)
expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined()
// the unlisted custom local plugin keeps its enabled state (not nuked by the plan)
expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy()
expect(installs).toBe(1)
})
it('installs once after NPM dependency changes and keeps committed files on install failure', async () => { it('installs once after NPM dependency changes and keeps committed files on install failure', async () => {
const project = await committedProject() const project = await committedProject()
const registry = createBuiltinRegistry(project.profile) const registry = createBuiltinRegistry(project.profile)