diff --git a/src/command.ts b/src/command.ts index 3f747d19..45c0c44a 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(options?: Interfaces.Input, argv = this.argv): Promise> { @@ -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 afbc99d4..5a1c9421 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 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 0c8cacbc..95c59420 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): 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 cf9fe587..438837be 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 e944edae..54090a90 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 bb7285b1..7c7a8ee8 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; }; } diff --git a/test/command/command.test.ts b/test/command/command.test.ts index 9d799a5b..72e8693c 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 75ca5436..fce30c8c 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 => {} const findCommand = async (): Promise => { - 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 { - 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 { - return new MyComandClass() as unknown as ICommand.Class + return new MyCommandClass() as unknown as ICommand.Class }, pluginType: types[1] ?? 'core', pluginAlias: '@My/pluginb',