Repository: oclif/core
Pull request #511 (2022-09-30 19:32:16)

feat: support flag and command deprecations
Support deprecation warnings for flags and commands

### Commands

```typescript
export default class MyCommand extends Command {
  static state = 'deprecated'
}
```

```
Warning: The "my command" command has been deprecated.
```

```typescript
export default class MyCommand extends Command {
  static state = 'deprecated'
  static deprecationOptions = {
    to: 'my other command"
    version: '2.0.0',
  }
}
```

```
Warning: The "my command" command has been deprecated and will be removed in version 2.0.0. Use "my other command" instead.
```

### Flags

```typescript
export default class MyCommand extends Command {
  static flags = {
    name: Flags.string({
      char: 'n',
      summary: 'Name to print.',
      deprecated: {
        to: 'full-name',
        version: '2.0.0',
      }
    }),
   'full-name': Flags.string({
      summary: 'Name to print.',
    }),
  }
}
```

```
Warning: The "name" flag has been deprecated and will be removed in version 2.0.0. Use "full-name" instead.
```

```typescript
export default class MyCommand extends Command {
  static flags = {
    name: Flags.string({
      char: 'n',
      summary: 'Name to print.',
      deprecated: true
    }),
  }
}
```

```
Warning: The "name" flag has been deprecated.
```

@W-11836498@


Patch:
diff --git a/package.json b/package.json
index c3f4021..742112d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@oclif/core",
   "description": "base library for oclif CLIs",
