diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts index 0c6a20f1..f88a5668 100644 --- a/src/interfaces/parser.ts +++ b/src/interfaces/parser.ts @@ -171,6 +171,10 @@ export type FlagProps = { * This is helpful if the default value contains sensitive data that shouldn't be published to npm. */ noCacheDefault?: boolean + /** + * At least one of these flags must be provided. + */ + atLeastOne?: string[] } export type ArgProps = { diff --git a/src/parser/validate.ts b/src/parser/validate.ts index 9e2b32c2..df6d1fff 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -80,7 +80,11 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} } if (flag.exactlyOne && flag.exactlyOne.length > 0) { - return [validateAcrossFlags(flag)] + return [validateExactlyOneAcrossFlags(flag)] + } + + if (flag.atLeastOne && flag.atLeastOne.length > 0) { + return [validateAtLeastOneAcrossFlags(flag)] } return [] @@ -115,8 +119,8 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} const getPresentFlags = (flags: Record): string[] => Object.keys(flags).filter((key) => key !== undefined) - function validateAcrossFlags(flag: Flag): Validation { - const base = {name: flag.name, validationFn: 'validateAcrossFlags'} + function validateExactlyOneAcrossFlags(flag: Flag): Validation { + const base = {name: flag.name, validationFn: 'validateExactlyOneAcrossFlags'} const intersection = Object.entries(parse.input.flags) .map((entry) => entry[0]) // array of flag names .filter((flagName) => parse.output.flags[flagName] !== undefined) // with values @@ -131,6 +135,22 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} return {...base, status: 'success'} } + function validateAtLeastOneAcrossFlags(flag: Flag): Validation { + const base = {name: flag.name, validationFn: 'validateAtLeastOneAcrossFlags'} + const intersection = Object.entries(parse.input.flags) + .map((entry) => entry[0]) // array of flag names + .filter((flagName) => parse.output.flags[flagName] !== undefined) // with values + .filter((flagName) => flag.atLeastOne && flag.atLeastOne.includes(flagName)) // and in the atLeastOne list + if (intersection.length === 0) { + // the command's atLeastOne may or may not include itself, so we'll use Set to add + de-dupe + const deduped = uniq(flag.atLeastOne?.map((flag) => `--${flag}`) ?? []).join(', ') + const reason = `At least one of the following must be provided: ${deduped}` + return {...base, reason, status: 'failed'} + } + + return {...base, status: 'success'} + } + async function validateExclusive(name: string, flags: FlagRelationship[]): Promise { const base = {name, validationFn: 'validateExclusive'} const resolved = await resolveFlags(flags) diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts index 1a5cbd48..a6ed592e 100644 --- a/test/parser/parse.test.ts +++ b/test/parser/parse.test.ts @@ -1644,6 +1644,59 @@ See more help with --help`) }) }) + describe('atLeastOne', () => { + it('throws if none are set', async () => { + let message = '' + try { + await parse([], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar']}), + }, + }) + } catch (error: any) { + message = error.message + } + + expect(message).to.include('At least one of the following must be provided: --bar, --foo') + }) + + it('succeeds if one is set', async () => { + const out = await parse(['--foo', 'a'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.foo).to.equal('a') + }) + + it('succeeds if some are set', async () => { + const out = await parse(['--bar', 'b'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.bar).to.equal('b') + }) + + it('succeeds if all are set', async () => { + const out = await parse(['--foo', 'a', '--bar', 'b', '--baz', 'c'], { + flags: { + foo: Flags.string({atLeastOne: ['foo', 'bar', 'baz']}), + bar: Flags.string({char: 'b', atLeastOne: ['foo', 'bar', 'baz']}), + baz: Flags.string({char: 'z'}), + }, + }) + expect(out.flags.foo).to.equal('a') + expect(out.flags.bar).to.equal('b') + expect(out.flags.baz).to.equal('c') + }) + }) + describe('allowNo', () => { it('is undefined if not set', async () => { const out = await parse([], {