Skip to content

Commit 8ac8d37

Browse files
committed
feat: prompt for missing required values and dashboard placeholders before planning
Plan, apply and test now prompt the user for values that are still needed instead of failing validation: - Required keys that are missing (any depth). oneOf/anyOf branches are evaluated individually; if no branch is satisfied and the user hasn't started one, they choose which option to configure. Discriminator consts are filled automatically. allOf and additionalProperties are supported. - Values equal to the dashboard placeholder '<Replace me here!>' (any depth). Null values are treated as an explicit "no value" and are not prompted. Answers apply to the in-memory project only and are never written back to the config. Invalid answers fail plugin validation as before. New pieces: - entities/missing-parameter.ts: MissingParameter / ResourceMissingParameters types - utils/missing-parameters.ts: pure schema walker, value coercion, path helpers - common/resolve-missing-parameters.ts: MissingParametersResolver step (runs after plugin initialization, before validation) - Reporter.promptMissingParameters implemented for Default (ink form), Plain/Debug (readline, masked for sensitive params), Json (throws), Stub Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QnBk2Qs7TDiTz4V6x1wKK
1 parent 93e0312 commit 8ac8d37

15 files changed

Lines changed: 1099 additions & 8 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
MissingParameter,
3+
MissingParameterPrompt,
4+
MissingParametersResult,
5+
ResourceMissingParameters,
6+
} from '../entities/missing-parameter.js';
7+
import { ResourceConfig } from '../entities/resource-config.js';
8+
import { Reporter } from '../ui/reporters/reporter.js';
9+
import { coerceParameterValue, formatParameterPath, setValueAtPath } from '../utils/missing-parameters.js';
10+
import { InitializationResult } from './initialize-plugins.js';
11+
12+
/**
13+
* Prompts the user for values that are still needed before planning: required keys that are missing and values set to
14+
* the dashboard placeholder ('<Replace me here!>'). Answers are applied to the in-memory project only. They are never
15+
* written back to the config file.
16+
*
17+
* Must run after plugins are initialized (resource schemas are needed) and before validation.
18+
*/
19+
export const MissingParametersResolver = {
20+
async run(initializationResult: InitializationResult, reporter: Reporter): Promise<void> {
21+
const { project, pluginManager, resourceDefinitions } = initializationResult;
22+
23+
// Unknown types are reported by validation
24+
const types = [...new Set(project.resourceConfigs.map((r) => r.type))].filter((t) => resourceDefinitions.has(t));
25+
const resourceInfoMap = new Map(
26+
(await pluginManager.getMultipleResourceInfo(types)).map((info) => [info.type, info])
27+
);
28+
29+
const requests: ResourceMissingParameters[] = [];
30+
for (const resource of project.resourceConfigs) {
31+
const resourceInfo = resourceInfoMap.get(resource.type);
32+
if (!resourceInfo) {
33+
continue;
34+
}
35+
36+
const missing = await MissingParametersResolver.resolveChoices(
37+
resourceInfo.findMissingParameters(resource),
38+
resource,
39+
reporter,
40+
);
41+
42+
// Values known from the schema (ex: discriminators of the chosen option) are filled without asking
43+
for (const parameter of missing.filter((p) => p.knownValue !== undefined)) {
44+
MissingParametersResolver.setParameter(resource, parameter, parameter.knownValue);
45+
}
46+
47+
const sensitiveParameters = resourceDefinitions.get(resource.type)?.sensitiveParameters ?? [];
48+
const prompts: MissingParameterPrompt[] = missing
49+
.filter((p) => p.knownValue === undefined)
50+
.map((p) => ({
51+
...p,
52+
label: formatParameterPath(p.path),
53+
sensitive: sensitiveParameters.includes('*') || sensitiveParameters.includes(String(p.path[0])),
54+
}));
55+
56+
if (prompts.length > 0) {
57+
requests.push({ resource, parameters: prompts });
58+
}
59+
}
60+
61+
if (requests.length === 0) {
62+
return;
63+
}
64+
65+
const answers = await reporter.promptMissingParameters(requests);
66+
for (const [idx, { resource, parameters }] of requests.entries()) {
67+
for (const [parameterIdx, parameter] of parameters.entries()) {
68+
const value = coerceParameterValue(answers[idx]?.[parameterIdx], parameter.type, parameter.itemsType);
69+
MissingParametersResolver.setParameter(resource, parameter, value);
70+
}
71+
}
72+
},
73+
74+
/**
75+
* Flattens the result into a list of parameters. Where the schema allows multiple options (oneOf / anyOf) the user is
76+
* asked to pick one first.
77+
*/
78+
async resolveChoices(
79+
result: MissingParametersResult,
80+
resource: ResourceConfig,
81+
reporter: Reporter,
82+
): Promise<MissingParameter[]> {
83+
const parameters = [...result.parameters];
84+
85+
for (const choice of result.choices) {
86+
const location = choice.path.length > 0 ? ` at "${formatParameterPath(choice.path)}"` : '';
87+
const selected = await reporter.promptOptions(
88+
`Resource "${resource.id}" requires one of the following options${location}. Which one would you like to configure?`,
89+
choice.options.map((o) => o.label),
90+
);
91+
92+
const option = choice.options[selected];
93+
if (!option) {
94+
throw new Error(`Invalid option selected for resource "${resource.id}"`);
95+
}
96+
97+
parameters.push(...await MissingParametersResolver.resolveChoices(option.result, resource, reporter));
98+
}
99+
100+
// The same value can be reported by more than one branch of a schema
101+
const seen = new Set<string>();
102+
return parameters.filter((p) => {
103+
const key = JSON.stringify(p.path);
104+
if (seen.has(key)) return false;
105+
seen.add(key);
106+
return true;
107+
});
108+
},
109+
110+
setParameter(resource: ResourceConfig, parameter: MissingParameter, value: unknown): void {
111+
const [key, ...rest] = parameter.path;
112+
resource.setParameter(String(key), setValueAtPath(resource.parameters[key], rest, value));
113+
},
114+
};

