Repository: oclif/core (ts)

Issue:
"AbortController is not defined" error on Node 14
**Describe the bug**
Running any CLI based on `@oclif/core` 2+ on Node 14 consistenly fails with "ReferenceError: AbortController is not defined" in some cases.

**To Reproduce**
Steps to reproduce the behavior:
1. create a new oclif CLI with `npx oclif generate my-oclif-core2-cli`
2. install the latest Node 14 with `nvm install 14 && nvm use 14`
3. run the `hello` command when the terminal is not interactive: `head -n1 package.json | bin/dev hello -f NodeJS`
4. See the error
```
ReferenceError: AbortController is not defined
    at /path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/parser/parse.js:28:20
    at new Promise (<anonymous>)
    at readStdin (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/parser/parse.js:26:12)
    at Parser._args (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/parser/parse.js:275:35)
    at Parser.parse (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/parser/parse.js:161:43)
    at Object.parse (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/parser/index.js:18:33)
    at Hello.parse (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/command.js:193:38)
    at Hello.run (/path/to/my-oclif-core2-cli/src/commands/hello/index.ts:28:40)
    at Hello._run (/path/to/my-oclif-core2-cli/node_modules/@oclif/core/lib/command.js:107:33)
```

**Expected behavior**
The CLI should produce the same output on Node 14 as it does on Node 16 or newer:
```
$ head -n1 package.json | bin/dev hello -f NodeJS
hello { from NodeJS! (./src/commands/hello/index.ts)
```

**Screenshots**
<img width="963" alt="image" src="https://user-images.githubusercontent.com/22416150/218792967-1c1a40b9-46c5-4040-8e92-84b4b9b4630f.png">


**Environment (please complete the following information):**
 - MacOS Monterey
 - zsh 5.8.1

