diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts index a15b226..200b3ac 100644 --- a/packages/nopy/src/nopy.prompts.ts +++ b/packages/nopy/src/nopy.prompts.ts @@ -236,6 +236,43 @@ interface FormChoice { initial: string; } +/** + * The label to prompt a schema field with: its `.describe()`, read through the + * wrappers that hide it, falling back to the bare key. + * + * In zod 4 a description lives in `z.globalRegistry` keyed by the schema + * *instance*, and `.default()` returns a new `ZodDefault` around the described + * type rather than mutating it. So the wrapper carries no description of its + * own, and the chaining order used to decide whether the label survived: + * + * ``` + * z.boolean().describe('Update package cache').default(false) → 'UPDATE' + * z.boolean().default(false).describe('Update package cache') → the sentence + * ``` + * + * 15 of the 22 core cubes were written the first way, so most prompts showed a + * bare key. Unwrapping makes the two orders equivalent, which is the answer that + * cannot regress — the alternative was to re-order every manifest and hope the + * next one written gets it right. + * + * Discriminates on {@link zodKind}, not `instanceof`, for the reason given + * there. Falling open here only costs an ugly label, but there is no reason to. + */ +function promptLabel(zodType: unknown, key: string): string { + let current = zodType; + + while (current) { + const description = (current as { description?: string }).description; + if (description) return description; + + const kind = zodKind(current); + if (kind !== 'default' && kind !== 'optional' && kind !== 'nullable') break; + current = zodInner(current); + } + + return key; +} + /** * Asks the user for a cube's variables and records the answers. * @@ -269,11 +306,11 @@ export async function VariableAssignment( if (Object.keys(variablesToConfigure).length === 0) return; - const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => { - const zodType = schema[key]; - const description = zodType?.description || key; - return { name: key, message: description, initial: String(value ?? '') }; - }); + const choices: FormChoice[] = Object.entries(variablesToConfigure).map(([key, value]) => ({ + name: key, + message: promptLabel(schema[key], key), + initial: String(value ?? ''), + })); const form = new (Enquirer as any).Form({ name: 'variables', diff --git a/packages/nopy/tests/prompts.pty.test.ts b/packages/nopy/tests/prompts.pty.test.ts index 7ab5a60..dc38b60 100644 --- a/packages/nopy/tests/prompts.pty.test.ts +++ b/packages/nopy/tests/prompts.pty.test.ts @@ -31,8 +31,12 @@ const hasPython = spawnSync('python3', ['--version']).status === 0; /** Down arrow — how the form moves from one field to the next. */ const DOWN = '\u001b[B'; +// Waits for the *description*, not the key. The probe schema is written +// `.describe(…).default(…)` — the order that used to lose the label — so this +// step also witnesses the zod-wrapper unwrapping through a real enquirer render +// rather than through the mocked Form in `prompts.test.ts`. const STEPS = [ - { expect: 'ALPHA', send: 'alpha-typed', settle: 0.6 }, + { expect: 'First value', send: 'alpha-typed', settle: 0.6 }, { send: DOWN, settle: 0.4 }, { send: 'beta-typed', settle: 0.4 }, { send: '\r', settle: 1.2 }, diff --git a/packages/nopy/tests/prompts.test.ts b/packages/nopy/tests/prompts.test.ts index 4cc87a3..3760f91 100644 --- a/packages/nopy/tests/prompts.test.ts +++ b/packages/nopy/tests/prompts.test.ts @@ -356,6 +356,34 @@ describe('VariableAssignment', () => { ]); }); + it('finds the label whichever side of .default() it was declared on', async () => { + // zod 4 keys a description to the schema *instance* and `.default()` returns + // a new wrapper, so `describe().default()` used to prompt with the bare key. + // 15 of the 22 core cubes were written that way round. + const both = cube( + 'both', + 'Both', + z.object({ + BEFORE: z.boolean().describe('Update package cache').default(false), + AFTER: z.boolean().default(false).describe('Restart afterwards'), + WRAPPED: z.string().describe('Optional note').optional().default('n/a'), + NEITHER: z.string().default('x'), + }) + ); + const variables = new Variables(); + variables.assign('both', 'default', both.getDefaults()); + formRun.mockResolvedValue({}); + + await VariableAssignment(both, variables); + + expect(formChoices().map((c) => c.message)).toEqual([ + 'Update package cache', + 'Restart afterwards', + 'Optional note', + 'NEITHER', + ]); + }); + it('offers the value the run would use, not the bare schema default', async () => { const svc = cube('svc', 'Service', schema); const variables = new Variables({ port: 2222 });