‎src/entities/missing-parameter.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ResourceConfig } from './resource-config.js';
2+
3+
/**
4+
* The placeholder value the Codify dashboard writes into configs for values the user still needs to fill in.
5+
*/
6+
export const PLACEHOLDER_VALUE = '<Replace me here!>';
7+
8+
/** Location of a value inside a resource's parameters. Strings are object keys, numbers are array indices. */
9+
export type ParameterPath = Array<number | string>;
10+
11+
/**
12+
* A single value that must be supplied by the user before the resource can be planned.
13+
*/
14+
export interface MissingParameter {
15+
/** Path from the root of the resource's parameters. Ex: ['hosts', 0, 'HostName'] */
16+
path: ParameterPath;
17+
/** 'missing' if the key is absent but required by the schema. 'placeholder' if the value is {@link PLACEHOLDER_VALUE} */
18+
reason: 'missing' | 'placeholder';
19+
/** JSON schema type of the value (if known). Used to convert the user's input. */
20+
type?: string;
21+
/** JSON schema type of the array items (if type is 'array'). */
22+
itemsType?: string;
23+
description?: string;
24+
/** Set when the value is known without asking the user (a schema const, ex: a discriminator). It is filled automatically. */
25+
knownValue?: unknown;
26+
}
27+
28+
/**
29+
* Raised when a schema contains a oneOf / anyOf where multiple branches are equally valid. Ex: git-repository
30+
* requires either 'directory' or 'parentDirectory'. The user must pick one of the options before values can be prompted.
31+
*/
32+
export interface MissingParameterChoice {
33+
/** Path to the object the choice applies to */
34+
path: ParameterPath;
35+
options: Array<{ label: string; result: MissingParametersResult }>;
36+
}
37+
38+
export interface MissingParametersResult {
39+
parameters: MissingParameter[];
40+
choices: MissingParameterChoice[];
41+
}
42+
43+
/**
44+
* A missing parameter that is ready to be shown to the user.
45+
*/
46+
export interface MissingParameterPrompt extends MissingParameter {
47+
/** Human readable path. Ex: hosts[0].HostName */
48+
label: string;
49+
/** Input should be masked */
50+
sensitive: boolean;
51+
}
52+
53+
/**
54+
* All of the values that need to be prompted for a single resource. Passed to {@link Reporter.promptMissingParameters}.
55+
*/
56+
export interface ResourceMissingParameters {
57+
resource: ResourceConfig;
58+
parameters: MissingParameterPrompt[];
59+
}

‎src/entities/resource-info.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { GetResourceInfoResponseData } from '@codifycli/schemas';
22

3+
import { findMissingParameters } from '../utils/missing-parameters.js';
4+
import { MissingParametersResult } from './missing-parameter.js';
35
import { ResourceConfig } from './resource-config.js';
46

57
interface ParameterInfo {
@@ -50,6 +52,14 @@ export class ResourceInfo implements GetResourceInfoResponseData {
5052
})
5153
}
5254

