From a971b4080b183c88d9c861728a3d30d79190b25d Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Mon, 14 Sep 2026 16:40:20 +0530 Subject: [PATCH] feat: add Deprecated field to flags and commands Setting Deprecated on a flag or command prints a one-line warning to the root ErrWriter when the flag is set (on the command line or from its Sources) or the command is invoked, and execution continues. The message format mirrors cobra and pflag: Command "old" is deprecated, use "new" instead Flag --old-flag has been deprecated, use --new-flag instead Deprecated flags and commands are not hidden from help; set Hidden for that. --- command.go | 4 + command_run.go | 28 +++++ command_test.go | 160 +++++++++++++++++++++++++ docs/v3/examples/flags/advanced.md | 63 ++++++++++ docs/v3/examples/subcommands/basics.md | 60 ++++++++++ flag.go | 7 ++ flag_bool_with_inverse.go | 6 + flag_impl.go | 6 + godoc-current.txt | 19 +++ testdata/godoc-v3.x.txt | 19 +++ 10 files changed, 372 insertions(+) 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