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:
@@ -28,6 +28,17 @@ export interface ConfigWorkflowResult {
|
||||
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 {
|
||||
return `feature:${feature.id}`
|
||||
}
|
||||
@@ -66,48 +77,58 @@ export class ConfigWorkflow {
|
||||
}
|
||||
|
||||
/** 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 configurator = new FeatureConfigurator(this.port)
|
||||
const features = registry.all().filter(feature => feature.isApplicable(project.profile))
|
||||
const inspections = new Map(edit.inspections().map(item => [item.id, item]))
|
||||
const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile))
|
||||
const desired = requireAnswer(await this.port.nestedMultiselect<string, string>({
|
||||
message: 'Configure the project',
|
||||
showChanges: true,
|
||||
options: [
|
||||
...features.map((feature) => {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
|
||||
const inconsistent = installation.state === 'inconsistent'
|
||||
const selectedOptions = new Set(installation.options.length > 0
|
||||
? installation.options
|
||||
: feature.defaultOptions(project.profile))
|
||||
return {
|
||||
value: featureTarget(feature),
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || installation.state === 'enabled' || inconsistent,
|
||||
disabled: inconsistent,
|
||||
...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
|
||||
...feature.mode === 'single' ? {} : {
|
||||
choiceMode: feature.mode,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: selectedOptions.has(option.id),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
...custom.map(entry => ({
|
||||
value: pluginTarget(entry.id),
|
||||
label: `${entry.name} [custom]`,
|
||||
default: !entry.disabled,
|
||||
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',
|
||||
showChanges: true,
|
||||
options: [
|
||||
...features.map((feature) => {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
|
||||
const inconsistent = installation.state === 'inconsistent'
|
||||
const selectedOptions = new Set(installation.options.length > 0
|
||||
? installation.options
|
||||
: feature.defaultOptions(project.profile))
|
||||
return {
|
||||
value: featureTarget(feature),
|
||||
label: feature.summary,
|
||||
required: feature.required,
|
||||
default: feature.required || installation.state === 'enabled' || inconsistent,
|
||||
disabled: inconsistent,
|
||||
...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
|
||||
...feature.mode === 'single' ? {} : {
|
||||
choiceMode: feature.mode,
|
||||
choices: feature.options.map(option => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
default: selectedOptions.has(option.id),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}),
|
||||
...custom.map(entry => ({
|
||||
value: pluginTarget(entry.id),
|
||||
label: `${entry.name} [custom]`,
|
||||
default: !entry.disabled,
|
||||
})),
|
||||
],
|
||||
}))
|
||||
const desiredByTarget = new Map(desired.map(item => [item.value, item]))
|
||||
const targetProfile = {
|
||||
...project.profile,
|
||||
@@ -117,6 +138,9 @@ export class ConfigWorkflow {
|
||||
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) {
|
||||
const installation = inspections.get(feature.id)
|
||||
/* v8 ignore next -- inspections() is built from this exact feature registry */
|
||||
@@ -124,7 +148,7 @@ export class ConfigWorkflow {
|
||||
if (installation.state === 'inconsistent') continue
|
||||
const choice = desiredByTarget.get(featureTarget(feature))
|
||||
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()) {
|
||||
@@ -176,6 +200,7 @@ export class ConfigWorkflow {
|
||||
project: SdkProject,
|
||||
edit: ReturnType<SdkProject['edit']>,
|
||||
configurator: FeatureConfigurator,
|
||||
planned?: FeatureSelection,
|
||||
): Promise<void> {
|
||||
const options = choice?.choices.length
|
||||
? choice.choices
|
||||
@@ -183,7 +208,9 @@ export class ConfigWorkflow {
|
||||
? installation.options
|
||||
: feature.defaultOptions(project.profile)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -191,10 +218,7 @@ export class ConfigWorkflow {
|
||||
if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`)
|
||||
if (!sameOptions(installation.options, options)) {
|
||||
const selection: FeatureSelection = await configurator.configure(
|
||||
feature,
|
||||
project.profile,
|
||||
installation.selection,
|
||||
options,
|
||||
feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {},
|
||||
)
|
||||
edit.configureFeature(feature, selection)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import {
|
||||
HeadlessPromptPort,
|
||||
LocalPluginBlueprint,
|
||||
NpmPackageManager,
|
||||
SdkProject,
|
||||
@@ -29,7 +30,7 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts'
|
||||
import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
|
||||
import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.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'
|
||||
|
||||
const temporary: string[] = []
|
||||
@@ -399,6 +400,28 @@ describe('ConfigWorkflow', () => {
|
||||
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 () => {
|
||||
const project = await committedProject()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
|
||||
Reference in New Issue
Block a user