55+
/**
56+
* Values the resource still needs before it can be planned: required keys (per the schema) that are missing and
57+
* values set to the dashboard placeholder.
58+
*/
59+
findMissingParameters(resource: ResourceConfig): MissingParametersResult {
60+
return findMissingParameters(this.schema, resource.parameters);
61+
}
62+
5363
getParameterInfo(): ParameterInfo[] {
5464
if (!this.parametersCache) {
5565
const { schema } = this;

‎src/orchestrators/plan.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Config } from '@codifycli/schemas';
22

33
import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
4+
import { MissingParametersResolver } from '../common/resolve-missing-parameters.js';
45
import { Plan } from '../entities/plan.js';
56
import { Project } from '../entities/project.js';
67
import { ProcessName, SubProcessName, ctx } from '../events/context.js';
@@ -36,6 +37,8 @@ export class PlanOrchestrator {
3637

3738
await createStartupShellScriptsIfNotExists();
3839

40+
// Prompt for missing required values and dashboard placeholders. Must happen before validation.
41+
await MissingParametersResolver.run(initializationResult, reporter);
3942
await ValidateOrchestrator.run({ existing: initializationResult, noProgress: args.noProgress }, reporter);
4043
project.resolveDependenciesAndCalculateEvalOrder(resourceDefinitions);
4144
if (OsUtils.isMacOS()) {

‎src/orchestrators/test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import path from 'node:path';
77
import semver from 'semver';
88

99
import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
10+
import { MissingParametersResolver } from '../common/resolve-missing-parameters.js';
1011
import { ProcessName, SubProcessName, ctx } from '../events/context.js';
1112
import { Reporter } from '../ui/reporters/reporter.js';
1213
import { StubReporter } from '../ui/reporters/stub-reporter.js';
@@ -36,6 +37,7 @@ export const TestOrchestrator = {
3637
ctx.subprocessStarted(SubProcessName.TEST_INITIALIZE_AND_VALIDATE);
3738
// Perform validation initially to ensure the project is valid
3839
const initializationResult = await PluginInitOrchestrator.run({ ...args, noProgress: true }, reporter);
40+
await MissingParametersResolver.run(initializationResult, reporter);
3941
await ValidateOrchestrator.run({ existing: initializationResult, noProgress: true }, reporter);
4042
ctx.subprocessFinished(SubProcessName.TEST_INITIALIZE_AND_VALIDATE);
4143

‎src/ui/reporters/default-reporter.tsx‎

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FormProps, FormReturnValue } from '@codifycli/ink-form';
1+
import { FormField, FormProps, FormReturnValue } from '@codifycli/ink-form';
22
import { CommandRequestData } from '@codifycli/schemas';
33
import cliCursor from 'cli-cursor';
44
import { render } from 'ink';
@@ -8,6 +8,7 @@ import stripAnsi from 'strip-ansi'
88

99
import { PluginError } from '../../common/errors.js';
1010
import { ApplyResult } from '../../entities/apply-result.js';
11+
import { MissingParameterPrompt, ResourceMissingParameters } from '../../entities/missing-parameter.js';
1112
import { Plan, ResourcePlan } from '../../entities/plan.js';
1213
import { ResourceConfig } from '../../entities/resource-config.js';
1314
import { ResourceInfo } from '../../entities/resource-info.js';
@@ -171,7 +172,7 @@ export class DefaultReporter implements Reporter {
171172
return [];
172173
}
173174

174-
fullscreen()
175+
enterFullScreen()
175176
process.on('beforeExit', exitFullScreen);
176177

177178
let props;
@@ -225,16 +226,46 @@ export class DefaultReporter implements Reporter {
225226
core: { type: v.section.title },
226227
parameters: v.value,
227228
}))
229+
}
228230

