Skip to content

Commit

Permalink
feat: add .commandDir(dir) to API to apply all command modules from a…
Browse files Browse the repository at this point in the history
… relative directory (#494)

* feat: add .commandDir() API using require-directory

* support for nested subcommands with relative dir

* allow command module to only define command and desc

* test: add some for commandDir

* docs: document the .commandDir() method

* use const where possible based on @maxrimue's review
  • Loading branch information
nexdrew authored and bcoe committed May 30, 2016
1 parent 5dd1e63 commit b299dff
Show file tree
Hide file tree
Showing 11 changed files with 426 additions and 6 deletions.
134 changes: 132 additions & 2 deletions README.md
Expand Up @@ -587,8 +587,138 @@ yargs.command('get <source> [proxy]', 'make a get HTTP request', require('my-mod
.argv
```

.completion([cmd], [description], [fn]);
----------------------------------------
.commandDir(directory, [opts])
------------------------------

Apply command modules from a directory relative to the module calling this method.

This allows you to organize multiple commands into their own modules under a
single directory and apply all of them at once instead of calling
`.command(require('./dir/module'))` multiple times.

By default, it ignores subdirectories. This is so you can use a directory
structure to represent your command hierarchy, where each command applies its
subcommands using this method in its builder function. See the example below.

Note that yargs assumes all modules in the given directory are command modules
and will error if non-command modules are encountered. In this scenario, you
can either move your module to a different directory or use the `exclude` or
`visit` option to manually filter it out. More on that below.

`directory` is a relative directory path as a string (required).

`opts` is an options object (optional). The following options are valid:

- `recurse`: boolean, default `false`

Look for command modules in all subdirectories and apply them as a flattened
(non-hierarchical) list.

- `extensions`: array of strings, default `['js']`

The types of files to look for when requiring command modules.

- `visit`: function

A synchronous function called for each command module encountered. Accepts
`commandObject`, `pathToFile`, and `filename` as arguments. Returns
`commandObject` to include the command; any falsy value to exclude/skip it.

- `include`: RegExp or function

Whitelist certain modules. See [`require-directory` whitelisting](https://www.npmjs.com/package/require-directory#whitelisting) for details.

- `exclude`: RegExp or function

Blacklist certain modules. See [`require-directory` blacklisting](https://www.npmjs.com/package/require-directory#blacklisting) for details.

### Example command hierarchy using `.commandDir()`

Desired CLI:

```sh
$ myapp --help
$ myapp init
$ myapp remote --help
$ myapp remote add base http://yargs.js.org
$ myapp remote prune base
$ myapp remote prune base fork whatever
```

Directory structure:

```
myapp/
├─ cli.js
└─ cmds/
├─ init.js
├─ remote.js
└─ remote_cmds/
├─ add.js
└─ prune.js
```

cli.js:

```js
#!/usr/bin/env node
require('yargs')
.commandDir('cmds')
.demand(1)
.help()
.argv
```

cmds/init.js:

```js
exports.command = 'init [dir]'
exports.desc = 'Create an empty repo'
exports.builder = {
dir: {
default: '.'
}
}
exports.handler = function (argv) {
console.log('init called for dir', argv.dir)
}
```

cmds/remote.js:

```js
exports.command = 'remote <command>'
exports.desc = 'Manage set of tracked repos'
exports.builder = function (yargs) {
return yargs.commandDir('remote_cmds')
}
exports.handler = function (argv) {}
```

cmds/remote_cmds/add.js:

```js
exports.command = 'add <name> <url>'
exports.desc = 'Add remote named <name> for repo at url <url>'
exports.builder = {}
exports.handler = function (argv) {
console.log('adding remote %s at url %s', argv.name, argv.url)
}
```

cmds/remote_cmds/prune.js:

```js
exports.command = 'prune <name> [names..]'
exports.desc = 'Delete tracked branches gone stale for remotes'
exports.builder = {}
exports.handler = function (argv) {
console.log('pruning remotes %s', [].concat(argv.name).concat(argv.names).join(', '))
}
```

.completion([cmd], [description], [fn])
---------------------------------------

Enable bash-completion shortcuts for commands and options.

Expand Down
43 changes: 40 additions & 3 deletions lib/command.js
@@ -1,3 +1,6 @@
const path = require('path')
const requireDirectory = require('require-directory')

// handles parsing positional arguments,
// and populating argv with said positional
// arguments.
Expand All @@ -6,9 +9,13 @@ module.exports = function (yargs, usage, validation) {

var handlers = {}
self.addHandler = function (cmd, description, builder, handler) {
// allow a module to define all properties
if (typeof cmd === 'object' && typeof cmd.command === 'string' && cmd.builder && typeof cmd.handler === 'function') {
self.addHandler(cmd.command, extractDesc(cmd), cmd.builder, cmd.handler)
// allow modules that define (a) all properties or (b) only command and description
if (typeof cmd === 'object' && typeof cmd.command === 'string') {
if (cmd.builder && typeof cmd.handler === 'function') {
self.addHandler(cmd.command, extractDesc(cmd), cmd.builder, cmd.handler)
} else {
self.addHandler(cmd.command, extractDesc(cmd))
}
return
}

Expand All @@ -35,6 +42,36 @@ module.exports = function (yargs, usage, validation) {
}
}

self.addDirectory = function (dir, context, req, mainFilename, opts) {
opts = opts || {}
// dir should be relative to the command module
dir = path.join(context.dirs[context.commands.join('|')] || '', dir)
// disable recursion to support nested directories of subcommands
if (typeof opts.recurse !== 'boolean') opts.recurse = false
// exclude 'json', 'coffee' from require-directory defaults
if (!Array.isArray(opts.extensions)) opts.extensions = ['js']
// allow consumer to define their own visitor function
const parentVisit = typeof opts.visit === 'function' ? opts.visit : function (o) { return o }
// call addHandler via visitor function
opts.visit = function (obj, joined, filename) {
const visited = parentVisit(obj, joined, filename)
// allow consumer to skip modules with their own visitor
if (visited) {
// check for cyclic reference
// each command file path should only be seen once per execution
if (~context.files.indexOf(joined)) return visited
// keep track of visited files in context.files
context.files.push(joined)
// map "command path" to the directory path it came from
// so that dir can be relative in the API
context.dirs[context.commands.concat(parseCommand(visited.command).cmd).join('|')] = dir
self.addHandler(visited)
}
return visited
}
requireDirectory({ require: req, filename: mainFilename }, dir, opts)
}

function extractDesc (obj) {
for (var keys = ['describe', 'description', 'desc'], i = 0, l = keys.length, test; i < l; i++) {
test = obj[keys[i]]
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -19,6 +19,7 @@
"os-locale": "^1.4.0",
"pkg-conf": "^1.1.2",
"read-pkg-up": "^1.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^1.0.1",
"set-blocking": "^2.0.0",
"string-width": "^1.0.1",
Expand Down
118 changes: 118 additions & 0 deletions test/command.js
@@ -1,6 +1,7 @@
/* global describe, it, beforeEach */
var yargs = require('../')
var expect = require('chai').expect
var checkOutput = require('./helpers/utils').checkOutput

require('chai').should()

Expand Down Expand Up @@ -318,4 +319,121 @@ describe('Command', function () {
commands[0].should.deep.equal([module.command, module.describe])
})
})

describe('commandDir', function () {
it('supports relative dirs', function () {
var r = checkOutput(function () {
return yargs('--help').help().wrap(null)
// assumes cwd is node_modules/mocha/bin
.commandDir('../../../test/fixtures/cmddir')
.argv
})
r.should.have.property('exit').and.be.true
r.should.have.property('errors').with.length(0)
r.should.have.property('logs')
r.logs.join('\n').split(/\n+/).should.deep.equal([
'Commands:',
' dream [command] [opts] Go to sleep and dream',
'Options:',
' --help Show help [boolean]',
''
])
})

it('supports nested subcommands', function () {
var r = checkOutput(function () {
return yargs('dream --help').help().wrap(null)
// assumes cwd is node_modules/mocha/bin
.commandDir('../../../test/fixtures/cmddir')
.argv
}, [ './command' ])
r.should.have.property('exit').and.be.true
r.should.have.property('errors').with.length(0)
r.should.have.property('logs')
r.logs[0].split(/\n+/).should.deep.equal([
'./command dream [command] [opts]',
'Commands:',
' of-memory <memory> Dream about a specific memory',
' within-a-dream [command] [opts] Dream within a dream',
'Options:',
' --help Show help [boolean]',
' --shared Is the dream shared with others? [boolean]',
' --extract Attempt extraction? [boolean]',
''
])
})

it('supports a "recurse" boolean option', function () {
var r = checkOutput(function () {
return yargs('--help').help().wrap(null)
// assumes cwd is node_modules/mocha/bin
.commandDir('../../../test/fixtures/cmddir', { recurse: true })
.argv
})
r.should.have.property('exit').and.be.true
r.should.have.property('errors').with.length(0)
r.should.have.property('logs')
r.logs.join('\n').split(/\n+/).should.deep.equal([
'Commands:',
' limbo [opts] Get lost in pure subconscious',
' inception [command] [opts] Enter another dream, where inception is possible',
' within-a-dream [command] [opts] Dream within a dream',
' dream [command] [opts] Go to sleep and dream',
'Options:',
' --help Show help [boolean]',
''
])
})

it('supports a "visit" function option', function () {
var commandObject
var pathToFile
var filename
var r = checkOutput(function () {
return yargs('--help').help().wrap(null)
// assumes cwd is node_modules/mocha/bin
.commandDir('../../../test/fixtures/cmddir', {
visit: function (_commandObject, _pathToFile, _filename) {
commandObject = _commandObject
pathToFile = _pathToFile
filename = _filename
return false // exclude command
}
})
.argv
})
commandObject.should.have.property('command').and.equal('dream [command] [opts]')
commandObject.should.have.property('desc').and.equal('Go to sleep and dream')
commandObject.should.have.property('builder')
commandObject.should.have.property('handler')
pathToFile.should.contain(require('path').join('test', 'fixtures', 'cmddir', 'dream.js'))
filename.should.equal('dream.js')
r.should.have.property('exit').and.be.true
r.should.have.property('errors').with.length(0)
r.should.have.property('logs')
r.logs.join('\n').split(/\n+/).should.deep.equal([
'Options:',
' --help Show help [boolean]',
''
])
})

it('detects and ignores cyclic dir references', function () {
var r = checkOutput(function () {
return yargs('cyclic --help').help().wrap(null)
// assumes cwd is node_modules/mocha/bin
.commandDir('../../../test/fixtures/cmddir_cyclic')
.argv
}, [ './command' ])
r.should.have.property('exit').and.be.true
r.should.have.property('errors').with.length(0)
r.should.have.property('logs')
r.logs.join('\n').split(/\n+/).should.deep.equal([
'./command cyclic',
'Options:',
' --help Show help [boolean]',
''
])
})
})
})
28 changes: 28 additions & 0 deletions test/fixtures/cmddir/deep/deeper/deeper_still/limbo.js
@@ -0,0 +1,28 @@
exports.command = 'limbo [opts]'
exports.desc = 'Get lost in pure subconscious'
exports.builder = {
'with-self-exit': {
desc: 'Pretty much your only way out',
type: 'boolean'
},
'with-totem': {
desc: 'Take your totem with you',
type: 'boolean'
}
}
exports.handler = function (argv) {
var factor = 3
if (!argv.withSelfExit) throw new Error('You entered limbo without a way out!')
if (!argv.withTotem) factor -= 2
if (argv.extract) {
if (!chancesLevel4(factor)) throw new Error('You didn\'t have much chance anyway, you\'re stuck in limbo!')
if (!argv._msg) argv._msg = 'You have accomplished the impossible. Inception successful.'
return
}
if (!chancesLevel4(factor)) throw new Error('You rolled the dice and lost, you\'re stuck in limbo!')
if (!argv._msg) argv._msg = 'Can you ever be sure of what\'s real anymore?'
}

function chancesLevel4 (factor) {
return Math.floor(Math.random() * 10) < factor
}

0 comments on commit b299dff

Please sign in to comment.