-  "version": "1.16.7",
+  "version": "1.17.0",
   "author": "Salesforce",
   "bugs": "https://github.com/oclif/core/issues",
   "dependencies": {
diff --git a/src/command.ts b/src/command.ts
index 3f747d1..45c0c44 100644
--- a/src/command.ts
+++ b/src/command.ts
@@ -1,13 +1,15 @@
 import {fileURLToPath} from 'url'
 
 import {format, inspect} from 'util'
-import {CliUx} from './index'
+import {CliUx, toConfiguredId} from './index'
 import {Config} from './config'
 import * as Interfaces from './interfaces'
 import * as Errors from './errors'
 import {PrettyPrintableError} from './errors'
 import * as Parser from './parser'
 import * as Flags from './flags'
+import {Deprecation} from './interfaces/parser'
+import {formatCommandDeprecationWarning, formatFlagDeprecationWarning} from './help/util'
 
 const pjson = require('../package.json')
 
@@ -52,11 +54,13 @@ export default abstract class Command {
    */
   static description: string | undefined
 
-  /** Hide the command from help? */
+  /** Hide the command from help */
   static hidden: boolean
 
-  /** Mark the command as a given state (e.g. beta) in help? */
-  static state?: string;
+  /** Mark the command as a given state (e.g. beta or deprecated) in help */
+  static state?: 'beta' | 'deprecated' | string;
+
+  static deprecationOptions?: Deprecation;
 
   /**
    * An override string (or strings) for the default usage documentation.
@@ -239,13 +243,23 @@ export default abstract class Command {
     this.debug('init version: %s argv: %o', this.ctor._base, this.argv)
     if (this.config.debug) Errors.config.debug = true
     if (this.config.errlog) Errors.config.errlog = this.config.errlog
-    // global['cli-ux'].context = global['cli-ux'].context || {
-    //   command: compact([this.id, ...this.argv]).join(' '),
-    //   version: this.config.userAgent,
-    // }
     const g: any = global
     g['http-call'] = g['http-call'] || {}
     g['http-call']!.userAgent = this.config.userAgent
+    this.checkForDeprecations()
+  }
+
+  protected checkForDeprecations() {
+    if (this.ctor.state === 'deprecated') {
+      const cmdName = toConfiguredId(this.ctor.id, this.config)
+      this.warn(formatCommandDeprecationWarning(cmdName, this.ctor.deprecationOptions))
+    }
+
+    for (const [flag, opts] of Object.entries(this.ctor.flags ?? {})) {
+      if (opts.deprecated) {
+        this.warn(formatFlagDeprecationWarning(flag, opts.deprecated))
+      }
+    }
   }
 
   protected async parse<F extends Interfaces.FlagOutput, G extends Interfaces.FlagOutput, A extends { [name: string]: any }>(options?: Interfaces.Input<F, G>, argv = this.argv): Promise<Interfaces.ParserOutput<F, G, A>> {
@@ -276,7 +290,6 @@ export default abstract class Command {
     try {
       const config = Errors.config
       if (config.errorLogger) await config.errorLogger.flush()
-      // tslint:disable-next-line no-console
     } catch (error: any) {
       console.error(error)
     }
diff --git a/src/config/config.ts b/src/config/config.ts
index afbc99d..5a1c942 100644
--- a/src/config/config.ts
+++ b/src/config/config.ts
@@ -752,6 +752,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
         allowNo: flag.allowNo,
         dependsOn: flag.dependsOn,
         exclusive: flag.exclusive,
+        deprecated: flag.deprecated,
         aliases: flag.aliases,
       }
     } else {
@@ -772,6 +773,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
         relationships: flag.relationships,
         exclusive: flag.exclusive,
         default: await defaultToCached(flag),
+        deprecated: flag.deprecated,
         aliases: flag.aliases,
       }
       // a command-level placeholder in the manifest so that oclif knows it should regenerate the command during help-time
@@ -805,6 +807,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
     state: c.state,
     aliases: c.aliases || [],
     examples: c.examples || (c as any).example,
+    deprecationOptions: c.deprecationOptions,
     flags,
     args,
   }
diff --git a/src/help/index.ts b/src/help/index.ts
index 7b2e9cf..93df1cc 100644
--- a/src/help/index.ts
+++ b/src/help/index.ts
@@ -5,7 +5,7 @@ import {error} from '../errors'
 import CommandHelp from './command'
 import RootHelp from './root'
 import {compact, sortBy, uniqBy} from '../util'
-import {getHelpFlagAdditions, standardizeIDFromArgv} from './util'
+import {formatCommandDeprecationWarning, getHelpFlagAdditions, standardizeIDFromArgv, toConfiguredId} from './util'
 import {HelpFormatter} from './formatter'
 import {toCached} from '../config/config'
 export {CommandHelp} from './command'
@@ -139,7 +139,14 @@ export class Help extends HelpBase {
     const plugin = this.config.plugins.find(p => p.name === command.pluginName)
 
     const state = this.config.pjson?.oclif?.state || plugin?.pjson?.oclif?.state || command.state
-    if (state) this.log(`This command is in ${state}.\n`)
+
+    if (state) {
+      this.log(
+        state === 'deprecated' ?
+          `${formatCommandDeprecationWarning(toConfiguredId(name, this.config), command.deprecationOptions)}` :
+          `This command is in ${state}.\n`,
+      )
+    }
 
     const summary = this.summary(command)
     if (summary) {
@@ -170,7 +177,14 @@ export class Help extends HelpBase {
     let rootCommands = this.sortedCommands
 
     const state = this.config.pjson?.oclif?.state
-    if (state) this.log(`${this.config.bin} is in ${state}.\n`)
+    if (state) {
+      this.log(
+        state === 'deprecated' ?
+          `${this.config.bin} is deprecated` :
+          `${this.config.bin} is in ${state}.\n`,
+      )
+    }
+
     this.log(this.formatRoot())
     this.log('')
 
diff --git a/src/help/util.ts b/src/help/util.ts
index 0c8cacb..95c5942 100644
--- a/src/help/util.ts
+++ b/src/help/util.ts
@@ -3,6 +3,7 @@ import {Config as IConfig, HelpOptions} from '../interfaces'
 import {Help, HelpBase} from '.'
 import ModuleLoader from '../module-loader'
 import {collectUsableIds} from '../config/util'
+import {Deprecation} from '../interfaces/parser'
 
 interface HelpBaseDerived {
   new(config: IConfig, opts?: Partial<HelpOptions>): HelpBase;
@@ -81,8 +82,8 @@ export function toStandardizedId(commandID: string, config: IConfig): string {
 }
 
 export function toConfiguredId(commandID: string, config: IConfig): string {
-  const defaultTopicSeperator = ':'
-  return commandID.replace(new RegExp(defaultTopicSeperator, 'g'), config.topicSeparator || defaultTopicSeperator)
+  const defaultTopicSeparator = ':'
+  return commandID.replace(new RegExp(defaultTopicSeparator, 'g'), config.topicSeparator || defaultTopicSeparator)
 }
 
 export function standardizeIDFromArgv(argv: string[], config: IConfig): string[] {
@@ -97,3 +98,32 @@ export function getHelpFlagAdditions(config: IConfig): string[] {
   const additionalHelpFlags = config.pjson.oclif.additionalHelpFlags ?? []
   return [...new Set([...helpFlags, ...additionalHelpFlags]).values()]
 }
+
+export function formatFlagDeprecationWarning(flag: string, opts: true | Deprecation): string {
+  let message = `The "${flag}" flag has been deprecated`
+  if (opts === true) return `${message}.`
+  if (opts.message) return opts.message
+
+  if (opts.version) {
+    message += ` and will be removed in version ${opts.version}`
+  }
+
+  message += opts.to ? `. Use "${opts.to}" instead.` : '.'
+
+  return message
+}
+
+export function formatCommandDeprecationWarning(command: string, opts?: Deprecation): string {
+  let message = `The "${command}" command has been deprecated`
+  if (!opts) return `${message}.`
+
+  if (opts.message) return opts.message
+
+  if (opts.version) {
+    message += ` and will be removed in version ${opts.version}`
+  }
+
+  message += opts.to ? `. Use "${opts.to}" instead.` : '.'
+
+  return message
+}
diff --git a/src/interfaces/command.ts b/src/interfaces/command.ts
index cf9fe58..438837b 100644
--- a/src/interfaces/command.ts
+++ b/src/interfaces/command.ts
@@ -1,5 +1,5 @@
 import {Config, LoadOptions} from './config'
-import {ArgInput, BooleanFlagProps, FlagInput, OptionFlagProps} from './parser'
+import {ArgInput, BooleanFlagProps, Deprecation, FlagInput, OptionFlagProps} from './parser'
 import {Plugin as IPlugin} from './plugin'
 
 export type Example = string | {
@@ -11,11 +11,16 @@ export interface CommandProps {
   /** A command ID, used mostly in error or verbose reporting. */
   id: string;
 
-  /** Hide the command from help? */
+  /** Hide the command from help */
   hidden: boolean;
 
-  /** Mark the command as a given state (e.g. beta) in help? */
-  state?: string;
+  /** Mark the command as a given state (e.g. beta or deprecated) in help */
+  state?: 'beta' | 'deprecated' | string;
+
+  /**
+   * Provide details to the deprecation warning if state === 'deprecated'
+   */
+  deprecationOptions?: Deprecation;
 
   /** An array of aliases for this command. */
   aliases: string[];
diff --git a/src/interfaces/parser.ts b/src/interfaces/parser.ts
index e944eda..54090a9 100644
--- a/src/interfaces/parser.ts
+++ b/src/interfaces/parser.ts
@@ -92,6 +92,12 @@ export type Relationship = {
   flags: FlagRelationship[];
 }
 
+export type Deprecation = {
+  to?: string;
+  message?: string;
+  version?: string;
+}
+
 export type FlagProps = {
   name: string;
   char?: AlphabetLowercase | AlphabetUppercase;
@@ -143,6 +149,10 @@ export type FlagProps = {
    * Define complex relationships between flags.
    */
   relationships?: Relationship[];
+  /**
+   * Make the flag as deprecated.
+   */
+  deprecated?: true | Deprecation;
   /**
    * Alternate names that can be used for this flag.
    */
diff --git a/src/interfaces/pjson.ts b/src/interfaces/pjson.ts
index bb7285b..7c7a8ee 100644
--- a/src/interfaces/pjson.ts
+++ b/src/interfaces/pjson.ts
@@ -46,7 +46,7 @@ export namespace PJSON {
       };
       additionalHelpFlags?: string[];
       additionalVersionFlags?: string[];
-      state?: string;
+      state?: 'beta' | 'deprecated' | string;
     };
   }
 


Test patch:
diff --git a/test/command/command.test.ts b/test/command/command.test.ts
index 9d799a5..72e8693 100644
--- a/test/command/command.test.ts
+++ b/test/command/command.test.ts
@@ -1,7 +1,7 @@
 import {expect, fancy} from 'fancy-test'
 // import path = require('path')
 
-import {Command as Base, Flags as flags} from '../../src'
+import {Command as Base, Flags} from '../../src'
 // import {TestHelpClassConfig} from './helpers/test-help-in-src/src/test-help-plugin'
 
 // const pjson = require('../package.json')
@@ -204,7 +204,7 @@ describe('command', () => {
     .it('has a flag', async ctx => {
       class CMD extends Base {
         static flags = {
-          foo: flags.string(),
+          foo: Flags.string(),
         }
 
         async run() {
@@ -233,6 +233,76 @@ describe('command', () => {
     .it('uses util.format()')
   })
 
+  describe('deprecated flags', () => {
+    fancy
+    .stdout()
+    .stderr()
+    .do(async () => {
+      class CMD extends Command {
+        static flags = {
+          name: Flags.string({
+            deprecated: {
+              to: '--full-name',
+              version: '2.0.0',
+            },
+          }),
+        }
+
+        async run() {
+          this.log('running command')
+        }
+      }
+      await CMD.run([])
+    })
+    .do(ctx => expect(ctx.stderr).to.include('Warning: The "name" flag has been deprecated'))
+    .it('shows warning for deprecated flags')
+  })
+
+  describe('deprecated state', () => {
+    fancy
+    .stdout()
+    .stderr()
+    .do(async () => {
+      class CMD extends Command {
+        static id = 'my:command'
+        static state = 'deprecated'
+        async run() {
+          this.log('running command')
+        }
+      }
+      await CMD.run([])
+    })
+    .do(ctx => expect(ctx.stderr).to.include('Warning: The "my:command" command has been deprecated'))
+    .it('shows warning for deprecated flags')
+  })
+
+  describe('deprecated state with options', () => {
+    fancy
+    .stdout()
+    .stderr()
+    .do(async () => {
+      class CMD extends Command {
+        static id = 'my:command'
+        static state = 'deprecated'
+        static deprecationOptions = {
+          version: '2.0.0',
+          to: 'my:other:command',
+        }
+
+        async run() {
+          this.log('running command')
+        }
+      }
+      await CMD.run([])
+    })
+    .do(ctx => {
+      expect(ctx.stderr).to.include('Warning: The "my:command" command has been deprecated')
+      expect(ctx.stderr).to.include('in version 2.0.0')
+      expect(ctx.stderr).to.include('Use "my:other:command" instead')
+    })
+    .it('shows warning for deprecated flags')
+  })
+
   describe('stdout err', () => {
     fancy
     .stdout()
diff --git a/test/config/config.flexible.test.ts b/test/config/config.flexible.test.ts
index 75ca543..fce30c8 100644
--- a/test/config/config.flexible.test.ts
+++ b/test/config/config.flexible.test.ts
@@ -18,7 +18,7 @@ interface Options {
 }
 
 // @ts-expect-error
-class MyComandClass implements ICommand.Class {
+class MyCommandClass implements ICommand.Class {
   _base = ''
 
   aliases: string[] = []
@@ -57,7 +57,7 @@ describe('Config with flexible taxonomy', () => {
 
     const load = async (): Promise<void> => {}
     const findCommand = async (): Promise<ICommand.Class> => {
-      return new MyComandClass() as unknown as ICommand.Class
+      return new MyCommandClass() as unknown as ICommand.Class
     }
 
     const commandPluginA: ICommand.Loadable = {
@@ -70,7 +70,7 @@ describe('Config with flexible taxonomy', () => {
       hidden: false,
       id: commandIds[0],
       async load(): Promise<ICommand.Class> {
-        return new MyComandClass() as unknown as ICommand.Class
+        return new MyCommandClass() as unknown as ICommand.Class
       },
       pluginType: types[0] ?? 'core',
       pluginAlias: '@My/plugina',
@@ -85,7 +85,7 @@ describe('Config with flexible taxonomy', () => {
       hidden: false,
       id: commandIds[1],
       async load(): Promise<ICommand.Class> {
-        return new MyComandClass() as unknown as ICommand.Class
+        return new MyCommandClass() as unknown as ICommand.Class
       },
       pluginType: types[1] ?? 'core',
       pluginAlias: '@My/pluginb',