Repository: oclif/core
Pull request #163 (2021-05-14 05:14:23)

Strengthened ModuleLoader & unit tests; now supports mixed ESM / CJS plugins
@amphro I do have a second PR which is pertinent. I made one mistake in the separate unit tests insofar that at the very tail end I relied on just the separate ModuleLoader unit test to pass outside of a full runtime use case and this was the one time I didn't test final changes against my ESM middleware / CLI. 

What went wrong: In the `ModuleLoader.resolvePath` method when I created the the bad path tests `require.resolve` used a 2nd time would throw an exception. I didn't add in tests for good paths w/ no extensions which is how Oclif works. `require.resolve` will resolve the extension (`.js`). I switched to `path.resolve` and while the unit tests passed the aspect of not resolving the extension failed when running w/ my external ESM middleware & ESM CLI after #160 was merged.

So the initial unit test while everything passed I made a small mistake in modifying the ModuleLoader code to satisfy the tests and didn't do a full runtime test. 

--------

This PR significantly hardens ModuleLoader restoring correct behavior. In addition I didn't want to go back to using `require.resolve` and made a custom extension resolution algorithm that checks for (`.js`, `.mjs`, `.cjs`). There is a major benefit to this as I worked out a way to support mixed CJS / ESM loading of plugins. Oclif can now support fully ESM plugins w/ `"type": "module"` in the package.json and all `.js` files being ESM. There is also support now for loading ESM via `.mjs` file extensions from a CommonJS plugin and vice versa with an ESM plugin w/ `"type": "module"` one can load CJS via the `.cjs` extension. 

I strengthened the unit tests for ModuleLoader, but more importantly I added complete runtime tests for ESM, mixed ESM w/ CJS (`.cjs`), and mixed CJS w/ ESM (`.mjs`) which you can find in `./test/config/fixtures/` -> `esm`, `mixed-cjs-esm`, and `mixed-esm-cjs`.

Note (edit update below): For the new runtime tests I initially I copied the style of `./test/config/fixtures/typescript` which includes an unnecessary `yarn.lock` file. I removed them from the ESM / CJS runtime tests in a commit on Friday. 

The files changed / count increase is from the new runtime tests. 

And yeah I tested this PR w/ my external ESM Oclif middleware / CLI as well, so it is definitely fixed. 

Not ideal per se w/ two PRs for the core ESM addition, but this PR definitely gets things there 100% and has full unit tests and newly added runtime tests to back it up.

typhonrt: I did decide to remove the unnecessary `yarn.lock` files from the newly added ESM / CJS tests in `./test/config/fixtures` This file can also be removed from the `./test/config/fixtures/typescript` test as well. There is no installation for these tests and the mocked package.json is the only configuration necessary for the ESM / CJS runtime tests added. 

This makes it really obvious what has changed in this PR. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90583f9..91ad459 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
 
 All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
 
