diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts index 252f3dc9c..47f6c6dc7 100644 --- a/src/interfaces/parser.ts +++ b/src/interfaces/parser.ts @@ -9,6 +9,7 @@ export type CLIParseErrorOptions = { input?: ParserInput output?: ParserOutput } + exit?: number } export type OutputArgs = {[P in keyof T]: any} diff --git a/src/parser/errors.ts b/src/parser/errors.ts index 67d6344aa..146c01b56 100644 --- a/src/parser/errors.ts +++ b/src/parser/errors.ts @@ -19,9 +19,9 @@ export type Validation = { export class CLIParseError extends CLIError { public parse: CLIParseErrorOptions['parse'] - constructor(options: CLIParseErrorOptions & {message: string}) { + constructor(options: CLIParseErrorOptions & {exit?: number; message: string}) { options.message += '\nSee more help with --help' - super(options.message) + super(options.message, {exit: options.exit}) this.parse = options.parse } } @@ -29,7 +29,7 @@ export class CLIParseError extends CLIError { export class InvalidArgsSpecError extends CLIParseError { public args: ArgInput - constructor({args, parse}: CLIParseErrorOptions & {args: ArgInput}) { + constructor({args, exit, parse}: CLIParseErrorOptions & {args: ArgInput}) { let message = 'Invalid argument spec' const namedArgs = Object.values(args).filter((a) => a.name) if (namedArgs.length > 0) { @@ -41,7 +41,7 @@ export class InvalidArgsSpecError extends CLIParseError { message += `:\n${list}` } - super({message, parse}) + super({exit, message, parse}) this.args = args } } @@ -51,6 +51,7 @@ export class RequiredArgsError extends CLIParseError { constructor({ args, + exit, flagsWithMultiple, parse, }: CLIParseErrorOptions & {args: Arg[]; flagsWithMultiple?: string[]}) { @@ -71,7 +72,7 @@ export class RequiredArgsError extends CLIParseError { message += '\nAlternatively, you can use "--" to signify the end of the flags and the beginning of arguments.' } - super({message, parse}) + super({exit, message, parse}) this.args = args } } @@ -79,10 +80,10 @@ export class RequiredArgsError extends CLIParseError { export class RequiredFlagError extends CLIParseError { public flag: Flag - constructor({flag, parse}: CLIParseErrorOptions & {flag: Flag}) { + constructor({exit, flag, parse}: CLIParseErrorOptions & {flag: Flag}) { const usage = renderList(flagUsages([flag], {displayRequired: false})) const message = `Missing required flag:\n${usage}` - super({message, parse}) + super({exit, message, parse}) this.flag = flag } } @@ -90,9 +91,9 @@ export class RequiredFlagError extends CLIParseError { export class UnexpectedArgsError extends CLIParseError { public args: unknown[] - constructor({args, parse}: CLIParseErrorOptions & {args: unknown[]}) { + constructor({args, exit, parse}: CLIParseErrorOptions & {args: unknown[]}) { const message = `Unexpected argument${args.length === 1 ? '' : 's'}: ${args.join(', ')}` - super({message, parse}) + super({exit, message, parse}) this.args = args } } @@ -100,9 +101,9 @@ export class UnexpectedArgsError extends CLIParseError { export class NonExistentFlagsError extends CLIParseError { public flags: string[] - constructor({flags, parse}: CLIParseErrorOptions & {flags: string[]}) { + constructor({exit, flags, parse}: CLIParseErrorOptions & {flags: string[]}) { const message = `Nonexistent flag${flags.length === 1 ? '' : 's'}: ${flags.join(', ')}` - super({message, parse}) + super({exit, message, parse}) this.flags = flags } } @@ -122,11 +123,11 @@ export class ArgInvalidOptionError extends CLIParseError { } export class FailedFlagValidationError extends CLIParseError { - constructor({failed, parse}: CLIParseErrorOptions & {failed: Validation[]}) { + constructor({exit, failed, parse}: CLIParseErrorOptions & {failed: Validation[]}) { const reasons = failed.map((r) => r.reason) const deduped = uniq(reasons) const errString = deduped.length === 1 ? 'error' : 'errors' const message = `The following ${errString} occurred:\n ${chalk.dim(deduped.join('\n '))}` - super({message, parse}) + super({exit, message, parse}) } } diff --git a/src/parser/validate.ts b/src/parser/validate.ts index 2a72fee20..cfec0f827 100644 --- a/src/parser/validate.ts +++ b/src/parser/validate.ts @@ -14,13 +14,14 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} function validateArgs() { if (parse.output.nonExistentFlags?.length > 0) { - throw new NonExistentFlagsError({flags: parse.output.nonExistentFlags, parse}) + // this is the first error that could be thrown, exit codes 1 and 2 have been used, so we'll start with 3 + throw new NonExistentFlagsError({exit: 3, flags: parse.output.nonExistentFlags, parse}) } const maxArgs = Object.keys(parse.input.args).length if (parse.input.strict && parse.output.argv.length > maxArgs) { const extras = parse.output.argv.slice(maxArgs) - throw new UnexpectedArgsError({args: extras, parse}) + throw new UnexpectedArgsError({args: extras, exit: 4, parse}) } const missingRequiredArgs: Arg[] = [] @@ -32,7 +33,7 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} } else if (hasOptional) { // (required arg) check whether an optional has occurred before // optionals should follow required, not before - throw new InvalidArgsSpecError({args: parse.input.args, parse}) + throw new InvalidArgsSpecError({args: parse.input.args, exit: 5, parse}) } if (arg.required && !parse.output.args[name] && parse.output.args[name] !== 0) { @@ -45,7 +46,8 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} .filter(([_, flagDef]) => flagDef.type === 'option' && Boolean(flagDef.multiple)) .map(([name]) => name) - throw new RequiredArgsError({args: missingRequiredArgs, flagsWithMultiple, parse}) + // if a command is missing args -> exit 6 + throw new RequiredArgsError({args: missingRequiredArgs, exit: 6, flagsWithMultiple, parse}) } } @@ -76,7 +78,8 @@ export async function validate(parse: {input: ParserInput; output: ParserOutput} const results = await Promise.all(promises) const failed = results.filter((r) => r.status === 'failed') - if (failed.length > 0) throw new FailedFlagValidationError({failed, parse}) + // if a command is missing flags -> exit 7 + if (failed.length > 0) throw new FailedFlagValidationError({exit: 7, failed, parse}) } async function resolveFlags(flags: FlagRelationship[]): Promise> { diff --git a/test/parser/validate.test.ts b/test/parser/validate.test.ts index ac9ee6d20..aacde0e1e 100644 --- a/test/parser/validate.test.ts +++ b/test/parser/validate.test.ts @@ -28,6 +28,85 @@ describe('validate', () => { '--': true, } + it('will exit 3 when a nonExistentFlags flag is passed', async () => { + const output = { + args: {}, + argv: [], + nonExistentFlags: ['foobar'], + } + + try { + // @ts-expect-error + await validate({input, output}) + fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.oclif.exit).to.equal(3) + expect(err.message).to.include('Nonexistent flag: foobar') + } + }) + + it('will exit 4 when an unexpected argument is found', async () => { + const output = { + args: {}, + argv: ['found', 'me'], + nonExistentFlags: [], + } + + try { + // @ts-expect-error + await validate({input, output}) + fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.oclif.exit).to.equal(4) + expect(err.message).to.include('Unexpected arguments: found, me') + } + }) + + it('throws when required flag is mixed with args -> exit 5', async () => { + const input = { + argv: [], + flags: { + foo: { + description: 'foo flag', + required: true, + }, + }, + raw: [ + { + type: 'flag', + flag: 'foo', + input: 'value', + }, + ], + args: {foo: {required: false}, bar: {required: true}}, + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {foobar: 'value'}, + raw: [], + metadata: { + flags: {}, + }, + } + + try { + // @ts-expect-error + await validate({input, output}) + fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('Invalid argument spec') + expect(err.oclif.exit).to.equal(5) + } + }) + it('enforces exclusivity for flags', async () => { const output = { args: {}, @@ -58,6 +137,7 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) expect(err.message).to.include('--dessert=cheesecake cannot also be provided when using --dinner') } }) @@ -153,6 +233,50 @@ describe('validate', () => { } catch (error) { const err = error as CLIError expect(err.message).to.include('Missing required flag') + expect(err.oclif.exit).to.equal(7) + } + }) + + it('throws when required flag is missing value', async () => { + const input = { + argv: [], + flags: { + foobar: { + description: 'foobar flag', + required: true, + }, + }, + raw: [ + { + type: 'flag', + flag: 'foobar', + input: 'value', + }, + ], + args: {foobar: {required: true}}, + strict: true, + context: {}, + '--': true, + } + + const output = { + args: {}, + argv: [], + flags: {foobar: 'value'}, + raw: [], + metadata: { + flags: {}, + }, + } + + try { + // @ts-expect-error + await validate({input, output}) + fail('should have thrown') + } catch (error) { + const err = error as CLIError + expect(err.message).to.include('Missing 1 required arg') + expect(err.oclif.exit).to.equal(6) } }) @@ -272,6 +396,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include( 'All of the following must be provided when using --dessert: --cookies, --sprinkles', ) @@ -322,6 +448,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include( 'All of the following must be provided when using --dessert: --cookies, --sprinkles', ) @@ -372,6 +500,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include('All of the following must be provided when using --dessert: --cookies') } }) @@ -453,6 +583,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include( 'One of the following must be provided when using --dessert: --cookies, --sprinkles', ) @@ -503,6 +635,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include( 'One of the following must be provided when using --dessert: --cookies, --sprinkles', ) @@ -553,6 +687,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include('One of the following must be provided when using --dessert: --cookies') } }) @@ -639,6 +775,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include('--sprinkles=true cannot also be provided when using --dessert') } }) @@ -696,6 +834,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include('--sprinkles=true cannot also be provided when using --dessert') } }) @@ -827,6 +967,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include('--cookies=false cannot also be provided when using --dessert') } }) @@ -925,6 +1067,8 @@ describe('validate', () => { fail('should have thrown') } catch (error) { const err = error as CLIError + expect(err.oclif.exit).to.equal(7) + expect(err.message).to.include( 'All of the following must be provided when using --dessert: --cookies, --cake', )