Repository: yargs/yargs
Pull request #527 (2016-06-06 07:09:37)

feat(command): derive missing command string from module filename
Mentioned in #494, this PR makes a `command` string optional for module-based commands, using the base filename of the `require()`d module for the command when the `command` string is not specified/exported.

Not only does this get us one step closer to implementing #492, but it also paves the way for supporting an "auto" mode for `commandDir()`, where conventions can be used to determine subcommand hierarchies and command names can be defined by filenames of submodules.

In this implementation, a `command` string is derived by filename via the following:

- For `.command()`: filename is looked up in `require.cache` via the new [`which-module`](https://github.com/nexdrew/which-module) dependency
- For `.commandDir()`: filename is already available via the `require-directory` API

This logic only applies when the command module does not export a `command` string. For non-module command objects (ones that are not `require()`d), a descriptive error is thrown if a `command` string is not given.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/6473066/badge)](https://coveralls.io/builds/6473066)

Coverage remained the same at 100.0% when pulling **363e18144cb6239c359dea11081cf482b9b258d7 on derive-command-from-filename** into **365fb9a88865e0bce6532f8f7c745e80cd18e28e on master**.

bcoe: Looks good to me :shipit: 

Patch:
diff --git a/lib/command.js b/lib/command.js
index 77555e9..b753d3a 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -1,5 +1,7 @@
 const path = require('path')
+const inspect = require('util').inspect
 const requireDirectory = require('require-directory')
+const whichModule = require('which-module')
 
 // handles parsing positional arguments,
 // and populating argv with said positional
@@ -10,11 +12,12 @@ module.exports = function (yargs, usage, validation) {
   var handlers = {}
   self.addHandler = function (cmd, description, builder, handler) {
     // allow modules that define (a) all properties or (b) only command and description
-    if (typeof cmd === 'object' && typeof cmd.command === 'string') {
+    if (typeof cmd === 'object') {
+      const commandString = typeof cmd.command === 'string' ? cmd.command : moduleName(cmd)
       if (cmd.builder && typeof cmd.handler === 'function') {
-        self.addHandler(cmd.command, extractDesc(cmd), cmd.builder, cmd.handler)
+        self.addHandler(commandString, extractDesc(cmd), cmd.builder, cmd.handler)
       } else {
-        self.addHandler(cmd.command, extractDesc(cmd))
+        self.addHandler(commandString, extractDesc(cmd))
       }
       return
     }
@@ -64,7 +67,7 @@ module.exports = function (yargs, usage, validation) {
         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
+        context.dirs[context.commands.concat(parseCommand(visited.command || commandFromFilename(filename)).cmd).join('|')] = dir
         self.addHandler(visited)
       }
       return visited
@@ -72,6 +75,19 @@ module.exports = function (yargs, usage, validation) {
     requireDirectory({ require: req, filename: mainFilename }, dir, opts)
   }
 
+  // lookup module object from require()d command and derive name
+  // if module was not require()d and no name given, throw error
+  function moduleName (obj) {
+    const mod = whichModule(obj)
+    if (!mod) throw new Error('No command name given for module: ' + inspect(obj))
+    return commandFromFilename(mod.filename)
+  }
+
+  // derive command name from filename
+  function commandFromFilename (filename) {
+    return path.basename(filename, path.extname(filename))
+  }
+
   function extractDesc (obj) {
     for (var keys = ['describe', 'description', 'desc'], i = 0, l = keys.length, test; i < l; i++) {
       test = obj[keys[i]]
diff --git a/package.json b/package.json
index 2ec9212..eeee27d 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "require-main-filename": "^1.0.1",
     "set-blocking": "^2.0.0",
     "string-width": "^1.0.1",
+    "which-module": "^1.0.0",
     "window-size": "^0.2.0",
     "y18n": "^3.2.1",
     "yargs-parser": "^2.4.0"


Test patch:
diff --git a/test/command.js b/test/command.js
index 34e2a9f..965942a 100644
--- a/test/command.js
+++ b/test/command.js
@@ -435,5 +435,24 @@ describe('Command', function () {
         ''
       ])
     })
+
+    it('derives \'command\' string from filename when not exported', function () {
+      var r = checkOutput(function () {
+        return yargs('--help').help().wrap(null)
+          // assumes cwd is node_modules/mocha/bin
+          .commandDir('../../../test/fixtures/cmddir_noname')
+          .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:',
+        '  nameless  Command name derived from module filename',
+        'Options:',
+        '  --help  Show help  [boolean]',
+        ''
+      ])
+    })
   })
 })
diff --git a/test/fixtures/cmddir_noname/nameless.js b/test/fixtures/cmddir_noname/nameless.js
new file mode 100644
index 0000000..6da3ae9
--- /dev/null
+++ b/test/fixtures/cmddir_noname/nameless.js
@@ -0,0 +1,14 @@
+exports.description = 'Command name derived from module filename'
+
+exports.builder = {
+  banana: {
+    default: 'cool'
+  },
+  batman: {
+    default: 'sad'
+  }
+}
+
+exports.handler = function (argv) {
+  global.commandHandlerCalledWith = argv
+}
diff --git a/test/yargs.js b/test/yargs.js
index 347c40f..f829722 100644
--- a/test/yargs.js
+++ b/test/yargs.js
@@ -377,6 +377,31 @@ describe('yargs dsl tests', function () {
       global.commandHandlerCalledWith.foo.should.equal('bar')
       delete global.commandHandlerCalledWith
     })
+
+    it('derives \'command\' string from filename when missing', function () {
+      var argv = yargs('nameless --foo bar')
+        .command(require('./fixtures/cmddir_noname/nameless'))
+        .argv
+
+      argv.banana.should.equal('cool')
+      argv.batman.should.equal('sad')
+      argv.foo.should.equal('bar')
+
+      global.commandHandlerCalledWith.banana.should.equal('cool')
+      global.commandHandlerCalledWith.batman.should.equal('sad')
+      global.commandHandlerCalledWith.foo.should.equal('bar')
+      delete global.commandHandlerCalledWith
+    })
+
+    it('throws error for non-module command object missing \'command\' string', function () {
+      expect(function () {
+        yargs.command({
+          desc: 'A command with no name',
+          builder: function (yargs) { return yargs },
+          handler: function (argv) {}
+        })
+      }).to.throw(/No command name given for module: { desc: 'A command with no name',\n {2}builder: \[Function\],\n {2}handler: \[Function\] }/)
+    })
   })
 
   describe('terminalWidth', function () {