+### [0.5.7](https://github.com/oclif/core/compare/v0.5.6...v0.5.7) (2021-05-17)
+
+
+### Bug Fixes
+
+* conversion of spaced commands to colon commands ([#164](https://github.com/oclif/core/issues/164)) ([9503d32](https://github.com/oclif/core/commit/9503d323d6e0dffe98a0a7005f676daeebd9ec44))
+
 ### [0.5.6](https://github.com/oclif/core/compare/v0.5.5...v0.5.6) (2021-05-13)
 
 
diff --git a/package.json b/package.json
index f0d1c1c..45d021e 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@oclif/core",
   "description": "base library for oclif CLIs",
-  "version": "0.5.6",
+  "version": "0.5.7",
   "author": "Jeff Dickey @jdxcode",
   "bugs": "https://github.com/oclif/core/issues",
   "dependencies": {
diff --git a/src/config/plugin.ts b/src/config/plugin.ts
index 5bf8f36..429d01b 100644
--- a/src/config/plugin.ts
+++ b/src/config/plugin.ts
@@ -156,7 +156,7 @@ export class Plugin implements IPlugin {
     }
     this._debug(`loading IDs from ${this.commandsDir}`)
     const patterns = [
-      '**/*.+(js|ts|tsx)',
+      '**/*.+(js|cjs|mjs|ts|tsx)',
       '!**/*.+(d.ts|test.ts|test.js|spec.ts|spec.js)?(x)',
     ]
     const ids = globby.sync(patterns, {cwd: this.commandsDir})
diff --git a/src/help/index.ts b/src/help/index.ts
index 37426b8..aa861c5 100644
--- a/src/help/index.ts
+++ b/src/help/index.ts
@@ -9,9 +9,9 @@ import {renderList} from './list'
 import RootHelp from './root'
 import {stdtermwidth} from '../screen'
 import {compact, sortBy, uniqBy} from '../util'
-import {standarizeIDFromArgv, template} from './util'
+import {standardizeIDFromArgv, template} from './util'
 
-export {standarizeIDFromArgv, getHelpClass} from './util'
+export {standardizeIDFromArgv, getHelpClass} from './util'
 
 const wrap = require('wrap-ansi')
 const {
@@ -94,7 +94,7 @@ export class Help extends HelpBase {
   }
 
   public showHelp(argv: string[]) {
-    if (this.config.topicSeparator !== ':') argv = standarizeIDFromArgv(argv, this.config)
+    if (this.config.topicSeparator !== ':') argv = standardizeIDFromArgv(argv, this.config)
     const subject = getHelpSubject(argv)
     if (!subject) {
       if (this.config.pjson.oclif.default) {
diff --git a/src/help/util.ts b/src/help/util.ts
index 3300a4c..35fe99b 100644
--- a/src/help/util.ts
+++ b/src/help/util.ts
@@ -85,7 +85,7 @@ export function toConfiguredId(commandID: string, config: IConfig): string {
   return commandID.replace(new RegExp(':', 'g'), config.topicSeparator)
 }
 
-export function standarizeIDFromArgv(argv: string[], config: IConfig): string[] {
+export function standardizeIDFromArgv(argv: string[], config: IConfig): string[] {
   if (argv.length === 0) return argv
   if (config.topicSeparator === ' ') argv = collateSpacedCmdIDFromArgs(argv, config)
   else if (config.topicSeparator !== ':') argv[0] = toStandardizedId(argv[0], config)
diff --git a/src/main.ts b/src/main.ts
index 6db42f2..aa7325d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,7 +2,7 @@ import {format, inspect} from 'util'
 
 import * as Interfaces from './interfaces'
 import {Config} from './config'
-import {getHelpClass, standarizeIDFromArgv} from './help'
+import {getHelpClass, standardizeIDFromArgv} from './help'
 
 const log = (message = '', ...args: any[]) => {
   // tslint:disable-next-line strict-type-predicates
@@ -28,9 +28,9 @@ export async function run(argv = process.argv.slice(2), options?: Interfaces.Loa
   // return Main.run(argv, options)
   const config = await Config.load(options || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) as Config
 
-  // run init hook
-  if (config.topicSeparator !== ':') argv = standarizeIDFromArgv(argv, config)
+  if (config.topicSeparator !== ':' && !argv[0].includes(':')) argv = standardizeIDFromArgv(argv, config)
   let [id, ...argvSlice] = argv
+  // run init hook
   await config.runHook('init', {id, argv: argvSlice})
 
   // display version if applicable
diff --git a/src/module-loader.ts b/src/module-loader.ts
index 1ae3678..f1f4563 100644
--- a/src/module-loader.ts
+++ b/src/module-loader.ts
@@ -1,5 +1,6 @@
 import * as path from 'path'
 import * as url from 'url'
+import * as fs from 'fs-extra'
 
 import {ModuleLoadError} from './errors'
 import {Config as IConfig} from './interfaces'
@@ -8,6 +9,11 @@ import * as Config from './config'
 
 const getPackageType = require('get-package-type')
 
+/**
+ * Defines file extension resolution when source files do not have an extension.
+ */
+const s_EXTENSIONS: string[] = ['.js', '.mjs', '.cjs']
+
 /**
  * Provides a mechanism to use dynamic import / import() with tsconfig -> module: commonJS as otherwise import() gets
  * transpiled to require().
@@ -38,13 +44,15 @@ export default class ModuleLoader {
    * @returns {Promise<*>} The entire ESM module from dynamic import or CJS module by require.
    */
   static async load(config: IConfig|IPlugin, modulePath: string): Promise<any> {
-    const {isESM, filePath} = ModuleLoader.resolvePath(config, modulePath)
+    let filePath
+    let isESM
     try {
+      ({isESM, filePath} = ModuleLoader.resolvePath(config, modulePath))
       // It is important to await on _importDynamic to catch the error code.
       return isESM ? await _importDynamic(url.pathToFileURL(filePath)) : require(filePath)
     } catch (error) {
       if (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND') {
-        throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath}`)
+        throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath || modulePath}`)
       }
       throw error
     }
@@ -68,13 +76,15 @@ export default class ModuleLoader {
    *                                                                   file path and whether the module is ESM.
    */
   static async loadWithData(config: IConfig|IPlugin, modulePath: string): Promise<{isESM: boolean; module: any; filePath: string}> {
-    const {isESM, filePath} = ModuleLoader.resolvePath(config, modulePath)
+    let filePath
+    let isESM
     try {
+      ({isESM, filePath} = ModuleLoader.resolvePath(config, modulePath))
       const module = isESM ? await _importDynamic(url.pathToFileURL(filePath)) : require(filePath)
       return {isESM, module, filePath}
     } catch (error) {
       if (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND') {
-        throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath}`)
+        throw new ModuleLoadError(`${isESM ? 'import()' : 'require'} failed to load ${filePath || modulePath}`)
       }
       throw error
     }
@@ -106,8 +116,9 @@ export default class ModuleLoader {
 
   /**
    * Resolves a modulePath first by `require.resolve` to allow Node to resolve an actual module. If this fails then
-   * the `modulePath` is resolved from the root of the provided config. `path.resolve` is used for ESM and `tsPath`
-   * for non-ESM paths.
+   * the `modulePath` is resolved from the root of the provided config. `Config.tsPath` is used for initial resolution.
+   * If this file path does not exist then several extensions are tried from `s_EXTENSIONS` in order: '.js', '.mjs',
+   * '.cjs'. After a file path has been selected `isPathModule` is used to determine if the file is an ES Module.
    *
    * @param {IConfig|IPlugin} config - Oclif config or plugin config.
    * @param {string} modulePath - File path to load.
@@ -115,14 +126,28 @@ export default class ModuleLoader {
    * @returns {{isESM: boolean, filePath: string}} An object including file path and whether the module is ESM.
    */
   static resolvePath(config: IConfig|IPlugin, modulePath: string): {isESM: boolean; filePath: string} {
-    let isESM = config.pjson.type === 'module'
-    let filePath
+    let isESM: boolean
+    let filePath: string
 
     try {
       filePath = require.resolve(modulePath)
       isESM = ModuleLoader.isPathModule(filePath)
     } catch (error) {
-      filePath = isESM ? path.resolve(path.join(config.root, modulePath)) : Config.tsPath(config.root, modulePath)
+      filePath = Config.tsPath(config.root, modulePath)
+
+      // Try all supported extensions.
+      if (!fs.existsSync(filePath)) {
+        for (const extension of s_EXTENSIONS) {
+          const testPath = `${filePath}${extension}`
+
+          if (fs.existsSync(testPath)) {
+            filePath = testPath
+            break
+          }
+        }
+      }
+
+      isESM = ModuleLoader.isPathModule(filePath)
     }
 
     return {isESM, filePath}


Test patch:
diff --git a/test/config/esm.test.ts b/test/config/esm.test.ts
new file mode 100644
index 0000000..1400b07
--- /dev/null
+++ b/test/config/esm.test.ts
@@ -0,0 +1,52 @@
+import * as path from 'path'
+
+import {Config} from '../../src/config'
+
+import {expect, fancy} from './test'
+
+const root = path.resolve(__dirname, 'fixtures/esm')
+const p = (p: string) => path.join(root, p)
+
+const withConfig = fancy
+.add('config', () => Config.load(root))
+
+describe('esm', () => {
+  withConfig
+  .it('has commandsDir', ({config}) => {
+    expect(config.plugins[0]).to.deep.include({
+      commandsDir: p('src/commands'),
+    })
+  })
+
+  withConfig
+  .stdout()
+  .it('runs esm command and prerun & postrun hooks', async ctx => {
+    await ctx.config.runCommand('foo:bar:baz')
+    expect(ctx.stdout).to.equal('running esm prerun hook\nit works!\nrunning esm postrun hook\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs faulty command, only prerun hook triggers', async ctx => {
+    try {
+      await ctx.config.runCommand('foo:bar:fail')
+    } catch {
+      console.log('caught error')
+    }
+    expect(ctx.stdout).to.equal('running esm prerun hook\nit fails!\ncaught error\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs esm command, postrun hook captures command result', async ctx => {
+    await ctx.config.runCommand('foo:bar:test-result')
+    expect(ctx.stdout).to.equal('running esm prerun hook\nit works!\nrunning esm postrun hook\nreturned success!\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs init hook', async ctx => {
+    await (ctx.config.runHook as any)('init', {id: 'myid', argv: ['foo']})
+    expect(ctx.stdout).to.equal('running esm init hook\n')
+  })
+})
diff --git a/test/config/fixtures/esm/package.json b/test/config/fixtures/esm/package.json
new file mode 100644
index 0000000..316d453
--- /dev/null
+++ b/test/config/fixtures/esm/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "esm-plugin",
+  "description": "Module type ESM; All ESM source",
+  "private": true,
+  "type": "module",
+  "files": [],
+  "oclif": {
+    "commands": "./src/commands",
+    "hooks": {
+      "init": "./src/hooks/init",
+      "prerun": [
+        "./src/hooks/prerun"
+      ],
+      "postrun": [
+        "./src/hooks/postrun"
+      ]
+    }
+  },
+  "devDependencies": {
+    "globby": "^8.0.1"
+  }
+}
diff --git a/test/config/fixtures/esm/src/commands/foo/bar/baz.js b/test/config/fixtures/esm/src/commands/foo/bar/baz.js
new file mode 100644
index 0000000..778ed84
--- /dev/null
+++ b/test/config/fixtures/esm/src/commands/foo/bar/baz.js
@@ -0,0 +1,5 @@
+export class Command {
+  static run() {
+    console.log('it works!')
+  }
+}
diff --git a/test/config/fixtures/esm/src/commands/foo/bar/fail.js b/test/config/fixtures/esm/src/commands/foo/bar/fail.js
new file mode 100644
index 0000000..a69e634
--- /dev/null
+++ b/test/config/fixtures/esm/src/commands/foo/bar/fail.js
@@ -0,0 +1,6 @@
+export class Command {
+  static run() {
+    console.log('it fails!')
+    throw new Error('random error')
+  }
+}
diff --git a/test/config/fixtures/esm/src/commands/foo/bar/test-result.js b/test/config/fixtures/esm/src/commands/foo/bar/test-result.js
new file mode 100644
index 0000000..f46c8fb
--- /dev/null
+++ b/test/config/fixtures/esm/src/commands/foo/bar/test-result.js
@@ -0,0 +1,6 @@
+export class Command {
+  static run() {
+    console.log('it works!')
+    return 'returned success!'
+  }
+}
diff --git a/test/config/fixtures/esm/src/hooks/init.js b/test/config/fixtures/esm/src/hooks/init.js
new file mode 100644
index 0000000..42644ce
--- /dev/null
+++ b/test/config/fixtures/esm/src/hooks/init.js
@@ -0,0 +1,3 @@
+export function init() {
+  console.log('running esm init hook')
+}
diff --git a/test/config/fixtures/esm/src/hooks/postrun.js b/test/config/fixtures/esm/src/hooks/postrun.js
new file mode 100644
index 0000000..0a8a219
--- /dev/null
+++ b/test/config/fixtures/esm/src/hooks/postrun.js
@@ -0,0 +1,6 @@
+export default function postrun(options) {
+  console.log('running esm postrun hook')
+  if (options.Command.id === 'foo:bar:test-result') {
+    console.log(options.result)
+  }
+}
diff --git a/test/config/fixtures/esm/src/hooks/prerun.js b/test/config/fixtures/esm/src/hooks/prerun.js
new file mode 100644
index 0000000..54faae8
--- /dev/null
+++ b/test/config/fixtures/esm/src/hooks/prerun.js
@@ -0,0 +1,3 @@
+export default function prerun() {
+  console.log('running esm prerun hook')
+}
diff --git a/test/config/fixtures/mixed-cjs-esm/package.json b/test/config/fixtures/mixed-cjs-esm/package.json
new file mode 100644
index 0000000..e00ca4d
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "mixed-cjs-esm-plugin",
+  "description": "CommonJS module, but mixed w/ ESM by .mjs extension",
+  "private": true,
+  "files": [],
+  "oclif": {
+    "commands": "./src/commands",
+    "hooks": {
+      "init": "./src/hooks/init",
+      "prerun": [
+        "./src/hooks/prerun"
+      ],
+      "postrun": [
+        "./src/hooks/postrun"
+      ]
+    }
+  },
+  "devDependencies": {
+    "globby": "^8.0.1"
+  }
+}
diff --git a/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/baz.mjs b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/baz.mjs
new file mode 100644
index 0000000..778ed84
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/baz.mjs
@@ -0,0 +1,5 @@
+export class Command {
+  static run() {
+    console.log('it works!')
+  }
+}
diff --git a/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/fail.cjs b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/fail.cjs
new file mode 100644
index 0000000..9ec88f3
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/fail.cjs
@@ -0,0 +1,8 @@
+class Command {
+  static run() {
+    console.log('it fails!')
+    throw new Error('random error')
+  }
+}
+
+module.exports = Command
diff --git a/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/test-result.js b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/test-result.js
new file mode 100644
index 0000000..aefeea9
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/commands/foo/bar/test-result.js
@@ -0,0 +1,8 @@
+class Command {
+  static run() {
+    console.log('it works!')
+    return 'returned success!'
+  }
+}
+
+module.exports = Command
diff --git a/test/config/fixtures/mixed-cjs-esm/src/hooks/init.js b/test/config/fixtures/mixed-cjs-esm/src/hooks/init.js
new file mode 100644
index 0000000..f3df34f
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/hooks/init.js
@@ -0,0 +1,5 @@
+function init() {
+  console.log('running mixed-cjs-esm init hook')
+}
+
+module.exports = init
diff --git a/test/config/fixtures/mixed-cjs-esm/src/hooks/postrun.mjs b/test/config/fixtures/mixed-cjs-esm/src/hooks/postrun.mjs
new file mode 100644
index 0000000..451c96d
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/hooks/postrun.mjs
@@ -0,0 +1,6 @@
+export default function postrun(options) {
+  console.log('running mixed-cjs-esm postrun hook')
+  if (options.Command.id === 'foo:bar:test-result') {
+    console.log(options.result)
+  }
+}
diff --git a/test/config/fixtures/mixed-cjs-esm/src/hooks/prerun.cjs b/test/config/fixtures/mixed-cjs-esm/src/hooks/prerun.cjs
new file mode 100644
index 0000000..6d665cf
--- /dev/null
+++ b/test/config/fixtures/mixed-cjs-esm/src/hooks/prerun.cjs
@@ -0,0 +1,5 @@
+function prerun() {
+  console.log('running mixed-cjs-esm prerun hook')
+}
+
+module.exports = prerun
diff --git a/test/config/fixtures/mixed-esm-cjs/package.json b/test/config/fixtures/mixed-esm-cjs/package.json
new file mode 100644
index 0000000..96d35ef
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "mixed-esm-cjs-plugin",
+  "description": "Module type ESM, but mixed w/ CommonJS by .cjs extension",
+  "private": true,
+  "type": "module",
+  "files": [],
+  "oclif": {
+    "commands": "./src/commands",
+    "hooks": {
+      "init": "./src/hooks/init",
+      "prerun": [
+        "./src/hooks/prerun"
+      ],
+      "postrun": [
+        "./src/hooks/postrun"
+      ]
+    }
+  },
+  "devDependencies": {
+    "globby": "^8.0.1"
+  }
+}
diff --git a/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/baz.js b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/baz.js
new file mode 100644
index 0000000..778ed84
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/baz.js
@@ -0,0 +1,5 @@
+export class Command {
+  static run() {
+    console.log('it works!')
+  }
+}
diff --git a/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/fail.cjs b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/fail.cjs
new file mode 100644
index 0000000..9ec88f3
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/fail.cjs
@@ -0,0 +1,8 @@
+class Command {
+  static run() {
+    console.log('it fails!')
+    throw new Error('random error')
+  }
+}
+
+module.exports = Command
diff --git a/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/test-result.js b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/test-result.js
new file mode 100644
index 0000000..755ba23
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/commands/foo/bar/test-result.js
@@ -0,0 +1,7 @@
+export default class Command {
+  static run() {
+    console.log('it works!')
+    return 'returned success!'
+  }
+}
+
diff --git a/test/config/fixtures/mixed-esm-cjs/src/hooks/init.js b/test/config/fixtures/mixed-esm-cjs/src/hooks/init.js
new file mode 100644
index 0000000..2e4cd66
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/hooks/init.js
@@ -0,0 +1,3 @@
+export default function init() {
+  console.log('running mixed-esm-cjs init hook')
+}
diff --git a/test/config/fixtures/mixed-esm-cjs/src/hooks/postrun.js b/test/config/fixtures/mixed-esm-cjs/src/hooks/postrun.js
new file mode 100644
index 0000000..2340bba
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/hooks/postrun.js
@@ -0,0 +1,6 @@
+export default function postrun(options) {
+  console.log('running mixed-esm-cjs postrun hook')
+  if (options.Command.id === 'foo:bar:test-result') {
+    console.log(options.result)
+  }
+}
diff --git a/test/config/fixtures/mixed-esm-cjs/src/hooks/prerun.cjs b/test/config/fixtures/mixed-esm-cjs/src/hooks/prerun.cjs
new file mode 100644
index 0000000..fcc6d6b
--- /dev/null
+++ b/test/config/fixtures/mixed-esm-cjs/src/hooks/prerun.cjs
@@ -0,0 +1,5 @@
+function prerun() {
+  console.log('running mixed-esm-cjs prerun hook')
+}
+
+module.exports = prerun
diff --git a/test/config/mixed-cjs-esm.test.ts b/test/config/mixed-cjs-esm.test.ts
new file mode 100644
index 0000000..a89b91d
--- /dev/null
+++ b/test/config/mixed-cjs-esm.test.ts
@@ -0,0 +1,52 @@
+import * as path from 'path'
+
+import {Config} from '../../src/config'
+
+import {expect, fancy} from './test'
+
+const root = path.resolve(__dirname, 'fixtures/mixed-cjs-esm')
+const p = (p: string) => path.join(root, p)
+
+const withConfig = fancy
+.add('config', () => Config.load(root))
+
+describe('mixed-cjs-esm', () => {
+  withConfig
+  .it('has commandsDir', ({config}) => {
+    expect(config.plugins[0]).to.deep.include({
+      commandsDir: p('src/commands'),
+    })
+  })
+
+  withConfig
+  .stdout()
+  .it('runs mixed-cjs-esm command and prerun & postrun hooks', async ctx => {
+    await ctx.config.runCommand('foo:bar:baz')
+    expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit works!\nrunning mixed-cjs-esm postrun hook\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs faulty command, only prerun hook triggers', async ctx => {
+    try {
+      await ctx.config.runCommand('foo:bar:fail')
+    } catch {
+      console.log('caught error')
+    }
+    expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit fails!\ncaught error\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs mixed-cjs-esm command, postrun hook captures command result', async ctx => {
+    await ctx.config.runCommand('foo:bar:test-result')
+    expect(ctx.stdout).to.equal('running mixed-cjs-esm prerun hook\nit works!\nrunning mixed-cjs-esm postrun hook\nreturned success!\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs init hook', async ctx => {
+    await (ctx.config.runHook as any)('init', {id: 'myid', argv: ['foo']})
+    expect(ctx.stdout).to.equal('running mixed-cjs-esm init hook\n')
+  })
+})
diff --git a/test/config/mixed-esm-cjs.test.ts b/test/config/mixed-esm-cjs.test.ts
new file mode 100644
index 0000000..3ee6b1f
--- /dev/null
+++ b/test/config/mixed-esm-cjs.test.ts
@@ -0,0 +1,52 @@
+import * as path from 'path'
+
+import {Config} from '../../src/config'
+
+import {expect, fancy} from './test'
+
+const root = path.resolve(__dirname, 'fixtures/mixed-esm-cjs')
+const p = (p: string) => path.join(root, p)
+
+const withConfig = fancy
+.add('config', () => Config.load(root))
+
+describe('mixed-cjs-esm', () => {
+  withConfig
+  .it('has commandsDir', ({config}) => {
+    expect(config.plugins[0]).to.deep.include({
+      commandsDir: p('src/commands'),
+    })
+  })
+
+  withConfig
+  .stdout()
+  .it('runs mixed-esm-cjs command and prerun & postrun hooks', async ctx => {
+    await ctx.config.runCommand('foo:bar:baz')
+    expect(ctx.stdout).to.equal('running mixed-esm-cjs prerun hook\nit works!\nrunning mixed-esm-cjs postrun hook\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs faulty command, only prerun hook triggers', async ctx => {
+    try {
+      await ctx.config.runCommand('foo:bar:fail')
+    } catch {
+      console.log('caught error')
+    }
+    expect(ctx.stdout).to.equal('running mixed-esm-cjs prerun hook\nit fails!\ncaught error\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs mixed-esm-cjs command, postrun hook captures command result', async ctx => {
+    await ctx.config.runCommand('foo:bar:test-result')
+    expect(ctx.stdout).to.equal('running mixed-esm-cjs prerun hook\nit works!\nrunning mixed-esm-cjs postrun hook\nreturned success!\n')
+  })
+
+  withConfig
+  .stdout()
+  .it('runs init hook', async ctx => {
+    await (ctx.config.runHook as any)('init', {id: 'myid', argv: ['foo']})
+    expect(ctx.stdout).to.equal('running mixed-esm-cjs init hook\n')
+  })
+})
diff --git a/test/help/util.test.ts b/test/help/util.test.ts
index 5ea2fa0..542902c 100644
--- a/test/help/util.test.ts
+++ b/test/help/util.test.ts
@@ -2,7 +2,7 @@
 import {resolve} from 'path'
 import {Config, Interfaces} from '../../src'
 import {expect, test} from '@oclif/test'
-import {getHelpClass, standarizeIDFromArgv} from '../../src/help'
+import {getHelpClass, standardizeIDFromArgv} from '../../src/help'
 import configuredHelpClass from  '../../src/help/_test-help-class'
 
 describe('util', () => {
@@ -42,11 +42,11 @@ describe('util', () => {
     })
   })
 
-  describe('#standarizeIDFromArgv', () => {
+  describe('#standardizeIDFromArgv', () => {
     test
     .it('should return standardized id when topic separator is a colon', () => {
       config.pjson.oclif.topicSeparator = ':'
-      const actual = standarizeIDFromArgv(['foo:bar', '--baz'], config)
+      const actual = standardizeIDFromArgv(['foo:bar', '--baz'], config)
       expect(actual).to.deep.equal(['foo:bar', '--baz'])
     })
 
@@ -54,7 +54,7 @@ describe('util', () => {
     .stub(Config.prototype, 'commandIDs', () => ['foo', 'foo:bar'])
     .it('should return standardized id when topic separator is a space', () => {
       config.topicSeparator = ' '
-      const actual = standarizeIDFromArgv(['foo', 'bar', '--baz'], config)
+      const actual = standardizeIDFromArgv(['foo', 'bar', '--baz'], config)
       expect(actual).to.deep.equal(['foo:bar', '--baz'])
     })
 
@@ -62,7 +62,7 @@ describe('util', () => {
     .stub(Config.prototype, 'commandIDs', () => ['foo', 'foo:bar'])
     .it('should return standardized id when topic separator is a space and command is misspelled', () => {
       config.topicSeparator = ' '
-      const actual = standarizeIDFromArgv(['foo', 'ba', '--baz'], config)
+      const actual = standardizeIDFromArgv(['foo', 'ba', '--baz'], config)
       expect(actual).to.deep.equal(['foo:ba', '--baz'])
     })
   })
diff --git a/test/module-loader/fixtures/cjs/package.json b/test/module-loader/fixtures/cjs/package.json
index 5bbefff..0967ef4 100644
--- a/test/module-loader/fixtures/cjs/package.json
+++ b/test/module-loader/fixtures/cjs/package.json
@@ -1,3 +1,1 @@
-{
-  "type": "commonjs"
-}
+{}
diff --git a/test/module-loader/fixtures/success.cjs b/test/module-loader/fixtures/cjs/success-ext.cjs
similarity index 100%
rename from test/module-loader/fixtures/success.cjs
rename to test/module-loader/fixtures/cjs/success-ext.cjs
diff --git a/test/module-loader/fixtures/esm/empty-package/package.json b/test/module-loader/fixtures/esm/empty-package/package.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/test/module-loader/fixtures/esm/empty-package/package.json
@@ -0,0 +1,1 @@
+{}
diff --git a/test/module-loader/fixtures/success.mjs b/test/module-loader/fixtures/esm/empty-package/success-ext.mjs
similarity index 100%
rename from test/module-loader/fixtures/success.mjs
rename to test/module-loader/fixtures/esm/empty-package/success-ext.mjs
diff --git a/test/module-loader/module-loader.test.ts b/test/module-loader/module-loader.test.ts
index 3f98efb..2c8bbdb 100644
--- a/test/module-loader/module-loader.test.ts
+++ b/test/module-loader/module-loader.test.ts
@@ -18,6 +18,14 @@ const data = {
       message: `[MODULE_NOT_FOUND] import() failed to load ${path.resolve('./test/module-loader/fixtures/esm/errors/bad_path.js')}`,
       isESM: true,
     },
+    // Non-existent path / no extension
+    {
+      path: './test/module-loader/fixtures/esm/errors/bad_path',
+      type: ModuleLoadError,
+      message: `[MODULE_NOT_FOUND] require failed to load ${path.resolve('./test/module-loader/fixtures/esm/errors/bad_path')}`,
+      isESM: true,
+    },
+
     // Non-existent path
     {
       path: './test/module-loader/fixtures/cjs/errors/bad_path.cjs',
@@ -25,6 +33,13 @@ const data = {
       message: `[MODULE_NOT_FOUND] require failed to load ${path.resolve('./test/module-loader/fixtures/cjs/errors/bad_path.cjs')}`,
       isESM: false,
     },
+    // Non-existent path / no extension
+    {
+      path: './test/module-loader/fixtures/cjs/errors/bad_path',
+      type: ModuleLoadError,
+      message: `[MODULE_NOT_FOUND] require failed to load ${path.resolve('./test/module-loader/fixtures/cjs/errors/bad_path')}`,
+      isESM: false,
+    },
 
     // Incomplete source file
     {
@@ -52,11 +67,27 @@ const data = {
     },
     // ESM source file loaded due to mjs file type.
     {
-      path: './test/module-loader/fixtures/success.mjs',
+      path: './test/module-loader/fixtures/esm/empty-package/success-ext.mjs',
       defaultModule: '{"default":"SUCCESS_MJS","namedExport":"SUCCESS_NAMED_MJS"}',
-      filePath: `${path.resolve('./test/module-loader/fixtures/success.mjs')}`,
+      filePath: `${path.resolve('./test/module-loader/fixtures/esm/empty-package/success-ext.mjs')}`,
       isESM: true,
     },
+    // No extension / ESM source file. Loads package.json in './test/module-loader/fixtures/esm/' for getPackageType check.
+    {
+      path: './test/module-loader/fixtures/esm/success',
+      defaultModule: '{"default":"SUCCESS","namedExport":"SUCCESS_NAMED"}',
+      filePath: `${path.resolve('./test/module-loader/fixtures/esm/success.js')}`,
+      isESM: true,
+      isESMOverride: false,  // With no extension `ModuleLoader.isPathModule` will return CJS
+    },
+    // No extension / ESM source file loaded due to mjs file type.
+    {
+      path: './test/module-loader/fixtures/esm/empty-package/success-ext',
+      defaultModule: '{"default":"SUCCESS_MJS","namedExport":"SUCCESS_NAMED_MJS"}',
+      filePath: `${path.resolve('./test/module-loader/fixtures/esm/empty-package/success-ext.mjs')}`,
+      isESM: true,
+      isESMOverride: false,  // With no extension `ModuleLoader.isPathModule` will return CJS
+    },
 
     // CJS source loaded from package.json in './test/module-loader/fixtures/cjs/' which doesn't have "type": "module".
     {
@@ -67,11 +98,26 @@ const data = {
     },
     // CJS source file loaded due to cjs file type.
     {
-      path: './test/module-loader/fixtures/success.cjs',
+      path: './test/module-loader/fixtures/cjs/success-ext.cjs',
       defaultModule: '["SUCCESS_CJS"]',
-      filePath: `${path.resolve('./test/module-loader/fixtures/success.cjs')}`,
+      filePath: `${path.resolve('./test/module-loader/fixtures/cjs/success-ext.cjs')}`,
       isESM: false,
     },
+    // No extension / CJS source loaded from package.json in './test/module-loader/fixtures/cjs/' which doesn't have "type": "module".
+    {
+      path: './test/module-loader/fixtures/cjs/success',
+      defaultModule: '["SUCCESS"]',
+      filePath: `${path.resolve('./test/module-loader/fixtures/cjs/success.js')}`,
+      isESM: false,
+    },
+    // No extension / CJS source file loaded due to cjs file type.
+    {
+      path: './test/module-loader/fixtures/cjs/success-ext',
+      defaultModule: '["SUCCESS_CJS"]',
+      filePath: `${path.resolve('./test/module-loader/fixtures/cjs/success-ext.cjs')}`,
+      isESM: false,
+    },
+
     // CJS NPM module; just check that it loads as CJS.
     {
       path: 'eslint',
@@ -86,15 +132,9 @@ describe('ModuleLoader:', () => {
     for (const module of data.modules) {
       it(`${module.path}`, async () => {
         const config = new Config({root: process.cwd()})
-        const configESM = new Config({root: process.cwd()})
-
         await config.load()
-        await configESM.load()
-
-        // Must set type manually for ESM config.
-        configESM.pjson.type = 'module'
 
-        const result = await ModuleLoader.load(module.isESM ? configESM : config, module.path)
+        const result = await ModuleLoader.load(config, module.path)
 
         // Test that the default module as a string.
         if (module.defaultModule) {
@@ -108,15 +148,9 @@ describe('ModuleLoader:', () => {
     for (const module of data.modules) {
       it(`${module.path}`, async () => {
         const config = new Config({root: process.cwd()})
-        const configESM = new Config({root: process.cwd()})
-
         await config.load()
-        await configESM.load()
 
-        // Must set type manually for ESM config.
-        configESM.pjson.type = 'module'
-
-        const result = await ModuleLoader.loadWithData(module.isESM ? configESM : config, module.path)
+        const result = await ModuleLoader.loadWithData(config, module.path)
 
         // Test the exported module as a string.
         if (module.defaultModule) {
@@ -139,8 +173,11 @@ describe('ModuleLoader:', () => {
       it(`${module.path}`,  () => {
         const result = ModuleLoader.isPathModule(module.path)
 
+        // For extensionless ESM data `isPathModule` will return false
+        const test = typeof module.isESMOverride === 'boolean' ? module.isESMOverride : module.isESM
+
         // Test source type.
-        assert.strictEqual(result, module.isESM)
+        assert.strictEqual(result, test)
       })
     }
   })
@@ -152,17 +189,10 @@ describe('ModuleLoader Failures:', () => {
     for (const error of data.errors) {
       it(`${error.path}`, async () => {
         const config = new Config({root: process.cwd()})
-        const configESM = new Config({root: process.cwd()})
-
         await config.load()
-        await configESM.load()
-
-        // Must set type manually for ESM config.
-        configESM.pjson.type = 'module'
 
         await expect(ModuleLoader.load(
-          error.isESM ? configESM : config,
-          error.path)).to.eventually.be.rejectedWith(error.message).and.be.an.instanceOf(error.type)
+          config, error.path)).to.eventually.be.rejectedWith(error.message).and.be.an.instanceOf(error.type)
       })
     }
   })
@@ -171,17 +201,10 @@ describe('ModuleLoader Failures:', () => {
     for (const error of data.errors) {
       it(`${error.path}`, async () => {
         const config = new Config({root: process.cwd()})
-        const configESM = new Config({root: process.cwd()})
-
         await config.load()
-        await configESM.load()
-
-        // Must set type manually for ESM config.
-        configESM.pjson.type = 'module'
 
         await expect(ModuleLoader.loadWithData(
-          error.isESM ? configESM : config,
-          error.path)).to.eventually.be.rejectedWith(error.message).and.be.an.instanceOf(error.type)
+          config, error.path)).to.eventually.be.rejectedWith(error.message).and.be.an.instanceOf(error.type)
       })
     }
   })