Skip to content

Commit 8114f98

Browse files
committed
fix: auto-coerce cli string values for union types
1 parent d76cc44 commit 8114f98

3 files changed

Lines changed: 113 additions & 37 deletions

File tree

.changeset/union-type-coercion.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'padrone': minor
3+
---
4+
5+
Auto-coerce CLI string values for union types (e.g. `z.union([z.boolean(), z.string()])`) — `--flag true` now correctly passes boolean `true` instead of the string `"true"`

packages/padrone/src/core/args.ts

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,58 @@ export function preprocessArgs(
208208
return result;
209209
}
210210

211+
/**
212+
* Walk a JSON schema fragment and collect the set of allowed primitive types,
213+
* descending into `anyOf` / `oneOf` (used for unions). For variants whose type
214+
* is `array`, item types are collected separately into `itemTypes`.
215+
*/
216+
function collectAllowedTypes(prop: Record<string, any> | undefined, types: Set<string>, itemTypes: Set<string>): void {
217+
if (!prop) return;
218+
219+
if (prop.type !== undefined) {
220+
const list = Array.isArray(prop.type) ? prop.type : [prop.type];
221+
for (const t of list) {
222+
if (typeof t === 'string') types.add(t);
223+
}
224+
if (list.includes('array') && prop.items) {
225+
collectAllowedTypes(prop.items, itemTypes, new Set());
226+
}
227+
}
228+
229+
const variants = prop.anyOf ?? prop.oneOf;
230+
if (Array.isArray(variants)) {
231+
for (const variant of variants) collectAllowedTypes(variant, types, itemTypes);
232+
}
233+
}
234+
235+
/** Coerce a single CLI string to a primitive based on the set of allowed types. */
236+
function coerceScalar(value: unknown, allowedTypes: Set<string>): unknown {
237+
if (typeof value !== 'string') return value;
238+
239+
if (allowedTypes.has('boolean')) {
240+
const lower = value.toLowerCase();
241+
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'on') return true;
242+
if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'off') return false;
243+
}
244+
245+
if (allowedTypes.has('number') || allowedTypes.has('integer')) {
246+
const trimmed = value.trim();
247+
if (trimmed !== '') {
248+
const num = Number(trimmed);
249+
if (!Number.isNaN(num)) return num;
250+
}
251+
}
252+
253+
return value;
254+
}
255+
211256
/**
212257
* Auto-coerce CLI string values to match the expected schema types.
213258
* Handles: string → number, string → boolean for primitive schema fields.
214259
* Arrays of primitives are also coerced element-wise.
260+
* Union types (`anyOf` / `oneOf`) are coerced to the most specific matching
261+
* primitive — e.g. `--test true` for `z.union([z.boolean(), z.string()])`
262+
* becomes the boolean `true` rather than the string "true".
215263
*/
216264
export function coerceArgs(data: Record<string, unknown>, schema: StandardJSONSchemaV1): Record<string, unknown> {
217265
let properties: Record<string, any>;
@@ -229,43 +277,21 @@ export function coerceArgs(data: Record<string, unknown>, schema: StandardJSONSc
229277
const prop = properties[key];
230278
if (!prop) continue;
231279

232-
const targetType = prop.type as string | undefined;
233-
234-
if (targetType === 'number' || targetType === 'integer') {
235-
if (typeof value === 'string') {
236-
const num = Number(value);
237-
if (!Number.isNaN(num)) result[key] = num;
238-
}
239-
} else if (targetType === 'boolean') {
240-
if (typeof value === 'string') {
241-
const lower = value.toLowerCase();
242-
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'on') result[key] = true;
243-
else if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'off') result[key] = false;
244-
}
245-
} else if (targetType === 'array') {
246-
// Coerce single items to array
247-
const arr = Array.isArray(value) ? value : [value];
248-
const itemType = prop.items?.type as string | undefined;
249-
if (itemType === 'number' || itemType === 'integer') {
250-
result[key] = arr.map((v) => {
251-
if (typeof v === 'string') {
252-
const num = Number(v);
253-
return Number.isNaN(num) ? v : num;
254-
}
255-
return v;
256-
});
257-
} else if (itemType === 'boolean') {
258-
result[key] = arr.map((v) => {
259-
if (typeof v === 'string') {
260-
const lower = v.toLowerCase();
261-
if (lower === 'true' || lower === '1' || lower === 'yes' || lower === 'on') return true;
262-
if (lower === 'false' || lower === '0' || lower === 'no' || lower === 'off') return false;
263-
}
264-
return v;
265-
});
266-
} else if (!Array.isArray(value)) {
267-
result[key] = arr;
268-
}
280+
const types = new Set<string>();
281+
const itemTypes = new Set<string>();
282+
collectAllowedTypes(prop, types, itemTypes);
283+
284+
const isArrayValue = Array.isArray(value);
285+
const allowsArray = types.has('array');
286+
const allowsScalar = types.has('string') || types.has('boolean') || types.has('number') || types.has('integer');
287+
288+
if (isArrayValue && allowsArray) {
289+
result[key] = value.map((v) => coerceScalar(v, itemTypes));
290+
} else if (!isArrayValue && allowsArray && !allowsScalar) {
291+
// Wrap single value into an array when only array shapes are allowed
292+
result[key] = [coerceScalar(value, itemTypes)];
293+
} else if (!isArrayValue) {
294+
result[key] = coerceScalar(value, types);
269295
}
270296
}
271297

