Skip to content

Commit 6858b73

Browse files
authored
feat!: throw error if option is unknown (#6)
BREAKING CHANGE: An error is now thrown when an unsupported option is used.
1 parent fcdd1b5 commit 6858b73

File tree

2 files changed

+172
-3
lines changed

2 files changed

+172
-3
lines changed

src/index.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,4 +294,123 @@ describe('index', () => {
294294

295295
expect(() => processConfig(config, ['unknown'])).toThrow(/Unknown command:.*unknown/);
296296
});
297+
298+
it('should throw error for unknown long option', () => {
299+
const config = defineConfig({
300+
commands: {
301+
test: defineCommand({
302+
options: defineOptions(
303+
z.object({
304+
verbose: z.boolean().default(false),
305+
}),
306+
),
307+
action: vi.fn(),
308+
}),
309+
},
310+
});
311+
312+
expect(() => processConfig(config, ['test', '--unknown'])).toThrow(ZliError);
313+
expect(() => processConfig(config, ['test', '--unknown'])).toThrow('Unknown option: \x1b[36m--unknown\x1b[0m');
314+
});
315+
316+
it('should throw error for unknown short option', () => {
317+
const config = defineConfig({
318+
commands: {
319+
test: defineCommand({
320+
options: defineOptions(
321+
z.object({
322+
verbose: z.boolean().default(false),
323+
}),
324+
{ v: 'verbose' },
325+
),
326+
action: vi.fn(),
327+
}),
328+
},
329+
});
330+
331+
expect(() => processConfig(config, ['test', '-x'])).toThrow(ZliError);
332+
expect(() => processConfig(config, ['test', '-x'])).toThrow('Unknown option: \x1b[36m-x\x1b[0m');
333+
});
334+
335+
it('should throw error for unknown option with kebab-case display', () => {
336+
const config = defineConfig({
337+
commands: {
338+
test: defineCommand({
339+
options: defineOptions(
340+
z.object({
341+
verbose: z.boolean().default(false),
342+
}),
343+
),
344+
action: vi.fn(),
345+
}),
346+
},
347+
});
348+
349+
expect(() => processConfig(config, ['test', '--no-build'])).toThrow(ZliError);
350+
expect(() => processConfig(config, ['test', '--no-build'])).toThrow('Unknown option: \x1b[36m--no-build\x1b[0m');
351+
});
352+
353+
it('should allow valid kebab-case options', () => {
354+
const config = defineConfig({
355+
commands: {
356+
test: defineCommand({
357+
options: defineOptions(
358+
z.object({
359+
androidMax: z.string(),
360+
verbose: z.boolean().default(false),
361+
}),
362+
),
363+
action: vi.fn(),
364+
}),
365+
},
366+
});
367+
368+
const result = processConfig(config, ['test', '--android-max', '10', '--verbose']);
369+
expect(result.options).toEqual({ androidMax: '10', verbose: true });
370+
});
371+
372+
it('should allow standard help and version flags', () => {
373+
const config = defineConfig({
374+
commands: {
375+
test: defineCommand({
376+
options: defineOptions(
377+
z.object({
378+
name: z.string(),
379+
}),
380+
),
381+
action: vi.fn(),
382+
}),
383+
},
384+
});
385+
386+
// Help should trigger exit, not unknown option error
387+
expect(() => processConfig(config, ['test', '--help'])).toThrow('process.exit called');
388+
});
389+
390+
it('should throw error for unknown option when no schema defined', () => {
391+
const config = defineConfig({
392+
commands: {
393+
test: defineCommand({
394+
action: vi.fn(),
395+
}),
396+
},
397+
});
398+
399+
expect(() => processConfig(config, ['test', '--unknown'])).toThrow(ZliError);
400+
expect(() => processConfig(config, ['test', '--unknown'])).toThrow('Unknown option: \x1b[36m--unknown\x1b[0m');
401+
});
402+
403+
it('should allow help and version when no schema defined', () => {
404+
const config = defineConfig({
405+
meta: { version: '1.0.0' },
406+
commands: {
407+
test: defineCommand({
408+
action: vi.fn(),
409+
}),
410+
},
411+
});
412+
413+
// Help should trigger exit, not unknown option error
414+
expect(() => processConfig(config, ['test', '--help'])).toThrow('process.exit called');
415+
});
297416
});

