Repository: oclif/core
Pull request #166 (2021-05-27 06:16:16)

ESM quality of life improvements
@amphro 
This rounds out ESM support for @oclif/core v2. 

ESM quality of life improvements:
- support file URL string as options input (IE 'import.meta.url').
- added `./flush.js` as an export from @oclif/core; `./src/index.ts`
- added main runtime ESM test w/ file URL as options.
- raised package.json Node engine level to `>= 12.0.0`. 

The added runtime tests focus on Main.run using a file URL as options. I also changed one of the Config ESM tests to use file URL as options as well. 

Please see the follow up message for a note about CommonJS deprecations when I raised the engine level to `>= 12.0.0`

typhonrt: Regarding the CommonJS deprecations please see the following image which popped up in my IDE / WebStorm:

![Screen Shot 2021-05-26 at 10 11 37 PM](https://user-images.githubusercontent.com/311473/119775546-9e880780-be78-11eb-970e-3142a0650961.png)

It appears after Node 12.19.0 and 14.6.0 that `module.parent` is deprecated. The suggestion is to use `require.main` or `module.children` instead. Since this is a CommonJS change to Oclif I did not make any modifications. It might be prudent to track this in a separate issue and work out any changes necessary so that deprecated functionality isn't depended on for future Node versions. 

Relevant Node documentation:
https://nodejs.org/api/modules.html#modules_module_parent

Patch:
diff --git a/package.json b/package.json
index 48bc5de..e81e4ba 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
     "typescript": "3.8.3"
   },
   "engines": {
-    "node": ">=8.0.0"
+    "node": ">=12.0.0"
   },
   "files": [
     "/lib",
diff --git a/src/command.ts b/src/command.ts
index 0914577..5feb477 100644
--- a/src/command.ts
+++ b/src/command.ts
@@ -1,3 +1,5 @@
+import {fileURLToPath} from 'url'
+
 import {format, inspect} from 'util'
 
 import {Config} from './config'
@@ -76,6 +78,12 @@ export default abstract class Command {
    */
   static run: Interfaces.Command.Class['run'] = async function (this: Interfaces.Command.Class, argv?: string[], opts?) {
     if (!argv) argv = process.argv.slice(2)
+
+    // Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
+    if (typeof opts === 'string' && opts.startsWith('file://')) {
+      opts = fileURLToPath(opts)
+    }
+
     const config = await Config.load(opts || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname)
     const cmd = new this(argv, config)
     return cmd._run(argv)
diff --git a/src/config/config.ts b/src/config/config.ts
index 58f0932..1823d2b 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -2,7 +2,7 @@ import {CLIError, error, exit, warn} from '../errors'
 import * as Lodash from 'lodash'
 import * as os from 'os'
 import * as path from 'path'
-import {URL} from 'url'
+import {fileURLToPath, URL} from 'url'
 import {format} from 'util'
 
 import {Options, Plugin as IPlugin} from '../interfaces/plugin'
@@ -96,6 +96,11 @@ export class Config implements IConfig {
   constructor(public options: Options) {}
 
   static async load(opts: LoadOptions = (module.parent && module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) {
+    // Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
+    if (typeof opts === 'string' && opts.startsWith('file://')) {
+      opts = fileURLToPath(opts)
+    }
+
     if (typeof opts === 'string') opts = {root: opts}
     if (isConfig(opts)) return opts
     const config = new Config(opts)
diff --git a/src/index.ts b/src/index.ts
index 70ff35e..c338a07 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,8 @@ import * as Parser from './parser'
 import {Hook} from './interfaces/hooks'
 import {settings, Settings} from './settings'
 
+const flush = require('../flush')
+
 export {
   Command,
   Config,
@@ -32,6 +34,7 @@ export {
   toConfiguredId,
   settings,
   Settings,
+  flush,
 }
 
 function checkCWD() {
diff --git a/src/main.ts b/src/main.ts
index b106a56..ce01e7e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,3 +1,5 @@
+import {fileURLToPath} from 'url'
+
 import {format, inspect} from 'util'
 
 import * as Interfaces from './interfaces'
@@ -25,6 +27,11 @@ const versionOverride = (argv: string[]): boolean => {
 }
 
 export async function run(argv = process.argv.slice(2), options?: Interfaces.LoadOptions) {
+  // Handle the case when a file URL string or URL is passed in such as 'import.meta.url'; covert to file path.
+  if ((typeof options === 'string' && options.startsWith('file://')) || options instanceof URL) {
+    options = fileURLToPath(options)
+  }
+
   // return Main.run(argv, options)
   const config = await Config.load(options || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) as Config
 


Test patch:
diff --git a/test/command/fixtures/esm/package.json b/test/command/fixtures/esm/package.json
new file mode 100644
index 0000000..9cae015
--- /dev/null
+++ b/test/command/fixtures/esm/package.json
@@ -0,0 +1,26 @@
+{
+  "name": "oclif-esm",
+  "version": "0.0.0",
+  "description": "base library for oclif CLIs w/ ESM",
+  "private": true,
+  "type": "module",
+  "files": [],
+  "oclif": {
+    "commands": "./src/commands",
+    "topicSeparator": " ",
+    "plugins": [
+      "@oclif/plugin-help",
+      "@oclif/plugin-plugins"
+    ],
+    "topics": {
+      "foo": {
+        "description": "foo topic description",
+        "subtopics": {
+          "bar": {
+            "description": "foo bar topic description"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/test/command/fixtures/esm/src/commands/foo/bar/fail.js b/test/command/fixtures/esm/src/commands/foo/bar/fail.js
new file mode 100644
index 0000000..52d6cc0
--- /dev/null
+++ b/test/command/fixtures/esm/src/commands/foo/bar/fail.js
@@ -0,0 +1,8 @@
+export class Command {
+  static description = 'fail description'
+
+  static run() {
+    console.log('it fails!')
+    throw new Error('random error')
+  }
+}
diff --git a/test/command/fixtures/esm/src/commands/foo/bar/succeed.js b/test/command/fixtures/esm/src/commands/foo/bar/succeed.js
new file mode 100644
index 0000000..0469bf0
--- /dev/null
+++ b/test/command/fixtures/esm/src/commands/foo/bar/succeed.js
@@ -0,0 +1,8 @@
+export class Command {
+  static description = 'succeed description'
+
+  static run() {
+    console.log('it works!')
+    return 'returned success!'
+  }
+}
diff --git a/test/command/fixtures/esm/src/commands/foo/baz.js b/test/command/fixtures/esm/src/commands/foo/baz.js
new file mode 100644
index 0000000..b2bbc40
--- /dev/null
+++ b/test/command/fixtures/esm/src/commands/foo/baz.js
@@ -0,0 +1,7 @@
+export class Command {
+  static description = 'foo baz description'
+
+  static run() {
+    console.log('running Baz')
+  }
+}
diff --git a/test/command/main-esm.test.ts b/test/command/main-esm.test.ts
new file mode 100644
index 0000000..e1be91d
--- /dev/null
+++ b/test/command/main-esm.test.ts
@@ -0,0 +1,93 @@
+import {expect, fancy} from 'fancy-test'
+import * as path from 'path'
+import * as url from 'url'
+
+import {run} from '../../src/main'
+
+// This tests file URL / import.meta.url simulation.
+const convertToFileURL = (filepath: string) => url.pathToFileURL(filepath).toString()
+
+let root = path.resolve(__dirname, '../../package.json')
+const pjson = require(root)
+const version = `@oclif/core/${pjson.version} ${process.platform}-${process.arch} node-${process.version}`
+
+root = convertToFileURL(root)
+
+describe('main-esm', () => {
+  fancy
+  .stdout()
+  .do(() => run(['plugins'], root))
+  .do((output: any) => expect(output.stdout).to.equal('no plugins installed\n'))
+  .it('runs plugins')
+
+  fancy
+  .stdout()
+  .do(() => run(['--version'], root))
+  .do((output: any) => expect(output.stdout).to.equal(version + '\n'))
+  .it('runs --version')
+
+  fancy
+  .stdout()
+  .do(() => run(['--help'], root))
+  .do((output: any) => expect(output.stdout).to.equal(`base library for oclif CLIs
+
+VERSION
+  ${version}
+
+USAGE
+  $ oclif [COMMAND]
+
+TOPICS
+  plugins  list installed plugins
+
+COMMANDS
+  help     display help for oclif
+  plugins  list installed plugins
+
+`))
+  .it('runs --help')
+
+  fancy
+  .stdout()
+  .do(() => run(['--help', 'foo'], convertToFileURL(path.resolve(__dirname, 'fixtures/esm/package.json'))))
+  .do((output: any) => expect(output.stdout).to.equal(`foo topic description
+
+USAGE
+  $ oclif-esm foo COMMAND
+
+TOPICS
+  foo bar  foo bar topic description
+
+COMMANDS
+  foo baz  foo baz description
+
+`))
+  .it('runs spaced topic help')
+
+  fancy
+  .stdout()
+  .do(() => run(['foo', 'bar', '--help'], convertToFileURL(path.resolve(__dirname, 'fixtures/esm/package.json'))))
+  .do((output: any) => expect(output.stdout).to.equal(`foo bar topic description
+
+USAGE
+  $ oclif-esm foo bar COMMAND
+
+COMMANDS
+  foo bar fail     fail description
+  foo bar succeed  succeed description
+
+`))
+  .it('runs spaced topic help v2')
+
+  fancy
+  .stdout()
+  .do(() => run(['foo', 'baz'], convertToFileURL(path.resolve(__dirname, 'fixtures/esm/package.json'))))
+  .do((output: any) => expect(output.stdout).to.equal('running Baz\n'))
+  .it('runs foo:baz with space separator')
+
+  fancy
+  .stdout()
+  .do(() => run(['foo', 'bar', 'succeed'], convertToFileURL(path.resolve(__dirname, 'fixtures/esm/package.json'))))
+  .do((output: any) => expect(output.stdout).to.equal('it works!\n'))
+  .it('runs foo:bar:succeed with space separator')
+})
diff --git a/test/config/esm.test.ts b/test/config/esm.test.ts
index 1400b07..5c98f87 100644
--- a/test/config/esm.test.ts
+++ b/test/config/esm.test.ts
@@ -1,3 +1,4 @@
+import * as url from 'url'
 import * as path from 'path'
 
 import {Config} from '../../src/config'
@@ -7,8 +8,11 @@ import {expect, fancy} from './test'
 const root = path.resolve(__dirname, 'fixtures/esm')
 const p = (p: string) => path.join(root, p)
 
+// This tests file URL / import.meta.url simulation.
+const rootAsFileURL = url.pathToFileURL(root).toString()
+
 const withConfig = fancy
-.add('config', () => Config.load(root))
+.add('config', () => Config.load(rootAsFileURL))
 
 describe('esm', () => {
   withConfig