packages/padrone/tests/cli-validation.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,51 @@ describe('CLI validation improvements', () => {
114114
const result = program.eval('test --count 42');
115115
expect(result.args?.count).toBe(42);
116116
});
117+
118+
it('should coerce string to boolean for z.union([z.boolean(), z.string()])', () => {
119+
const program = createPadrone('app').command('run', (c) =>
120+
c.arguments(z.object({ test: z.union([z.boolean(), z.string()]) })).action((args) => args),
121+
);
122+
123+
expect(program.eval('run --test true').args?.test).toBe(true);
124+
expect(program.eval('run --test false').args?.test).toBe(false);
125+
});
126+
127+
it('should coerce string to literal boolean for z.union([z.literal(true), z.string()])', () => {
128+
const program = createPadrone('app').command('run', (c) =>
129+
c.arguments(z.object({ test: z.union([z.literal(true), z.string()]) })).action((args) => args),
130+
);
131+
132+
const result = program.eval('run --test true');
133+
expect(result.args?.test).toBe(true);
134+
});
135+
136+
it('should leave non-boolean strings as string in boolean|string union', () => {
137+
const program = createPadrone('app').command('run', (c) =>
138+
c.arguments(z.object({ test: z.union([z.boolean(), z.string()]) })).action((args) => args),
139+
);
140+
141+
const result = program.eval('run --test hello');
142+
expect(result.args?.test).toBe('hello');
143+
});
144+
145+
it('should coerce string to number for z.union([z.number(), z.string()])', () => {
146+
const program = createPadrone('app').command('run', (c) =>
147+
c.arguments(z.object({ test: z.union([z.number(), z.string()]) })).action((args) => args),
148+
);
149+
150+
expect(program.eval('run --test 42').args?.test).toBe(42);
151+
expect(program.eval('run --test hello').args?.test).toBe('hello');
152+
});
153+
154+
it('should prefer boolean coercion over number when both are allowed', () => {
155+
const program = createPadrone('app').command('run', (c) =>
156+
c.arguments(z.object({ test: z.union([z.boolean(), z.number()]) })).action((args) => args),
157+
);
158+
159+
expect(program.eval('run --test true').args?.test).toBe(true);
160+
expect(program.eval('run --test 42').args?.test).toBe(42);
161+
});
117162
});
118163

119164
// ====================================================================

0 commit comments

Comments
 (0)