Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Add isRequired flag option (#141)
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
sbencoding and sindresorhus committed May 7, 2020
1 parent c4c5ee2 commit 1eede6a
Show file tree
Hide file tree
Showing 10 changed files with 304 additions and 15 deletions.
22 changes: 21 additions & 1 deletion index.d.ts
Expand Up @@ -3,10 +3,21 @@ import {PackageJson} from 'type-fest';
declare namespace meow {
type FlagType = 'string' | 'boolean' | 'number';

/**
Callback function to determine if a flag is required during runtime.
@param flags - Contains the flags converted to camel-case excluding aliases.
@param input - Contains the non-flag arguments.
@returns True if the flag is required, otherwise false.
*/
type IsRequiredPredicate = (flags: Readonly<AnyFlags>, input: readonly string[]) => boolean;

interface Flag<Type extends FlagType, Default> {
readonly type?: Type;
readonly alias?: string;
readonly default?: Default;
readonly isRequired?: boolean | IsRequiredPredicate;
readonly isMultiple?: boolean;
}

Expand All @@ -25,6 +36,8 @@ declare namespace meow {
- `type`: Type of value. (Possible values: `string` `boolean` `number`)
- `alias`: Usually used to define a short flag alias.
- `default`: Default value when the flag is not specified.
- `isRequired`: Determine if the flag is required.
If it's only known at runtime whether the flag is requried or not you can pass a Function instead of a boolean, which based on the given flags and other non-flag arguments should decide if the flag is required.
- `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false)
@example
Expand All @@ -34,7 +47,14 @@ declare namespace meow {
type: 'string',
alias: 'u',
default: ['rainbow', 'cat'],
isMultiple: true
isMultiple: true,
isRequired: (flags, input) => {
if (flags.otherFlag) {
return true;
}
return false;
}
}
}
```
Expand Down
49 changes: 49 additions & 0 deletions index.js
Expand Up @@ -15,6 +15,46 @@ const arrify = require('arrify');
delete require.cache[__filename];
const parentDir = path.dirname(module.parent.filename);

const isFlagMissing = (flagName, definedFlags, receivedFlags, input) => {
const flag = definedFlags[flagName];
let isFlagRequired = true;

if (typeof flag.isRequired === 'function') {
isFlagRequired = flag.isRequired(receivedFlags, input);
if (typeof isFlagRequired !== 'boolean') {
throw new TypeError(`Return value for isRequired callback should be of type boolean, but ${typeof isFlagRequired} was returned.`);
}
}

if (typeof receivedFlags[flagName] === 'undefined') {
return isFlagRequired;
}

return flag.isMultiple && receivedFlags[flagName].length === 0;
};

const getMissingRequiredFlags = (flags, receivedFlags, input) => {
const missingRequiredFlags = [];
if (typeof flags === 'undefined') {
return [];
}

for (const flagName of Object.keys(flags)) {
if (flags[flagName].isRequired && isFlagMissing(flagName, flags, receivedFlags, input)) {
missingRequiredFlags.push({key: flagName, ...flags[flagName]});
}
}

return missingRequiredFlags;
};

const reportMissingRequiredFlags = missingRequiredFlags => {
console.error(`Missing required flag${missingRequiredFlags.length > 1 ? 's' : ''}`);
for (const flag of missingRequiredFlags) {
console.error(`\t--${flag.key}${flag.alias ? `, -${flag.alias}` : ''}`);
}
};

const buildParserFlags = ({flags, booleanDefault}) =>
Object.entries(flags).reduce((parserFlags, [flagKey, flagValue]) => {
const flag = {...flagValue};
Expand Down Expand Up @@ -155,6 +195,15 @@ const meow = (helpText, options) => {
delete flags[flagValue.alias];
}

// Get a list of missing flags that are required
const missingRequiredFlags = getMissingRequiredFlags(options.flags, flags, input);

// Print error message for missing flags that are required
if (missingRequiredFlags.length > 0) {
reportMissingRequiredFlags(missingRequiredFlags);
process.exit(2);
}

return {
input,
flags,
Expand Down
5 changes: 5 additions & 0 deletions package.json
Expand Up @@ -68,5 +68,10 @@
"ignores": [
"estest/index.js"
]
},
"ava": {
"files": [
"test/*"
]
}
}
14 changes: 13 additions & 1 deletion readme.md
Expand Up @@ -137,6 +137,11 @@ The key is the flag name and the value is an object with any of:
- `type`: Type of value. (Possible values: `string` `boolean` `number`)
- `alias`: Usually used to define a short flag alias.
- `default`: Default value when the flag is not specified.
- `isRequired`: Determine if the flag is required. (Default: false)
- If it's only known at runtime whether the flag is requried or not, you can pass a `Function` instead of a `boolean`, which based on the given flags and other non-flag arguments, should decide if the flag is required. Two arguments are passed to the function:
- The first argument is the **flags** object, which contains the flags converted to camel-case excluding aliases.
- The second argument is the **input** string array, which contains the non-flag arguments.
- The function should return a `boolean`, true if the flag is required, otherwise false.
- `isMultiple`: Indicates a flag can be set multiple times. Values are turned into an array. (Default: false)
Example:
Expand All @@ -147,7 +152,14 @@ flags: {
type: 'string',
alias: 'u',
default: ['rainbow', 'cat'],
isMultiple: true
isMultiple: true,
isRequired: (flags, input) => {
if (flags.otherFlag) {
return true;
}

return false;
}
}
}
```
Expand Down
39 changes: 39 additions & 0 deletions test/fixtures/fixture-required-function.js
@@ -0,0 +1,39 @@
#!/usr/bin/env node
'use strict';
const meow = require('../..');

const cli = meow({
description: 'Custom description',
help: `
Usage
foo <input>
`,
flags: {
trigger: {
type: 'boolean',
alias: 't'
},
withTrigger: {
type: 'string',
isRequired: (flags, _) => {
return flags.trigger;
}
},
allowError: {
type: 'boolean',
alias: 'a'
},
shouldError: {
type: 'boolean',
isRequired: (flags, _) => {
if (flags.allowError) {
return 'should error';
}

return false;
}
}
}
});

console.log(`${cli.flags.trigger},${cli.flags.withTrigger}`);
21 changes: 21 additions & 0 deletions test/fixtures/fixture-required-multiple.js
@@ -0,0 +1,21 @@
#!/usr/bin/env node
'use strict';
const meow = require('../..');

const cli = meow({
description: 'Custom description',
help: `
Usage
foo <input>
`,
flags: {
test: {
type: 'number',
alias: 't',
isRequired: true,
isMultiple: true
}
}
});

console.log(cli.flags.test);
27 changes: 27 additions & 0 deletions test/fixtures/fixture-required.js
@@ -0,0 +1,27 @@
#!/usr/bin/env node
'use strict';
const meow = require('../..');

const cli = meow({
description: 'Custom description',
help: `
Usage
foo <input>
`,
flags: {
test: {
type: 'string',
alias: 't',
isRequired: true
},
number: {
type: 'number',
isRequired: true
},
notRequired: {
type: 'string'
}
}
});

console.log(`${cli.flags.test},${cli.flags.number}`);
2 changes: 1 addition & 1 deletion fixture.js → test/fixtures/fixture.js
@@ -1,6 +1,6 @@
#!/usr/bin/env node
'use strict';
const meow = require('.');
const meow = require('../..');

const cli = meow({
description: 'Custom description',
Expand Down
115 changes: 115 additions & 0 deletions test/is-required-flag.js
@@ -0,0 +1,115 @@
import test from 'ava';
import execa from 'execa';
const path = require('path');

const fixtureRequiredPath = path.join(__dirname, 'fixtures', 'fixture-required.js');
const fixtureRequiredFunctionPath = path.join(__dirname, 'fixtures', 'fixture-required-function.js');
const fixtureRequiredMultiplePath = path.join(__dirname, 'fixtures', 'fixture-required-multiple.js');

test('spawn cli and test not specifying required flags', async t => {
try {
await execa(fixtureRequiredPath, []);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.regex(stderr, /--test, -t/);
t.regex(stderr, /--number/);
t.notRegex(stderr, /--notRequired/);
}
});

test('spawn cli and test specifying all required flags', async t => {
const {stdout} = await execa(fixtureRequiredPath, [
'-t',
'test',
'--number',
'6'
]);
t.is(stdout, 'test,6');
});

test('spawn cli and test specifying required string flag with an empty string as value', async t => {
try {
await execa(fixtureRequiredPath, ['--test', '']);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.notRegex(stderr, /--test, -t/);
}
});

test('spawn cli and test specifying required number flag without a number', async t => {
try {
await execa(fixtureRequiredPath, ['--number']);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.regex(stderr, /--number/);
}
});

test('spawn cli and test setting isRequired as a function and not specifying any flags', async t => {
const {stdout} = await execa(fixtureRequiredFunctionPath, []);
t.is(stdout, 'false,undefined');
});

test('spawn cli and test setting isRequired as a function and specifying only the flag that activates the isRequired condition for the other flag', async t => {
try {
await execa(fixtureRequiredFunctionPath, ['--trigger']);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.regex(stderr, /--withTrigger/);
}
});

test('spawn cli and test setting isRequired as a function and specifying both the flags', async t => {
const {stdout} = await execa(fixtureRequiredFunctionPath, ['--trigger', '--withTrigger', 'specified']);
t.is(stdout, 'true,specified');
});

test('spawn cli and test setting isRequired as a function and check if returning a non-boolean value throws an error', async t => {
try {
await execa(fixtureRequiredFunctionPath, ['--allowError', '--shouldError', 'specified']);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 1/);
t.regex(stderr, /Return value for isRequired callback should be of type boolean, but string was returned./);
}
});

test('spawn cli and test isRequired with isMultiple giving a single value', async t => {
const {stdout} = await execa(fixtureRequiredMultiplePath, ['--test', '1']);
t.is(stdout, '[ 1 ]');
});

test('spawn cli and test isRequired with isMultiple giving a multiple values', async t => {
const {stdout} = await execa(fixtureRequiredMultiplePath, ['--test', '1', '2', '3']);
t.is(stdout, '[ 1, 2, 3 ]');
});

test('spawn cli and test isRequired with isMultiple giving no values, but flag is given', async t => {
try {
await execa(fixtureRequiredMultiplePath, ['--test']);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.regex(stderr, /--test/);
}
});

test('spawn cli and test isRequired with isMultiple giving no values, but flag is not given', async t => {
try {
await execa(fixtureRequiredMultiplePath, []);
} catch (error) {
const {stderr, message} = error;
t.regex(message, /Command failed with exit code 2/);
t.regex(stderr, /Missing required flag/);
t.regex(stderr, /--test/);
}
});

0 comments on commit 1eede6a

Please sign in to comment.