src/index.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,18 @@ function isZodArrayType(zodType: any): boolean {
348348
return innerType instanceof z.ZodArray || (innerType._def && innerType._def.typeName === 'ZodArray');
349349
}
350350

351+
/**
352+
* Creates an error for unknown options with proper flag prefix and display name.
353+
*
354+
* @param optionName - The unknown option name
355+
* @returns ZliError with formatted error message
356+
*/
357+
function createUnknownOptionError(optionName: string): ZliError {
358+
const flagPrefix = optionName.length === 1 ? '-' : '--';
359+
const displayName = optionName.length === 1 ? optionName : camelToKebab(optionName);
360+
return new ZliError(`Unknown option: \x1b[36m${flagPrefix}${displayName}\x1b[0m`);
361+
}
362+
351363
/**
352364
* Extracts the default value from a Zod type, handling nested optional and default wrappers.
353365
*
@@ -390,11 +402,12 @@ function extractDefaultValue(zodType: any): string | undefined {
390402
* Validates and transforms command options using Zod schema validation.
391403
* Processes aliases and kebab-case conversion before validation.
392404
* Ensures that single values for array fields are converted to arrays.
405+
* Throws an error if any unknown options are provided.
393406
*
394407
* @param flags - Raw parsed flags from command line
395408
* @param optionsDef - Optional options definition with schema and aliases
396409
* @returns Validated options object matching the schema
397-
* @throws Error if validation fails
410+
* @throws Error if validation fails or unknown options are provided
398411
*
399412
* @example
400413
* validateOptions({ 'android-max': '10' }, { schema: z.object({ androidMax: z.string() }) })
@@ -409,15 +422,52 @@ function validateOptions<T extends z.ZodObject<any> = z.ZodObject<any>>(
409422
optionsDef?: OptionsDefinition<T>,
410423
): any {
411424
if (!optionsDef) {
425+
// Check for unknown options when no schema is defined
426+
const { _, ...options } = flags;
427+
const unknownOptions = Object.keys(options);
428+
if (unknownOptions.length > 0) {
429+
// Find the first unknown option that looks like a flag
430+
const firstUnknown = unknownOptions.find((key) => key !== 'help' && key !== 'version');
431+
if (firstUnknown) {
432+
throw createUnknownOptionError(firstUnknown);
433+
}
434+
}
412435
return {};
413436
}
414437

438+
// Get valid option names from schema
439+
const schemaKeys = Object.keys(optionsDef.schema.shape);
440+
const validNames = new Set<string>();
441+
442+
// Add camelCase names
443+
schemaKeys.forEach((key) => validNames.add(key));
444+
445+
// Add kebab-case versions
446+
schemaKeys.forEach((key) => validNames.add(camelToKebab(key)));
447+
448+
// Add aliases
449+
if (optionsDef.aliases) {
450+
Object.keys(optionsDef.aliases).forEach((alias) => validNames.add(alias));
451+
}
452+
453+
// Add standard help and version flags
454+
validNames.add('help');
455+
validNames.add('version');
456+
457+
// Check for unknown options before processing
458+
const { _, ...options } = flags;
459+
for (const optionName of Object.keys(options)) {
460+
if (!validNames.has(optionName)) {
461+
throw createUnknownOptionError(optionName);
462+
}
463+
}
464+
415465
const resolvedAliases = resolveAliases(flags, optionsDef.aliases);
416466
const resolvedKebab = resolveKebabCase(resolvedAliases, optionsDef.schema);
417-
const { _, ...options } = resolvedKebab;
467+
const { _: resolvedUnderscore, ...resolvedOptions } = resolvedKebab;
418468

419469
// Normalize single values to arrays for fields that expect arrays
420-
const normalizedOptions = normalizeArrayFields(options, optionsDef.schema);
470+
const normalizedOptions = normalizeArrayFields(resolvedOptions, optionsDef.schema);
421471

422472
return optionsDef.schema.parse(normalizedOptions);
423473
}

0 commit comments

Comments
 (0)