Repository: yargs/yargs
Pull request #865 (2017-05-01 00:54:55)

feat: allow extends to load configuration from a module
Rather than adding more configuration for the `extends` feature, I've opted to simplify our logic somewhat:

it's now always assumed that either:

* extends, regardless of whether it comes in through `pkgConf` or `config`, points to the absolute path of a configuration file -- this approach might not solve 100% of use-cases, but I like how much it simplifies the problem.
* extends may now also represent the name of a module to require  (this assumes that the module exports configuration in its index, see https://www.npmjs.com/package/yargs-test-extends) -- _I think this feature allows users to build something resembling babel-presets on top of yargs!_

`BREAKING CHANGE: extends functionality now always loads the JSON provided, rather than reading from a specific key`

@rcoy-v great work on the `extends` feature; I'm really excited to pull this into `nyc`.


Patch:
diff --git a/README.md b/README.md
index 0654760..a5aa3d6 100644
--- a/README.md
+++ b/README.md
@@ -1008,7 +1008,41 @@ $ 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.
+### `extends` Keyword
+
+`config` and `pkgConf` can provide the `extends` keyword to
+indicate that configuration should inherit from another location.
+
+The value of extends can be either a relative or absolute path to a JSON
+configuration file, e.g.,
+
+```js
+yargs.config({
+  extends: './configs/a.json',
+  logLevel: 'verbose'
+})
+```
+
+Or, a module can be provided (this is useful for creating functionality like
+  [babel-presets](https://babeljs.io/docs/plugins/)).
+
+**my-library.js**
+
+```js
+yargs.pkgConf('nyc')
+```
+
+**consuming package.json**
+
+```json
+{
+  "nyc": {
+    "extends": "nyc-babel-config"
+  }
+}
+```
+
+Where `nyc-babel-config` is a package that exports configuration in its index.
 
 <a name="conflicts"></a>.conflicts(x, y)
 ----------------------------------------------
@@ -1679,8 +1713,6 @@ 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
index ec751b0..5fc69fa 100644
--- a/lib/apply-extends.js
+++ b/lib/apply-extends.js
@@ -15,22 +15,33 @@ function getPathToDefaultConfig (cwd, pathToExtend) {
   return path.resolve(cwd, pathToExtend)
 }
 
-function applyExtends (config, cwd, subKey) {
+function applyExtends (config, cwd) {
   var defaultConfig = {}
 
   if (config.hasOwnProperty('extends')) {
-    var pathToDefault = getPathToDefaultConfig(cwd, config.extends)
+    if (typeof config.extends !== 'string') return defaultConfig
+    var isPath = /\.json$/.test(config.extends)
+    var pathToDefault = null
+    if (!isPath) {
+      try {
+        pathToDefault = require.resolve(config.extends)
+      } catch (err) {
+        // most likely this simply isn't a module.
+      }
+    } else {
+      pathToDefault = getPathToDefaultConfig(cwd, config.extends)
+    }
+    // maybe the module uses key for some other reason,
+    // err on side of caution.
+    if (!pathToDefault && !isPath) return config
 
     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)
+    defaultConfig = isPath ? JSON.parse(fs.readFileSync(pathToDefault, 'utf8')) : require(config.extends)
+    delete config.extends
+    defaultConfig = applyExtends(defaultConfig, path.dirname(pathToDefault))
   }
 
   previouslyVisitedConfigs = []
diff --git a/package.json b/package.json
index 29ddc14..96b518e 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,8 @@
     "rimraf": "^2.5.0",
     "standard": "^8.6.0",
     "standard-version": "^3.0.0",
-    "which": "^1.2.9"
+    "which": "^1.2.9",
+    "yargs-test-extends": "^1.0.1"
   },
   "scripts": {
     "pretest": "standard",
diff --git a/yargs.js b/yargs.js
index df0f904..a3eb0c2 100644
--- a/yargs.js
+++ b/yargs.js
@@ -478,7 +478,7 @@ function Yargs (processArgs, cwd, parentRequire) {
 
     // If an object exists in the key, add it to options.configObjects
     if (obj[key] && typeof obj[key] === 'object') {
-      conf = applyExtends(obj[key], path || cwd, key)
+      conf = applyExtends(obj[key], path || cwd)
       options.configObjects = (options.configObjects || []).concat(conf)
     }
 


Test patch:
diff --git a/test/fixtures/extends/packageA/package.json b/test/fixtures/extends/packageA/package.json
index 236e60f..6e5112c 100644
--- a/test/fixtures/extends/packageA/package.json
+++ b/test/fixtures/extends/packageA/package.json
@@ -1,6 +1,6 @@
 {
   "foo": {
     "a": 80,
-    "extends": "../packageB/package.json"
+    "extends": "../packageB/index.json"
   }
-}
\ No newline at end of file
+}
diff --git a/test/fixtures/extends/packageB/index.json b/test/fixtures/extends/packageB/index.json
new file mode 100644
index 0000000..8f4f7c8
--- /dev/null
+++ b/test/fixtures/extends/packageB/index.json
@@ -0,0 +1,4 @@
+{
+  "a": 90,
+  "b": "riffiwobbles"
+}
diff --git a/test/fixtures/extends/packageB/package.json b/test/fixtures/extends/packageB/package.json
deleted file mode 100644
index 473fda7..0000000
--- a/test/fixtures/extends/packageB/package.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "foo": {
-    "a": 90,
-    "b": "riffiwobbles"
-  }
-}
\ No newline at end of file
diff --git a/test/yargs.js b/test/yargs.js
index 5e46d8b..20a9f26 100644
--- a/test/yargs.js
+++ b/test/yargs.js
@@ -1216,6 +1216,31 @@ describe('yargs dsl tests', function () {
         argv.b.should.equal(22)
         argv.z.should.equal(15)
       })
+
+      // see: https://www.npmjs.com/package/yargs-test-extends
+      it('allows a module to be extended, rather than a JSON file', () => {
+        var argv = yargs()
+          .config({
+            a: 2,
+            extends: 'yargs-test-extends'
+          })
+          .argv
+
+        argv.a.should.equal(2)
+        argv.c.should.equal(201)
+      })
+
+      it('ignores an extends key that does not look like a path or module', () => {
+        var argv = yargs()
+          .config({
+            a: 2,
+            extends: 'batman'
+          })
+          .argv
+
+        argv.a.should.equal(2)
+        argv.extends.should.equal('batman')
+      })
     })
   })