diff --git a/index.d.ts b/index.d.ts index a54fe3f..e92d14a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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, input: readonly string[]) => boolean; + interface Flag { readonly type?: Type; readonly alias?: string; readonly default?: Default; + readonly isRequired?: boolean | IsRequiredPredicate; readonly isMultiple?: boolean; } @@ -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 @@ -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; + } } } ``` diff --git a/index.js b/index.js index d2974d4..559366d 100644 --- a/index.js +++ b/index.js @@ -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}; @@ -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, diff --git a/package.json b/package.json index b83e450..428dae1 100644 --- a/package.json +++ b/package.json @@ -68,5 +68,10 @@ "ignores": [ "estest/index.js" ] + }, + "ava": { + "files": [ + "test/*" + ] } } diff --git a/readme.md b/readme.md index 9d59c78..4f3fdf2 100644 --- a/readme.md +++ b/readme.md @@ -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: @@ -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; + } } } ``` diff --git a/test/fixtures/fixture-required-function.js b/test/fixtures/fixture-required-function.js new file mode 100755 index 0000000..ef3c443 --- /dev/null +++ b/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 + `, + 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}`); diff --git a/test/fixtures/fixture-required-multiple.js b/test/fixtures/fixture-required-multiple.js new file mode 100755 index 0000000..fa424e2 --- /dev/null +++ b/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 + `, + flags: { + test: { + type: 'number', + alias: 't', + isRequired: true, + isMultiple: true + } + } +}); + +console.log(cli.flags.test); diff --git a/test/fixtures/fixture-required.js b/test/fixtures/fixture-required.js new file mode 100755 index 0000000..e34577b --- /dev/null +++ b/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 + `, + flags: { + test: { + type: 'string', + alias: 't', + isRequired: true + }, + number: { + type: 'number', + isRequired: true + }, + notRequired: { + type: 'string' + } + } +}); + +console.log(`${cli.flags.test},${cli.flags.number}`); diff --git a/fixture.js b/test/fixtures/fixture.js similarity index 94% rename from fixture.js rename to test/fixtures/fixture.js index c96580f..6917bae 100755 --- a/fixture.js +++ b/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', diff --git a/test/is-required-flag.js b/test/is-required-flag.js new file mode 100644 index 0000000..66907fb --- /dev/null +++ b/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/); + } +}); diff --git a/test.js b/test/test.js similarity index 91% rename from test.js rename to test/test.js index 7e147b0..946a069 100644 --- a/test.js +++ b/test/test.js @@ -2,9 +2,10 @@ import test from 'ava'; import indentString from 'indent-string'; import execa from 'execa'; import path from 'path'; -import pkg from './package.json'; -import meow from '.'; +import pkg from '../package.json'; +import meow from '..'; +const fixturePath = path.join(__dirname, 'fixtures', 'fixture.js'); const NODE_MAJOR_VERSION = process.versions.node.split('.')[0]; test('return object', t => { @@ -39,47 +40,47 @@ test('support help shortcut', t => { }); test('spawn cli and show version', async t => { - const {stdout} = await execa('./fixture.js', ['--version']); + const {stdout} = await execa(fixturePath, ['--version']); t.is(stdout, pkg.version); }); test('spawn cli and disabled autoVersion and autoHelp', async t => { - const {stdout} = await execa('./fixture.js', ['--version', '--help']); + const {stdout} = await execa(fixturePath, ['--version', '--help']); t.is(stdout, 'version\nhelp\nmeow\ncamelCaseOption'); }); test('spawn cli and disabled autoVersion', async t => { - const {stdout} = await execa('./fixture.js', ['--version', '--no-auto-version']); + const {stdout} = await execa(fixturePath, ['--version', '--no-auto-version']); t.is(stdout, 'version\nautoVersion\nmeow\ncamelCaseOption'); }); test('spawn cli and not show version', async t => { - const {stdout} = await execa('./fixture.js', ['--version=beta']); + const {stdout} = await execa(fixturePath, ['--version=beta']); t.is(stdout, 'version\nmeow\ncamelCaseOption'); }); test('spawn cli and show help screen', async t => { - const {stdout} = await execa('./fixture.js', ['--help']); + const {stdout} = await execa(fixturePath, ['--help']); t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); }); test('spawn cli and disabled autoHelp', async t => { - const {stdout} = await execa('./fixture.js', ['--help', '--no-auto-help']); + const {stdout} = await execa(fixturePath, ['--help', '--no-auto-help']); t.is(stdout, 'help\nautoHelp\nmeow\ncamelCaseOption'); }); test('spawn cli and not show help', async t => { - const {stdout} = await execa('./fixture.js', ['--help=all']); + const {stdout} = await execa(fixturePath, ['--help=all']); t.is(stdout, 'help\nmeow\ncamelCaseOption'); }); test('spawn cli and test input', async t => { - const {stdout} = await execa('./fixture.js', ['-u', 'cat']); + const {stdout} = await execa(fixturePath, ['-u', 'cat']); t.is(stdout, 'unicorn\nmeow\ncamelCaseOption'); }); test('spawn cli and test input flag', async t => { - const {stdout} = await execa('./fixture.js', ['--camel-case-option', 'bar']); + const {stdout} = await execa(fixturePath, ['--camel-case-option', 'bar']); t.is(stdout, 'bar'); }); @@ -485,7 +486,7 @@ if (NODE_MAJOR_VERSION >= 14) { test('supports es modules', async t => { try { const {stdout} = await execa('node', ['index.js', '--version'], { - cwd: path.join(__dirname, 'estest') + cwd: path.join(__dirname, '..', 'estest') }); t.regex(stdout, /1.2.3/); } catch (error) {