diff --git a/command.go b/command.go index 02f9f76658..21faf1859a 100644 --- a/command.go +++ b/command.go @@ -82,6 +82,10 @@ type Command struct { InvalidFlagAccessHandler InvalidFlagAccessFunc `json:"-"` // Boolean to hide this command from help or completion Hidden bool `json:"hidden"` + // Deprecation message for this command. If non-empty, a warning is + // printed to ErrWriter when the command is invoked. Setting it does not + // hide the command; set Hidden as well for that. + Deprecated string `json:"deprecated"` // List of all authors who contributed (string or fmt.Stringer) // TODO: ~string | fmt.Stringer when interface unions are available Authors []any `json:"authors"` diff --git a/command_run.go b/command_run.go index 34398f8e98..8e9da80c22 100644 --- a/command_run.go +++ b/command_run.go @@ -332,6 +332,8 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context // First, resolve the chain of nested commands up to the parent. cmdChain := commandChain(cmd) + printDeprecationWarnings(cmdChain) + // Run ArgValidator from the nearest ancestor that sets one. if validator := findArgValidator(cmd); validator != nil { if err := validator(ctx, cmd); err != nil { @@ -421,6 +423,32 @@ func commandChain(cmd *Command) []*Command { return cmdChain } +// printDeprecationWarnings writes a warning to the root ErrWriter for each +// deprecated command in the chain and for each deprecated flag that has been +// set, whether on the command line or from one of its Sources. +func printDeprecationWarnings(cmdChain []*Command) { + w := cmdChain[0].ErrWriter + warned := map[Flag]struct{}{} + for _, cmd := range cmdChain { + if cmd.Deprecated != "" { + fmt.Fprintf(w, "Command %q is deprecated, %s\n", cmd.Name, cmd.Deprecated) + } + for _, fl := range cmd.appliedFlags { + if _, inSet := cmd.setFlags[fl]; !inSet { + continue + } + if _, done := warned[fl]; done { + continue + } + if df, ok := fl.(DeprecatedFlag); ok && df.GetDeprecated() != "" { + warned[fl] = struct{}{} + name := fl.Names()[0] + fmt.Fprintf(w, "Flag %s%s has been deprecated, %s\n", prefixFor(name), name, df.GetDeprecated()) + } + } + } +} + func findArgValidator(cmd *Command) ArgValidatorFunc { for c := cmd; c != nil; c = c.parent { if c.ArgValidator != nil { diff --git a/command_test.go b/command_test.go index 823eacff1f..e3e7597d8f 100644 --- a/command_test.go +++ b/command_test.go @@ -5410,6 +5410,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": "", @@ -5431,6 +5432,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some usage text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5449,6 +5451,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5474,6 +5477,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": "", @@ -5495,6 +5499,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5513,6 +5518,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5548,6 +5554,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5580,6 +5587,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5615,6 +5623,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5631,6 +5640,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": true, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5681,6 +5691,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some usage text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5699,6 +5710,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5724,6 +5736,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": "", @@ -5745,6 +5758,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5763,6 +5777,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": null, "copyright": "", "metadata": null, @@ -5788,6 +5803,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "some 'usage' text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": "value", @@ -5808,6 +5824,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": "", @@ -5829,6 +5846,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "another usage text", "required": false, "hidden": false, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5849,6 +5867,7 @@ func TestJSONExportCommand(t *testing.T) { "usage": "", "required": false, "hidden": true, + "deprecated": "", "hideDefault": false, "local": false, "defaultValue": false, @@ -5865,6 +5884,7 @@ func TestJSONExportCommand(t *testing.T) { "hideHelpCommand": false, "hideVersion": false, "hidden": false, + "deprecated": "", "authors": [ "Harrison ", { @@ -6612,3 +6632,143 @@ func TestRunWithNoOsArgs(t *testing.T) { }) } } + +func TestCommand_Deprecated(t *testing.T) { + newCmd := func() *Command { + return &Command{ + Name: "app", + Flags: []Flag{ + &StringFlag{Name: "old-flag", Aliases: []string{"o"}, Deprecated: "use --new-flag instead"}, + &StringFlag{Name: "x", Deprecated: "use --new-flag instead"}, + &StringFlag{Name: "env-flag", Sources: EnvVars("APP_ENV_FLAG"), Deprecated: "use --new-flag instead"}, + &BoolWithInverseFlag{Name: "color", Deprecated: "it is always on"}, + &StringFlag{Name: "new-flag"}, + }, + Commands: []*Command{ + { + Name: "old", + Deprecated: "use \"new\" instead", + }, + { + Name: "new", + }, + { + Name: "group", + Deprecated: "use \"new\" instead", + Commands: []*Command{ + {Name: "sub"}, + }, + }, + }, + Action: func(context.Context, *Command) error { return nil }, + } + } + + tests := []struct { + name string + args []string + env map[string]string + wantErr string + }{ + { + name: "nothing deprecated used", + args: []string{"app", "--new-flag", "v", "new"}, + }, + { + name: "deprecated command", + args: []string{"app", "old"}, + wantErr: "Command \"old\" is deprecated, use \"new\" instead\n", + }, + { + name: "deprecated parent command", + args: []string{"app", "group", "sub"}, + wantErr: "Command \"group\" is deprecated, use \"new\" instead\n", + }, + { + name: "deprecated flag", + args: []string{"app", "--old-flag", "v"}, + wantErr: "Flag --old-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated flag set by alias", + args: []string{"app", "-o", "v"}, + wantErr: "Flag --old-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated short flag", + args: []string{"app", "-x", "v"}, + wantErr: "Flag -x has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated inverse bool flag", + args: []string{"app", "--no-color"}, + wantErr: "Flag --color has been deprecated, it is always on\n", + }, + { + name: "deprecated flag set from env", + args: []string{"app"}, + env: map[string]string{"APP_ENV_FLAG": "v"}, + wantErr: "Flag --env-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated persistent flag set on subcommand", + args: []string{"app", "new", "--old-flag", "v"}, + wantErr: "Flag --old-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated persistent flag set on parent and subcommand warns once", + args: []string{"app", "--old-flag", "v", "new", "--old-flag", "w"}, + wantErr: "Flag --old-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "deprecated command and flag", + args: []string{"app", "old", "--old-flag", "v"}, + wantErr: "Command \"old\" is deprecated, use \"new\" instead\n" + + "Flag --old-flag has been deprecated, use --new-flag instead\n", + }, + { + name: "help for deprecated command", + args: []string{"app", "old", "--help"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for k, v := range test.env { + t.Setenv(k, v) + } + + var errBuf bytes.Buffer + cmd := newCmd() + cmd.Writer = io.Discard + cmd.ErrWriter = &errBuf + + require.NoError(t, cmd.Run(buildTestContext(t), test.args)) + assert.Equal(t, test.wantErr, errBuf.String()) + }) + } +} + +func TestCommand_DeprecatedStillRuns(t *testing.T) { + var got string + cmd := &Command{ + Name: "app", + ErrWriter: io.Discard, + Commands: []*Command{ + { + Name: "old", + Deprecated: "use \"new\" instead", + Flags: []Flag{ + &StringFlag{Name: "old-flag", Deprecated: "use --new-flag instead"}, + }, + Action: func(_ context.Context, cmd *Command) error { + got = cmd.String("old-flag") + return nil + }, + }, + }, + } + + require.NoError(t, cmd.Run(buildTestContext(t), []string{"app", "old", "--old-flag", "v"})) + assert.Equal(t, "v", got) +} diff --git a/docs/v3/examples/flags/advanced.md b/docs/v3/examples/flags/advanced.md index e5a5fff7e5..495e0c1df9 100644 --- a/docs/v3/examples/flags/advanced.md +++ b/docs/v3/examples/flags/advanced.md @@ -334,6 +334,69 @@ If the command is run without the `lang` flag, the user will see the following m Required flag "lang" not set ``` +#### Deprecated Flags + +You can mark a flag as *deprecated* by setting the `Deprecated` field to a message +explaining what to use instead. When a deprecated flag is set, either on the command +line or from one of its `Sources` such as an environment variable, a warning is +written to the root command's `ErrWriter` and the command continues to run. + +A deprecated flag is still shown in help output. Set `Hidden: true` as well to hide it. + + +```go +package main + +import ( + "fmt" + "log" + "os" + "context" + + "github.com/urfave/cli/v3" +) + +func main() { + cmd := &cli.Command{ + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "lang", + Value: "english", + Usage: "language for the greeting", + Deprecated: "use --language instead", + }, + &cli.StringFlag{ + Name: "language", + Value: "english", + Usage: "language for the greeting", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + output := "Hello" + if cmd.String("lang") == "spanish" || cmd.String("language") == "spanish" { + output = "Hola" + } + fmt.Println(output) + return nil + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} +``` + +If the command is run with `--lang spanish`, the user will see the following warning +before the output + +``` +Flag --lang has been deprecated, use --language instead +``` + #### Flag Groups You can make groups of flags that are mutually exclusive of each other. diff --git a/docs/v3/examples/subcommands/basics.md b/docs/v3/examples/subcommands/basics.md index 555602665a..5ea41407fc 100644 --- a/docs/v3/examples/subcommands/basics.md +++ b/docs/v3/examples/subcommands/basics.md @@ -75,3 +75,63 @@ func main() { } } ``` + +#### Deprecated Commands + +A command can be marked as *deprecated* by setting the `Deprecated` field to a +message explaining what to use instead. When the command is invoked, a warning is +written to the root command's `ErrWriter` and the command continues to run. + +A deprecated command is still listed in help output. Set `Hidden: true` as well to +hide it. + + +```go +package main + +import ( + "fmt" + "log" + "os" + "context" + + "github.com/urfave/cli/v3" +) + +func main() { + cmd := &cli.Command{ + Commands: []*cli.Command{ + { + Name: "remove", + Usage: "remove a task from the list", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("removed task:", cmd.Args().First()) + return nil + }, + }, + { + Name: "rm", + Usage: "remove a task from the list", + Deprecated: "use \"remove\" instead", + Action: func(ctx context.Context, cmd *cli.Command) error { + fmt.Println("removed task:", cmd.Args().First()) + return nil + }, + }, + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatal(err) + } +} +``` + +Running `rm task` prints the following warning before the output + +``` +Command "rm" is deprecated, use "remove" instead +``` diff --git a/flag.go b/flag.go index 3cb9ab9608..0e4f7a1b7a 100644 --- a/flag.go +++ b/flag.go @@ -118,6 +118,13 @@ type RequiredFlag interface { IsRequired() bool } +// DeprecatedFlag is an interface that allows us to mark flags as deprecated +type DeprecatedFlag interface { + // GetDeprecated returns the deprecation message, or an empty string + // if the flag is not deprecated + GetDeprecated() string +} + // DocGenerationFlag is an interface that allows documentation generation for the flag type DocGenerationFlag interface { // TakesValue returns true if the flag takes a value, otherwise false diff --git a/flag_bool_with_inverse.go b/flag_bool_with_inverse.go index 33c0927257..801a840c5c 100644 --- a/flag_bool_with_inverse.go +++ b/flag_bool_with_inverse.go @@ -18,6 +18,7 @@ type BoolWithInverseFlag struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value bool `json:"defaultValue"` // default value for this flag if not set by from any source Destination *bool `json:"-"` // destination pointer for value when set @@ -225,6 +226,11 @@ func (bif *BoolWithInverseFlag) GetDefaultText() string { return boolValue{}.ToString(bif.Value) } +// GetDeprecated returns the deprecation message of the flag +func (bif *BoolWithInverseFlag) GetDeprecated() string { + return bif.Deprecated +} + // GetCategory returns the category of the flag func (bif *BoolWithInverseFlag) GetCategory() string { return bif.Category diff --git a/flag_impl.go b/flag_impl.go index be702d33f4..808c82ee41 100644 --- a/flag_impl.go +++ b/flag_impl.go @@ -63,6 +63,7 @@ type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value T `json:"defaultValue"` // default value for this flag if not set by from any source Destination *T `json:"-"` // destination pointer for value when set @@ -255,6 +256,11 @@ func (f *FlagBase[T, C, V]) IsVisible() bool { return !f.Hidden } +// GetDeprecated returns the deprecation message of the flag +func (f *FlagBase[T, C, V]) GetDeprecated() string { + return f.Deprecated +} + // GetCategory returns the category of the flag func (f *FlagBase[T, C, V]) GetCategory() string { return f.Category diff --git a/godoc-current.txt b/godoc-current.txt index 45e60e2566..fc06976077 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -357,6 +357,7 @@ type BoolWithInverseFlag struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value bool `json:"defaultValue"` // default value for this flag if not set by from any source Destination *bool `json:"-"` // destination pointer for value when set @@ -383,6 +384,9 @@ func (bif *BoolWithInverseFlag) GetCategory() string func (bif *BoolWithInverseFlag) GetDefaultText() string GetDefaultText returns the default text for this flag +func (bif *BoolWithInverseFlag) GetDeprecated() string + GetDeprecated returns the deprecation message of the flag + func (bif *BoolWithInverseFlag) GetEnvVars() []string GetEnvVars returns the env vars for this flag @@ -508,6 +512,10 @@ type Command struct { InvalidFlagAccessHandler InvalidFlagAccessFunc `json:"-"` // Boolean to hide this command from help or completion Hidden bool `json:"hidden"` + // Deprecation message for this command. If non-empty, a warning is + // printed to ErrWriter when the command is invoked. Setting it does not + // hide the command; set Hidden as well for that. + Deprecated string `json:"deprecated"` // List of all authors who contributed (string or fmt.Stringer) // TODO: ~string | fmt.Stringer when interface unions are available Authors []any `json:"authors"` @@ -868,6 +876,13 @@ type Countable interface { Countable is an interface to enable detection of flag values which support repetitive flags +type DeprecatedFlag interface { + // GetDeprecated returns the deprecation message, or an empty string + // if the flag is not deprecated + GetDeprecated() string +} + DeprecatedFlag is an interface that allows us to mark flags as deprecated + type DocGenerationFlag interface { // TakesValue returns true if the flag takes a value, otherwise false TakesValue() bool @@ -996,6 +1011,7 @@ type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value T `json:"defaultValue"` // default value for this flag if not set by from any source Destination *T `json:"-"` // destination pointer for value when set @@ -1027,6 +1043,9 @@ func (f *FlagBase[T, C, V]) GetCategory() string func (f *FlagBase[T, C, V]) GetDefaultText() string GetDefaultText returns the default text for this flag +func (f *FlagBase[T, C, V]) GetDeprecated() string + GetDeprecated returns the deprecation message of the flag + func (f *FlagBase[T, C, V]) GetEnvVars() []string GetEnvVars returns the env vars for this flag diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index 45e60e2566..fc06976077 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -357,6 +357,7 @@ type BoolWithInverseFlag struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value bool `json:"defaultValue"` // default value for this flag if not set by from any source Destination *bool `json:"-"` // destination pointer for value when set @@ -383,6 +384,9 @@ func (bif *BoolWithInverseFlag) GetCategory() string func (bif *BoolWithInverseFlag) GetDefaultText() string GetDefaultText returns the default text for this flag +func (bif *BoolWithInverseFlag) GetDeprecated() string + GetDeprecated returns the deprecation message of the flag + func (bif *BoolWithInverseFlag) GetEnvVars() []string GetEnvVars returns the env vars for this flag @@ -508,6 +512,10 @@ type Command struct { InvalidFlagAccessHandler InvalidFlagAccessFunc `json:"-"` // Boolean to hide this command from help or completion Hidden bool `json:"hidden"` + // Deprecation message for this command. If non-empty, a warning is + // printed to ErrWriter when the command is invoked. Setting it does not + // hide the command; set Hidden as well for that. + Deprecated string `json:"deprecated"` // List of all authors who contributed (string or fmt.Stringer) // TODO: ~string | fmt.Stringer when interface unions are available Authors []any `json:"authors"` @@ -868,6 +876,13 @@ type Countable interface { Countable is an interface to enable detection of flag values which support repetitive flags +type DeprecatedFlag interface { + // GetDeprecated returns the deprecation message, or an empty string + // if the flag is not deprecated + GetDeprecated() string +} + DeprecatedFlag is an interface that allows us to mark flags as deprecated + type DocGenerationFlag interface { // TakesValue returns true if the flag takes a value, otherwise false TakesValue() bool @@ -996,6 +1011,7 @@ type FlagBase[T any, C any, VC ValueCreator[T, C]] struct { Sources ValueSourceChain `json:"-"` // sources to load flag value from Required bool `json:"required"` // whether the flag is required or not Hidden bool `json:"hidden"` // whether to hide the flag in help output + Deprecated string `json:"deprecated"` // deprecation message, if set a warning is printed when the flag is set Local bool `json:"local"` // whether the flag needs to be applied to subcommands as well Value T `json:"defaultValue"` // default value for this flag if not set by from any source Destination *T `json:"-"` // destination pointer for value when set @@ -1027,6 +1043,9 @@ func (f *FlagBase[T, C, V]) GetCategory() string func (f *FlagBase[T, C, V]) GetDefaultText() string GetDefaultText returns the default text for this flag +func (f *FlagBase[T, C, V]) GetDeprecated() string + GetDeprecated returns the deprecation message of the flag + func (f *FlagBase[T, C, V]) GetEnvVars() []string GetEnvVars returns the env vars for this flag