229-
function fullscreen() {
230-
process.stdout.write('\u001B[?1049h');
231-
process.stdout.write('\u001B[?1000h');
231+
async promptMissingParameters(requests: ResourceMissingParameters[]): Promise<unknown[][]> {
232+
if (requests.length === 0) {
233+
return [];
232234
}
233235

234-
function exitFullScreen() {
235-
process.stdout.write('\u001B[?1049l');
236-
process.stdout.write('\u001B[?1000l');
236+
const prevRenderState = this.getRenderState();
237+
const fieldName = (resourceIdx: number, parameterIdx: number) => `r${resourceIdx}_p${parameterIdx}`;
238+
239+
const formProps: FormProps = {
240+
form: {
241+
title: 'Missing values: fill out the values below to continue',
242+
description: 'These values are only used for this run. They will not be saved to your config.',
243+
sections: requests.map(({ resource, parameters }, resourceIdx) => ({
244+
title: resource.id,
245+
description: resource.resourceInfo?.description,
246+
fields: parameters.map((parameter, parameterIdx) => toMissingParameterFormField(parameter, fieldName(resourceIdx, parameterIdx))),
247+
})),
248+
},
237249
}
250+
251+
enterFullScreen();
252+
process.on('beforeExit', exitFullScreen);
253+
254+
const userInput = await this.updateStateAndAwaitEvent<FormReturnValue>(() =>
255+
this.updateRenderState(RenderStatus.IMPORT_PROMPT, formProps),
256+
RenderEvent.PROMPT_IMPORT_PARAMETERS_RESULT
257+
);
258+
259+
exitFullScreen();
260+
process.off('beforeExit', exitFullScreen);
261+
262+
await this.updateRenderState(prevRenderState.status, prevRenderState.data);
263+
264+
// Field names are unique across sections so all of the values can be merged together
265+
const values: Record<string, unknown> = Object.assign({}, ...userInput.map((section) => section.value));
266+
return requests.map(({ parameters }, resourceIdx) =>
267+
parameters.map((_, parameterIdx) => values[fieldName(resourceIdx, parameterIdx)])
268+
);
238269
}
239270

240271
async displayImportResult(importResult: ImportResult, showConfigs: boolean): Promise<void> {
@@ -476,3 +507,50 @@ export class DefaultReporter implements Reporter {
476507
});
477508
}
478509
}
510+
511+
function enterFullScreen() {
512+
process.stdout.write('\u001B[?1049h');
513+
process.stdout.write('\u001B[?1000h');
514+
}
515+
516+
function exitFullScreen() {
517+
process.stdout.write('\u001B[?1049l');
518+
process.stdout.write('\u001B[?1000l');
519+
}
520+
521+
/** Values are returned as typed by the form. They are converted to the schema type by the MissingParametersResolver. */
522+
function toMissingParameterFormField(parameter: MissingParameterPrompt, name: string): FormField {
523+
const base = { name, label: parameter.label, description: parameter.description, required: true };
524+
525+
if (parameter.sensitive) {
526+
return { ...base, type: 'string', mask: '*' };
527+
}
528+
529+
switch (parameter.type) {
530+
case 'integer': {
531+
return { ...base, type: 'integer' };
532+
}
533+
534+
case 'number': {
535+
return { ...base, type: 'float' };
536+
}
537+
538+
case 'boolean': {
539+
return { ...base, type: 'boolean', initialValue: false };
540+
}
541+
542+
case 'array': {
543+
return ['integer', 'number', 'string', undefined].includes(parameter.itemsType)
544+
? { ...base, type: 'array' }
545+
: { ...base, type: 'string', description: `${parameter.description ?? ''} (JSON array)`.trim() };
546+
}
547+
548+
case 'object': {
549+
return { ...base, type: 'string', description: `${parameter.description ?? ''} (JSON object)`.trim() };
550+
}
551+
552+
default: {
553+
return { ...base, type: 'string' };
554+
}
555+
}
556+
}

‎src/ui/reporters/json-reporter.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { CommandRequestData } from '@codifycli/schemas';
22

33
import { PluginError } from '../../common/errors.js';
44
import { ApplyResult } from '../../entities/apply-result.js';
5+
import { ResourceMissingParameters } from '../../entities/missing-parameter.js';
56
import { Plan } from '../../entities/plan.js';
67
import { ResourceConfig } from '../../entities/resource-config.js';
78
import { ImportResult } from '../../orchestrators/import.js';
@@ -53,6 +54,11 @@ export class JsonReporter implements Reporter {
5354
throw new Error('Json reporter error: cannot prompt user for values while using Json reporter. Use a different reporter.');
5455
}
5556

57+
async promptMissingParameters(requests: ResourceMissingParameters[]): Promise<unknown[][]> {
58+
const missing = requests.flatMap(({ resource, parameters }) => parameters.map((p) => `${resource.id}.${p.label}`));
59+
throw new Error(`Json reporter error: the following values are missing from the config and cannot be prompted while using the Json reporter: ${JSON.stringify(missing)}`);
60+
}
61+
5662
async displayImportResult(importResult: ImportResult): Promise<void> {
5763
console.log(JSON.stringify(importResult.result.map((r) => r.raw)));
5864
}

0 commit comments

Comments
 (0)