Repository: oclif/core
Pull request #818 (2023-10-10 21:03:40)

fix: warn about ts-node and node 20
- Warn when `ts-node` is being used to execute `bin/dev.js` and node is >= 20
- Fall back to compiled source when `ts-node` cannot be used

Fixes https://github.com/oclif/core/issues/817

@W-14270164@


Patch:
diff --git a/.eslintrc.json b/.eslintrc.json
index 6f5df21..1af383e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -7,6 +7,7 @@
     "perfectionist/sort-object-types": "off",
     "perfectionist/sort-union-types": "off",
     "@typescript-eslint/no-explicit-any": "off",
-    "@typescript-eslint/no-namespace": "off"
+    "@typescript-eslint/no-namespace": "off",
+    "valid-jsdoc": "off"
   }
 }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24e8e40..395d8a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+## [3.0.5](https://github.com/oclif/core/compare/3.0.5-dev.0...3.0.5) (2023-10-11)
+
+
+
+## [3.0.5-dev.0](https://github.com/oclif/core/compare/3.0.4...3.0.5-dev.0) (2023-10-10)
+
+
+### Bug Fixes
+
+* avoid fs.access for checking for file existence ([c4277a9](https://github.com/oclif/core/commit/c4277a984223eae89dabef36e9bee2ed565f3616))
+
+
+
 ## [3.0.4](https://github.com/oclif/core/compare/3.0.3...3.0.4) (2023-10-09)
 
 
diff --git a/README.md b/README.md
index 1c577b4..f2f812a 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,4 @@
-@oclif/core
-===========
+# @oclif/core
 
 base library for oclif CLIs
 
@@ -7,9 +6,7 @@ base library for oclif CLIs
 [![Downloads/week](https://img.shields.io/npm/dw/@oclif/core.svg)](https://npmjs.org/package/@oclif/core)
 [![License](https://img.shields.io/npm/l/@oclif/core.svg)](https://github.com/oclif/core/blob/main/package.json)
 
-
-Migrating
-=====
+# Migrating
 
 See the [v3 migration guide](./guides/V3_MIGRATION.md) for an overview of breaking changes that occurred between v2 and v3.
 
@@ -17,19 +14,18 @@ See the [v2 migration guide](./guides/V2_MIGRATION.md) for an overview of breaki
 
 Migrating from `@oclif/config` and `@oclif/command`? See the [v1 migration guide](./guides/PRE_CORE_MIGRATION.md).
 
-CLI UX
-=====
+# CLI UX
 
 The [ux README](./src/cli-ux/README.md) contains detailed usage examples of using the `ux` export.
 
-Usage
-=====
+# Usage
 
 We strongly encourage you generate an oclif CLI using the [oclif cli](https://github.com/oclif/oclif). The generator will generate an npm package with `@oclif/core` as a dependency.
 
 You can, however, use `@oclif/core` in a standalone script like this:
+
 ```typescript
-#!/usr/bin/env ts-node
+#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
 
 import * as fs from 'fs'
 import {Command, Flags, flush, handle} from '@oclif/core'
@@ -54,11 +50,14 @@ class LS extends Command {
   }
 }
 
-LS.run().then(async () => {
-  await flush()
-}, async (err) => {
-  await handle(err)
-})
+LS.run().then(
+  async () => {
+    await flush()
+  },
+  async (err) => {
+    await handle(err)
+  },
+)
 ```
 
 Then run it like this:
diff --git a/package.json b/package.json
index 2ea4969..22f0502 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@oclif/core",
   "description": "base library for oclif CLIs",
-  "version": "3.0.4",
+  "version": "3.0.5",
   "author": "Salesforce",
   "bugs": "https://github.com/oclif/core/issues",
   "dependencies": {
diff --git a/src/config/plugin.ts b/src/config/plugin.ts
index a63d0b8..340f42d 100644
--- a/src/config/plugin.ts
+++ b/src/config/plugin.ts
@@ -1,5 +1,6 @@
+/* eslint-disable no-await-in-loop */
 import {sync} from 'globby'
-import {dirname, join, parse, relative, sep} from 'node:path'
+import {basename, dirname, join, parse, relative, sep} from 'node:path'
 import {inspect} from 'node:util'
 
 import {Command} from '../command'
@@ -11,7 +12,7 @@ import {Topic} from '../interfaces/topic'
 import {loadWithData, loadWithDataFromManifest} from '../module-loader'
 import {OCLIF_MARKER_OWNER, Performance} from '../performance'
 import {cacheCommand} from '../util/cache-command'
-import {exists, readJson, requireJson} from '../util/fs'
+import {readJson, requireJson, safeReadJson} from '../util/fs'
 import {compact, isProd, mapValues} from '../util/util'
 import {tsPath} from './ts-node'
 import {Debug, getCommandIdPermutations, resolvePackage} from './util'
@@ -41,41 +42,56 @@ function* up(from: string) {
   yield from
 }
 
-async function findSourcesRoot(root: string) {
+async function findSourcesRoot(root: string, name?: string) {
+  // If we know the plugin name then we just need to traverse the file
+  // system until we find the directory that matches the plugin name.
+  if (name) {
+    for (const next of up(root)) {
+      if (next.endsWith(basename(name))) return next
+    }
+  }
+
+  // If there's no plugin name (typically just the root plugin), then we need
+  // to traverse the file system until we find a directory with a package.json
   for (const next of up(root)) {
-    const cur = join(next, 'package.json')
-    // eslint-disable-next-line no-await-in-loop
-    if (await exists(cur)) return dirname(cur)
+    // Skip the bin directory
+    if (
+      basename(dirname(next)) === 'bin' &&
+      ['dev', 'dev.cmd', 'dev.js', 'run', 'run.cmd', 'run.js'].includes(basename(next))
+    ) {
+      continue
+    }
+
+    try {
+      const cur = join(next, 'package.json')
+      if (await safeReadJson<PJSON>(cur)) return dirname(cur)
+    } catch {}
   }
 }
 
 /**
- * @returns string
- * @param name string
- * @param root string
- * find package root
- * for packages installed into node_modules this will go up directories until
- * it finds a node_modules directory with the plugin installed into it
+ * Find package root for packages installed into node_modules. This will go up directories
+ * until it finds a node_modules directory with the plugin installed into it
  *
  * This is needed because some oclif plugins do not declare the `main` field in their package.json
  * https://github.com/oclif/config/pull/289#issuecomment-983904051
+ *
+ * @returns string
+ * @param name string
+ * @param root string
  */
 async function findRootLegacy(name: string | undefined, root: string): Promise<string | undefined> {
   for (const next of up(root)) {
     let cur
     if (name) {
       cur = join(next, 'node_modules', name, 'package.json')
-      // eslint-disable-next-line no-await-in-loop
-      if (await exists(cur)) return dirname(cur)
-      try {
-        // eslint-disable-next-line no-await-in-loop
-        const pkg = await readJson<PJSON>(join(next, 'package.json'))
-        if (pkg.name === name) return next
-      } catch {}
+      if (await safeReadJson<PJSON>(cur)) return dirname(cur)
+
+      const pkg = await safeReadJson<PJSON>(join(next, 'package.json'))
+      if (pkg?.name === name) return next
     } else {
       cur = join(next, 'package.json')
-      // eslint-disable-next-line no-await-in-loop
-      if (await exists(cur)) return dirname(cur)
+      if (await safeReadJson<PJSON>(cur)) return dirname(cur)
     }
   }
 }
@@ -87,7 +103,7 @@ async function findRoot(name: string | undefined, root: string) {
       pkgPath = resolvePackage(name, {paths: [root]})
     } catch {}
 
-    return pkgPath ? findSourcesRoot(dirname(pkgPath)) : findRootLegacy(name, root)
+    return pkgPath ? findSourcesRoot(dirname(pkgPath), name) : findRootLegacy(name, root)
   }
 
   return findSourcesRoot(root)
diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts
index 7a0f591..e78546a 100644
--- a/src/config/ts-node.ts
+++ b/src/config/ts-node.ts
@@ -1,5 +1,5 @@
 import {existsSync} from 'node:fs'
-import {join, relative as pathRelative} from 'node:path'
+import {join, relative as pathRelative, sep} from 'node:path'
 import * as TSNode from 'ts-node'
 
 import {memoizedWarn} from '../errors'
@@ -95,6 +95,10 @@ function registerTSNode(root: string): TSConfig | undefined {
       sourceMap: tsconfig.compilerOptions.sourceMap ?? true,
       target: tsconfig.compilerOptions.target ?? 'es2019',
       typeRoots,
+      ...(tsconfig.compilerOptions.moduleResolution
+        ? {moduleResolution: tsconfig.compilerOptions.moduleResolution}
+        : {}),
+      ...(tsconfig.compilerOptions.jsx ? {jsx: tsconfig.compilerOptions.jsx} : {}),
     },
     cwd: root,
     esm: tsconfig['ts-node']?.esm ?? true,
@@ -105,22 +109,69 @@ function registerTSNode(root: string): TSConfig | undefined {
     transpileOnly: true,
   }
 
-  if (tsconfig.compilerOptions.moduleResolution) {
-    // @ts-expect-error TSNode.RegisterOptions.compilerOptions is typed as a plain object
-    conf.compilerOptions.moduleResolution = tsconfig.compilerOptions.moduleResolution
-  }
-
-  if (tsconfig.compilerOptions.jsx) {
-    // @ts-expect-error TSNode.RegisterOptions.compilerOptions is typed as a plain object
-    conf.compilerOptions.jsx = tsconfig.compilerOptions.jsx
-  }
-
   tsNode.register(conf)
   REGISTERED.add(root)
 
   return tsconfig
 }
 
+/**
+ * Skip ts-node registration for ESM plugins in production.
+ * The node ecosystem is not mature enough to support auto-transpiling ESM modules at this time.
+ * See the following:
+ * - https://github.com/TypeStrong/ts-node/issues/1791#issuecomment-1149754228
+ * - https://github.com/nodejs/node/issues/49432
+ * - https://github.com/nodejs/node/pull/49407
+ * - https://github.com/nodejs/node/issues/34049
+ *
+ * We still register ts-node for ESM plugins when NODE_ENV is "test" or "development" and root plugin is also ESM
+ * since that allows plugins to be auto-transpiled when developing locally using `bin/dev.js`.
+ */
+function cannotTranspileEsm(root: string, plugin: Plugin | undefined, isProduction: boolean): boolean {
+  return (isProduction || ROOT_PLUGIN?.moduleType === 'commonjs') && plugin?.moduleType === 'module'
+}
+
+/**
+ * If the dev script is run with ts-node for an ESM plugin, skip ts-node registration
+ * and fall back on compiled source since ts-node executable cannot transpile ESM in Node 20+
+ *
+ * See the following:
+ * https://nodejs.org/en/blog/announcements/v20-release-announce#custom-esm-loader-hooks-nearing-stable
+ * https://github.com/oclif/core/issues/817
+ * https://github.com/TypeStrong/ts-node/issues/1997
+ */
+function cannotUseTsNode(root: string, plugin: Plugin | undefined, isProduction: boolean): boolean {
+  if (plugin?.moduleType !== 'module' || isProduction) return false
+
+  const nodeMajor = Number.parseInt(process.version.replace('v', '').split('.')[0], 10)
+  const tsNodeExecIsUsed = process.execArgv[0] === '--require' && process.execArgv[1].split(sep).includes(`ts-node`)
+  return tsNodeExecIsUsed && nodeMajor >= 20
+}
+
+/**
+ * Determine the path to the source file from the compiled ./lib files
+ */
+function determinePath(root: string, orig: string): string {
+  const tsconfig = registerTSNode(root)
+  if (!tsconfig) return orig
+  const {outDir, rootDir, rootDirs} = tsconfig.compilerOptions
+  const rootDirPath = rootDir || (rootDirs || [])[0]
+  if (!rootDirPath || !outDir) return orig
+  // rewrite path from ./lib/foo to ./src/foo
+  const lib = join(root, outDir) // ./lib
+  const src = join(root, rootDirPath) // ./src
+  const relative = pathRelative(lib, orig) // ./commands
+  // For hooks, it might point to a js file, not a module. Something like "./hooks/myhook.js" which doesn't need the js.
+  const out = join(src, relative).replace(/\.js$/, '') // ./src/commands
+  // this can be a directory of commands or point to a hook file
+  // if it's a directory, we check if the path exists. If so, return the path to the directory.
+  // For hooks, it might point to a module, not a file. Something like "./hooks/myhook"
+  // That file doesn't exist, and the real file is "./hooks/myhook.ts"
+  // In that case we attempt to resolve to the filename. If it fails it will revert back to the lib path
+  if (existsSync(out) || existsSync(out + '.ts')) return out
+  return orig
+}
+
 /**
  * Convert a path from the compiled ./lib files to the ./src typescript source
  * this is for developing typescript plugins/CLIs
@@ -142,27 +193,15 @@ export function tsPath(root: string, orig: string | undefined, plugin?: Plugin):
   }
 
   const isProduction = isProd()
-  /**
-   * Skip ts-node registration for ESM plugins.
-   * The node ecosystem is not mature enough to support auto-transpiling ESM modules at this time.
-   * See the following:
-   * - https://github.com/TypeStrong/ts-node/issues/1791#issuecomment-1149754228
-   * - https://github.com/nodejs/node/issues/49432
-   * - https://github.com/nodejs/node/pull/49407
-   * - https://github.com/nodejs/node/issues/34049
-   *
-   * We still register ts-node for ESM plugins when NODE_ENV is "test" or "development" and root plugin is also ESM.
-   * In other words, this allows plugins to be auto-transpiled when developing locally using `bin/dev.js`.
-   */
-  if ((isProduction || ROOT_PLUGIN?.moduleType === 'commonjs') && plugin?.moduleType === 'module') {
+
+  if (cannotTranspileEsm(root, plugin, isProduction)) {
     debug(
       `Skipping ts-node registration for ${root} because it's an ESM module (NODE_ENV: ${process.env.NODE_ENV}, root plugin module type: ${ROOT_PLUGIN?.moduleType})))`,
     )
-    if (plugin.type === 'link')
+    if (plugin?.type === 'link')
       memoizedWarn(
-        `${plugin.name} is a linked ESM module and cannot be auto-transpiled. Existing compiled source will be used instead.`,
+        `${plugin?.name} is a linked ESM module and cannot be auto-transpiled. Existing compiled source will be used instead.`,
       )
-
     return orig
   }
 
@@ -171,25 +210,16 @@ export function tsPath(root: string, orig: string | undefined, plugin?: Plugin):
     return orig
   }
 
-  try {
-    const tsconfig = registerTSNode(root)
-    if (!tsconfig) return orig
-    const {outDir, rootDir, rootDirs} = tsconfig.compilerOptions
-    const rootDirPath = rootDir || (rootDirs || [])[0]
-    if (!rootDirPath || !outDir) return orig
-    // rewrite path from ./lib/foo to ./src/foo
-    const lib = join(root, outDir) // ./lib
-    const src = join(root, rootDirPath) // ./src
-    const relative = pathRelative(lib, orig) // ./commands
-    // For hooks, it might point to a js file, not a module. Something like "./hooks/myhook.js" which doesn't need the js.
-    const out = join(src, relative).replace(/\.js$/, '') // ./src/commands
-    // this can be a directory of commands or point to a hook file
-    // if it's a directory, we check if the path exists. If so, return the path to the directory.
-    // For hooks, it might point to a module, not a file. Something like "./hooks/myhook"
-    // That file doesn't exist, and the real file is "./hooks/myhook.ts"
-    // In that case we attempt to resolve to the filename. If it fails it will revert back to the lib path
-    if (existsSync(out) || existsSync(out + '.ts')) return out
+  if (cannotUseTsNode(root, plugin, isProduction)) {
+    debug(`Skipping ts-node registration for ${root} because ts-node is run in node version ${process.version}"`)
+    memoizedWarn(
+      `ts-node executable cannot transpile ESM in Node 20. Existing compiled source will be used instead. See https://github.com/oclif/core/issues/817.`,
+    )
     return orig
+  }
+
+  try {
+    return determinePath(root, orig)
   } catch (error: any) {
     debug(error)
     return orig
diff --git a/src/execute.ts b/src/execute.ts
index f15e346..767d173 100644
--- a/src/execute.ts
+++ b/src/execute.ts
@@ -12,7 +12,7 @@ import {settings} from './settings'
  *
  * @example For ESM dev.js
  * ```
- * #!/usr/bin/env ts-node
+ * #!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
  * async function main() {
  *   const oclif = await import('@oclif/core')
  *   await oclif.execute({development: true, dir: import.meta.url})
diff --git a/src/util/fs.ts b/src/util/fs.ts
index ebf9dd9..ac6346a 100644
--- a/src/util/fs.ts
+++ b/src/util/fs.ts
@@ -1,5 +1,5 @@
-import {readFileSync} from 'node:fs'
-import {access, readFile, stat} from 'node:fs/promises'
+import {Stats, readFileSync} from 'node:fs'
+import {readFile, stat} from 'node:fs/promises'
 import {join} from 'node:path'
 
 const debug = require('debug')
@@ -8,34 +8,41 @@ export function requireJson<T>(...pathParts: string[]): T {
   return JSON.parse(readFileSync(join(...pathParts), 'utf8'))
 }
 
-export async function exists(path: string): Promise<boolean> {
+/**
+ * Parser for Args.directory and Flags.directory. Checks that the provided path
+ * exists and is a directory.
+ * @param input flag or arg input
+ * @returns Promise<string>
+ */
+export const dirExists = async (input: string): Promise<string> => {
+  let dirStat: Stats
   try {
-    await access(path)
-    return true
+    dirStat = await stat(input)
   } catch {
-    return false
-  }
-}
-
-export const dirExists = async (input: string): Promise<string> => {
-  if (!(await exists(input))) {
     throw new Error(`No directory found at ${input}`)
   }
 
-  const fileStat = await stat(input)
-  if (!fileStat.isDirectory()) {
+  if (!dirStat.isDirectory()) {
     throw new Error(`${input} exists but is not a directory`)
   }
 
   return input
 }
 
+/**
+ * Parser for Args.file and Flags.file. Checks that the provided path
+ * exists and is a file.
+ * @param input flag or arg input
+ * @returns Promise<string>
+ */
 export const fileExists = async (input: string): Promise<string> => {
-  if (!(await exists(input))) {
+  let fileStat: Stats
+  try {
+    fileStat = await stat(input)
+  } catch {
     throw new Error(`No file found at ${input}`)
   }
 
-  const fileStat = await stat(input)
   if (!fileStat.isFile()) {
     throw new Error(`${input} exists but is not a file`)
   }
@@ -55,3 +62,9 @@ export function readJsonSync<T = unknown>(path: string, parse = true): T | strin
   const contents = readFileSync(path, 'utf8')
   return parse ? (JSON.parse(contents) as T) : contents
 }
+
+export async function safeReadJson<T>(path: string): Promise<T | undefined> {
+  try {
+    return await readJson<T>(path)
+  } catch {}
+}


Test patch:
diff --git a/test/integration/plugins.e2e.ts b/test/integration/plugins.e2e.ts
index f467a44..6bf15f8 100644
--- a/test/integration/plugins.e2e.ts
+++ b/test/integration/plugins.e2e.ts
@@ -9,7 +9,7 @@ describe('oclif plugins', () => {
   let executor: Executor
   before(async () => {
     executor = await setup(__filename, {
-      repo: 'https://github.com/oclif/hello-world',
+      repo: 'https://github.com/oclif/hello-world-esm',
       plugins: [
         '@oclif/plugin-autocomplete',
         '@oclif/plugin-commands',
@@ -31,13 +31,13 @@ describe('oclif plugins', () => {
       })
 
       it('should show description', () => {
-        expect(help.stdout).to.include('oclif example Hello World CLI')
+        expect(help.stdout).to.include('oclif example Hello World CLI (ESM)')
       })
       it('should show version', () => {
-        expect(help.stdout).to.include('VERSION\n  oclif-hello-world/0.0.0')
+        expect(help.stdout).to.include('VERSION\n  oclif-hello-world-esm')
       })
       it('should show usage', () => {
-        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world [COMMAND]')
+        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world-esm [COMMAND]')
       })
       it('should show topics', () => {
         expect(help.stdout).to.include('TOPICS\n  plugins')
@@ -58,13 +58,13 @@ describe('oclif plugins', () => {
         expect(help.stdout).to.include('List installed plugins.')
       })
       it('should show usage', () => {
-        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world plugins [--json] [--core]')
+        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world-esm plugins [--json] [--core]')
       })
       it('should show description', () => {
         expect(help.stdout).to.include('DESCRIPTION\n  List installed plugins.')
       })
       it('should show examples', () => {
-        expect(help.stdout).to.include('EXAMPLES\n  $ oclif-hello-world plugins')
+        expect(help.stdout).to.include('EXAMPLES\n  $ oclif-hello-world-esm plugins')
       })
       it('should show commands', () => {
         const regex =
@@ -83,7 +83,7 @@ describe('oclif plugins', () => {
         expect(help.stdout).to.include('Installs a plugin into the CLI.')
       })
       it('should show usage', () => {
-        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world plugins:install PLUGIN...')
+        expect(help.stdout).to.include('USAGE\n  $ oclif-hello-world-esm plugins:install PLUGIN...')
       })
       it('should show arguments', () => {
         expect(help.stdout).to.include('ARGUMENTS\n  PLUGIN  Plugin to install.')
@@ -98,13 +98,13 @@ describe('oclif plugins', () => {
         expect(help.stdout).to.include('DESCRIPTION\n  Installs a plugin into the CLI.')
       })
       it('should show aliases', () => {
-        expect(help.stdout).to.include('ALIASES\n  $ oclif-hello-world plugins:add')
+        expect(help.stdout).to.include('ALIASES\n  $ oclif-hello-world-esm plugins:add')
       })
       it('should show examples', () => {
         expect(help.stdout).to.include('EXAMPLES\n')
-        expect(help.stdout).to.include('$ oclif-hello-world plugins:install myplugin')
-        expect(help.stdout).to.include('$ oclif-hello-world plugins:install https://github.com/someuser/someplugin')
-        expect(help.stdout).to.include('$ oclif-hello-world plugins:install someuser/someplugin')
+        expect(help.stdout).to.include('$ oclif-hello-world-esm plugins:install myplugin')
+        expect(help.stdout).to.include('$ oclif-hello-world-esm plugins:install https://github.com/someuser/someplugin')
+        expect(help.stdout).to.include('$ oclif-hello-world-esm plugins:install someuser/someplugin')
       })
     })
   })
@@ -261,7 +261,7 @@ describe('oclif plugins', () => {
       version = await executor.executeCommand('version')
     })
 
-    it('should show version', () => expect(version.stdout).to.include('oclif-hello-world/0.0.0'))
+    it('should show version', () => expect(version.stdout).to.include('oclif-hello-world-esm'))
     it('should show platform', () => expect(version.stdout).to.include(process.platform))
     it('should show arch', () => expect(version.stdout).to.include(arch()))
     it('should show node version', () => expect(version.stdout).to.include(process.version))
diff --git a/test/parser/parse.test.ts b/test/parser/parse.test.ts
index 7efd9e1..bb19aac 100644
--- a/test/parser/parse.test.ts
+++ b/test/parser/parse.test.ts
@@ -1552,11 +1552,9 @@ See more help with --help`)
 
   describe('fs flags', () => {
     const sandbox = createSandbox()
-    let accessStub: SinonStub
     let statStub: SinonStub
 
     beforeEach(() => {
-      accessStub = sandbox.stub(fs.promises, 'access')
       statStub = sandbox.stub(fs.promises, 'stat')
     })
 
@@ -1570,18 +1568,15 @@ See more help with --help`)
         const out = await parse([`--dir=${testDir}`], {
           flags: {dir: Flags.directory({exists: false})},
         })
-        expect(accessStub.callCount).to.equal(0)
         expect(out.flags).to.deep.include({dir: testDir})
       })
       it('passes if dir !exists but exists not defined', async () => {
         const out = await parse([`--dir=${testDir}`], {
           flags: {dir: Flags.directory()},
         })
-        expect(accessStub.callCount).to.equal(0)
         expect(out.flags).to.deep.include({dir: testDir})
       })
       it('passes when dir exists', async () => {
-        accessStub.resolves()
         statStub.returns({isDirectory: () => true})
         const out = await parse([`--dir=${testDir}`], {
           flags: {dir: Flags.directory({exists: true})},
@@ -1589,7 +1584,7 @@ See more help with --help`)
         expect(out.flags).to.deep.include({dir: testDir})
       })
       it("fails when dir doesn't exist", async () => {
-        accessStub.throws()
+        statStub.throws()
         try {
           const out = await parse([`--dir=${testDir}`], {
             flags: {dir: Flags.directory({exists: true})},
@@ -1601,7 +1596,6 @@ See more help with --help`)
         }
       })
       it('fails when dir exists but is not a dir', async () => {
-        accessStub.resolves()
         statStub.returns({isDirectory: () => false})
         try {
           const out = await parse([`--dir=${testDir}`], {
@@ -1616,7 +1610,6 @@ See more help with --help`)
       describe('custom parse functions', () => {
         const customParseException = 'NOT_OK'
         it('accepts custom parse that passes', async () => {
-          accessStub.resolves()
           statStub.returns({isDirectory: () => true})
           const out = await parse([`--dir=${testDir}`], {
             flags: {
@@ -1630,7 +1623,6 @@ See more help with --help`)
         })
 
         it('accepts custom parse that fails', async () => {
-          accessStub.resolves()
           statStub.returns({isDirectory: () => true})
           try {
             const out = await parse([`--dir=${testDir}`], {
@@ -1657,17 +1649,14 @@ See more help with --help`)
           flags: {file: Flags.file({exists: false})},
         })
         expect(out.flags).to.deep.include({file: testFile})
-        expect(accessStub.callCount).to.equal(0)
       })
       it("passes if file doesn't exist but not exists not defined", async () => {
         const out = await parse([`--file=${testFile}`], {
           flags: {file: Flags.file()},
         })
         expect(out.flags).to.deep.include({file: testFile})
-        expect(accessStub.callCount).to.equal(0)
       })
       it('passes when file exists', async () => {
-        accessStub.resolves()
         statStub.returns({isFile: () => true})
         const out = await parse([`--file=${testFile}`], {
           flags: {file: Flags.file({exists: true})},
@@ -1675,7 +1664,7 @@ See more help with --help`)
         expect(out.flags).to.deep.include({file: testFile})
       })
       it("fails when dir doesn't exist", async () => {
-        accessStub.throws()
+        statStub.throws()
         try {
           const out = await parse([`--file=${testFile}`], {
             flags: {file: Flags.file({exists: true})},
@@ -1687,7 +1676,6 @@ See more help with --help`)
         }
       })
       it('fails when file exists but is not a file', async () => {
-        accessStub.resolves()
         statStub.returns({isFile: () => false})
         try {
           const out = await parse([`--file=${testFile}`], {
@@ -1702,7 +1690,6 @@ See more help with --help`)
       describe('custom parse functions', () => {
         const customParseException = 'NOT_OK'
         it('accepts custom parse that passes', async () => {
-          accessStub.resolves()
           statStub.returns({isFile: () => true})
           const out = await parse([`--dir=${testFile}`], {
             flags: {
@@ -1716,7 +1703,6 @@ See more help with --help`)
         })
 
         it('accepts custom parse that fails', async () => {
-          accessStub.resolves()
           statStub.returns({isFile: () => true})
           try {
             const out = await parse([`--dir=${testFile}`], {
diff --git a/test/util/fs.test.ts b/test/util/fs.test.ts
index a8712aa..228e8be 100644
--- a/test/util/fs.test.ts
+++ b/test/util/fs.test.ts
@@ -1,6 +1,6 @@
 import {expect} from 'chai'
 
-import {readJson} from '../../src/util/fs'
+import {readJson, safeReadJson} from '../../src/util/fs'
 
 describe('readJson', () => {
   it('should return parsed JSON', async () => {
@@ -18,3 +18,15 @@ describe('readJson', () => {
     }
   })
 })
+
+describe('safeReadJson', () => {
+  it('should return parsed JSON', async () => {
+    const json = await safeReadJson<{name: string}>('package.json')
+    expect(json?.name).to.equal('@oclif/core')
+  })
+
+  it('should return undefined if the file does not exist', async () => {
+    const json = await safeReadJson('does-not-exist.json')
+    expect(json).to.be.undefined
+  })
+})