Repository: yargs/yargs
Pull request #779 (2017-02-10 15:45:53)

Allow provided config object to extend other configs

bcoe: @rcoy-v I'm going to try to put a few hours in against yargs tomorrow, and would love to work on landing this.

Do you have any time to address @nexdrew's suggestions, I could also work on making a few edits and landing this myself -- your call.
rcoy-v: I might be able to pick this up again tomorrow, but I can't promise that. I'm fine if you want to make edits to this pull request.
bcoe: @rcoy-v awesome, I'm really excited about this feature.
bcoe: @rcoy-v @JaKXz I'm at [Hack Illinois](https://hackillinois.org/) this coming weekend; I was thinking getting yargs@7 out over the weekend would be a great task for my team -- tldr; expect to see a bunch of these tickets land on the weekend.
bcoe: @nexdrew is this the new argument validation, I think we might find a few edge-cases where we need to loosen the rules a bit.
nexdrew: @bcoe 

> is this the new argument validation?

Negative. This error is thrown by the builtin Node `path` module. The `argsert` definition for `.pkgConf()` looks correct to me:

```js
argsert('<string> [string]', [key, path], arguments.length)
```

The 2nd string is optional.
JaKXz: @nexdrew won't `arguments.length` give you a number [which doesn't match the type of optional string]?
nexdrew: @JaKXz As I understand it, the 3rd argument to `argsert()` should be the number of arguments given. The actual types are checked via the 2nd argument, which is an array representing the actual argument values.
bcoe: @JaKXz, @nexdrew is correct -- the use of `argsert` in yargs is dead-simple, based on various performance bike-shedding discussions.
nexdrew: @rcoy-v See [this PR against your branch](https://github.com/rcoy-v/yargs/pull/1) to add the changes I requested. I _think_ that will put this over the finish line. Let me know what you think. Thanks!

Patch:
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..91c3361
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,7 @@
+root = true
+
+[*.js]
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
diff --git a/README.md b/README.md
index 7f36555..ef2948e 100644
--- a/README.md
+++ b/README.md
@@ -980,6 +980,8 @@ $ node test.js
   '$0': 'test.js' }
 ```
 
+Note that a configuration object may extend from a JSON file using the `"extends"` property. When doing so, the `"extends"` value should be a path (relative or absolute) to the extended JSON file.
+
 <a name="conflicts"></a>.conflicts(x, y)
 ----------------------------------------------
 
@@ -1649,6 +1651,8 @@ as a configuration object.
 `cwd` can optionally be provided, the package.json will be read
 from this location.
 
+Note that a configuration stanza in package.json may extend from an identically keyed stanza in another package.json file using the `"extends"` property. When doing so, the `"extends"` value should be a path (relative or absolute) to the extended package.json file.
+
 .recommendCommands()
 ---------------------------
 
diff --git a/lib/apply-extends.js b/lib/apply-extends.js
new file mode 100644
index 0000000..1675fb4
--- /dev/null
+++ b/lib/apply-extends.js
@@ -0,0 +1,41 @@
+var fs = require('fs')
+var path = require('path')
+var assign = require('./assign')
+var YError = require('./yerror')
+
+var previouslyVisitedConfigs = []
+
+function checkForCircularExtends (path) {
+  if (previouslyVisitedConfigs.indexOf(path) > -1) {
+    throw new YError("Circular extended configurations: '" + path + "'.")
+  }
+}
+
+function getPathToDefaultConfig (cwd, pathToExtend) {
+  return path.isAbsolute(pathToExtend) ? pathToExtend : path.join(cwd, pathToExtend)
+}
+
+function applyExtends (config, cwd, subKey) {
+  var defaultConfig = {}
+
+  if (config.hasOwnProperty('extends')) {
+    var pathToDefault = getPathToDefaultConfig(cwd, config.extends)
+
+    checkForCircularExtends(pathToDefault)
+
+    previouslyVisitedConfigs.push(pathToDefault)
+    delete config.extends
+
+    defaultConfig = JSON.parse(fs.readFileSync(pathToDefault, 'utf8'))
+    if (subKey) {
+      defaultConfig = defaultConfig[subKey] || {}
+    }
+    defaultConfig = applyExtends(defaultConfig, path.dirname(pathToDefault), subKey)
+  }
+
+  previouslyVisitedConfigs = []
+
+  return assign(defaultConfig, config)
+}
+
+module.exports = applyExtends
diff --git a/package.json b/package.json
index 0911614..ec5eb96 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
     "string-width": "^1.0.2",
     "which-module": "^1.0.0",
     "y18n": "^3.2.1",
-    "yargs-parser": "^4.2.1"
+    "yargs-parser": "^5.0.0"
   },
   "devDependencies": {
     "chai": "^3.4.1",
diff --git a/yargs.js b/yargs.js
index f39150d..17b8376 100644
--- a/yargs.js
+++ b/yargs.js
@@ -9,6 +9,7 @@ const Validation = require('./lib/validation')
 const Y18n = require('y18n')
 const objFilter = require('./lib/obj-filter')
 const setBlocking = require('set-blocking')
+const applyExtends = require('./lib/apply-extends')
 const YError = require('./lib/yerror')
 
 var exports = module.exports = Yargs
@@ -304,6 +305,7 @@ function Yargs (processArgs, cwd, parentRequire) {
     argsert('[object|string] [string|function] [function]', [key, msg, parseFn], arguments.length)
     // allow a config object to be provided directly.
     if (typeof key === 'object') {
+      key = applyExtends(key, cwd)
       options.configObjects = (options.configObjects || []).concat(key)
       return self
     }
@@ -319,6 +321,7 @@ function Yargs (processArgs, cwd, parentRequire) {
     ;(Array.isArray(key) ? key : [key]).forEach(function (k) {
       options.config[k] = parseFn || true
     })
+
     return self
   }
 
@@ -469,11 +472,14 @@ function Yargs (processArgs, cwd, parentRequire) {
   self.pkgConf = function (key, path) {
     argsert('<string> [string]', [key, path], arguments.length)
     var conf = null
-    var obj = pkgUp(path)
+    // prefer cwd to require-main-filename in this method
+    // since we're looking for e.g. "nyc" config in nyc consumer
+    // rather than "yargs" config in nyc (where nyc is the main filename)
+    var obj = pkgUp(path || cwd)
 
     // If an object exists in the key, add it to options.configObjects
     if (obj[key] && typeof obj[key] === 'object') {
-      conf = obj[key]
+      conf = applyExtends(obj[key], path || cwd, key)
       options.configObjects = (options.configObjects || []).concat(conf)
     }
 
@@ -935,7 +941,7 @@ function Yargs (processArgs, cwd, parentRequire) {
     options.configuration = pkgUp()['yargs'] || {}
     const parsed = Parser.detailed(args, options)
     var argv = parsed.argv
-    if (parseContext) argv = assign(parseContext, argv)
+    if (parseContext) argv = assign(argv, parseContext)
     var aliases = parsed.aliases
 
     argv.$0 = self.$0


Test patch:
diff --git a/test/fixtures/extends/circular_1.json b/test/fixtures/extends/circular_1.json
new file mode 100644
index 0000000..4cd6629
--- /dev/null
+++ b/test/fixtures/extends/circular_1.json
@@ -0,0 +1,4 @@
+{
+  "a": 44,
+  "extends": "./circular_2.json"
+}
\ No newline at end of file
diff --git a/test/fixtures/extends/circular_2.json b/test/fixtures/extends/circular_2.json
new file mode 100644
index 0000000..85b7de8
--- /dev/null
+++ b/test/fixtures/extends/circular_2.json
@@ -0,0 +1,4 @@
+{
+  "b": "any",
+  "extends": "./circular_1.json"
+}
\ No newline at end of file
diff --git a/test/fixtures/extends/config_1.json b/test/fixtures/extends/config_1.json
new file mode 100644
index 0000000..043fb87
--- /dev/null
+++ b/test/fixtures/extends/config_1.json
@@ -0,0 +1,5 @@
+{
+  "a": 30,
+  "b": 22,
+  "extends": "./config_2.json"
+}
\ No newline at end of file
diff --git a/test/fixtures/extends/config_2.json b/test/fixtures/extends/config_2.json
new file mode 100644
index 0000000..886f419
--- /dev/null
+++ b/test/fixtures/extends/config_2.json
@@ -0,0 +1,3 @@
+{
+  "z": 15
+}
\ No newline at end of file
diff --git a/test/fixtures/extends/packageA/package.json b/test/fixtures/extends/packageA/package.json
new file mode 100644
index 0000000..236e60f
--- /dev/null
+++ b/test/fixtures/extends/packageA/package.json
@@ -0,0 +1,6 @@
+{
+  "foo": {
+    "a": 80,
+    "extends": "../packageB/package.json"
+  }
+}
\ No newline at end of file
diff --git a/test/fixtures/extends/packageB/package.json b/test/fixtures/extends/packageB/package.json
new file mode 100644
index 0000000..473fda7
--- /dev/null
+++ b/test/fixtures/extends/packageB/package.json
@@ -0,0 +1,6 @@
+{
+  "foo": {
+    "a": 90,
+    "b": "riffiwobbles"
+  }
+}
\ No newline at end of file
diff --git a/test/yargs.js b/test/yargs.js
index 5d5771d..45719d2 100644
--- a/test/yargs.js
+++ b/test/yargs.js
@@ -1,16 +1,21 @@
-/* global context, describe, it, beforeEach */
+/* global context, describe, it, beforeEach, afterEach */
 
 var expect = require('chai').expect
 var fs = require('fs')
 var path = require('path')
 var checkOutput = require('./helpers/utils').checkOutput
-var yargs = require('../')
+var yargs
+var YError = require('../lib/yerror')
 
 require('chai').should()
 
 describe('yargs dsl tests', function () {
   beforeEach(function () {
-    yargs.reset()
+    yargs = require('../')
+  })
+
+  afterEach(function () {
+    delete require.cache[require.resolve('../')]
   })
 
   it('should use bin name for $0, eliminating path', function () {
@@ -768,6 +773,14 @@ describe('yargs dsl tests', function () {
       a1.foo.should.equal('bar')
       a1.context.should.equal('look at me go!')
     })
+
+    // see https://github.com/yargs/yargs/issues/724
+    it('overrides parsed value of argv with context object', function () {
+      var a1 = yargs.parse('-x=33', {
+        x: 42
+      })
+      a1.x.should.equal(42)
+    })
   })
 
   // yargs.parse(['foo', '--bar'], function (err, argv, output) {}
@@ -1155,6 +1168,42 @@ describe('yargs dsl tests', function () {
       argv.foo.should.equal(1)
       argv.bar.should.equal(2)
     })
+
+    describe('extends', function () {
+      it('applies default configurations when given config object', function () {
+        var argv = yargs
+          .config({
+            extends: './test/fixtures/extends/config_1.json',
+            a: 1
+          })
+          .argv
+
+        argv.a.should.equal(1)
+        argv.b.should.equal(22)
+        argv.z.should.equal(15)
+      })
+
+      it('protects against circular extended configurations', function () {
+        expect(function () {
+          yargs.config({extends: './test/fixtures/extends/circular_1.json'})
+        }).to.throw(YError)
+      })
+
+      it('handles aboslute paths', function () {
+        var absolutePath = path.join(process.cwd(), 'test', 'fixtures', 'extends', 'config_1.json')
+
+        var argv = yargs
+          .config({
+            a: 2,
+            extends: absolutePath
+          })
+          .argv
+
+        argv.a.should.equal(2)
+        argv.b.should.equal(22)
+        argv.z.should.equal(15)
+      })
+    })
   })
 
   describe('normalize', function () {
@@ -1411,6 +1460,20 @@ describe('yargs dsl tests', function () {
 
       argv.foo.should.equal('a')
     })
+
+    it('should apply default configurations from extended packages', function () {
+      var argv = yargs().pkgConf('foo', 'test/fixtures/extends/packageA').argv
+
+      argv.a.should.equal(80)
+      argv.b.should.equals('riffiwobbles')
+    })
+
+    it('should apply extended configurations from cwd when no path is given', function () {
+      var argv = yargs('', 'test/fixtures/extends/packageA').pkgConf('foo').argv
+
+      argv.a.should.equal(80)
+      argv.b.should.equals('riffiwobbles')
+    })
   })
 
   describe('skipValidation', function () {