diff --git a/estest/index.js b/estest/index.js index 4372709..3f62756 100644 --- a/estest/index.js +++ b/estest/index.js @@ -1,4 +1,4 @@ -import meow from '../index.js'; +import meow from '../source/index.js'; meow( ` diff --git a/index.d.ts b/index.d.ts index 7c46621..24fea92 100644 --- a/index.d.ts +++ b/index.d.ts @@ -202,6 +202,8 @@ export type Options = { /** `package.json` as an `Object`. Default: Closest `package.json` upwards. + Note: Setting this stops `meow` from finding a package.json. + _You most likely don't need this option._ */ readonly pkg?: Record; diff --git a/index.js b/index.js deleted file mode 100644 index 53999b7..0000000 --- a/index.js +++ /dev/null @@ -1,371 +0,0 @@ -import {dirname} from 'node:path'; -import process from 'node:process'; -import {fileURLToPath} from 'node:url'; -import buildParserOptions from 'minimist-options'; -import parseArguments from 'yargs-parser'; -import camelCaseKeys from 'camelcase-keys'; -import decamelize from 'decamelize'; -import decamelizeKeys from 'decamelize-keys'; -import trimNewlines from 'trim-newlines'; -import redent from 'redent'; -import {readPackageUpSync} from 'read-pkg-up'; -import hardRejection from 'hard-rejection'; -import normalizePackageData from 'normalize-package-data'; - -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 && isFlagRequired; -}; - -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 decamelizeFlagKey = flagKey => `--${decamelize(flagKey, {separator: '-'})}`; - -const reportMissingRequiredFlags = missingRequiredFlags => { - console.error(`Missing required flag${missingRequiredFlags.length > 1 ? 's' : ''}`); - for (const flag of missingRequiredFlags) { - console.error(`\t${decamelizeFlagKey(flag.key)}${flag.shortFlag ? `, -${flag.shortFlag}` : ''}`); - } -}; - -const joinFlagKeys = (flagKeys, prefix = '--') => `\`${prefix}${flagKeys.join(`\`, \`${prefix}`)}\``; - -const validateOptions = options => { - const invalidOptionFilters = { - flags: { - keyContainsDashes: { - filter: ([flagKey]) => flagKey.includes('-') && flagKey !== '--', - message: flagKeys => `Flag keys may not contain '-'. Invalid flags: ${joinFlagKeys(flagKeys, '')}`, - }, - aliasIsSet: { - filter: ([, flag]) => flag.alias !== undefined, - message: flagKeys => `The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: ${joinFlagKeys(flagKeys)}`, - }, - choicesNotAnArray: { - filter: ([, flag]) => flag.choices !== undefined && !Array.isArray(flag.choices), - message: flagKeys => `The option \`choices\` must be an array. Invalid flags: ${joinFlagKeys(flagKeys)}`, - }, - choicesNotMatchFlagType: { - filter: ([, flag]) => flag.type && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type), - message(flagKeys) { - const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey].type}')`); - return `Each value of the option \`choices\` must be of the same type as its flag. Invalid flags: ${flagKeysAndTypes.join(', ')}`; - }, - }, - defaultNotInChoices: { - filter: ([, flag]) => flag.default && Array.isArray(flag.choices) && [flag.default].flat().every(value => flag.choices.includes(value)), - message: flagKeys => `Each value of the option \`default\` must exist within the option \`choices\`. Invalid flags: ${joinFlagKeys(flagKeys)}`, - }, - }, - }; - - const errorMessages = []; - - for (const [optionKey, filters] of Object.entries(invalidOptionFilters)) { - const optionEntries = Object.entries(options[optionKey]); - - for (const {filter, message} of Object.values(filters)) { - const invalidOptions = optionEntries.filter(option => filter(option)); - const invalidOptionKeys = invalidOptions.map(([key]) => key); - - if (invalidOptions.length > 0) { - errorMessages.push(message(invalidOptionKeys)); - } - } - } - - if (errorMessages.length > 0) { - throw new Error(errorMessages.join('\n')); - } -}; - -const validateChoicesByFlag = (flagKey, flagValue, receivedInput) => { - const {choices, isRequired} = flagValue; - - if (!choices) { - return; - } - - const valueMustBeOneOf = `Value must be one of: [\`${choices.join('`, `')}\`]`; - - if (!receivedInput) { - if (isRequired) { - return `Flag \`${decamelizeFlagKey(flagKey)}\` has no value. ${valueMustBeOneOf}`; - } - - return; - } - - if (Array.isArray(receivedInput)) { - const unknownValues = receivedInput.filter(index => !choices.includes(index)); - - if (unknownValues.length > 0) { - const valuesText = unknownValues.length > 1 ? 'values' : 'value'; - - return `Unknown ${valuesText} for flag \`${decamelizeFlagKey(flagKey)}\`: \`${unknownValues.join('`, `')}\`. ${valueMustBeOneOf}`; - } - } else if (!choices.includes(receivedInput)) { - return `Unknown value for flag \`${decamelizeFlagKey(flagKey)}\`: \`${receivedInput}\`. ${valueMustBeOneOf}`; - } -}; - -const validateChoices = (flags, receivedFlags) => { - const errors = []; - - for (const [flagKey, flagValue] of Object.entries(flags)) { - const receivedInput = receivedFlags[flagKey]; - const errorMessage = validateChoicesByFlag(flagKey, flagValue, receivedInput); - - if (errorMessage) { - errors.push(errorMessage); - } - } - - if (errors.length > 0) { - throw new Error(`${errors.join('\n')}`); - } -}; - -const reportUnknownFlags = unknownFlags => { - console.error([ - `Unknown flag${unknownFlags.length > 1 ? 's' : ''}`, - ...unknownFlags, - ].join('\n')); -}; - -const buildParserFlags = ({flags, booleanDefault}) => { - const parserFlags = {}; - - for (const [flagKey, flagValue] of Object.entries(flags)) { - const flag = {...flagValue}; - - // `buildParserOptions` expects `flag.alias` - if (flag.shortFlag) { - flag.alias = flag.shortFlag; - delete flag.shortFlag; - } - - if ( - typeof booleanDefault !== 'undefined' - && flag.type === 'boolean' - && !Object.prototype.hasOwnProperty.call(flag, 'default') - ) { - flag.default = flag.isMultiple ? [booleanDefault] : booleanDefault; - } - - if (flag.isMultiple) { - flag.type = flag.type ? `${flag.type}-array` : 'array'; - flag.default = flag.default ?? []; - delete flag.isMultiple; - } - - if (Array.isArray(flag.aliases)) { - if (flag.alias) { - flag.aliases.push(flag.alias); - } - - flag.alias = flag.aliases; - delete flag.aliases; - } - - parserFlags[flagKey] = flag; - } - - return parserFlags; -}; - -const validateFlags = (flags, options) => { - for (const [flagKey, flagValue] of Object.entries(options.flags)) { - if (flagKey !== '--' && !flagValue.isMultiple && Array.isArray(flags[flagKey])) { - throw new Error(`The flag --${flagKey} can only be set once.`); - } - } -}; - -/* eslint complexity: off */ -const meow = (helpText, options = {}) => { - if (typeof helpText !== 'string') { - options = helpText; - helpText = ''; - } - - if (!options.importMeta?.url) { - throw new TypeError('The `importMeta` option is required. Its value must be `import.meta`.'); - } - - const foundPackage = readPackageUpSync({ - cwd: dirname(fileURLToPath(options.importMeta.url)), - normalize: false, - }); - - options = { - pkg: foundPackage ? foundPackage.packageJson : {}, - argv: process.argv.slice(2), - flags: {}, - inferType: false, - input: 'string', - help: helpText, - autoHelp: true, - autoVersion: true, - booleanDefault: false, - hardRejection: true, - allowUnknownFlags: true, - ...options, - }; - - if (options.hardRejection) { - hardRejection(); - } - - validateOptions(options); - let parserOptions = { - arguments: options.input, - ...buildParserFlags(options), - }; - - parserOptions = decamelizeKeys(parserOptions, '-', {exclude: ['stopEarly', '--']}); - - if (options.inferType) { - delete parserOptions.arguments; - } - - // Add --help and --version to known flags if autoHelp or autoVersion are set - if (!options.allowUnknownFlags) { - if (options.autoHelp && !parserOptions.help) { - parserOptions.help = {type: 'boolean'}; - } - - if (options.autoVersion && !parserOptions.version) { - parserOptions.version = {type: 'boolean'}; - } - } - - parserOptions = buildParserOptions(parserOptions); - - parserOptions.configuration = { - ...parserOptions.configuration, - 'greedy-arrays': false, - }; - - if (parserOptions['--']) { - parserOptions.configuration['populate--'] = true; - } - - if (!options.allowUnknownFlags) { - // Collect unknown options in `argv._` to be checked later. - parserOptions.configuration['unknown-options-as-args'] = true; - } - - const {pkg: package_} = options; - const argv = parseArguments(options.argv, parserOptions); - let help = trimNewlines((options.help || '').replace(/\t+\n*$/, '')); - - if (help.includes('\n')) { - help = redent(help, 2); - } - - normalizePackageData(package_); - - process.title = package_.bin ? Object.keys(package_.bin)[0] : package_.name; - - let {description} = options; - if (!description && description !== false) { - ({description} = package_); - } - - // Change to `&&=` when targeting Node 15+ - if (description) { - description = help ? `\n ${description}\n` : `\n${description}`; - } - - help = (description || '') + (help ? `\n${help}\n` : '\n'); - - const showHelp = code => { - console.log(help); - process.exit(typeof code === 'number' ? code : 2); - }; - - const showVersion = () => { - console.log(typeof options.version === 'string' ? options.version : package_.version); - process.exit(0); - }; - - if (argv._.length === 0 && options.argv.length === 1) { - if (argv.version === true && options.autoVersion) { - showVersion(); - } else if (argv.help === true && options.autoHelp) { - showHelp(0); - } - } - - const input = argv._; - delete argv._; - - if (!options.allowUnknownFlags) { - const unknownFlags = input.filter(item => typeof item === 'string' && item.startsWith('-')); - if (unknownFlags.length > 0) { - reportUnknownFlags(unknownFlags); - process.exit(2); - } - } - - const flags = camelCaseKeys(argv, {exclude: ['--', /^\w$/]}); - const unnormalizedFlags = {...flags}; - - validateFlags(flags, options); - validateChoices(options.flags, flags); - - for (const flagValue of Object.values(options.flags)) { - if (Array.isArray(flagValue.aliases)) { - for (const alias of flagValue.aliases) { - delete flags[alias]; - } - } - - delete flags[flagValue.shortFlag]; - } - - const missingRequiredFlags = getMissingRequiredFlags(options.flags, flags, input); - if (missingRequiredFlags.length > 0) { - reportMissingRequiredFlags(missingRequiredFlags); - process.exit(2); - } - - return { - input, - flags, - unnormalizedFlags, - pkg: package_, - help, - showHelp, - showVersion, - }; -}; - -export default meow; diff --git a/package.json b/package.json index 5e0b5cc..9827c69 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "type": "module", "exports": { "types": "./index.d.ts", - "default": "./index.js" + "default": "./source/index.js" }, "engines": { "node": ">=14.16" @@ -22,7 +22,7 @@ "test": "xo && ava && tsd" }, "files": [ - "index.js", + "source", "index.d.ts" ], "keywords": [ @@ -48,24 +48,24 @@ "@types/minimist": "^1.2.2", "camelcase-keys": "^8.0.2", "decamelize": "^6.0.0", - "decamelize-keys": "^1.1.0", + "decamelize-keys": "^2.0.1", "hard-rejection": "^2.1.0", "minimist-options": "4.1.0", - "normalize-package-data": "^4.0.1", + "normalize-package-data": "^5.0.0", "read-pkg-up": "^9.1.0", "redent": "^4.0.0", - "trim-newlines": "^4.0.2", - "type-fest": "^3.1.0", + "trim-newlines": "^5.0.0", + "type-fest": "^3.7.1", "yargs-parser": "^21.1.1" }, "devDependencies": { - "ava": "^4.3.3", + "ava": "^5.2.0", "common-tags": "^1.8.2", - "execa": "^6.1.0", + "execa": "^7.1.1", "indent-string": "^5.0.0", "read-pkg": "^7.1.0", - "tsd": "^0.24.1", - "xo": "^0.52.4" + "tsd": "^0.28.0", + "xo": "^0.53.1" }, "xo": { "rules": { diff --git a/readme.md b/readme.md index a3251d0..c8a8076 100644 --- a/readme.md +++ b/readme.md @@ -181,7 +181,7 @@ Default: `true` Automatically show the version text when the `--version` flag is present. Useful to set this value to `false` when a CLI manages child CLIs with their own version text. - This option is only considered when there is only one argument in `process.argv`. +This option is only considered when there is only one argument in `process.argv`. ##### pkg @@ -190,6 +190,8 @@ Default: Closest package.json upwards package.json as an `object`. +Note: Setting this stops `meow` from finding a package.json. + *You most likely don't need this option.* ##### argv diff --git a/source/index.js b/source/index.js new file mode 100644 index 0000000..56664b1 --- /dev/null +++ b/source/index.js @@ -0,0 +1,103 @@ +import process from 'node:process'; +import parseArguments from 'yargs-parser'; +import camelCaseKeys from 'camelcase-keys'; +import {trimNewlines} from 'trim-newlines'; +import redent from 'redent'; +import hardRejection from 'hard-rejection'; +import normalizePackageData from 'normalize-package-data'; +import {buildOptions} from './options.js'; +import {buildParserOptions} from './parser.js'; +import {validate, checkUnknownFlags, checkMissingRequiredFlags} from './validate.js'; + +const buildResult = (options, parserOptions) => { + const {pkg: package_} = options; + const argv = parseArguments(options.argv, parserOptions); + let help = trimNewlines((options.help || '').replace(/\t+\n*$/, '')); + + if (help.includes('\n')) { + help = redent(help, 2); + } + + normalizePackageData(package_); + + let {description} = options; + if (!description && description !== false) { + ({description} = package_); + } + + // Change to `&&=` when targeting Node 15+ + if (description) { + description = help ? `\n ${description}\n` : `\n${description}`; + } + + help = (description || '') + (help ? `\n${help}\n` : '\n'); + + const showHelp = code => { + console.log(help); + process.exit(typeof code === 'number' ? code : 2); + }; + + const showVersion = () => { + console.log(typeof options.version === 'string' ? options.version : package_.version); + process.exit(0); + }; + + if (argv._.length === 0 && options.argv.length === 1) { + if (argv.version === true && options.autoVersion) { + showVersion(); + } else if (argv.help === true && options.autoHelp) { + showHelp(0); + } + } + + const input = argv._; + delete argv._; + + if (!options.allowUnknownFlags) { + checkUnknownFlags(input); + } + + const flags = camelCaseKeys(argv, {exclude: ['--', /^\w$/]}); + const unnormalizedFlags = {...flags}; + + validate(flags, options); + + for (const flagValue of Object.values(options.flags)) { + if (Array.isArray(flagValue.aliases)) { + for (const alias of flagValue.aliases) { + delete flags[alias]; + } + } + + delete flags[flagValue.shortFlag]; + } + + checkMissingRequiredFlags(options.flags, flags, input); + + return { + input, + flags, + unnormalizedFlags, + pkg: package_, + help, + showHelp, + showVersion, + }; +}; + +const meow = (helpText, options = {}) => { + const parsedOptions = buildOptions(helpText, options); + + if (parsedOptions.hardRejection) { + hardRejection(); + } + + const parserOptions = buildParserOptions(parsedOptions); + const result = buildResult(parsedOptions, parserOptions); + + process.title = result.pkg.bin ? Object.keys(result.pkg.bin)[0] : result.pkg.name; + + return result; +}; + +export default meow; diff --git a/source/options.js b/source/options.js new file mode 100644 index 0000000..7fb167e --- /dev/null +++ b/source/options.js @@ -0,0 +1,90 @@ +import process from 'node:process'; +import {dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {readPackageUpSync} from 'read-pkg-up'; +import {decamelizeFlagKey, joinFlagKeys} from './utils.js'; + +const validateOptions = options => { + const invalidOptionFilters = { + flags: { + keyContainsDashes: { + filter: ([flagKey]) => flagKey.includes('-') && flagKey !== '--', + message: flagKeys => `Flag keys may not contain '-'. Invalid flags: ${joinFlagKeys(flagKeys, '')}`, + }, + aliasIsSet: { + filter: ([, flag]) => flag.alias !== undefined, + message: flagKeys => `The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: ${joinFlagKeys(flagKeys)}`, + }, + choicesNotAnArray: { + filter: ([, flag]) => flag.choices !== undefined && !Array.isArray(flag.choices), + message: flagKeys => `The option \`choices\` must be an array. Invalid flags: ${joinFlagKeys(flagKeys)}`, + }, + choicesNotMatchFlagType: { + filter: ([, flag]) => flag.type && Array.isArray(flag.choices) && flag.choices.some(choice => typeof choice !== flag.type), + message(flagKeys) { + const flagKeysAndTypes = flagKeys.map(flagKey => `(\`${decamelizeFlagKey(flagKey)}\`, type: '${options.flags[flagKey].type}')`); + return `Each value of the option \`choices\` must be of the same type as its flag. Invalid flags: ${flagKeysAndTypes.join(', ')}`; + }, + }, + defaultNotInChoices: { + filter: ([, flag]) => flag.default && Array.isArray(flag.choices) && [flag.default].flat().every(value => flag.choices.includes(value)), + message: flagKeys => `Each value of the option \`default\` must exist within the option \`choices\`. Invalid flags: ${joinFlagKeys(flagKeys)}`, + }, + }, + }; + + const errorMessages = []; + + for (const [optionKey, filters] of Object.entries(invalidOptionFilters)) { + const optionEntries = Object.entries(options[optionKey]); + + for (const {filter, message} of Object.values(filters)) { + const invalidOptions = optionEntries.filter(option => filter(option)); + const invalidOptionKeys = invalidOptions.map(([key]) => key); + + if (invalidOptions.length > 0) { + errorMessages.push(message(invalidOptionKeys)); + } + } + } + + if (errorMessages.length > 0) { + throw new Error(errorMessages.join('\n')); + } +}; + +export const buildOptions = (helpText, options) => { + if (typeof helpText !== 'string') { + options = helpText; + helpText = ''; + } + + if (!options.importMeta?.url) { + throw new TypeError('The `importMeta` option is required. Its value must be `import.meta`.'); + } + + const foundPackage = readPackageUpSync({ + cwd: dirname(fileURLToPath(options.importMeta.url)), + normalize: false, + }); + + const parsedOptions = { + pkg: foundPackage ? foundPackage.packageJson : {}, + argv: process.argv.slice(2), + flags: {}, + inferType: false, + input: 'string', + help: helpText, + autoHelp: true, + autoVersion: true, + booleanDefault: false, + hardRejection: true, + allowUnknownFlags: true, + allowParentFlags: true, + ...options, + }; + + validateOptions(parsedOptions); + + return parsedOptions; +}; diff --git a/source/parser.js b/source/parser.js new file mode 100644 index 0000000..6ddf4f5 --- /dev/null +++ b/source/parser.js @@ -0,0 +1,83 @@ +import constructParserOptions from 'minimist-options'; +import decamelizeKeys from 'decamelize-keys'; + +const buildParserFlags = ({flags, booleanDefault}) => { + const parserFlags = {}; + + for (const [flagKey, flagValue] of Object.entries(flags)) { + const flag = {...flagValue}; + + // `minimist-options` expects `flag.alias` + if (flag.shortFlag) { + flag.alias = flag.shortFlag; + delete flag.shortFlag; + } + + if ( + typeof booleanDefault !== 'undefined' + && flag.type === 'boolean' + && !Object.prototype.hasOwnProperty.call(flag, 'default') + ) { + flag.default = flag.isMultiple ? [booleanDefault] : booleanDefault; + } + + if (flag.isMultiple) { + flag.type = flag.type ? `${flag.type}-array` : 'array'; + flag.default = flag.default ?? []; + delete flag.isMultiple; + } + + if (Array.isArray(flag.aliases)) { + if (flag.alias) { + flag.aliases.push(flag.alias); + } + + flag.alias = flag.aliases; + delete flag.aliases; + } + + parserFlags[flagKey] = flag; + } + + return parserFlags; +}; + +export const buildParserOptions = options => { + let parserOptions = buildParserFlags(options); + parserOptions.arguments = options.input; + + parserOptions = decamelizeKeys(parserOptions, {separator: '-', exclude: ['stopEarly', '--']}); + + if (options.inferType) { + delete parserOptions.arguments; + } + + // Add --help and --version to known flags if autoHelp or autoVersion are set + if (!options.allowUnknownFlags) { + if (options.autoHelp && !parserOptions.help) { + parserOptions.help = {type: 'boolean'}; + } + + if (options.autoVersion && !parserOptions.version) { + parserOptions.version = {type: 'boolean'}; + } + } + + parserOptions = constructParserOptions(parserOptions); + + parserOptions.configuration = { + ...parserOptions.configuration, + 'greedy-arrays': false, + }; + + if (parserOptions['--']) { + parserOptions.configuration['populate--'] = true; + } + + if (!options.allowUnknownFlags) { + // Collect unknown options in `argv._` to be checked later. + parserOptions.configuration['unknown-options-as-args'] = true; + } + + return parserOptions; +}; diff --git a/source/utils.js b/source/utils.js new file mode 100644 index 0000000..6325b1b --- /dev/null +++ b/source/utils.js @@ -0,0 +1,5 @@ +import decamelize from 'decamelize'; + +export const decamelizeFlagKey = flagKey => `--${decamelize(flagKey, {separator: '-'})}`; + +export const joinFlagKeys = (flagKeys, prefix = '--') => `\`${prefix}${flagKeys.join(`\`, \`${prefix}`)}\``; diff --git a/source/validate.js b/source/validate.js new file mode 100644 index 0000000..5089316 --- /dev/null +++ b/source/validate.js @@ -0,0 +1,120 @@ +import process from 'node:process'; +import {decamelizeFlagKey} from './utils.js'; + +const validateFlags = (flags, options) => { + for (const [flagKey, flagValue] of Object.entries(options.flags)) { + if (flagKey !== '--' && !flagValue.isMultiple && Array.isArray(flags[flagKey])) { + throw new Error(`The flag --${flagKey} can only be set once.`); + } + } +}; + +const validateChoicesByFlag = (flagKey, flagValue, receivedInput) => { + const {choices, isRequired} = flagValue; + + if (!choices) { + return; + } + + const valueMustBeOneOf = `Value must be one of: [\`${choices.join('`, `')}\`]`; + + if (!receivedInput) { + if (isRequired) { + return `Flag \`${decamelizeFlagKey(flagKey)}\` has no value. ${valueMustBeOneOf}`; + } + + return; + } + + if (Array.isArray(receivedInput)) { + const unknownValues = receivedInput.filter(index => !choices.includes(index)); + + if (unknownValues.length > 0) { + const valuesText = unknownValues.length > 1 ? 'values' : 'value'; + + return `Unknown ${valuesText} for flag \`${decamelizeFlagKey(flagKey)}\`: \`${unknownValues.join('`, `')}\`. ${valueMustBeOneOf}`; + } + } else if (!choices.includes(receivedInput)) { + return `Unknown value for flag \`${decamelizeFlagKey(flagKey)}\`: \`${receivedInput}\`. ${valueMustBeOneOf}`; + } +}; + +const validateChoices = (flags, receivedFlags) => { + const errors = []; + + for (const [flagKey, flagValue] of Object.entries(flags)) { + const receivedInput = receivedFlags[flagKey]; + const errorMessage = validateChoicesByFlag(flagKey, flagValue, receivedInput); + + if (errorMessage) { + errors.push(errorMessage); + } + } + + if (errors.length > 0) { + throw new Error(`${errors.join('\n')}`); + } +}; + +export const validate = (flags, options) => { + validateFlags(flags, options); + validateChoices(options.flags, flags); +}; + +const reportUnknownFlags = unknownFlags => { + console.error([ + `Unknown flag${unknownFlags.length > 1 ? 's' : ''}`, + ...unknownFlags, + ].join('\n')); +}; + +export const checkUnknownFlags = input => { + const unknownFlags = input.filter(item => typeof item === 'string' && item.startsWith('-')); + if (unknownFlags.length > 0) { + reportUnknownFlags(unknownFlags); + process.exit(2); + } +}; + +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 && isFlagRequired; +}; + +const reportMissingRequiredFlags = missingRequiredFlags => { + console.error(`Missing required flag${missingRequiredFlags.length > 1 ? 's' : ''}`); + for (const flag of missingRequiredFlags) { + console.error(`\t${decamelizeFlagKey(flag.key)}${flag.shortFlag ? `, -${flag.shortFlag}` : ''}`); + } +}; + +export const checkMissingRequiredFlags = (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]}); + } + } + + if (missingRequiredFlags.length > 0) { + reportMissingRequiredFlags(missingRequiredFlags); + process.exit(2); + } +}; diff --git a/test/_utils.js b/test/_utils.js new file mode 100644 index 0000000..2b7f498 --- /dev/null +++ b/test/_utils.js @@ -0,0 +1,17 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {execa} from 'execa'; + +export const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const getFixture = fixture => path.join(__dirname, 'fixtures', fixture); + +export const spawnFixture = async (fixture = 'fixture.js', args = []) => { + // Allow calling with args first + if (Array.isArray(fixture)) { + args = fixture; + fixture = 'fixture.js'; + } + + return execa(getFixture(fixture), args); +}; diff --git a/test/aliases.js b/test/aliases.js new file mode 100644 index 0000000..39972ad --- /dev/null +++ b/test/aliases.js @@ -0,0 +1,85 @@ +import test from 'ava'; +import meow from '../source/index.js'; + +const importMeta = import.meta; + +test('aliases - accepts one', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=baz'], + flags: { + fooBar: { + type: 'string', + aliases: ['foo'], + }, + }, + }).flags, { + fooBar: 'baz', + }); +}); + +test('aliases - accepts multiple', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=baz1', '--bar=baz2'], + flags: { + fooBar: { + type: 'string', + aliases: ['foo', 'bar'], + isMultiple: true, + }, + }, + }).flags, { + fooBar: ['baz1', 'baz2'], + }); +}); + +test('aliases - can be a short flag', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--f=baz'], + flags: { + fooBar: { + type: 'string', + aliases: ['f'], + }, + }, + }).flags, { + fooBar: 'baz', + }); +}); + +test('aliases - works with short flag', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=baz1', '--bar=baz2', '-f=baz3'], + flags: { + fooBar: { + type: 'string', + shortFlag: 'f', + aliases: ['foo', 'bar'], + isMultiple: true, + }, + }, + }).flags, { + fooBar: ['baz1', 'baz2', 'baz3'], + }); +}); + +test('aliases - unnormalized flags', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=baz'], + flags: { + fooBar: { + type: 'string', + aliases: ['foo'], + shortFlag: 'f', + }, + }, + }).unnormalizedFlags, { + fooBar: 'baz', + foo: 'baz', + f: 'baz', + }); +}); diff --git a/test/allow-unknown-flags.js b/test/allow-unknown-flags.js index 5fdd387..f4449bc 100644 --- a/test/allow-unknown-flags.js +++ b/test/allow-unknown-flags.js @@ -1,22 +1,19 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; import test from 'ava'; import indentString from 'indent-string'; -import {execa} from 'execa'; import {readPackage} from 'read-pkg'; +import {spawnFixture} from './_utils.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixtureAllowUnknownFlags = path.join(__dirname, 'fixtures', 'fixture-allow-unknown-flags.js'); -const fixtureAllowUnknownFlagsWithHelp = path.join(__dirname, 'fixtures', 'fixture-allow-unknown-flags-with-help.js'); +const fixtureFolder = 'allow-unknown-flags'; + +const allowUnknownFlags = `${fixtureFolder}/fixture.js`; +const allowUnknownFlagsWithHelp = `${fixtureFolder}/fixture-with-help.js`; test('spawn CLI and test specifying unknown flags', async t => { - const error = await t.throwsAsync( - execa(fixtureAllowUnknownFlags, ['--foo', 'bar', '--unspecified-a', '--unspecified-b', 'input-is-allowed']), - { - message: /^Command failed with exit code 2/, - }, + const {stderr} = await t.throwsAsync( + spawnFixture(allowUnknownFlags, ['--foo', 'bar', '--unspecified-a', '--unspecified-b', 'input-is-allowed']), + {message: /^Command failed with exit code 2/}, ); - const {stderr} = error; + t.regex(stderr, /Unknown flags/); t.regex(stderr, /--unspecified-a/); t.regex(stderr, /--unspecified-b/); @@ -24,52 +21,48 @@ test('spawn CLI and test specifying unknown flags', async t => { }); test('spawn CLI and test specifying known flags', async t => { - const {stdout} = await execa(fixtureAllowUnknownFlags, ['--foo', 'bar']); + const {stdout} = await spawnFixture(allowUnknownFlags, ['--foo', 'bar']); t.is(stdout, 'bar'); }); test('spawn CLI and test help as a known flag', async t => { - const {stdout} = await execa(fixtureAllowUnknownFlags, ['--help']); + const {stdout} = await spawnFixture(allowUnknownFlags, ['--help']); t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); }); test('spawn CLI and test version as a known flag', async t => { const pkg = await readPackage(); - const {stdout} = await execa(fixtureAllowUnknownFlags, ['--version']); + const {stdout} = await spawnFixture(allowUnknownFlags, ['--version']); t.is(stdout, pkg.version); }); test('spawn CLI and test help as an unknown flag', async t => { - const error = await t.throwsAsync( - execa(fixtureAllowUnknownFlags, ['--help', '--no-auto-help']), - { - message: /^Command failed with exit code 2/, - }, + const {stderr} = await t.throwsAsync( + spawnFixture(allowUnknownFlags, ['--help', '--no-auto-help']), + {message: /^Command failed with exit code 2/}, ); - const {stderr} = error; + t.regex(stderr, /Unknown flag/); t.regex(stderr, /--help/); }); test('spawn CLI and test version as an unknown flag', async t => { - const error = await t.throwsAsync( - execa(fixtureAllowUnknownFlags, ['--version', '--no-auto-version']), - { - message: /^Command failed with exit code 2/, - }, + const {stderr} = await t.throwsAsync( + spawnFixture(allowUnknownFlags, ['--version', '--no-auto-version']), + {message: /^Command failed with exit code 2/}, ); - const {stderr} = error; + t.regex(stderr, /Unknown flag/); t.regex(stderr, /--version/); }); test('spawn CLI and test help with custom config', async t => { - const {stdout} = await execa(fixtureAllowUnknownFlagsWithHelp, ['-h']); + const {stdout} = await spawnFixture(allowUnknownFlagsWithHelp, ['-h']); t.is(stdout, indentString('\nCustom description\n\nUsage\n foo \n\n', 2)); }); test('spawn CLI and test version with custom config', async t => { const pkg = await readPackage(); - const {stdout} = await execa(fixtureAllowUnknownFlagsWithHelp, ['-v']); + const {stdout} = await spawnFixture(allowUnknownFlagsWithHelp, ['-v']); t.is(stdout, pkg.version); }); diff --git a/test/choices.js b/test/choices.js new file mode 100644 index 0000000..c526c52 --- /dev/null +++ b/test/choices.js @@ -0,0 +1,193 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import meow from '../source/index.js'; + +const importMeta = import.meta; + +test('choices - success case', t => { + const cli = meow({ + importMeta, + argv: ['--animal', 'cat', '--number=2.2'], + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + type: 'number', + choices: [1.1, 2.2, 3.3], + }, + }, + }); + + t.is(cli.flags.animal, 'cat'); + t.is(cli.flags.number, 2.2); +}); + +test('choices - throws if input does not match choices', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal', 'rainbow', '--number', 5], + flags: { + animal: { + choices: ['dog', 'cat', 'unicorn'], + }, + number: { + choices: [1, 2, 3], + }, + }, + }); + }, { + message: stripIndent` + Unknown value for flag \`--animal\`: \`rainbow\`. Value must be one of: [\`dog\`, \`cat\`, \`unicorn\`] + Unknown value for flag \`--number\`: \`5\`. Value must be one of: [\`1\`, \`2\`, \`3\`] + `, + }); +}); + +test('choices - throws if choices is not array', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal', 'cat'], + flags: { + animal: { + choices: 'cat', + }, + }, + }); + }, {message: 'The option `choices` must be an array. Invalid flags: `--animal`'}); +}); + +test('choices - does not throw error when isRequired is false', t => { + t.notThrows(() => { + meow({ + importMeta, + argv: [], + flags: { + animal: { + isRequired: false, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }); +}); + +test('choices - throw error when isRequired is true', t => { + t.throws(() => { + meow({ + importMeta, + argv: [], + flags: { + animal: { + isRequired: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }, {message: 'Flag `--animal` has no value. Value must be one of: [`dog`, `cat`, `unicorn`]'}); +}); + +test('choices - success with isMultiple', t => { + const cli = meow({ + importMeta, + argv: ['--animal=dog', '--animal=unicorn'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + + t.deepEqual(cli.flags.animal, ['dog', 'unicorn']); +}); + +test('choices - throws with isMultiple, one unknown value', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--animal=rabbit'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['dog', 'cat', 'unicorn'], + }, + }, + }); + }, {message: 'Unknown value for flag `--animal`: `rabbit`. Value must be one of: [`dog`, `cat`, `unicorn`]'}); +}); + +test('choices - throws with isMultiple, multiple unknown value', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--animal=rabbit'], + flags: { + animal: { + type: 'string', + isMultiple: true, + choices: ['cat', 'unicorn'], + }, + }, + }); + }, {message: 'Unknown values for flag `--animal`: `dog`, `rabbit`. Value must be one of: [`cat`, `unicorn`]'}); +}); + +test('choices - throws with multiple flags', t => { + t.throws(() => { + meow({ + importMeta, + argv: ['--animal=dog', '--plant=succulent'], + flags: { + animal: { + type: 'string', + choices: ['cat', 'unicorn'], + }, + plant: { + type: 'string', + choices: ['tree', 'flower'], + }, + }, + }); + }, {message: stripIndent` + Unknown value for flag \`--animal\`: \`dog\`. Value must be one of: [\`cat\`, \`unicorn\`] + Unknown value for flag \`--plant\`: \`succulent\`. Value must be one of: [\`tree\`, \`flower\`] + `}); +}); + +test('choices - choices must be of the same type', t => { + t.throws(() => { + meow({ + importMeta, + flags: { + number: { + type: 'number', + choices: [1, '2'], + }, + boolean: { + type: 'boolean', + choices: [true, 'false'], + }, + }, + }); + }, {message: 'Each value of the option `choices` must be of the same type as its flag. Invalid flags: (`--number`, type: \'number\'), (`--boolean`, type: \'boolean\')'}); +}); + +test('choices - default must only include valid choices', t => { + t.throws(() => { + meow({ + importMeta, + flags: { + number: { + type: 'number', + choices: [1, 2, 3], + default: 1, + }, + }, + }); + }, {message: 'Each value of the option `default` must exist within the option `choices`. Invalid flags: `--number`'}); +}); diff --git a/test/errors.js b/test/errors.js new file mode 100644 index 0000000..f20f175 --- /dev/null +++ b/test/errors.js @@ -0,0 +1,86 @@ +import test from 'ava'; +import {stripIndent} from 'common-tags'; +import meow from '../source/index.js'; + +const importMeta = import.meta; + +/** +A convenience-wrapper around `t.throws` with `meow`. + +@param {import('../index').Options} options `meow` options with `importMeta` set +@param {string} message The thrown error message. Strips indentation, so template literals can be used. +*/ +const meowThrows = test.macro((t, options, {message}) => { + options = { + importMeta, + ...options, + }; + + message = stripIndent(message); + + t.throws(() => meow(options), {message}); +}); + +test('invalid package url', meowThrows, { + importMeta: '/path/to/package', +}, {message: 'The `importMeta` option is required. Its value must be `import.meta`.'}); + +test('supports `number` flag type - throws on incorrect default value', meowThrows, { + argv: [], + flags: { + foo: { + type: 'number', + default: 'x', + }, + }, +}, {message: 'Expected "foo" default value to be of type "number", got "string"'}); + +test('flag declared in kebab-case is an error', meowThrows, { + flags: {'kebab-case': 'boolean', test: 'boolean', 'another-one': 'boolean'}, +}, {message: 'Flag keys may not contain \'-\'. Invalid flags: `kebab-case`, `another-one`'}); + +test('single flag set more than once is an error', meowThrows, { + argv: ['--foo=bar', '--foo=baz'], + flags: { + foo: { + type: 'string', + }, + }, +}, {message: 'The flag --foo can only be set once.'}); + +test('suggests renaming alias to shortFlag', meowThrows, { + flags: { + foo: { + type: 'string', + alias: 'f', + }, + bar: { + type: 'string', + alias: 'b', + }, + baz: { + type: 'string', + shortFlag: 'z', + }, + }, +}, {message: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `--foo`, `--bar`'}); + +test('options - multiple validation errors', meowThrows, { + flags: { + animal: { + type: 'string', + choices: 'cat', + }, + plant: { + type: 'string', + alias: 'p', + }, + 'some-thing': { + type: 'string', + }, + }, +}, {message: ` + Flag keys may not contain '-'. Invalid flags: \`some-thing\` + The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`--plant\` + The option \`choices\` must be an array. Invalid flags: \`--animal\` +`}); diff --git a/test/fixtures/fixture-allow-unknown-flags-with-help.js b/test/fixtures/allow-unknown-flags/fixture-with-help.js similarity index 86% rename from test/fixtures/fixture-allow-unknown-flags-with-help.js rename to test/fixtures/allow-unknown-flags/fixture-with-help.js index e84a527..f80eb4f 100755 --- a/test/fixtures/fixture-allow-unknown-flags-with-help.js +++ b/test/fixtures/allow-unknown-flags/fixture-with-help.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -7,7 +7,7 @@ const cli = meow({ help: ` Usage foo - `, + `, allowUnknownFlags: false, flags: { help: { diff --git a/test/fixtures/fixture-allow-unknown-flags.js b/test/fixtures/allow-unknown-flags/fixture.js similarity index 91% rename from test/fixtures/fixture-allow-unknown-flags.js rename to test/fixtures/allow-unknown-flags/fixture.js index 15fc53c..f35939f 100755 --- a/test/fixtures/fixture-allow-unknown-flags.js +++ b/test/fixtures/allow-unknown-flags/fixture.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import process from 'node:process'; -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -8,7 +8,7 @@ const cli = meow({ help: ` Usage foo - `, + `, autoVersion: !process.argv.includes('--no-auto-version'), autoHelp: !process.argv.includes('--no-auto-help'), allowUnknownFlags: false, diff --git a/test/fixtures/fixture.js b/test/fixtures/fixture.js index 342fd49..b51220c 100755 --- a/test/fixtures/fixture.js +++ b/test/fixtures/fixture.js @@ -1,6 +1,6 @@ #!/usr/bin/env node import process from 'node:process'; -import meow from '../../index.js'; +import meow from '../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -8,7 +8,7 @@ const cli = meow({ help: ` Usage foo - `, + `, autoVersion: !process.argv.includes('--no-auto-version'), autoHelp: !process.argv.includes('--no-auto-help'), flags: { diff --git a/test/fixtures/fixture-conditional-required-multiple.js b/test/fixtures/required/fixture-conditional-required-multiple.js similarity index 84% rename from test/fixtures/fixture-conditional-required-multiple.js rename to test/fixtures/required/fixture-conditional-required-multiple.js index 59c1601..43fbf56 100755 --- a/test/fixtures/fixture-conditional-required-multiple.js +++ b/test/fixtures/required/fixture-conditional-required-multiple.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -7,7 +7,7 @@ const cli = meow({ help: ` Usage foo - `, + `, flags: { test: { type: 'number', diff --git a/test/fixtures/fixture-required-function.js b/test/fixtures/required/fixture-required-function.js similarity index 91% rename from test/fixtures/fixture-required-function.js rename to test/fixtures/required/fixture-required-function.js index d6beda4..2a91b76 100755 --- a/test/fixtures/fixture-required-function.js +++ b/test/fixtures/required/fixture-required-function.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -7,7 +7,7 @@ const cli = meow({ help: ` Usage foo - `, + `, flags: { trigger: { type: 'boolean', diff --git a/test/fixtures/fixture-required-multiple.js b/test/fixtures/required/fixture-required-multiple.js similarity index 84% rename from test/fixtures/fixture-required-multiple.js rename to test/fixtures/required/fixture-required-multiple.js index f94d534..51439ff 100755 --- a/test/fixtures/fixture-required-multiple.js +++ b/test/fixtures/required/fixture-required-multiple.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -7,7 +7,7 @@ const cli = meow({ help: ` Usage foo - `, + `, flags: { test: { type: 'number', diff --git a/test/fixtures/fixture-required.js b/test/fixtures/required/fixture.js similarity index 89% rename from test/fixtures/fixture-required.js rename to test/fixtures/required/fixture.js index fffb59d..d2f165c 100755 --- a/test/fixtures/fixture-required.js +++ b/test/fixtures/required/fixture.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -import meow from '../../index.js'; +import meow from '../../../source/index.js'; const cli = meow({ importMeta: import.meta, @@ -7,7 +7,7 @@ const cli = meow({ help: ` Usage foo - `, + `, flags: { test: { type: 'string', diff --git a/test/fixtures/with-package-json/fixture.js b/test/fixtures/with-package-json/fixture.js new file mode 100755 index 0000000..b68255c --- /dev/null +++ b/test/fixtures/with-package-json/fixture.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import process from 'node:process'; +import meow from '../../../source/index.js'; + +meow({ + importMeta: import.meta, +}); + +console.log(process.title); diff --git a/test/fixtures/with-package-json/package.json b/test/fixtures/with-package-json/package.json new file mode 100644 index 0000000..e1bfb8d --- /dev/null +++ b/test/fixtures/with-package-json/package.json @@ -0,0 +1,5 @@ +{ + "name": "foo", + "bin": "./fixture.js", + "type": "module" +} diff --git a/test/help.js b/test/help.js new file mode 100644 index 0000000..7fd5492 --- /dev/null +++ b/test/help.js @@ -0,0 +1,46 @@ +import test from 'ava'; +import indentString from 'indent-string'; +import meow from '../source/index.js'; +import {spawnFixture} from './_utils.js'; + +const importMeta = import.meta; + +test('support help shortcut', t => { + t.is(meow(` + unicorn + cat + `, { + importMeta, + }).help, indentString('\nCLI app helper\n\nunicorn\ncat\n', 2)); +}); + +test('spawn cli and show help screen', async t => { + const {stdout} = await spawnFixture(['--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 spawnFixture(['--help', '--no-auto-help']); + t.is(stdout, 'help\nautoHelp\nmeow\ncamelCaseOption'); +}); + +test('spawn cli and not show help', async t => { + const {stdout} = await spawnFixture(['--help=all']); + t.is(stdout, 'help\nmeow\ncamelCaseOption'); +}); + +test('single line help messages are not indented', t => { + t.is(meow({ + importMeta, + description: false, + help: 'single line', + }).help, '\nsingle line\n'); +}); + +test('descriptions with no help are not indented', t => { + t.is(meow({ + importMeta, + help: false, + description: 'single line', + }).help, '\nsingle line\n'); +}); diff --git a/test/is-multiple.js b/test/is-multiple.js new file mode 100644 index 0000000..4b5469d --- /dev/null +++ b/test/is-multiple.js @@ -0,0 +1,211 @@ +import test from 'ava'; +import meow from '../source/index.js'; + +const importMeta = import.meta; + +test('isMultiple - unset flag returns empty array', t => { + t.deepEqual(meow({ + importMeta, + argv: [], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }).flags, { + foo: [], + }); +}); + +test('isMultiple - flag set once returns array', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=bar'], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }).flags, { + foo: ['bar'], + }); +}); + +test('isMultiple - flag set multiple times', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=bar', '--foo=baz'], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }).flags, { + foo: ['bar', 'baz'], + }); +}); + +test('isMultiple - flag with space separated values', t => { + const {input, flags} = meow({ + importMeta, + argv: ['--foo', 'bar', 'baz'], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }); + + t.deepEqual(input, ['baz']); + t.deepEqual(flags.foo, ['bar']); +}); + +test('isMultiple - flag with comma separated values', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo', 'bar,baz'], + flags: { + foo: { + type: 'string', + isMultiple: true, + }, + }, + }).flags, { + foo: ['bar,baz'], + }); +}); + +test('isMultiple - default to type string', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=bar'], + flags: { + foo: { + isMultiple: true, + }, + }, + }).flags, { + foo: ['bar'], + }); +}); + +test('isMultiple - boolean flag', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo', '--foo=false'], + flags: { + foo: { + type: 'boolean', + isMultiple: true, + }, + }, + }).flags, { + foo: [true, false], + }); +}); + +test('isMultiple - boolean flag is false by default', t => { + t.deepEqual(meow({ + importMeta, + argv: [], + flags: { + foo: { + type: 'boolean', + isMultiple: true, + }, + }, + }).flags, { + foo: [false], + }); +}); + +test('isMultiple - number flag', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo=1.3', '--foo=-1'], + flags: { + foo: { + type: 'number', + isMultiple: true, + }, + }, + }).flags, { + foo: [1.3, -1], + }); +}); + +test('isMultiple - flag default values', t => { + t.deepEqual(meow({ + importMeta, + argv: [], + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo'], + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true], + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5], + }, + }, + }).flags, { + string: ['foo'], + boolean: [true], + number: [0.5], + }); +}); + +test('isMultiple - multiple flag default values', t => { + t.deepEqual(meow({ + importMeta, + argv: [], + flags: { + string: { + type: 'string', + isMultiple: true, + default: ['foo', 'bar'], + }, + boolean: { + type: 'boolean', + isMultiple: true, + default: [true, false], + }, + number: { + type: 'number', + isMultiple: true, + default: [0.5, 1], + }, + }, + }).flags, { + string: ['foo', 'bar'], + boolean: [true, false], + number: [0.5, 1], + }); +}); + +// Happened in production 2020-05-10: https://github.com/sindresorhus/meow/pull/143#issuecomment-626287226 +test('isMultiple - handles multi-word flag name', t => { + t.deepEqual(meow({ + importMeta, + argv: ['--foo-bar=baz'], + flags: { + fooBar: { + type: 'string', + isMultiple: true, + }, + }, + }).flags, { + fooBar: ['baz'], + }); +}); diff --git a/test/is-required-flag.js b/test/is-required-flag.js index 25f3c61..f83337d 100644 --- a/test/is-required-flag.js +++ b/test/is-required-flag.js @@ -1,130 +1,111 @@ -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; import test from 'ava'; -import {execa} from 'execa'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -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'); -const fixtureConditionalRequiredMultiplePath = path.join( - __dirname, - 'fixtures', - 'fixture-conditional-required-multiple.js', -); +import {spawnFixture} from './_utils.js'; + +const fixtureFolder = 'required'; + +const required = `${fixtureFolder}/fixture.js`; +const requiredFunction = `${fixtureFolder}/fixture-required-function.js`; +const requiredMultiple = `${fixtureFolder}/fixture-required-multiple.js`; +const conditionalRequiredMultiple = `${fixtureFolder}/fixture-conditional-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.regex(stderr, /--kebab-case/); - t.notRegex(stderr, /--not-required/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(required), + {message: /^Command failed with exit code 2/}, + ); + + t.regex(stderr, /Missing required flag/); + t.regex(stderr, /--test, -t/); + t.regex(stderr, /--number/); + t.regex(stderr, /--kebab-case/); + t.notRegex(stderr, /--not-required/); }); test('spawn cli and test specifying all required flags', async t => { - const {stdout} = await execa(fixtureRequiredPath, [ - '-t', - 'test', - '--number', - '6', - '--kebab-case', - 'test', - ]); + const {stdout} = await spawnFixture(required, ['-t', 'test', '--number', '6', '--kebab-case', 'test']); 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/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(required, ['--test', '']), + {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/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(required, ['--number']), + {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, []); + const {stdout} = await spawnFixture(requiredFunction); 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, /--with-trigger/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(requiredFunction, ['--trigger']), + {message: /^Command failed with exit code 2/}, + ); + + t.regex(stderr, /Missing required flag/); + t.regex(stderr, /--with-trigger/); }); 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']); + const {stdout} = await spawnFixture(requiredFunction, ['--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./); - } + const {stderr} = await t.throwsAsync( + spawnFixture(requiredFunction, ['--allowError', '--shouldError', 'specified']), + {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']); + const {stdout} = await spawnFixture(requiredMultiple, ['--test', '1']); t.is(stdout, '[ 1 ]'); }); test('spawn cli and test isRequired with isMultiple giving multiple values', async t => { - const {stdout} = await execa(fixtureRequiredMultiplePath, ['--test', '1', '--test', '2']); + const {stdout} = await spawnFixture(requiredMultiple, ['--test', '1', '--test', '2']); t.is(stdout, '[ 1, 2 ]'); }); 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/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(requiredMultiple, ['--test']), + {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/); - } + const {stderr} = await t.throwsAsync( + spawnFixture(requiredMultiple), + {message: /^Command failed with exit code 2/}, + ); + + t.regex(stderr, /Missing required flag/); + t.regex(stderr, /--test/); }); test('spawn cli and test isRequire function that returns false with isMultiple given no values, but flag is not given', async t => { - const {stdout} = await execa(fixtureConditionalRequiredMultiplePath, []); + const {stdout} = await spawnFixture(conditionalRequiredMultiple); t.is(stdout, '[]'); }); diff --git a/test/test.js b/test/test.js index 0cedf62..898ed92 100644 --- a/test/test.js +++ b/test/test.js @@ -1,27 +1,15 @@ import path from 'node:path'; import process from 'node:process'; -import {fileURLToPath} from 'node:url'; import test from 'ava'; import indentString from 'indent-string'; -import {stripIndent} from 'common-tags'; import {execa} from 'execa'; import {readPackage} from 'read-pkg'; -import meow from '../index.js'; +import meow from '../source/index.js'; +import {spawnFixture, __dirname} from './_utils.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixturePath = path.join(__dirname, 'fixtures', 'fixture.js'); const importMeta = import.meta; const NODE_MAJOR_VERSION = process.versions.node.split('.')[0]; -test('invalid package url', t => { - const error = t.throws(() => { - meow({ - importMeta: '/path/to/package', - }); - }); - t.is(error.message, 'The `importMeta` option is required. Its value must be `import.meta`.'); -}); - test('return object', t => { const cli = meow({ importMeta, @@ -46,73 +34,54 @@ test('return object', t => { t.is(cli.help, indentString('\nCLI app helper\n\nUsage\n foo \n', 2)); }); -test('support help shortcut', t => { - const cli = meow(` - unicorn - cat - `, { - importMeta, - }); - t.is(cli.help, indentString('\nCLI app helper\n\nunicorn\ncat\n', 2)); -}); - test('spawn cli and show version', async t => { const pkg = await readPackage(); - const {stdout} = await execa(fixturePath, ['--version']); + const {stdout} = await spawnFixture(['--version']); t.is(stdout, pkg.version); }); test('spawn cli and disabled autoVersion and autoHelp', async t => { - const {stdout} = await execa(fixturePath, ['--version', '--help']); + const {stdout} = await spawnFixture(['--version', '--help']); t.is(stdout, 'version\nhelp\nmeow\ncamelCaseOption'); }); test('spawn cli and disabled autoVersion', async t => { - const {stdout} = await execa(fixturePath, ['--version', '--no-auto-version']); + const {stdout} = await spawnFixture(['--version', '--no-auto-version']); t.is(stdout, 'version\nautoVersion\nmeow\ncamelCaseOption'); }); test('spawn cli and not show version', async t => { - const {stdout} = await execa(fixturePath, ['--version=beta']); + const {stdout} = await spawnFixture(['--version=beta']); t.is(stdout, 'version\nmeow\ncamelCaseOption'); }); -test('spawn cli and show help screen', async t => { - 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(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(fixturePath, ['--help=all']); - t.is(stdout, 'help\nmeow\ncamelCaseOption'); -}); - test('spawn cli and test input', async t => { - const {stdout} = await execa(fixturePath, ['-u', 'cat']); + const {stdout} = await spawnFixture(['-u', 'cat']); t.is(stdout, 'unicorn\nmeow\ncamelCaseOption'); }); test('spawn cli and test input flag', async t => { - const {stdout} = await execa(fixturePath, ['--camel-case-option', 'bar']); + const {stdout} = await spawnFixture(['--camel-case-option', 'bar']); t.is(stdout, 'bar'); }); -test.serial.failing('pkg.bin as a string should work', t => { - meow({ +test('spawn cli and test process title', async t => { + const {stdout} = await spawnFixture('with-package-json/fixture.js'); + t.is(stdout, 'foo'); +}); + +test('setting pkg.bin should work', t => { + const cli = meow({ importMeta, pkg: { - importMeta, name: 'browser-sync', bin: './bin/browser-sync.js', }, }); - t.is(process.title, 'browser-sync'); + t.is(cli.pkg.name, 'browser-sync'); + t.is(cli.pkg.version, ''); + t.is(cli.version, undefined); }); test('single character flag casing should be preserved', t => { @@ -122,16 +91,6 @@ test('single character flag casing should be preserved', t => { }).flags, {F: true}); }); -test('flag declared in kebab-case is an error', t => { - const error = t.throws(() => { - meow({ - importMeta, - flags: {'kebab-case': 'boolean', test: 'boolean', 'another-one': 'boolean'}, - }); - }); - t.is(error.message, 'Flag keys may not contain \'-\'. Invalid flags: `kebab-case`, `another-one`'); -}); - test('type inference', t => { t.is(meow({importMeta, argv: ['5']}).input[0], '5'); t.is(meow({importMeta, argv: ['5']}, {input: 'string'}).input[0], '5'); @@ -345,578 +304,6 @@ test('supports `number` flag type - no flag but default', t => { t.is(cli, 2); }); -test('supports `number` flag type - throws on incorrect default value', t => { - t.throws(() => { - meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'number', - default: 'x', - }, - }, - }); - }); -}); - -test('isMultiple - unset flag returns empty array', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: [], - }); -}); - -test('isMultiple - flag set once returns array', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar'], - }); -}); - -test('isMultiple - flag set multiple times', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar', '--foo=baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar', 'baz'], - }); -}); - -test('isMultiple - flag with space separated values', t => { - const {input, flags} = meow({ - importMeta, - argv: ['--foo', 'bar', 'baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }); - - t.deepEqual(input, ['baz']); - t.deepEqual(flags.foo, ['bar']); -}); - -test('isMultiple - flag with comma separated values', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo', 'bar,baz'], - flags: { - foo: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar,baz'], - }); -}); - -test('single flag set more than once => throws', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--foo=bar', '--foo=baz'], - flags: { - foo: { - type: 'string', - }, - }, - }); - }, {message: 'The flag --foo can only be set once.'}); -}); - -test('isMultiple - default to type string', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=bar'], - flags: { - foo: { - isMultiple: true, - }, - }, - }).flags, { - foo: ['bar'], - }); -}); - -test('isMultiple - boolean flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo', '--foo=false'], - flags: { - foo: { - type: 'boolean', - isMultiple: true, - }, - }, - }).flags, { - foo: [true, false], - }); -}); - -test('isMultiple - boolean flag is false by default', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - foo: { - type: 'boolean', - isMultiple: true, - }, - }, - }).flags, { - foo: [false], - }); -}); - -test('isMultiple - number flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=1.3', '--foo=-1'], - flags: { - foo: { - type: 'number', - isMultiple: true, - }, - }, - }).flags, { - foo: [1.3, -1], - }); -}); - -test('isMultiple - flag default values', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - string: { - type: 'string', - isMultiple: true, - default: ['foo'], - }, - boolean: { - type: 'boolean', - isMultiple: true, - default: [true], - }, - number: { - type: 'number', - isMultiple: true, - default: [0.5], - }, - }, - }).flags, { - string: ['foo'], - boolean: [true], - number: [0.5], - }); -}); - -test('isMultiple - multiple flag default values', t => { - t.deepEqual(meow({ - importMeta, - argv: [], - flags: { - string: { - type: 'string', - isMultiple: true, - default: ['foo', 'bar'], - }, - boolean: { - type: 'boolean', - isMultiple: true, - default: [true, false], - }, - number: { - type: 'number', - isMultiple: true, - default: [0.5, 1], - }, - }, - }).flags, { - string: ['foo', 'bar'], - boolean: [true, false], - number: [0.5, 1], - }); -}); - -// Happened in production 2020-05-10: https://github.com/sindresorhus/meow/pull/143#issuecomment-626287226 -test('isMultiple - handles multi-word flag name', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo-bar=baz'], - flags: { - fooBar: { - type: 'string', - isMultiple: true, - }, - }, - }).flags, { - fooBar: ['baz'], - }); -}); - -test('suggests renaming alias to shortFlag', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - foo: { - type: 'string', - alias: 'f', - }, - bar: { - type: 'string', - alias: 'b', - }, - baz: { - type: 'string', - shortFlag: 'z', - }, - }, - }); - }, {message: 'The option `alias` has been renamed to `shortFlag`. The following flags need to be updated: `--foo`, `--bar`'}); -}); - -test('aliases - accepts one', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=baz'], - flags: { - fooBar: { - type: 'string', - aliases: ['foo'], - }, - }, - }).flags, { - fooBar: 'baz', - }); -}); - -test('aliases - accepts multiple', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=baz1', '--bar=baz2'], - flags: { - fooBar: { - type: 'string', - aliases: ['foo', 'bar'], - isMultiple: true, - }, - }, - }).flags, { - fooBar: ['baz1', 'baz2'], - }); -}); - -test('aliases - can be a short flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--f=baz'], - flags: { - fooBar: { - type: 'string', - aliases: ['f'], - }, - }, - }).flags, { - fooBar: 'baz', - }); -}); - -test('aliases - works with short flag', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=baz1', '--bar=baz2', '-f=baz3'], - flags: { - fooBar: { - type: 'string', - shortFlag: 'f', - aliases: ['foo', 'bar'], - isMultiple: true, - }, - }, - }).flags, { - fooBar: ['baz1', 'baz2', 'baz3'], - }); -}); - -test('aliases - unnormalized flags', t => { - t.deepEqual(meow({ - importMeta, - argv: ['--foo=baz'], - flags: { - fooBar: { - type: 'string', - aliases: ['foo'], - shortFlag: 'f', - }, - }, - }).unnormalizedFlags, { - fooBar: 'baz', - foo: 'baz', - f: 'baz', - }); -}); - -test('choices - success case', t => { - const cli = meow({ - importMeta, - argv: ['--animal', 'cat', '--number=2.2'], - flags: { - animal: { - choices: ['dog', 'cat', 'unicorn'], - }, - number: { - type: 'number', - choices: [1.1, 2.2, 3.3], - }, - }, - }); - - t.is(cli.flags.animal, 'cat'); - t.is(cli.flags.number, 2.2); -}); - -test('choices - throws if input does not match choices', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal', 'rainbow', '--number', 5], - flags: { - animal: { - choices: ['dog', 'cat', 'unicorn'], - }, - number: { - choices: [1, 2, 3], - }, - }, - }); - }, { - message: stripIndent` - Unknown value for flag \`--animal\`: \`rainbow\`. Value must be one of: [\`dog\`, \`cat\`, \`unicorn\`] - Unknown value for flag \`--number\`: \`5\`. Value must be one of: [\`1\`, \`2\`, \`3\`] - `, - }); -}); - -test('choices - throws if choices is not array', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal', 'cat'], - flags: { - animal: { - choices: 'cat', - }, - }, - }); - }, {message: 'The option `choices` must be an array. Invalid flags: `--animal`'}); -}); - -test('choices - does not throw error when isRequired is false', t => { - t.notThrows(() => { - meow({ - importMeta, - argv: [], - flags: { - animal: { - isRequired: false, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }); -}); - -test('choices - throw error when isRequired is true', t => { - t.throws(() => { - meow({ - importMeta, - argv: [], - flags: { - animal: { - isRequired: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }, {message: 'Flag `--animal` has no value. Value must be one of: [`dog`, `cat`, `unicorn`]'}); -}); - -test('choices - success with isMultiple', t => { - const cli = meow({ - importMeta, - argv: ['--animal=dog', '--animal=unicorn'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - - t.deepEqual(cli.flags.animal, ['dog', 'unicorn']); -}); - -test('choices - throws with isMultiple, one unknown value', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--animal=rabbit'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['dog', 'cat', 'unicorn'], - }, - }, - }); - }, {message: 'Unknown value for flag `--animal`: `rabbit`. Value must be one of: [`dog`, `cat`, `unicorn`]'}); -}); - -test('choices - throws with isMultiple, multiple unknown value', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--animal=rabbit'], - flags: { - animal: { - type: 'string', - isMultiple: true, - choices: ['cat', 'unicorn'], - }, - }, - }); - }, {message: 'Unknown values for flag `--animal`: `dog`, `rabbit`. Value must be one of: [`cat`, `unicorn`]'}); -}); - -test('choices - throws with multiple flags', t => { - t.throws(() => { - meow({ - importMeta, - argv: ['--animal=dog', '--plant=succulent'], - flags: { - animal: { - type: 'string', - choices: ['cat', 'unicorn'], - }, - plant: { - type: 'string', - choices: ['tree', 'flower'], - }, - }, - }); - }, {message: stripIndent` - Unknown value for flag \`--animal\`: \`dog\`. Value must be one of: [\`cat\`, \`unicorn\`] - Unknown value for flag \`--plant\`: \`succulent\`. Value must be one of: [\`tree\`, \`flower\`] - `}); -}); - -test('choices - choices must be of the same type', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - number: { - type: 'number', - choices: [1, '2'], - }, - boolean: { - type: 'boolean', - choices: [true, 'false'], - }, - }, - }); - }, {message: 'Each value of the option `choices` must be of the same type as its flag. Invalid flags: (`--number`, type: \'number\'), (`--boolean`, type: \'boolean\')'}); -}); - -test('choices - default must only include valid choices', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - number: { - type: 'number', - choices: [1, 2, 3], - default: 1, - }, - }, - }); - }, {message: 'Each value of the option `default` must exist within the option `choices`. Invalid flags: `--number`'}); -}); - -test('options - multiple validation errors', t => { - t.throws(() => { - meow({ - importMeta, - flags: { - animal: { - type: 'string', - choices: 'cat', - }, - plant: { - type: 'string', - alias: 'p', - }, - 'some-thing': { - type: 'string', - }, - }, - }); - }, {message: stripIndent` - Flag keys may not contain '-'. Invalid flags: \`some-thing\` - The option \`alias\` has been renamed to \`shortFlag\`. The following flags need to be updated: \`--plant\` - The option \`choices\` must be an array. Invalid flags: \`--animal\` - `}); -}); - -test('single line help messages are not indented', t => { - const cli = meow({ - importMeta, - description: false, - help: 'single line', - }); - - t.is(cli.help, '\nsingle line\n'); -}); - -test('descriptions with no help are not indented', t => { - const cli = meow({ - importMeta, - help: false, - description: 'single line', - }); - - t.is(cli.help, '\nsingle line\n'); -}); - if (NODE_MAJOR_VERSION >= 14) { test('supports es modules', async t => { try { diff --git a/tsconfig.json b/tsconfig.json index c848130..f26f355 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,9 +6,9 @@ "compilerOptions": { "strict": true, "jsx": "react", - "target": "ES2019", // Node.js 12 + "target": "ES2020", // Node.js 14 "lib": [ - "ES2019" + "ES2020" ], "module": "ES2020", "moduleResolution": "node",