├── .github └── workflows │ └── ci-module.yml ├── .gitignore ├── API.md ├── LICENSE.md ├── README.md ├── lib ├── index.js └── schemas.js ├── package.json └── test └── index.js /.github/workflows/ci-module.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | uses: hapijs/.github/.github/workflows/ci-module.yml@master 13 | with: 14 | min-node-version: 14 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | 2 | ## Usage 3 | 4 | ```js 5 | const Bossy = require('@hapi/bossy'); 6 | 7 | const definition = { 8 | h: { 9 | description: 'Show help', 10 | alias: 'help', 11 | type: 'boolean' 12 | }, 13 | n: { 14 | description: 'Show your name', 15 | alias: 'name' 16 | } 17 | }; 18 | 19 | const args = Bossy.parse(definition); 20 | 21 | if (args instanceof Error) { 22 | console.error(args.message); 23 | return; 24 | } 25 | 26 | if (args.h || !args.n) { 27 | console.log(Bossy.usage(definition, 'hello -n ')); 28 | return; 29 | } 30 | 31 | console.log('Hello ' + args.n); 32 | console.log('Hello ' + args.name); 33 | ``` 34 | 35 | ## Methods 36 | 37 | ### `parse(definition, [options])` 38 | 39 | Expects a *bossy* definition object and will return the parsed `process.argv` arguments provided. If there is an error 40 | then the return value will be an `instanceof Error`. 41 | 42 | Options accepts the following keys: 43 | * `argv` - custom argv array value. Defaults to process.argv. 44 | 45 | ### `usage(definition, [usage], [options])` 46 | 47 | Format a *bossy* definition object for display in the console. If `usage` is provided the returned value will 48 | include the usage value formatted at the top of the message. 49 | 50 | Options accepts the following keys: 51 | * `colors` - Determines if colors are enabled when formatting usage. Defaults to whatever TTY supports. 52 | 53 | ### `object(name, parsed)` 54 | 55 | Un-flattens dot-separated arguments based at `name` from `Bossy.parse()`'s output into an object. 56 | 57 | ```js 58 | const Bossy = require('@hapi/bossy'); 59 | 60 | const definition = { 61 | 'pet.name': { 62 | type: 'string' 63 | }, 64 | 'pet.age': { 65 | type: 'number' 66 | } 67 | }; 68 | 69 | // Example CLI args: --pet.name Maddie --pet.age 5 70 | 71 | const parsed = Bossy.parse(definition); // { 'pet.name': 'Maddie', 'pet.age': 5 } 72 | 73 | if (parsed instanceof Error) { 74 | console.error(parsed.message); 75 | return; 76 | } 77 | 78 | const pet = Bossy.object('pet', parsed); // { name: 'Maddie', age: 5 } 79 | ``` 80 | 81 | ## Definition Object 82 | 83 | The definition object should be structured with each object key representing the short form of an available command 84 | line argument. Each argument key supports the following properties: 85 | 86 | * `alias`: A string or array of strings that can also be used as the argument name. For example: 87 | 88 | ```js 89 | h: { 90 | alias: 'help' 91 | } 92 | ``` 93 | 94 | * `type`: Available types are: `boolean`, `range`, `number`, `string`, `json`, and `help`. Defaults to `string`. 95 | 96 | The `boolean` type may be negated by passing its argument prefixed with `no-`. 97 | For example, if the command line argument is named `color` then `--color` would 98 | ensure the boolean is `true` and `--no-color` would ensure it is `false`. 99 | 100 | `help` is a special type that allows the switch to be executed even though 101 | other paramters are required. Use case is to display a help message and 102 | quit. This will bypass all other errors, so be sure to capture it. It 103 | behaves like a `boolean`. 104 | 105 | The `json` type allows building an object using command line arguments that utilize 106 | dot-separated (`.`) paths and JSON values. For example, an object argument named 107 | `pet` might be built from `--pet '{ "type": "dog" }' --pet.name Maddie`, resulting in 108 | the parsing output `{ pet: { type: 'dog', name: 'Maddie' } }`. The contents of the 109 | flags are deeply merged together in the order they were specified. Additionally, 110 | JSON primitives (i.e. `null`, booleans, and numbers) and non-JSON are treated as strings 111 | by default, though this behavior may be controlled with the `parsePrimitives` option 112 | documented below. The following example demonstrates the default behavior: 113 | 114 | ```sh 115 | # CLI input 116 | create-pet --pet.type kangaroo --pet.legs 2 --pet.mammal true \ 117 | --pet '{ "name": "Maddie", "type": "dog" }' --pet.legs 4 118 | ``` 119 | ```js 120 | // Parsing output 121 | { pet: { name: 'Maddie', type: 'dog', legs: '4', mammal: 'true' } } 122 | ``` 123 | 124 | * `multiple` : Boolean to indicate if the same argument can be provided multiple times. If true, the parsed value 125 | will always be an array of `type`'s. Defaults to `false`. Does not apply to `json` type arguments. 126 | 127 | * `description`: Description message that will be returned with usage information. 128 | 129 | * `require`: Boolean to indicate if the argument is required. Defaults to `false` 130 | 131 | * `default`: A default value to assign to the argument if its not provided as an argument. 132 | 133 | * `valid`: A value or array of values that the argument is allowed to equal. Does not apply to `json` type arguments. 134 | 135 | * `parsePrimitives`: A value of `false`, `true`, or `'strict'` used to control the treatment of input to `json` type arguments. Defaults to `false`. Each of the settings are described below: 136 | 137 | - `false` - JSON primitives (i.e. `null`, booleans, and numbers) are treated as strings, non-JSON input is interpreted as a string, and the input may be a JSON array or object. This is the default behavior. 138 | 139 | ```sh 140 | # CLI input 141 | create-pet --pet.type kangaroo --pet.legs 2 --pet.mammal true \ 142 | --pet '{ "name": "Maddie", "type": "dog" }' --pet.legs 4 143 | ``` 144 | ```js 145 | // Parsing output 146 | { pet: { name: 'Maddie', type: 'dog', legs: '4', mammal: 'true' } } 147 | ``` 148 | 149 | - `true` - JSON primitives are parsed, non-JSON input is interpreted as a string, and the input may be a JSON array or object. 150 | 151 | For example, when `parsePrimitives` is `false`, `--pet.name null` will result in the output `{ pet: { name: 'null' } }`. However, when `parsePrimitives` is `true`, the same input would result in the output `{ pet: { name: null } }`. The same applies for other JSON primitives too, i.e. booleans and numbers. When this option is `true`, users may represent string values as JSON in order to avoid ambiguity, e.g. `--pet.name '"null"'`. It's recommended that applications using this option document the behavior for their users. 152 | 153 | ```sh 154 | # CLI input 155 | create-pet --pet.type kangaroo --pet.legs 2 --pet.mammal true \ 156 | --pet '{ "name": "Maddie", "type": "dog" }' --pet.legs 4 157 | ``` 158 | ```js 159 | // Parsing output 160 | { pet: { name: 'Maddie', type: 'dog', legs: 4, mammal: true } } 161 | ``` 162 | 163 | - `'strict'` - JSON primitives are parsed, non-JSON input is not allowed, and the input may not be a JSON array or object. In other words, the user may only set primitive values, and they are required to be valid JSON. 164 | 165 | When this option is used, users must represent string values as JSON, e.g. `--pet.name '"Maddie"'`. It's recommended that applications using this option document the behavior for their users. 166 | 167 | ```sh 168 | # CLI input 169 | create-pet --pet.type '"kangaroo"' --pet.legs 2 --pet.mammal true 170 | ``` 171 | ```js 172 | // Parsing output 173 | { pet: { type: 'kangaroo', legs: 2, mammal: true } } 174 | ``` 175 | 176 | The following input would result in an error because the input to `--pet.type` is invalid JSON: 177 | 178 | ```sh 179 | # CLI input 180 | create-pet --pet.type kangaroo --pet.legs 2 --pet.mammal true 181 | ``` 182 | 183 | The following input would result in an error because the input to `--pet` does not represent a JSON primitive: 184 | 185 | ```sh 186 | # CLI input 187 | create-pet --pet '{ "name": "Maddie", "type": "dog" }' --pet.type '"kangaroo"' 188 | ``` 189 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2022, Project contributors 2 | Copyright (c) 2014-2020, Sideway Inc 3 | Copyright (c) 2014, Walmart. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @hapi/bossy 4 | 5 | #### Command line options parser. 6 | 7 | **bossy** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. 8 | 9 | ### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support 10 | 11 | ## Useful resources 12 | 13 | - [Documentation and API](https://hapi.dev/family/bossy/) 14 | - [Version status](https://hapi.dev/resources/status/#bossy) (builds, dependencies, node versions, licenses, eol) 15 | - [Changelog](https://hapi.dev/family/bossy/changelog/) 16 | - [Project policies](https://hapi.dev/policies/) 17 | - [Free and commercial support options](https://hapi.dev/support/) 18 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Tty = require('tty'); 4 | 5 | const Boom = require('@hapi/boom'); 6 | const Hoek = require('@hapi/hoek'); 7 | const Bounce = require('@hapi/bounce'); 8 | const Bourne = require('@hapi/bourne'); 9 | const Validate = require('@hapi/validate'); 10 | 11 | const Schemas = require('./schemas'); 12 | 13 | 14 | const internals = {}; 15 | 16 | 17 | exports.parse = function (definition, options) { 18 | 19 | Validate.assert(definition, Schemas.definition, 'Invalid definition:'); 20 | Validate.assert(options, Schemas.parseOptions, 'Invalid options argument:'); 21 | 22 | const flags = {}; 23 | const keys = {}; 24 | definition = Schemas.definition.validate(definition).value; 25 | options = options || {}; 26 | 27 | const names = Object.keys(definition); 28 | for (let i = 0; i < names.length; ++i) { 29 | const name = names[i]; 30 | const def = Hoek.clone(definition[name]); 31 | def.name = name; 32 | keys[name] = def; 33 | if (def.alias) { 34 | for (let j = 0; j < def.alias.length; ++j) { 35 | keys[def.alias[j]] = def; 36 | } 37 | } 38 | 39 | if ((def.type === 'boolean' || def.type === 'json') && def.default !== undefined) { 40 | flags[name] = def.default; 41 | } 42 | else if (def.type === 'boolean') { 43 | flags[name] = false; 44 | } 45 | } 46 | 47 | const args = options.argv ?? process.argv.slice(2); 48 | let last = null; 49 | const errors = []; 50 | let help = false; 51 | 52 | for (let i = 0; i < args.length; ++i) { 53 | const arg = args[i]; 54 | if (arg[0] === '-') { 55 | 56 | // Key 57 | 58 | const char = arg[1]; 59 | if (!char) { 60 | errors.push(internals.formatError('Invalid empty \'-\' option')); 61 | continue; 62 | } 63 | 64 | if (char === '-' && arg.length <= 2) { 65 | errors.push(internals.formatError('Invalid empty \'--\' option')); 66 | continue; 67 | } 68 | 69 | const opts = (char === '-' ? [arg.slice(2)] : arg.slice(1).split('')); 70 | for (let j = 0; j < opts.length; ++j) { 71 | 72 | if (last) { 73 | errors.push(internals.formatError('Invalid option:', last.def.name, 'missing value')); 74 | continue; 75 | } 76 | 77 | const opt = opts[j]; 78 | 79 | let booleanNegationDef; 80 | 81 | if (opt.startsWith('no-')) { 82 | const maybeDef = keys[opt.replace('no-', '')]; 83 | if (maybeDef?.type === 'boolean') { 84 | booleanNegationDef = maybeDef; 85 | } 86 | } 87 | 88 | let jsonDef; 89 | 90 | if (opt.includes('.')) { 91 | const maybeDef = keys[opt.split('.')[0]]; 92 | if (maybeDef?.type === 'json') { 93 | jsonDef = maybeDef; 94 | } 95 | } 96 | 97 | const def = keys[opt] ?? booleanNegationDef ?? jsonDef; 98 | 99 | if (!def) { 100 | errors.push(internals.formatError('Unknown option:', opt)); 101 | continue; 102 | } 103 | 104 | if (def.type === 'help') { 105 | flags[def.name] = true; 106 | help = true; 107 | } 108 | else if (def.type === 'boolean') { 109 | flags[def.name] = def !== booleanNegationDef; 110 | } 111 | else if (def.type === 'number' && 112 | opts.length > 1) { 113 | 114 | args.splice(i + 1, 0, arg.split(char)[1]); 115 | last = { def, opt }; 116 | break; 117 | } 118 | else { 119 | last = { def, opt }; 120 | } 121 | } 122 | } 123 | else { 124 | 125 | // Value 126 | 127 | let value = arg; 128 | if (last) { 129 | if (last.def.type === 'number') { 130 | value = parseInt(arg, 10); 131 | 132 | if (!Number.isSafeInteger(value)) { 133 | errors.push(internals.formatError('Invalid value (non-number) for option:', last.def.name)); 134 | continue; 135 | } 136 | } 137 | 138 | if (last.def.valid && 139 | !last.def.valid.includes(value)) { 140 | 141 | const validValues = []; 142 | for (let j = 0; j < last.def.valid.length; ++j) { 143 | const valid = last.def.valid[j]; 144 | validValues.push(`'${valid}'`); 145 | } 146 | 147 | errors.push(internals.formatError('Invalid value for option:', last.def.name, '(valid: ' + validValues.join(',') + ')')); 148 | continue; 149 | } 150 | 151 | if (last.def.type === 'json') { 152 | 153 | const stringValue = value; 154 | 155 | try { 156 | value = Bourne.parse(value, { // E.g. { x } 157 | protoAction: 'remove' 158 | }); 159 | } 160 | catch (err) { 161 | // If the input doesn't look like JSON, we'll ignore that 162 | // and treat it as a string, unless parsePrimitives is 'strict' 163 | Bounce.ignore(err, SyntaxError); 164 | if (last.def.parsePrimitives === 'strict') { 165 | errors.push(internals.formatError('Invalid value for option:', last.opt, '(invalid JSON)')); 166 | continue; 167 | } 168 | } 169 | 170 | const isPrimitive = !value || typeof value !== 'object'; 171 | 172 | if (last.def.parsePrimitives === 'strict' && !isPrimitive) { 173 | errors.push(internals.formatError('Invalid value for option:', last.opt, '(non-primitive JSON value)')); 174 | continue; 175 | } 176 | 177 | if (!last.def.parsePrimitives && isPrimitive) { 178 | // When receiving JSON that parses to a non-object, treat it as a string, i.e. numbers, true, false, null. 179 | value = stringValue; 180 | } 181 | 182 | value = internals.valueAtArgPath(last.opt, value); 183 | 184 | if (!value || typeof value !== 'object') { 185 | errors.push(internals.formatError('Invalid value for option:', last.def.name, '(must be an object or array)')); 186 | continue; 187 | } 188 | 189 | // Necessary to protect from prototype poisoning in dot-separated path e.g. `--x.__proto__.y 1` 190 | Bourne.scan(value, { protoAction: 'remove' }); 191 | } 192 | } 193 | 194 | const name = last ? last.def.name : '_'; 195 | if (flags.hasOwnProperty(name)) { 196 | 197 | if (!last || 198 | last.def.multiple) { 199 | 200 | flags[name].push(value); 201 | } 202 | else if (last.def.type === 'json') { 203 | Hoek.merge(flags[name], value); 204 | } 205 | else { 206 | errors.push(internals.formatError('Multiple values are not allowed for option:', name)); 207 | continue; 208 | } 209 | } 210 | else { 211 | if (!last || 212 | last.def.multiple) { 213 | 214 | flags[name] = [].concat(value); 215 | } 216 | else { 217 | flags[name] = value; 218 | } 219 | } 220 | 221 | last = null; 222 | } 223 | } 224 | 225 | for (let i = 0; i < names.length; ++i) { 226 | const def = keys[names[i]]; 227 | if (def.type === 'range') { 228 | internals.parseRange(def, flags); 229 | } 230 | 231 | if (flags[def.name] === undefined) { 232 | flags[def.name] = def.default; 233 | } 234 | 235 | if (def.require && flags[def.name] === undefined) { 236 | errors.push(internals.formatError(definition)); 237 | } 238 | 239 | if (def.alias) { 240 | for (let j = 0; j < def.alias.length; ++j) { 241 | const alias = def.alias[j]; 242 | flags[alias] = flags[def.name]; 243 | } 244 | } 245 | } 246 | 247 | if (errors.length && !help) { 248 | return errors[0]; 249 | } 250 | 251 | return flags; 252 | }; 253 | 254 | 255 | exports.usage = function (definition, usage, options) { 256 | 257 | if ((arguments.length === 2) && (typeof usage === 'object')) { 258 | options = usage; 259 | usage = ''; 260 | } 261 | 262 | Validate.assert(definition, Schemas.definition, 'Invalid definition:'); 263 | Validate.assert(options, Schemas.usageOptions, 'Invalid options argument:'); 264 | 265 | definition = Schemas.definition.validate(definition).value; 266 | options = Schemas.usageOptions.validate(options || { colors: null }).value; 267 | const color = internals.colors(options.colors); 268 | const output = usage ? 'Usage: ' + usage + '\n\n' : '\n'; 269 | const col1 = ['Options:']; 270 | const col2 = ['\n']; 271 | 272 | const names = Object.keys(definition); 273 | for (let i = 0; i < names.length; ++i) { 274 | const name = names[i]; 275 | const def = definition[name]; 276 | 277 | let shortName = internals.getShortName(name, def.alias); 278 | let longName = (shortName === name) ? def.alias : name; 279 | if (!longName && 280 | shortName.length > 1) { 281 | 282 | longName = shortName; 283 | shortName = ''; 284 | } 285 | 286 | let formattedName = shortName ? ' -' + shortName : ''; 287 | if (longName) { 288 | const aliases = [].concat(longName); 289 | for (let j = 0; j < aliases.length; ++j) { 290 | formattedName += shortName ? ', ' : ' '; 291 | formattedName += '--' + aliases[j]; 292 | } 293 | } 294 | 295 | let formattedDesc = def.description ? color.gray(def.description) : ''; 296 | if (def.default || def.default === 0) { 297 | formattedDesc += formattedDesc.length ? ' ' : ''; 298 | formattedDesc += def.type === 'json' ? 299 | color.gray('(' + JSON.stringify(def.default) + ')') : 300 | color.gray('(' + def.default + ')'); 301 | } 302 | 303 | if (def.require) { 304 | formattedDesc += formattedDesc.length ? ' ' : ''; 305 | formattedDesc += color.yellow('(required)'); 306 | } 307 | 308 | col1.push(color.green(formattedName)); 309 | col2.push(formattedDesc); 310 | } 311 | 312 | return output + internals.formatColumns(col1, col2); 313 | }; 314 | 315 | 316 | exports.object = (opt, parsed) => { 317 | 318 | Hoek.assert(!opt.includes('.'), `Cannot build an object at a deep path: ${opt} (contains a dot)`); 319 | 320 | const initial = parsed.hasOwnProperty(opt) ? parsed[opt] : {}; 321 | const depth = (path) => path.split('.').length; 322 | 323 | return Object.entries(parsed) 324 | .filter(([key]) => key.startsWith(`${opt}.`)) 325 | .sort(([keyA], [keyB]) => depth(keyA) - depth(keyB)) // Shallow to deep 326 | .reduce((collect, [key, val]) => { 327 | 328 | return Hoek.applyToDefaults(collect, internals.valueAtArgPath(key, val)); 329 | }, initial); 330 | }; 331 | 332 | 333 | internals.formatError = function (...args) { 334 | 335 | let msg = ''; 336 | if (args.length > 1) { 337 | msg = args.join(' '); 338 | } 339 | else if (typeof args[0] === 'string') { 340 | msg = args[0]; 341 | } 342 | else { 343 | msg = exports.usage(args[0]); 344 | } 345 | 346 | return new Boom.Boom(msg); 347 | }; 348 | 349 | 350 | internals.getShortName = function (shortName, aliases) { 351 | 352 | if (!aliases) { 353 | return shortName; 354 | } 355 | 356 | for (let i = 0; i < aliases.length; ++i) { 357 | if (aliases[i] && aliases[i].length < shortName.length) { 358 | shortName = aliases[i]; 359 | } 360 | } 361 | 362 | return shortName; 363 | }; 364 | 365 | 366 | internals.formatColumns = function (col1, col2) { 367 | 368 | const rows = []; 369 | let col1Width = 0; 370 | col1.forEach((text) => { 371 | 372 | if (text.length > col1Width) { 373 | col1Width = text.length; 374 | } 375 | }); 376 | 377 | for (let i = 0; i < col1.length; ++i) { 378 | let row = col1[i]; 379 | const padding = new Array((col1Width - row.length) + 5).join(' '); 380 | 381 | row += padding + col2[i]; 382 | rows.push(row); 383 | } 384 | 385 | return rows.join('\n'); 386 | }; 387 | 388 | 389 | internals.parseRange = function (def, flags) { 390 | 391 | const value = flags[def.name]; 392 | if (!value) { 393 | return; 394 | } 395 | 396 | const values = []; 397 | const nums = [].concat(value).join(','); 398 | const ranges = nums.match(/(?:\d+\-\d+)|(?:\d+)/g); 399 | for (let i = 0; i < ranges.length; ++i) { 400 | let range = ranges[i]; 401 | 402 | range = range.split('-'); 403 | const from = parseInt(range[0], 10); 404 | if (range.length === 2) { 405 | const to = parseInt(range[1], 10); 406 | if (from > to) { 407 | for (let j = from; j >= to; --j) { 408 | values.push(j); 409 | } 410 | } 411 | else { 412 | for (let j = from; j <= to; ++j) { 413 | values.push(j); 414 | } 415 | } 416 | } 417 | else { 418 | values.push(from); 419 | } 420 | } 421 | 422 | flags[def.name] = values; 423 | }; 424 | 425 | 426 | internals.colors = function (enabled) { 427 | 428 | if (enabled === null) { 429 | enabled = Tty.isatty(1) && Tty.isatty(2); 430 | } 431 | 432 | const codes = { 433 | 'black': 0, 434 | 'gray': 90, 435 | 'red': 31, 436 | 'green': 32, 437 | 'yellow': 33, 438 | 'magenta': 35, 439 | 'redBg': 41, 440 | 'greenBg': 42 441 | }; 442 | 443 | const colors = {}; 444 | const names = Object.keys(codes); 445 | for (let i = 0; i < names.length; ++i) { 446 | const name = names[i]; 447 | colors[name] = internals.color(name, codes[name], enabled); 448 | } 449 | 450 | return colors; 451 | }; 452 | 453 | 454 | internals.color = function (name, code, enabled) { 455 | 456 | if (enabled) { 457 | const color = '\u001b[' + code + 'm'; 458 | return function colorFormat(text) { 459 | 460 | return color + text + '\u001b[0m'; 461 | }; 462 | } 463 | 464 | return function plainFormat(text) { 465 | 466 | return text; 467 | }; 468 | }; 469 | 470 | internals.valueAtArgPath = (path, value) => { 471 | 472 | path.split('.') // E.g. [name, lvl1, lvl2] 473 | .slice(1) // E.g. [lvl1, lvl2] 474 | .reverse() // E.g. [lvl2, lvl1] 475 | .forEach((key) => { // E.g. { x } --> { lvl2: { x } } --> { lvl1: { lvl2: { x } } } 476 | 477 | value = { [key]: value }; 478 | }); 479 | 480 | return value; 481 | }; 482 | -------------------------------------------------------------------------------- /lib/schemas.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Validate = require('@hapi/validate'); 4 | 5 | 6 | const internals = { 7 | validKeyRegex: /^[a-zA-Z0-9][a-zA-Z0-9-\.]*$/ 8 | }; 9 | 10 | 11 | exports.definition = Validate.object({}).pattern(internals.validKeyRegex, Validate.object({ 12 | alias: Validate.array().items(Validate.string().allow('')).single(), 13 | type: Validate.string().valid('json', 'boolean', 'range', 'number', 'string', 'help').default('string'), 14 | multiple: Validate.boolean() 15 | .when('type', { is: 'json', then: Validate.forbidden() }), 16 | description: Validate.string(), 17 | require: Validate.boolean(), 18 | default: Validate.any() 19 | .when('type', { is: 'json', then: [Validate.array(), Validate.object()] }), 20 | valid: Validate.array().items(Validate.any()).single() 21 | .when('type', { is: 'json', then: Validate.forbidden() }), 22 | parsePrimitives: Validate.boolean().allow('strict') 23 | .when('type', { is: 'json', otherwise: Validate.forbidden() }) 24 | }), { fallthrough: true }) 25 | .pattern(/\./, Validate.object({ type: Validate.invalid('json') })); 26 | 27 | 28 | exports.parseOptions = Validate.object({ 29 | argv: Validate.array().items(Validate.string()) 30 | }); 31 | 32 | 33 | exports.usageOptions = Validate.object({ 34 | colors: Validate.boolean().allow(null) 35 | }); 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hapi/bossy", 3 | "description": "Command line options parser", 4 | "version": "6.0.1", 5 | "repository": "git://github.com/hapijs/bossy", 6 | "main": "lib/index.js", 7 | "files": [ 8 | "lib" 9 | ], 10 | "keywords": [ 11 | "cli", 12 | "command line", 13 | "options", 14 | "parser" 15 | ], 16 | "eslintConfig": { 17 | "extends": [ 18 | "plugin:@hapi/module" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@hapi/boom": "^10.0.1", 23 | "@hapi/bounce": "^3.0.1", 24 | "@hapi/bourne": "^3.0.0", 25 | "@hapi/hoek": "^11.0.2", 26 | "@hapi/validate": "^2.0.1" 27 | }, 28 | "devDependencies": { 29 | "@hapi/code": "^9.0.3", 30 | "@hapi/eslint-plugin": "^6.0.0", 31 | "@hapi/lab": "^25.1.2" 32 | }, 33 | "scripts": { 34 | "test": "lab -a @hapi/code -t 100 -L", 35 | "test-cov-html": "lab -a @hapi/code -r html -o coverage.html" 36 | }, 37 | "license": "BSD-3-Clause" 38 | } 39 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Tty = require('tty'); 4 | 5 | const Bossy = require('../'); 6 | const Hoek = require('@hapi/hoek'); 7 | const Code = require('@hapi/code'); 8 | const Lab = require('@hapi/lab'); 9 | 10 | 11 | const { describe, it } = exports.lab = Lab.script(); 12 | const expect = Code.expect; 13 | 14 | 15 | describe('parse()', () => { 16 | 17 | const parse = function (line, definition, options) { 18 | 19 | const orig = process.argv; 20 | process.argv = [].concat('ignore', 'ignore', Array.isArray(line) ? line : line.split(' ')); 21 | const result = Bossy.parse(definition, options); 22 | process.argv = orig; 23 | return result; 24 | }; 25 | 26 | it('parses command line', () => { 27 | 28 | const line = '-a -cb --aa -C 1 -C42 -d x -d 2 -e 1-4,6-7 --i.x 2 --i.y.z one -f arg1 arg2 arg3'; 29 | const definition = { 30 | a: { 31 | type: 'boolean' 32 | }, 33 | A: { 34 | alias: 'aa', 35 | type: 'boolean' 36 | }, 37 | b: { 38 | type: 'boolean' 39 | }, 40 | c: { 41 | type: 'boolean', 42 | require: true 43 | }, 44 | C: { 45 | type: 'number', 46 | multiple: true 47 | }, 48 | d: { 49 | type: 'string', 50 | multiple: true 51 | }, 52 | e: { 53 | type: 'range' 54 | }, 55 | f: { 56 | 57 | }, 58 | g: { 59 | type: 'boolean' 60 | }, 61 | h: { 62 | type: 'string', 63 | default: 'hello', 64 | alias: 'H' 65 | }, 66 | i: { 67 | type: 'json', 68 | default: { x: 1, w: 3 } 69 | } 70 | }; 71 | 72 | const argv = parse(line, definition); 73 | expect(argv).to.not.be.instanceof(Error); 74 | expect(argv).to.equal({ a: true, 75 | A: true, 76 | b: true, 77 | c: true, 78 | g: false, 79 | C: [1, 42], 80 | d: ['x', '2'], 81 | e: [1, 2, 3, 4, 6, 7], 82 | f: 'arg1', 83 | _: ['arg2', 'arg3'], 84 | aa: true, 85 | h: 'hello', 86 | H: 'hello', 87 | i: { x: '2', y: { z: 'one' }, w: 3 } 88 | }); 89 | }); 90 | 91 | it('copies values into all of a key\'s aliases', () => { 92 | 93 | const line = '--path ./usr/home/bin -c -T 1-4,6-7 --time 9000'; 94 | const definition = { 95 | p: { 96 | alias: ['path', 'Path', '$PATH'] 97 | }, 98 | c: { 99 | alias: 'command', 100 | type: 'boolean' 101 | }, 102 | C: { 103 | type: 'number', 104 | alias: ['change', 'time'] 105 | }, 106 | t: { 107 | type: 'range', 108 | alias: ['T', 'tes'] 109 | }, 110 | h: { 111 | type: 'string', 112 | default: 'hello', 113 | alias: 'H' 114 | } 115 | }; 116 | 117 | const argv = parse(line, definition); 118 | expect(argv).to.not.be.instanceof(Error); 119 | expect(argv).to.equal({ 120 | c: true, 121 | p: './usr/home/bin', 122 | t: [1, 2, 3, 4, 6, 7], 123 | path: './usr/home/bin', 124 | Path: './usr/home/bin', 125 | '$PATH': './usr/home/bin', 126 | C: 9000, 127 | change: 9000, 128 | command: true, 129 | time: 9000, 130 | T: [1, 2, 3, 4, 6, 7], 131 | tes: [1, 2, 3, 4, 6, 7], 132 | h: 'hello', 133 | H: 'hello' 134 | }); 135 | }); 136 | 137 | it('does not return message when required parameter is missing if type help is being executed', () => { 138 | 139 | const line = '--try -q -h'; 140 | const definition = { 141 | h: { 142 | type: 'help' 143 | }, 144 | b: { 145 | type: 'number', 146 | require: true 147 | } 148 | }; 149 | 150 | const argv = parse(line, definition); 151 | expect(argv.h).to.equal(true); 152 | }); 153 | 154 | it('returns error message when required parameter is missing', () => { 155 | 156 | const line = '-a'; 157 | const definition = { 158 | a: { 159 | type: 'boolean' 160 | }, 161 | b: { 162 | type: 'number', 163 | require: true 164 | } 165 | }; 166 | 167 | const argv = parse(line, definition); 168 | expect(argv).to.be.instanceof(Error); 169 | }); 170 | 171 | it('returns list of valid options for multple options', () => { 172 | 173 | const line = '-a rezero'; 174 | const definition = { 175 | a: { 176 | type: 'string', 177 | valid: ['steins;gate','erased','death note'] 178 | } 179 | }; 180 | 181 | const argv = parse(line, definition); 182 | expect(argv.message).to.include('steins;gate'); 183 | expect(argv.message).to.include('erased'); 184 | expect(argv.message).to.include('death note'); 185 | expect(argv).to.be.instanceof(Error); 186 | }); 187 | 188 | it('returns error message when an unknown argument is used', () => { 189 | 190 | const line = '-ac'; 191 | const definition = { 192 | a: { 193 | type: 'boolean' 194 | } 195 | }; 196 | 197 | const argv = parse(line, definition); 198 | expect(argv).to.be.instanceof(Error); 199 | }); 200 | 201 | it('returns error message when an empty - is passed', () => { 202 | 203 | const line = '-'; 204 | const definition = { 205 | a: { 206 | type: 'boolean' 207 | } 208 | }; 209 | 210 | const argv = parse(line, definition); 211 | expect(argv).to.be.instanceof(Error); 212 | }); 213 | 214 | it('returns error message when an empty -- is passed', () => { 215 | 216 | const line = '--'; 217 | const definition = { 218 | a: { 219 | type: 'boolean' 220 | } 221 | }; 222 | 223 | const argv = parse(line, definition); 224 | expect(argv).to.be.instanceof(Error); 225 | }); 226 | 227 | it('returns error message when an empty value is passed', () => { 228 | 229 | const line = '-b -a'; 230 | const definition = { 231 | a: { 232 | type: 'string' 233 | }, 234 | b: { 235 | type: 'string' 236 | } 237 | }; 238 | 239 | const argv = parse(line, definition); 240 | expect(argv).to.be.instanceof(Error); 241 | }); 242 | 243 | it('returns error message when a non-number value is passed for a number argument', () => { 244 | 245 | const line = '-a hi'; 246 | const definition = { 247 | a: { 248 | type: 'number' 249 | } 250 | }; 251 | 252 | const argv = parse(line, definition); 253 | expect(argv).to.be.instanceof(Error); 254 | }); 255 | 256 | it('returns undefined when an empty value is passed for a range', () => { 257 | 258 | const line = '-a'; 259 | const definition = { 260 | a: { 261 | type: 'range' 262 | } 263 | }; 264 | 265 | const argv = parse(line, definition); 266 | expect(argv).to.equal({ a: undefined }); 267 | }); 268 | 269 | it('is able to parse a range plus an additional number', () => { 270 | 271 | const line = '-a 1-2,5'; 272 | const definition = { 273 | a: { 274 | type: 'range' 275 | } 276 | }; 277 | 278 | const argv = parse(line, definition); 279 | expect(argv).to.equal({ a: [1, 2, 5] }); 280 | }); 281 | 282 | it('is able to parse a range in reverse order', () => { 283 | 284 | const line = '-a 5-1'; 285 | const definition = { 286 | a: { 287 | type: 'range' 288 | } 289 | }; 290 | 291 | const argv = parse(line, definition); 292 | expect(argv).to.equal({ a: [5, 4, 3, 2, 1] }); 293 | }); 294 | 295 | it('allows a boolean to be defaulted to null', () => { 296 | 297 | const line = ''; 298 | const definition = { 299 | a: { 300 | type: 'boolean', 301 | default: null 302 | } 303 | }; 304 | 305 | const argv = parse(line, definition); 306 | expect(argv).to.equal({ a: null, _: [''] }); 307 | }); 308 | 309 | it('allows a boolean to be negated', () => { 310 | 311 | const line = '--no-a'; 312 | const definition = { 313 | a: { 314 | type: 'boolean', 315 | default: true 316 | } 317 | }; 318 | 319 | const argv = parse(line, definition); 320 | expect(argv).to.equal({ a: false }); 321 | }); 322 | 323 | it('allows a boolean that has already been passed to be negated and vice-versa', () => { 324 | 325 | const definition = { 326 | a: { 327 | type: 'boolean' 328 | } 329 | }; 330 | 331 | const argv1 = parse('-a --no-a', definition); 332 | expect(argv1).to.equal({ a: false }); 333 | 334 | const argv2 = parse('--no-a -a', definition); 335 | expect(argv2).to.equal({ a: true }); 336 | }); 337 | 338 | it('doesn\'t assume "no-" to denote boolean negation', () => { 339 | 340 | const line = '--no-a'; 341 | const definition = { 342 | 'no-a': { 343 | type: 'boolean' 344 | } 345 | }; 346 | 347 | const argv = parse(line, definition); 348 | expect(argv).to.equal({ 'no-a': true }); 349 | }); 350 | 351 | it('only negates booleans', () => { 352 | 353 | const line = '--no-a'; 354 | const definition = { 355 | a: { 356 | type: 'string' 357 | } 358 | }; 359 | 360 | const argv = parse(line, definition); 361 | expect(argv).to.be.instanceof(Error); 362 | expect(argv.message).to.contain('Unknown option: no-a'); 363 | }); 364 | 365 | it('prefers explicit argument to boolean negation in a conflict', () => { 366 | 367 | const line = '--no-a str'; 368 | const definition = { 369 | a: { 370 | type: 'boolean', 371 | default: true 372 | }, 373 | 'no-a': { 374 | type: 'string' 375 | } 376 | }; 377 | 378 | const argv = parse(line, definition); 379 | expect(argv).to.equal({ a: true, 'no-a': 'str' }); 380 | }); 381 | 382 | it('allows json to build an object, parsing primitives.', () => { 383 | 384 | const line = [ 385 | '--x', '{ "a": null, "b": { "c": 2 } }', 386 | '--x.b.d', '3', 387 | '--x.e', '["four"]', 388 | '--x.f', 'false', 389 | '--x.g', 'null' 390 | ]; 391 | const definition = { 392 | x: { 393 | type: 'json', 394 | parsePrimitives: true 395 | } 396 | }; 397 | 398 | const argv = parse(line, definition); 399 | expect(argv).to.equal({ 400 | x: { 401 | a: null, 402 | b: { c: 2, d: 3 }, 403 | e: ['four'], 404 | f: false, 405 | g: null 406 | } 407 | }); 408 | }); 409 | 410 | it('allows json to build an object, parsing primitives strictly.', () => { 411 | 412 | const line = [ 413 | '--x.a.b', '3', 414 | '--x.a.c', '4.2e2', 415 | '--x.d', 'false', 416 | '--x.e', 'true', 417 | '--x.f', 'null', 418 | '--x.g', '"str"', 419 | '--x.a.c', '4.2' 420 | ]; 421 | const definition = { 422 | x: { 423 | type: 'json', 424 | parsePrimitives: 'strict' 425 | } 426 | }; 427 | 428 | const argv = parse(line, definition); 429 | expect(argv).to.equal({ 430 | x: { 431 | a: { b: 3, c: 4.2 }, 432 | d: false, 433 | e: true, 434 | f: null, 435 | g: 'str' 436 | } 437 | }); 438 | }); 439 | 440 | it('does not allow json arg to contain invalid JSON, parsing primitives strictly.', () => { 441 | 442 | const definition = { 443 | x: { 444 | type: 'json', 445 | parsePrimitives: 'strict' 446 | } 447 | }; 448 | 449 | const line1 = ['--x.a', 'str']; 450 | const argv1 = parse(line1, definition); 451 | expect(argv1).to.be.instanceof(Error); 452 | expect(argv1.message).to.equal('Invalid value for option: x.a (invalid JSON)'); 453 | 454 | const line2 = ['--x.a', '{ "b": null']; 455 | const argv2 = parse(line2, definition); 456 | expect(argv2).to.be.instanceof(Error); 457 | expect(argv2.message).to.equal('Invalid value for option: x.a (invalid JSON)'); 458 | }); 459 | 460 | it('does not allow json arg to contain an array or object, parsing primitives strictly.', () => { 461 | 462 | const definition = { 463 | x: { 464 | type: 'json', 465 | parsePrimitives: 'strict' 466 | } 467 | }; 468 | 469 | const line1 = ['--x.a', '[1, 2]']; 470 | const argv1 = parse(line1, definition); 471 | expect(argv1).to.be.instanceof(Error); 472 | expect(argv1.message).to.equal('Invalid value for option: x.a (non-primitive JSON value)'); 473 | 474 | const line2 = ['--x.a', '{ "b": null }']; 475 | const argv2 = parse(line2, definition); 476 | expect(argv2).to.be.instanceof(Error); 477 | expect(argv2.message).to.equal('Invalid value for option: x.a (non-primitive JSON value)'); 478 | }); 479 | 480 | it('allows json to build an object, not parsing primitives.', () => { 481 | 482 | const line = ['--x', '{ "a": null, "b": { "c": 2 } }', '--x.b.d', '3', '--x.e', '["four"]', '--x.f', 'false', '--x.g', 'null']; 483 | const definition = { 484 | x: { 485 | type: 'json', 486 | parsePrimitives: false 487 | } 488 | }; 489 | 490 | const argv = parse(line, definition); 491 | expect(argv).to.equal({ x: { a: null, b: { c: 2, d: '3' }, e: ['four'], f: 'false', g: 'null' } }); 492 | }); 493 | 494 | it('allows json to build an object, by default not parsing primitives.', () => { 495 | 496 | const line = '--x.a null --x.b 2 --x.c true --x.d false --x.e str'; 497 | const definition = { 498 | x: { 499 | type: 'json' 500 | } 501 | }; 502 | 503 | const argv = parse(line, definition); 504 | expect(argv).to.equal({ x: { a: 'null', b: '2', c: 'true', d: 'false', e: 'str' } }); 505 | }); 506 | 507 | it('merges into json object defaults', () => { 508 | 509 | const line = ['--x.b', 'two', '--x', '{ "c": 3 }']; 510 | const definition = { 511 | x: { 512 | type: 'json', 513 | default: { a: 1, b: 4 } 514 | } 515 | }; 516 | 517 | const argv = parse(line, definition); 518 | expect(argv).to.equal({ x: { a: 1, b: 'two', c: 3 } }); 519 | expect(definition.x.default).to.equal({ a: 1, b: 4 }); // No mutation of defaults despite merge 520 | }); 521 | 522 | it('only sets json arg types deeply', () => { 523 | 524 | const line = '--a.b str'; 525 | const definition = { 526 | a: { 527 | type: 'string' 528 | } 529 | }; 530 | 531 | const argv = parse(line, definition); 532 | expect(argv).to.be.instanceof(Error); 533 | expect(argv.message).to.contain('Unknown option: a.b'); 534 | }); 535 | 536 | it('requires json args be objects', () => { 537 | 538 | const definition = { 539 | a: { 540 | type: 'json', 541 | parsePrimitives: true 542 | } 543 | }; 544 | 545 | const line1 = '--a str'; 546 | const argv1 = parse(line1, definition); 547 | expect(argv1).to.be.instanceof(Error); 548 | expect(argv1.message).to.contain('Invalid value for option: a (must be an object or array)'); 549 | 550 | const line2 = '--a null'; 551 | const argv2 = parse(line2, definition); 552 | expect(argv2).to.be.instanceof(Error); 553 | expect(argv2.message).to.contain('Invalid value for option: a (must be an object or array)'); 554 | }); 555 | 556 | it('handles missing arg for json-looking option', () => { 557 | 558 | const line = '--y.z str'; 559 | const definition = { 560 | x: { 561 | type: 'json' 562 | } 563 | }; 564 | 565 | const argv = parse(line, definition); 566 | expect(argv).to.be.instanceof(Error); 567 | expect(argv.message).to.contain('Unknown option: y.z'); 568 | }); 569 | 570 | it('requires json arg default be an array or object', () => { 571 | 572 | const definition = (def) => ({ 573 | x: { 574 | type: 'json', 575 | default: def 576 | } 577 | }); 578 | 579 | expect(() => parse('', definition([]))).to.not.throw(); 580 | expect(() => parse('', definition({}))).to.not.throw(); 581 | expect(() => parse('', definition('str'))).to.throw(/must be one of \[array, object\]/); 582 | expect(() => parse('', definition(null))).to.throw(/must be one of \[array, object\]/); 583 | expect(() => parse('', definition(100))).to.throw(/must be one of \[array, object\]/); 584 | }); 585 | 586 | it('does not allow passing valid option for json args', () => { 587 | 588 | const definition = { 589 | x: { 590 | type: 'json', 591 | valid: { x: 1 } 592 | } 593 | }; 594 | 595 | expect(() => parse('', definition)).to.throw(/"x\.valid" is not allowed/); 596 | }); 597 | 598 | it('does not allow passing multiple option for json args', () => { 599 | 600 | const definition = { 601 | x: { 602 | type: 'json', 603 | multiple: true 604 | } 605 | }; 606 | 607 | expect(() => parse('', definition)).to.throw(/"x\.multiple" is not allowed/); 608 | }); 609 | 610 | it('does not allow passing parsePrimitives option for non-json args', () => { 611 | 612 | const definition = { 613 | x: { 614 | type: 'string', 615 | parsePrimitives: false 616 | } 617 | }; 618 | 619 | expect(() => parse('', definition)).to.throw(/"x\.parsePrimitives" is not allowed/); 620 | }); 621 | 622 | it('does not allow json args to have a deep flag name', () => { 623 | 624 | const definition = { 625 | 'x.y': { 626 | type: 'json' 627 | } 628 | }; 629 | 630 | expect(() => parse('', definition)).to.throw(/"x\.y\.type" contains an invalid value/); 631 | }); 632 | 633 | it('protects from prototype poisoning when parsing JSON for json args', () => { 634 | 635 | const line = ['--x', '{ "y": 1, "__proto__": { "z": 2 } }']; 636 | const definition = { 637 | x: { 638 | type: 'json' 639 | } 640 | }; 641 | 642 | const argv = parse(line, definition); 643 | expect(argv).to.equal({ x: { y: 1 } }); 644 | }); 645 | 646 | it('protects from prototype poisoning in dot-separated json path', () => { 647 | 648 | const line = '--x.__proto__.y one --x.z two --x.__proto__.w three'; 649 | const definition = { 650 | x: { 651 | type: 'json' 652 | } 653 | }; 654 | 655 | const argv = parse(line, definition); 656 | expect(argv).to.equal({ x: { z: 'two' } }); 657 | }); 658 | 659 | it('allows custom argv to be passed in options in place of process.argv', () => { 660 | 661 | let argv = ['-a', '1-2,5']; 662 | const definition = { 663 | a: { 664 | type: 'range' 665 | } 666 | }; 667 | 668 | argv = Bossy.parse(definition, { argv }); 669 | expect(argv).to.equal({ a: [1, 2, 5] }); 670 | }); 671 | 672 | it('returns error message when multiple number values are passed in by default', () => { 673 | 674 | let argv = ['-a', '0', '-a', '1']; 675 | const definition = { 676 | a: { 677 | type: 'number' 678 | } 679 | }; 680 | 681 | argv = Bossy.parse(definition, { argv }); 682 | expect(argv).to.be.instanceof(Error); 683 | }); 684 | 685 | it('returns error message when multiple string values are passed in by default', () => { 686 | 687 | let argv = ['-a', 'x', '-a', 'y']; 688 | const definition = { 689 | a: { 690 | type: 'string' 691 | } 692 | }; 693 | 694 | argv = Bossy.parse(definition, { argv }); 695 | expect(argv).to.be.instanceof(Error); 696 | }); 697 | 698 | it('returns error message when multiple range values are passed in by default', () => { 699 | 700 | let argv = ['-a', '0,1-2,5', '-a', '8-9']; 701 | const definition = { 702 | a: { 703 | type: 'range' 704 | } 705 | }; 706 | 707 | argv = Bossy.parse(definition, { argv }); 708 | expect(argv).to.be.instanceof(Error); 709 | }); 710 | 711 | it('always returns an array when multiple number option is set to true', () => { 712 | 713 | let argv = ['-a', '0']; 714 | const definition = { 715 | a: { 716 | type: 'number', 717 | multiple: true 718 | } 719 | }; 720 | 721 | argv = Bossy.parse(definition, { argv }); 722 | expect(argv).to.equal({ a: [0] }); 723 | }); 724 | 725 | it('always returns an array when multiple string option is set to true', () => { 726 | 727 | let argv = ['-a', 'x']; 728 | const definition = { 729 | a: { 730 | type: 'string', 731 | multiple: true 732 | } 733 | }; 734 | 735 | argv = Bossy.parse(definition, { argv }); 736 | expect(argv).to.equal({ a: ['x'] }); 737 | }); 738 | 739 | it('always returns an array when multiple range option is set to true', () => { 740 | 741 | let argv = ['-a', '1']; 742 | const definition = { 743 | a: { 744 | type: 'range', 745 | multiple: true 746 | } 747 | }; 748 | 749 | argv = Bossy.parse(definition, { argv }); 750 | expect(argv).to.equal({ a: [1] }); 751 | }); 752 | 753 | it('allows multiple number values to be passed in', () => { 754 | 755 | let argv = ['-a', '0', '-a', '1']; 756 | const definition = { 757 | a: { 758 | type: 'number', 759 | multiple: true 760 | } 761 | }; 762 | 763 | argv = Bossy.parse(definition, { argv }); 764 | expect(argv).to.equal({ a: [0, 1] }); 765 | }); 766 | 767 | it('allows multiple string values to be passed in', () => { 768 | 769 | let argv = ['-a', 'x', '-a', 'y']; 770 | const definition = { 771 | a: { 772 | type: 'string', 773 | multiple: true 774 | } 775 | }; 776 | 777 | argv = Bossy.parse(definition, { argv }); 778 | expect(argv).to.equal({ a: ['x', 'y'] }); 779 | }); 780 | 781 | it('allows multiple range values to be passed in', () => { 782 | 783 | let argv = ['-a', '0,1-2,5', '-a', '8-9']; 784 | const definition = { 785 | a: { 786 | type: 'range', 787 | multiple: true 788 | } 789 | }; 790 | 791 | argv = Bossy.parse(definition, { argv }); 792 | expect(argv).to.equal({ a: [0, 1, 2, 5, 8, 9] }); 793 | }); 794 | 795 | it('allows non-json args to have a deep flag name', () => { 796 | 797 | const line = '--a.x --b.x 1 --c.x str --d.x 1-2'; 798 | const definition = { 799 | 'a.x': { 800 | type: 'boolean' 801 | }, 802 | 'b.x': { 803 | type: 'number' 804 | }, 805 | 'c.x': { 806 | type: 'string' 807 | }, 808 | 'd.x': { 809 | type: 'range' 810 | } 811 | }; 812 | 813 | const argv = parse(line, definition); 814 | expect(argv).to.equal({ 'a.x': true, 'b.x': 1, 'c.x': 'str', 'd.x': [1, 2] }); 815 | }); 816 | 817 | it('prefers non-json arg with deep flag name to json arg with the same base', () => { 818 | 819 | const line = '--a.x 1'; 820 | const definition = { 821 | a: { 822 | type: 'json' 823 | }, 824 | 'a.x': { 825 | type: 'number' 826 | } 827 | }; 828 | 829 | const argv = parse(line, definition); 830 | expect(argv).to.equal({ a: undefined, 'a.x': 1 }); 831 | }); 832 | 833 | it('returns error message when a value isn\'t found in the valid property', () => { 834 | 835 | const line = '-a 2'; 836 | const definition = { 837 | a: { 838 | type: 'number', 839 | valid: 1 840 | } 841 | }; 842 | 843 | const argv = parse(line, definition); 844 | expect(argv).to.be.instanceof(Error); 845 | }); 846 | 847 | it('returns error message when a value isn\'t found in array of valid values', () => { 848 | 849 | const line = '-a 4'; 850 | const definition = { 851 | a: { 852 | type: 'number', 853 | valid: [1, 2, 3] 854 | } 855 | }; 856 | 857 | const argv = parse(line, definition); 858 | expect(argv).to.be.instanceof(Error); 859 | }); 860 | 861 | it('doesn\'t return an error when the value is in the valid array', () => { 862 | 863 | const line = '-a 2'; 864 | const definition = { 865 | a: { 866 | type: 'number', 867 | valid: [1, 2, 3] 868 | } 869 | }; 870 | 871 | const argv = parse(line, definition); 872 | expect(argv).to.equal({ a: 2 }); 873 | }); 874 | 875 | it('doesn\'t return an error when the value is in equal to the valid value', () => { 876 | 877 | const line = '-a 0'; 878 | const definition = { 879 | a: { 880 | type: 'number', 881 | valid: 0 882 | } 883 | }; 884 | 885 | const argv = parse(line, definition); 886 | expect(argv).to.equal({ a: 0 }); 887 | }); 888 | 889 | it('displays unrecognized arguments in error message ', () => { 890 | 891 | const line = '-a 0 -b'; 892 | const definition = { 893 | a: { 894 | type: 'number', 895 | description: 'This needs a number' 896 | } 897 | }; 898 | 899 | const argv = parse(line, definition); 900 | expect(argv.message).to.contain('Unknown option: b'); 901 | }); 902 | 903 | it('throws on invalid input ', () => { 904 | 905 | const line = '-a 0 -b'; 906 | 907 | expect(() => { 908 | 909 | const definition = { 910 | a: { 911 | unknown: true 912 | } 913 | }; 914 | 915 | parse(line, definition); 916 | }).to.throw(Error, /^Invalid definition/); 917 | 918 | expect(() => { 919 | 920 | const definition = { 921 | a: { 922 | type: 'unknown' 923 | } 924 | }; 925 | 926 | parse(line, definition); 927 | }).to.throw(Error, /^Invalid definition/); 928 | 929 | expect(() => { 930 | 931 | const definition = { 932 | '!!': {} 933 | }; 934 | 935 | parse(line, definition); 936 | }).to.throw(Error, /^Invalid definition/); 937 | 938 | expect(() => { 939 | 940 | parse(line, {}, { args: ['-c'] }); 941 | }).to.throw(Error, /^Invalid options argument/); 942 | }); 943 | }); 944 | 945 | describe('usage()', () => { 946 | 947 | it('returns formatted usage information', () => { 948 | 949 | const definition = { 950 | a: { 951 | type: 'number', 952 | description: 'This needs a number' 953 | }, 954 | b: { 955 | alias: 'beta', 956 | require: true, 957 | description: 'Description for b' 958 | }, 959 | c: { 960 | require: true 961 | }, 962 | longname: { 963 | type: 'string' 964 | } 965 | }; 966 | 967 | const result = Bossy.usage(definition); 968 | expect(result).to.contain('-a'); 969 | expect(result).to.contain('This needs a number'); 970 | expect(result).to.contain('-b, --beta'); 971 | expect(result).to.contain('--longname'); 972 | }); 973 | 974 | it('returns formatted usage header when provided', () => { 975 | 976 | const definition = { 977 | h: { 978 | type: 'string', 979 | description: 'Show help' 980 | } 981 | }; 982 | 983 | const result = Bossy.usage(definition, 'bossy -h'); 984 | expect(result).to.contain('Usage: bossy -h'); 985 | expect(result).to.contain('-h'); 986 | expect(result).to.contain('Show help'); 987 | }); 988 | 989 | it('returns formatted usage information with colors when enabled', () => { 990 | 991 | const definition = { 992 | a: { 993 | alias: 'alpha', 994 | require: true, 995 | description: 'Description for b' 996 | } 997 | }; 998 | 999 | const result = Bossy.usage(definition, { colors: true }); 1000 | 1001 | expect(result).to.contain('-a'); 1002 | expect(result).to.contain('\u001b[0m'); 1003 | }); 1004 | 1005 | it('when colors are missing defaults to true if tty supports colors', () => { 1006 | 1007 | const definition = { 1008 | a: { 1009 | alias: 'alpha', 1010 | require: true, 1011 | description: 'Description for b' 1012 | } 1013 | }; 1014 | 1015 | const currentIsAtty = Tty.isatty; 1016 | 1017 | let count = 0; 1018 | Tty.isatty = () => { 1019 | 1020 | if (++count === 2) { 1021 | Tty.isatty = currentIsAtty; 1022 | } 1023 | 1024 | return true; 1025 | }; 1026 | 1027 | const result = Bossy.usage(definition); 1028 | 1029 | expect(result).to.contain('-a'); 1030 | expect(result).to.contain('\u001b[0m'); 1031 | }); 1032 | 1033 | it('when colors are missing defaults to false if tty doesn\'t support colors', () => { 1034 | 1035 | const definition = { 1036 | a: { 1037 | alias: 'alpha', 1038 | require: true, 1039 | description: 'Description for b' 1040 | } 1041 | }; 1042 | 1043 | const currentIsAtty = Tty.isatty; 1044 | 1045 | Tty.isatty = () => { 1046 | 1047 | Tty.isatty = currentIsAtty; 1048 | return false; 1049 | }; 1050 | 1051 | const result = Bossy.usage(definition); 1052 | 1053 | expect(result).to.contain('-a'); 1054 | expect(result).to.not.contain('\u001b[0m'); 1055 | }); 1056 | 1057 | it('returns colors usage information when passed as parameter', () => { 1058 | 1059 | const definition = { 1060 | a: { 1061 | alias: 'alpha', 1062 | require: true, 1063 | description: 'Description for b' 1064 | } 1065 | }; 1066 | 1067 | const result = Bossy.usage(definition, 'bossy -c', { colors: true }); 1068 | 1069 | expect(result).to.contain('bossy'); 1070 | expect(result).to.contain('-a'); 1071 | expect(result).to.contain('\u001b[0m'); 1072 | }); 1073 | 1074 | it('formatted usage message orders as -s,--long in first column', () => { 1075 | 1076 | const definition = { 1077 | a: { 1078 | type: 'number', 1079 | description: 'This needs a number' 1080 | }, 1081 | b: { 1082 | alias: 'beta', 1083 | description: 'Description for b' 1084 | }, 1085 | code: { 1086 | alias: 'c' 1087 | }, 1088 | d: { 1089 | alias: [''] 1090 | } 1091 | }; 1092 | 1093 | const result = Bossy.usage(definition); 1094 | expect(result).to.contain('-a'); 1095 | expect(result).to.contain('-b, --beta'); 1096 | expect(result).to.contain('-c, --code'); 1097 | }); 1098 | 1099 | it('formatted usage message orders shows default values', () => { 1100 | 1101 | const definition = { 1102 | aa: { 1103 | type: 'number', 1104 | description: 'This needs a number' 1105 | }, 1106 | b: { 1107 | alias: 'beta', 1108 | description: 'Description for b', 1109 | default: 'b' 1110 | }, 1111 | code: { 1112 | alias: 'c', 1113 | default: 'c' 1114 | }, 1115 | d: { 1116 | alias: [''] 1117 | }, 1118 | e: { 1119 | type: 'number', 1120 | default: 0 1121 | }, 1122 | f: { 1123 | type: 'json', 1124 | default: { x: 'y', z: 1 } 1125 | } 1126 | }; 1127 | 1128 | const result = Bossy.usage(definition); 1129 | expect(result).to.contain('-a'); 1130 | expect(result).to.contain('-b, --beta'); 1131 | expect(result).to.contain('(b)'); 1132 | expect(result).to.contain('-c, --code'); 1133 | expect(result).to.contain('(c)'); 1134 | expect(result).to.contain('-e'); 1135 | expect(result).to.contain('(0)'); 1136 | expect(result).to.contain('-f'); 1137 | expect(result).to.contain('({"x":"y","z":1})'); 1138 | }); 1139 | }); 1140 | 1141 | describe('object()', () => { 1142 | 1143 | const parse = function (line, definition) { 1144 | 1145 | return Bossy.parse(definition, { 1146 | argv: [].concat('ignore', 'ignore', Array.isArray(line) ? line : line.split(' ')) 1147 | }); 1148 | }; 1149 | 1150 | it('rolls-up parsed arguments with deep paths into an object', () => { 1151 | 1152 | const line = ['--x.a', '--x.b.c', '1', '--x.d.e', 'str', '--x.d.f', '1-2', '--x', '{ "b": { "c": "10" }, "d": { "g": "h" } }']; 1153 | const definition = { 1154 | x: { 1155 | type: 'json' 1156 | }, 1157 | 'x.a': { 1158 | type: 'boolean' 1159 | }, 1160 | 'x.b.c': { 1161 | type: 'number' 1162 | }, 1163 | 'x.d.e': { 1164 | type: 'string' 1165 | }, 1166 | 'x.d.f': { 1167 | type: 'range' 1168 | } 1169 | }; 1170 | 1171 | const argv = parse(line, definition); 1172 | const snapshot = Hoek.clone(argv); 1173 | 1174 | expect(argv).to.equal({ 1175 | 'x.a': true, 1176 | 'x.b.c': 1, 1177 | 'x.d.e': 'str', 1178 | 'x.d.f': [ 1179 | 1, 1180 | 2 1181 | ], 1182 | x: { 1183 | b: { 1184 | c: '10' 1185 | }, 1186 | d: { 1187 | g: 'h' 1188 | } 1189 | }, 1190 | _: [ 1191 | 'ignore', 1192 | 'ignore' 1193 | ] 1194 | }); 1195 | 1196 | expect(Bossy.object('x', argv)).to.equal({ 1197 | a: true, 1198 | b: { 1199 | c: 1 1200 | }, 1201 | d: { 1202 | e: 'str', 1203 | f: [ 1204 | 1, 1205 | 2 1206 | ], 1207 | g: 'h' 1208 | } 1209 | }); 1210 | 1211 | expect(argv).to.equal(snapshot); // No mutation despite merge 1212 | }); 1213 | 1214 | it('merges values shallow to deep', () => { 1215 | 1216 | const x = Bossy.object('x', { 1217 | 'x.a.b.c': 1, 1218 | 'x.d.e': 'two', 1219 | 'x.f': true, 1220 | x: { 1221 | a: { 1222 | b: { 1223 | c: 2, 1224 | g: 'two' 1225 | } 1226 | }, 1227 | d: { e: 'three', h: 3 }, 1228 | f: false, 1229 | i: null 1230 | } 1231 | }); 1232 | 1233 | expect(x).to.equal({ 1234 | a: { 1235 | b: { 1236 | c: 1, 1237 | g: 'two' 1238 | } 1239 | }, 1240 | d: { e: 'two', h: 3 }, 1241 | f: true, 1242 | i: null 1243 | }); 1244 | }); 1245 | 1246 | it('defaults initial value to an empty object', () => { 1247 | 1248 | expect(Bossy.object('x', {})).to.equal({}); 1249 | expect(Bossy.object('x', { 'x.a': 1 })).to.equal({ a: 1 }); 1250 | }); 1251 | 1252 | it('does not allow rolling-up a deep flag', () => { 1253 | 1254 | expect(() => Bossy.object('x.y', { 'x.y': {} })).to.throw('Cannot build an object at a deep path: x.y (contains a dot)'); 1255 | }); 1256 | }); 1257 | --------------------------------------------------------------------------------