**Additional context**
The root cause seems to be in using the [`AbortController`](https://github.com/oclif/core/blob/f78ae24975cb11ea320dd911ea7ab48df2c76d19/src/parser/parse.ts#L27) API assuming that it's always available. However, this API was added in Node 15 and is not available in Node 12 and 14. Since oclif [claims](https://github.com/oclif/oclif#-requirements) to support Node 12+ it should either load an AbortController API polyfill or stop using this API.
The use of `Array.at` is only on node-16. This breaks @oclif/plugins-plugin@^2.4.8
See: https://github.com/oclif/core/blame/3f4b0f825623448fc18627a4c924fa6cf0b1c470/src/parser/parse.ts#L248
(there should be a total of 6 occurrences of `Array.at` usage in that commit)

`Array.at`:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/at#browser_compatibility


@oclif/plugins-plugin@2.x uses @oclif/core@^2.8.2 and still supports node-14.
@oclif/plugins-plugin@3.x does drop node 14 support.

Consider reverting use of `Array.at` at that line (and the other 5 lines) with the equivalent node-14 compatible code:
```
fws.tokens?.slice(-1)?.input
```

If not the release with the use of this `Array.at` syntax should follow semver and be a major version update.

cc @mshanemc 
Using a `char` option with Flags.help causes "TypeError: cmd.exit is not a function"
**Describe the bug**
When configuring the help flag for a command, adding a `char` like so:
```javascript
class LS extends Command {
  static flags = {
    help: Flags.help({char: 'h'})  
  }
  ...
}
```
and then calling the command with: 
```shell
./bin/run ls -h
```
will print the help text, but afterwards will error out with 
```shell
TypeError: Parsing --help 
    cmd.exit is not a function
See more help with --help
```

**To Reproduce**
The quickest way to reproduce is using the https://github.com/oclif/hello-world repo, and adding the following to `src/commands/hello/index`:
```
static flags = {
  ...
  help: Flags.help({char: 'h'})
}
```

**Expected behavior**
The help text for the command is printed without error

**Screenshots**
![image](https://github.com/oclif/core/assets/96050034/4126d595-9813-43d8-a482-ac4f96fe9a31)


**Environment (please complete the following information):**
 - OS & version: MacOS Ventura 13.4
 - Shell/terminal & version: zsh 5.8


Pull request description:
fix: support node 14 again
- Reverts use of `Array.at` to ensure Node 14 compatibility
  - Fixes #731 
  - Fixes https://github.com/oclif/plugin-plugins/issues/624)
- Adds back support for reading flag values from stdin in Node 14 (See JSDoc on `readStdinLegacy` to understand the drawbacks of keeping this in)
  - Fixes #630
  - Fixes https://github.com/oclif/oclif/issues/1106
- Fixes regression with `Flags.help` and `Flags.version`
  - Fixes #740






Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f0bfd3..f6756d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,21 @@
+# [2.10.0](https://github.com/oclif/core/compare/2.9.5...2.10.0) (2023-07-25)
+
+
+### Features
+
+* add extensions to esm checking ([#694](https://github.com/oclif/core/issues/694)) ([#743](https://github.com/oclif/core/issues/743)) ([427aa5b](https://github.com/oclif/core/commit/427aa5b877047bcd248bf1dbe0969c9bb0457e36))
+
+
+
+## [2.9.5](https://github.com/oclif/core/compare/2.9.4...2.9.5) (2023-07-25)
+
+
+### Bug Fixes
+
+* corrected return type for Command.exit ([#715](https://github.com/oclif/core/issues/715)) ([#742](https://github.com/oclif/core/issues/742)) ([dd753f0](https://github.com/oclif/core/commit/dd753f045cde58d89b209e007926d4131f925570))
+
+
+
 ## [2.9.4](https://github.com/oclif/core/compare/2.9.3...2.9.4) (2023-07-18)
 
 
diff --git a/package.json b/package.json
index 7752985..341f436 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@oclif/core",
   "description": "base library for oclif CLIs",
-  "version": "2.9.4",
+  "version": "2.10.0",
   "author": "Salesforce",
   "bugs": "https://github.com/oclif/core/issues",
   "dependencies": {
diff --git a/src/command.ts b/src/command.ts
index fc19634..fe44263 100644
--- a/src/command.ts
+++ b/src/command.ts
@@ -242,8 +242,8 @@ export abstract class Command {
     return result as T
   }
 
-  public exit(code = 0): void {
-    return Errors.exit(code)
+  public exit(code = 0): never {
+    Errors.exit(code)
   }
 
   public warn(input: string | Error): string | Error {
diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts
index 58abb38..631b4ed 100644
--- a/src/interfaces/parser.ts
+++ b/src/interfaces/parser.ts
@@ -287,7 +287,7 @@ export type BooleanFlag<T> = FlagProps & BooleanFlagProps & {
    * specifying a default of false is the same as not specifying a default
    */
   default?: FlagDefault<boolean>;
-  parse: (input: boolean, context: Command, opts: FlagProps & BooleanFlagProps) => Promise<T>
+  parse: (input: boolean, context: FlagParserContext, opts: FlagProps & BooleanFlagProps) => Promise<T>
 }
 
 export type OptionFlagDefaults<T, P = CustomOptions, M = false> = FlagProps & OptionFlagProps & {
@@ -325,7 +325,7 @@ export type Input<TFlags extends FlagOutput, BFlags extends FlagOutput, AFlags e
   baseFlags?: FlagInput<BFlags>;
   args?: ArgInput<AFlags>;
   strict?: boolean;
-  context?: Command;
+  context?: ParserContext;
   '--'?: boolean;
 }
 
@@ -334,10 +334,14 @@ export type ParserInput = {
   flags: FlagInput<any>;
   args: ArgInput<any>;
   strict: boolean;
-  context: Command | undefined;
+  context: ParserContext | undefined;
   '--'?: boolean;
 }
 
+export type ParserContext = Command & {
+  token?: FlagToken | ArgToken;
+}
+
 export type CompletionContext = {
   args?: { [name: string]: string };
   flags?: { [name: string]: string };
diff --git a/src/module-loader.ts b/src/module-loader.ts
index 595d4c5..2764036 100644
--- a/src/module-loader.ts
+++ b/src/module-loader.ts
@@ -108,12 +108,13 @@ export default class ModuleLoader {
 
     switch (extension) {
     case '.js':
-      return getPackageType.sync(filePath) === 'module'
-
+    case '.jsx':
     case '.ts':
+    case '.tsx':
       return getPackageType.sync(filePath) === 'module'
 
     case '.mjs':
+    case '.mts':
       return true
 
     default:
diff --git a/src/parser/parse.ts b/src/parser/parse.ts
index b3aa0c1..9187e38 100644
--- a/src/parser/parse.ts
+++ b/src/parser/parse.ts
@@ -1,8 +1,24 @@
 /* eslint-disable no-await-in-loop */
 import {ArgInvalidOptionError, CLIError, FlagInvalidOptionError} from './errors'
-import {ArgToken, BooleanFlag, CompletableFlag, FlagToken, Metadata, MetadataFlag, OptionFlag, OutputArgs, OutputFlags, ParserInput, ParserOutput, ParsingToken} from '../interfaces/parser'
+import {
+  ArgParserContext,
+  ArgToken,
+  BooleanFlag,
+  CompletableFlag,
+  FlagParserContext,
+  FlagToken,
+  Metadata,
+  MetadataFlag,
+  OptionFlag,
+  OutputArgs,
+  OutputFlags,
+  ParserContext,
+  ParserInput,
+  ParserOutput,
+  ParsingToken,
+} from '../interfaces/parser'
 import * as readline from 'readline'
-import {isTruthy, pickBy} from '../util'
+import {isTruthy, last, pickBy} from '../util'
 
 let debug: any
 try {
@@ -12,16 +28,44 @@ try {
   debug = () => {}
 }
 
-const readStdin = async (): Promise<string | null> => {
-  const {stdin, stdout} = process
-  debug('stdin.isTTY', stdin.isTTY)
+/**
+ * Support reading from stdin in Node 14 and older.
+ *
+ * This generally works for Node 14 and older EXCEPT when it's being
+ * run from another process, in which case it will hang indefinitely. Because
+ * of that issue we updated this to use AbortController but since AbortController
+ * is only available in Node 16 and newer, we have to keep this legacy version.
+ *
+ * See these issues for more details on the hanging indefinitely bug:
+ * https://github.com/oclif/core/issues/330
+ * https://github.com/oclif/core/pull/363
+ *
+ * @returns Promise<string | null>
+ */
+const readStdinLegacy = async (): Promise<string | null> => {
+  const {stdin} = process
+  let result
   if (stdin.isTTY) return null
+  result = ''
+  stdin.setEncoding('utf8')
+  for await (const chunk of stdin) {
+    result += chunk
+  }
+
+  return result
+}
+
+const readStdinWithTimeout = async (): Promise<string | null> => {
+  const {stdin, stdout} = process
 
   // process.stdin.isTTY is true whenever it's running in a terminal.
   // process.stdin.isTTY is undefined when it's running in a pipe, e.g. echo 'foo' | my-cli command
   // process.stdin.isTTY is undefined when it's running in a spawned process, even if there's no pipe.
   // This means that reading from stdin could hang indefinitely while waiting for a non-existent pipe.
   // Because of this, we have to set a timeout to prevent the process from hanging.
+
+  if (stdin.isTTY) return null
+
   return new Promise(resolve => {
     let result = ''
     const ac = new AbortController()
@@ -53,6 +97,16 @@ const readStdin = async (): Promise<string | null> => {
   })
 }
 
+const readStdin = async (): Promise<string | null> => {
+  const {stdin, version} = process
+  debug('stdin.isTTY', stdin.isTTY)
+
+  const nodeMajorVersion = Number(version.split('.')[0].replace(/^v/, ''))
+  debug('node version', nodeMajorVersion)
+
+  return nodeMajorVersion >= 14 ? readStdinLegacy() : readStdinWithTimeout()
+}
+
 function isNegativeNumber(input: string): boolean {
   return /^-\d/g.test(input)
 }
@@ -65,12 +119,12 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
   private readonly booleanFlags: { [k: string]: BooleanFlag<any> }
   private readonly flagAliases: { [k: string]: BooleanFlag<any> | OptionFlag<any> }
 
-  private readonly context: any
+  private readonly context: ParserContext
 
   private currentFlag?: OptionFlag<any>
 
   constructor(private readonly input: T) {
-    this.context = input.context || {}
+    this.context = input.context ?? {} as ParserContext
     this.argv = [...input.argv]
     this._setNames()
     this.booleanFlags = pickBy(input.flags, f => f.type === 'boolean') as any
@@ -222,15 +276,25 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
       return input
     }
 
-    const parseFlagOrThrowError = async (input: any, flag: BooleanFlag<any> | OptionFlag<any>, token?: FlagToken, context: typeof this.context = {}) => {
+    const parseFlagOrThrowError = async (input: any, flag: BooleanFlag<any> | OptionFlag<any>, context: ParserContext | undefined, token?: FlagToken) => {
       if (!flag.parse) return input
 
+      const ctx = {
+        ...context,
+        token,
+        error: context?.error,
+        exit: context?.exit,
+        log: context?.log,
+        logToStderr: context?.logToStderr,
+        warn: context?.warn,
+      } as FlagParserContext
+
       try {
         if (flag.type === 'boolean') {
-          return await flag.parse(input, {...context, token}, flag)
+          return await flag.parse(input, ctx, flag)
         }
 
-        return await flag.parse(input, {...context, token}, flag)
+        return await flag.parse(input, ctx, flag)
       } catch (error: any) {
         error.message = `Parsing --${flag.name} \n\t${error.message}\nSee more help with --help`
         throw error
@@ -245,8 +309,16 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
       // user provided some input
       if (tokenLength) {
         // boolean
-        if (fws.inputFlag.flag.type === 'boolean' && fws.tokens?.at(-1)?.input) {
-          return {...fws, valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(i.tokens?.at(-1)?.input !== `--no-${i.inputFlag.name}`, i.inputFlag.flag, i.tokens?.at(-1), this.context)}
+        if (fws.inputFlag.flag.type === 'boolean' && last(fws.tokens)?.input) {
+          return {
+            ...fws,
+            valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(
+              last(i.tokens)?.input !== `--no-${i.inputFlag.name}`,
+              i.inputFlag.flag,
+              this.context,
+              last(i.tokens),
+            ),
+          }
         }
 
         // multiple with custom delimiter
@@ -256,19 +328,38 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
               ((i.tokens ?? []).flatMap(token => (token.input as string).split(i.inputFlag.flag.delimiter as string)))
               // trim, and remove surrounding doubleQuotes (which would hav been needed if the elements contain spaces)
               .map(v => v.trim().replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1'))
-              .map(async v => parseFlagOrThrowError(v, i.inputFlag.flag, {...i.tokens?.at(-1) as FlagToken, input: v}, this.context)),
+              .map(async v => parseFlagOrThrowError(v, i.inputFlag.flag, this.context, {...last(i.tokens) as FlagToken, input: v})),
             )).map(v => validateOptions(i.inputFlag.flag as OptionFlag<any>, v)),
           }
         }
 
         // multiple in the oclif-core style
         if (fws.inputFlag.flag.type === 'option' && fws.inputFlag.flag.multiple) {
-          return {...fws, valueFunction: async (i: FlagWithStrategy) => Promise.all((fws.tokens ?? []).map(token => parseFlagOrThrowError(validateOptions(i.inputFlag.flag as OptionFlag<any>, token.input as string), i.inputFlag.flag, token, this.context)))}
+          return {
+            ...fws,
+            valueFunction: async (i: FlagWithStrategy) =>
+              Promise.all(
+                (fws.tokens ?? []).map(token => parseFlagOrThrowError(
+                  validateOptions(i.inputFlag.flag as OptionFlag<any>, token.input as string),
+                  i.inputFlag.flag,
+                  this.context,
+                  token,
+                )),
+              ),
+          }
         }
 
         // simple option flag
         if (fws.inputFlag.flag.type === 'option') {
-          return {...fws, valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(validateOptions(i.inputFlag.flag as OptionFlag<any>, fws.tokens?.at(-1)?.input as string), i.inputFlag.flag, fws.tokens?.at(-1), this.context)}
+          return {
+            ...fws,
+            valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(
+              validateOptions(i.inputFlag.flag as OptionFlag<any>, last(fws.tokens)?.input as string),
+              i.inputFlag.flag,
+              this.context,
+              last(fws.tokens),
+            ),
+          }
         }
       }
 
@@ -276,11 +367,21 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
       if (fws.inputFlag.flag.env && process.env[fws.inputFlag.flag.env]) {
         const valueFromEnv = process.env[fws.inputFlag.flag.env]
         if (fws.inputFlag.flag.type === 'option' && valueFromEnv) {
-          return {...fws, valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(validateOptions(i.inputFlag.flag as OptionFlag<any>, valueFromEnv), i.inputFlag.flag, this.context)}
+          return {
+            ...fws,
+            valueFunction: async (i: FlagWithStrategy) => parseFlagOrThrowError(
+              validateOptions(i.inputFlag.flag as OptionFlag<any>, valueFromEnv),
+              i.inputFlag.flag,
+              this.context,
+            ),
+          }
         }
 
         if (fws.inputFlag.flag.type === 'boolean') {
-          return {...fws, valueFunction: async (i: FlagWithStrategy) => Promise.resolve(isTruthy(process.env[i.inputFlag.flag.env as string] ?? 'false'))}
+          return {
+            ...fws,
+            valueFunction: async (i: FlagWithStrategy) => Promise.resolve(isTruthy(process.env[i.inputFlag.flag.env as string] ?? 'false')),
+          }
         }
       }
 
@@ -381,11 +482,12 @@ export class Parser<T extends ParserInput, TFlags extends OutputFlags<T['flags']
     const args = {} as Record<string, unknown>
     const tokens = this._argTokens
     let stdinRead = false
-    const ctx = this.context
+    const ctx = this.context as ArgParserContext
 
     for (const [name, arg] of Object.entries(this.input.args)) {
       const token = tokens.find(t => t.arg === name)
-      ctx.token = token
+      ctx.token = token!
+
       if (token) {
         if (arg.options && !arg.options.includes(token.input)) {
           throw new ArgInvalidOptionError(arg, token.input)
diff --git a/src/util.ts b/src/util.ts
index b3b4cdb..2240890 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -22,6 +22,11 @@ export function uniqBy<T>(arr: T[], fn: (cur: T) => any): T[] {
   })
 }
 
+export function last<T>(arr?: T[]): T | undefined {
+  if (!arr) return
+  return arr.slice(-1)[0]
+}
+
 type SortTypes = string | number | undefined | boolean
 
 export function sortBy<T>(arr: T[], fn: (i: T) => SortTypes | SortTypes[]): T[] {


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 16cae5f..d5263d1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,6 +13,23 @@ jobs:
   windows-unit-tests:
     needs: linux-unit-tests
     uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main
+  node14:
+    needs: linux-unit-tests
+    strategy:
+      matrix:
+        os: ["ubuntu-latest", "windows-latest"]
+        node_version: [14.x]
+      fail-fast: false
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-node@v3
+        with:
+          node-version: ${{ matrix.node_version }}
+          cache: yarn
+      - run: yarn install
+      - run: yarn build
+      - run: yarn test
   e2e:
     needs: linux-unit-tests
     strategy:
diff --git a/test/util.test.ts b/test/util.test.ts
index 90b4faa..42a42e0 100644
--- a/test/util.test.ts
+++ b/test/util.test.ts
@@ -1,5 +1,5 @@
 import {expect} from 'chai'
-import {maxBy, sumBy, capitalize, ensureArgObject} from '../src/util'
+import {maxBy, sumBy, capitalize, ensureArgObject, last} from '../src/util'
 
 describe('capitalize', () => {
   it('capitalizes the string', () => {
@@ -34,6 +34,22 @@ describe('maxBy', () => {
   })
 })
 
+describe('last', () => {
+  it('returns undefined for empty array', () => {
+    expect(last([])).to.be.undefined
+  })
+  it('returns undefined for undefined', () => {
+    expect(last()).to.be.undefined
+  })
+  it('returns last value in the array', () => {
+    const arr: Item[] = [{x: 1}, {x: 3}, {x: 2}]
+    expect(last(arr)).to.equal(arr[2])
+  })
+  it('returns only item in array', () => {
+    expect(last([6])).to.equal(6)
+  })
+})
+
 describe('ensureArgObject', () => {
   it('should convert array of arguments to an object', () => {
     const args = [