|
|
@@ -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)
|
|
|
}
|