diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go index 1670548..9cd72af 100644 --- a/internal/commands/api/api.go +++ b/internal/commands/api/api.go @@ -40,6 +40,7 @@ type Opts struct { Client *client.Client Quiet bool Debug bool + DryRun bool Headers []string URL *url.URL Attributes map[string]string @@ -197,6 +198,7 @@ func NewCmdAPI(ctx *cmd.Context) *cmd.Command { opts.Debug = ctx.Profile.GetVerbosity() == "debug" || ctx.Profile.GetVerbosity() == "trace" opts.Quiet = ctx.Profile.IsQuiet() + opts.DryRun = ctx.IsDryRun() return runAPI(opts) }, @@ -233,6 +235,13 @@ func runAPI(opts *Opts) error { requestHeaders.Set("Accept", "application/vnd.api+json") } + // In dry-run mode, skip mutating requests and report what would have happened. + if opts.DryRun && isMutationMethod(method) { + fmt.Fprintf(opts.IO.Err(), "%s would send %s %s\n", opts.IO.ColorScheme().DryRunLabel(), method, opts.URL.String()) + writeDryRunRequest(opts.IO.Err(), method, opts.URL, requestHeaders, body) + return nil + } + // Make the request response, err := opts.Client.RawRequest(opts.ShutdownCtx, &client.Request{ Method: method, @@ -503,3 +512,38 @@ func writeHeaders(w io.Writer, headers http.Header) { fmt.Fprintf(w, "%s: %s\n", key, strings.Join(headers.Values(key), ", ")) } } + +func isMutationMethod(method string) bool { + switch strings.ToUpper(method) { + case http.MethodPost, http.MethodPatch, http.MethodPut, http.MethodDelete: + return true + default: + return false + } +} + +func writeDryRunRequest(w io.Writer, method string, u *url.URL, headers http.Header, body []byte) { + fmt.Fprintf(w, "> %s %s\n", method, u.String()) + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + fmt.Fprintf(w, "%s: %s\n", key, strings.Join(headers.Values(key), ", ")) + } + if len(body) == 0 { + return + } + fmt.Fprintln(w) + _, _ = w.Write(formatDryRunBody(body)) + fmt.Fprintln(w) +} + +func formatDryRunBody(body []byte) []byte { + var formatted bytes.Buffer + if err := json.Indent(&formatted, body, "", " "); err == nil { + return formatted.Bytes() + } + return body +} diff --git a/internal/commands/api/api_test.go b/internal/commands/api/api_test.go index e2457fd..67f13df 100644 --- a/internal/commands/api/api_test.go +++ b/internal/commands/api/api_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "sync" "testing" @@ -600,3 +601,28 @@ func serverURL(r *http.Request) string { } return scheme + "://" + r.Host } + +func TestWriteDryRunRequest(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + u, err := url.Parse("https://example.com/api/v2/projects") + if err != nil { + t.Fatal(err) + } + headers := http.Header{ + "Accept": []string{"application/vnd.api+json"}, + "Content-Type": []string{"application/vnd.api+json"}, + } + body := []byte(`{"data":{"type":"projects"}}`) + + writeDryRunRequest(io.Err(), http.MethodPost, u, headers, body) + + output := io.Error.String() + if !strings.Contains(output, "> POST https://example.com/api/v2/projects") { + t.Fatalf("expected request line, got %q", output) + } + if !strings.Contains(output, `"type": "projects"`) { + t.Fatalf("expected body, got %q", output) + } +} diff --git a/internal/commands/profile/profile.go b/internal/commands/profile/profile.go index c43fa2f..0e7e963 100644 --- a/internal/commands/profile/profile.go +++ b/internal/commands/profile/profile.go @@ -36,7 +36,7 @@ func NewCmdProfile(ctx *cmd.Context) *cmd.Command { {{ template "mdCodeOrBold" "tfcloud" }} has several global flags that have matching profile properties. Examples are the {{ template "mdCodeOrBold" "verbosity" }} and {{ template "mdCodeOrBold" "organization" }} properties and their respective flags - {{ template "mdCodeOrBold" "--verbose" }} and {{ template "mdCodeOrBold" "--organization" }}. + {{ template "mdCodeOrBold" "--debug" }} and {{ template "mdCodeOrBold" "--organization" }}. The difference between properties and flags is that flags apply only on the invoked command, while properties are persistent across all invocations. Thus profiles allow you to conviently maintain the same settings across command executions and multiple profiles allow you to easily diff --git a/internal/commands/profile/profiles/activate.go b/internal/commands/profile/profiles/activate.go index 1fce576..2ab843e 100644 --- a/internal/commands/profile/profiles/activate.go +++ b/internal/commands/profile/profiles/activate.go @@ -44,6 +44,7 @@ func NewCmdActivate(ctx *cmd.Context) *cmd.Command { NoAuthRequired: true, RunF: func(_ *cmd.Command, args []string) error { opts.Name = args[0] + opts.DryRun = ctx.IsDryRun() l, err := profile.NewLoader() if err != nil { return err @@ -61,6 +62,7 @@ type ActivateOpts struct { IO iostreams.IOStreams Profiles *profile.Loader Name string + DryRun bool } func activateRun(opts *ActivateOpts) error { @@ -85,6 +87,11 @@ func activateRun(opts *ActivateOpts) error { return fmt.Errorf("profile %q does not exist", opts.Name) } + if opts.DryRun { + fmt.Fprintf(opts.IO.Err(), "%s would activate profile %q\n", opts.IO.ColorScheme().DryRunLabel(), opts.Name) + return nil + } + // Save the new active profile active.Name = opts.Name if err := active.Write(); err != nil { diff --git a/internal/commands/profile/profiles/activate_test.go b/internal/commands/profile/profiles/activate_test.go index 6c26f77..f638804 100644 --- a/internal/commands/profile/profiles/activate_test.go +++ b/internal/commands/profile/profiles/activate_test.go @@ -86,3 +86,28 @@ func TestActivate(t *testing.T) { }) } } + +func TestActivateDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + for _, name := range []string{"foo", "bar"} { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = "foo" + r.NoError(active.Write()) + + opts := &ActivateOpts{IO: io, Profiles: l, Name: "bar", DryRun: true} + r.NoError(activateRun(opts)) + r.Contains(io.Error.String(), `would activate profile "bar"`) + + newActive, err := l.GetActiveProfile() + r.NoError(err) + r.Equal("foo", newActive.Name) +} diff --git a/internal/commands/profile/profiles/create.go b/internal/commands/profile/profiles/create.go index f02787f..ba1344c 100644 --- a/internal/commands/profile/profiles/create.go +++ b/internal/commands/profile/profiles/create.go @@ -64,6 +64,7 @@ func NewCmdCreate(ctx *cmd.Context) *cmd.Command { NoAuthRequired: true, RunF: func(_ *cmd.Command, args []string) error { opts.Name = args[0] + opts.DryRun = ctx.IsDryRun() l, err := profile.NewLoader() if err != nil { return err @@ -84,6 +85,7 @@ type CreateOpts struct { Name string NoActivate bool Hostname string + DryRun bool } func createRun(opts *CreateOpts) error { @@ -111,6 +113,15 @@ func createRun(opts *CreateOpts) error { p.Hostname = opts.Hostname } + if opts.DryRun { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s would create profile %q\n", cs.DryRunLabel(), opts.Name) + if !opts.NoActivate { + fmt.Fprintf(opts.IO.Err(), "%s would activate profile %q\n", cs.DryRunLabel(), opts.Name) + } + return nil + } + // Save the profile if err := p.Write(); err != nil { return fmt.Errorf("failed to save new profile: %w", err) diff --git a/internal/commands/profile/profiles/create_test.go b/internal/commands/profile/profiles/create_test.go index 6556d42..4e30356 100644 --- a/internal/commands/profile/profiles/create_test.go +++ b/internal/commands/profile/profiles/create_test.go @@ -45,3 +45,25 @@ func TestCreate(t *testing.T) { r.Contains(profiles, p1) r.Contains(profiles, p2) } + +func TestCreateDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + opts := &CreateOpts{ + IO: io, + Profiles: l, + Name: "dry_run_profile", + } + + opts.DryRun = true + r.NoError(createRun(opts)) + r.Contains(io.Error.String(), `would create profile "dry_run_profile"`) + r.Contains(io.Error.String(), `would activate profile "dry_run_profile"`) + + profiles, err := l.ListProfiles() + r.NoError(err) + r.NotContains(profiles, "dry_run_profile") +} diff --git a/internal/commands/profile/profiles/delete.go b/internal/commands/profile/profiles/delete.go index 5db9a2a..6552723 100644 --- a/internal/commands/profile/profiles/delete.go +++ b/internal/commands/profile/profiles/delete.go @@ -63,6 +63,7 @@ func NewCmdDelete(ctx *cmd.Context) *cmd.Command { } opts.Profiles = l opts.Names = args + opts.DryRun = ctx.IsDryRun() return deleteRun(opts) }, } @@ -75,7 +76,8 @@ type DeleteOpts struct { IO iostreams.IOStreams Profiles *profile.Loader - Names []string + Names []string + DryRun bool } func deleteRun(opts *DeleteOpts) error { @@ -125,6 +127,13 @@ func deleteRun(opts *DeleteOpts) error { } } + if opts.DryRun { + for _, toDelete := range opts.Names { + fmt.Fprintf(opts.IO.Err(), "%s would delete profile %q\n", cs.DryRunLabel(), toDelete) + } + return nil + } + for _, toDelete := range opts.Names { if err := opts.Profiles.DeleteProfile(toDelete); err != nil { return fmt.Errorf("failed to delete profile %q: %w", toDelete, err) diff --git a/internal/commands/profile/profiles/delete_test.go b/internal/commands/profile/profiles/delete_test.go index 7a8d494..9765c6e 100644 --- a/internal/commands/profile/profiles/delete_test.go +++ b/internal/commands/profile/profiles/delete_test.go @@ -136,3 +136,28 @@ func TestDelete(t *testing.T) { }) } } + +func TestDeleteDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + for _, name := range []string{"foo", "bar"} { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = "foo" + r.NoError(active.Write()) + + opts := &DeleteOpts{IO: io, Profiles: l, Names: []string{"bar"}, DryRun: true} + r.NoError(deleteRun(opts)) + r.Contains(io.Error.String(), `would delete profile "bar"`) + + profiles, err := l.ListProfiles() + r.NoError(err) + r.Contains(profiles, "bar") +} diff --git a/internal/commands/profile/profiles/rename.go b/internal/commands/profile/profiles/rename.go index 4ece151..1fbc7a4 100644 --- a/internal/commands/profile/profiles/rename.go +++ b/internal/commands/profile/profiles/rename.go @@ -63,6 +63,7 @@ func NewCmdRename(ctx *cmd.Context) *cmd.Command { return err } opts.Profiles = l + opts.DryRun = ctx.IsDryRun() return renameRun(opts) }, } @@ -76,6 +77,7 @@ type RenameOpts struct { Profiles *profile.Loader ExistingName string NewName string + DryRun bool } func renameRun(opts *RenameOpts) error { @@ -108,8 +110,21 @@ func renameRun(opts *RenameOpts) error { return fmt.Errorf("a profile with name %q already exists", opts.NewName) } + active, err := opts.Profiles.GetActiveProfile() + if err != nil { + return fmt.Errorf("failed to get active profile: %w", err) + } + // Update the name and save. existing.Name = opts.NewName + if opts.DryRun { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s would rename profile %q to %q\n", cs.DryRunLabel(), opts.ExistingName, opts.NewName) + if active.Name == opts.ExistingName { + fmt.Fprintf(opts.IO.Err(), "%s would activate profile %q\n", cs.DryRunLabel(), opts.NewName) + } + return nil + } if err := existing.Write(); err != nil { return fmt.Errorf("error saving renamed profile: %w", err) } @@ -122,12 +137,6 @@ func renameRun(opts *RenameOpts) error { return fmt.Errorf("failed to delete old profile: %w", err) } - // Get the active profile - active, err := opts.Profiles.GetActiveProfile() - if err != nil { - return fmt.Errorf("failed to get active profile: %w", err) - } - // If the active profile was the profile that we just renamed, update to the // new name. if active.Name == opts.ExistingName { diff --git a/internal/commands/profile/profiles/rename_test.go b/internal/commands/profile/profiles/rename_test.go index 2de5e37..8daad7b 100644 --- a/internal/commands/profile/profiles/rename_test.go +++ b/internal/commands/profile/profiles/rename_test.go @@ -117,3 +117,30 @@ func TestRename(t *testing.T) { }) } } + +func TestRenameDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + for _, name := range []string{"foo", "bar"} { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = "bar" + r.NoError(active.Write()) + + opts := &RenameOpts{IO: io, Profiles: l, ExistingName: "bar", NewName: "baz", DryRun: true} + r.NoError(renameRun(opts)) + r.Contains(io.Error.String(), `would rename profile "bar" to "baz"`) + r.Contains(io.Error.String(), `would activate profile "baz"`) + + profiles, err := l.ListProfiles() + r.NoError(err) + r.Contains(profiles, "bar") + r.NotContains(profiles, "baz") +} diff --git a/internal/commands/profile/property_docs.go b/internal/commands/profile/property_docs.go index 029e5bf..40d084f 100644 --- a/internal/commands/profile/property_docs.go +++ b/internal/commands/profile/property_docs.go @@ -41,7 +41,7 @@ func addCoreProperties(b *availablePropertiesBuilder) { b.AddProperty("", "quiet", "If True, prompts will be disabled and output will be minimized.") b.AddProperty("", "verbosity", ` Default logging verbosity for {{ template "mdCodeOrBold" "tfcloud" }} commands. This is the - equivalent of using the global {{ template "mdCodeOrBold" "--verbose" }} flag. Supported log levels: + equivalent of using the global {{ template "mdCodeOrBold" "--debug" }} flag. Supported log levels: {{ template "mdCodeOrBold" "trace" }}, {{ template "mdCodeOrBold" "debug" }}, {{ template "mdCodeOrBold" "info" }}, {{ template "mdCodeOrBold" "warn" }}, and {{ template "mdCodeOrBold" "error" }}.`) diff --git a/internal/commands/profile/set.go b/internal/commands/profile/set.go index a67049c..88e0626 100644 --- a/internal/commands/profile/set.go +++ b/internal/commands/profile/set.go @@ -71,6 +71,7 @@ func NewCmdSet(ctx *cmd.Context) *cmd.Command { opts.Property = args[0] opts.Value = args[1] + opts.DryRun = ctx.IsDryRun() return setRun(opts) }, } @@ -88,6 +89,7 @@ type SetOpts struct { // Arguments Property string Value string + DryRun bool } func setRun(opts *SetOpts) error { @@ -164,6 +166,15 @@ func setRun(opts *SetOpts) error { p.Token = "" } + if opts.DryRun { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s would set profile property %q to %q\n", cs.DryRunLabel(), opts.Property, opts.Value) + if hostnameChanged { + fmt.Fprintf(opts.IO.Err(), "%s would also clear organization and token for the active profile\n", cs.DryRunLabel()) + } + return nil + } + if err := p.Write(); err != nil { return err } diff --git a/internal/commands/profile/set_test.go b/internal/commands/profile/set_test.go index 88c5cbc..51b3b7a 100644 --- a/internal/commands/profile/set_test.go +++ b/internal/commands/profile/set_test.go @@ -151,3 +151,30 @@ func TestSet_Organization(t *testing.T) { } } + +func TestSetDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + + l := profile.TestLoader(t) + io := iostreams.Test() + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "original-org" + r.NoError(p.Write()) + o := &SetOpts{ + IO: io, + Profile: p, + Property: "organization", + Value: "dry-run-org", + } + + o.DryRun = true + r.NoError(setRun(o)) + r.Equal("dry-run-org", o.Profile.Organization) + r.Contains(io.Error.String(), `would set profile property "organization" to "dry-run-org"`) + + reloaded, err := l.LoadProfile("test") + r.NoError(err) + r.Equal("original-org", reloaded.Organization) +} diff --git a/internal/commands/profile/unset.go b/internal/commands/profile/unset.go index d31b909..0c56258 100644 --- a/internal/commands/profile/unset.go +++ b/internal/commands/profile/unset.go @@ -58,6 +58,7 @@ func NewCmdUnset(ctx *cmd.Context) *cmd.Command { return err } opts.Profiles = l + opts.DryRun = ctx.IsDryRun() return unsetRun(opts) }, @@ -74,6 +75,7 @@ type UnsetOpts struct { Property string Profiles *profile.Loader + DryRun bool } func unsetRun(opts *UnsetOpts) error { @@ -160,6 +162,11 @@ func unsetRun(opts *UnsetOpts) error { return fmt.Errorf("invalid profile: %w", err) } + if opts.DryRun { + fmt.Fprintf(opts.IO.Err(), "%s would unset profile property %q\n", opts.IO.ColorScheme().DryRunLabel(), opts.Property) + return nil + } + if err := p.Write(); err != nil { return err } diff --git a/internal/commands/profile/unset_test.go b/internal/commands/profile/unset_test.go index 813c01b..ebeb0a7 100644 --- a/internal/commands/profile/unset_test.go +++ b/internal/commands/profile/unset_test.go @@ -90,3 +90,30 @@ func TestUnset(t *testing.T) { }) } } + +func TestUnsetDryRun(t *testing.T) { + t.Parallel() + r := require.New(t) + + l := profile.TestLoader(t) + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "keep-me" + r.NoError(p.Write()) + + io := iostreams.Test() + o := &UnsetOpts{ + IO: io, + Profile: p, + Profiles: l, + Property: "organization", + } + + o.DryRun = true + r.NoError(unsetRun(o)) + r.Contains(io.Error.String(), `would unset profile property "organization"`) + + reloaded, err := l.LoadProfile("test") + r.NoError(err) + r.Equal("keep-me", reloaded.Organization) +} diff --git a/internal/commands/variable/variable_import.go b/internal/commands/variable/variable_import.go index 83a7070..6386f8e 100644 --- a/internal/commands/variable/variable_import.go +++ b/internal/commands/variable/variable_import.go @@ -29,6 +29,7 @@ type ImportOpts struct { Organization string Workspace string Overwrite bool + DryRun bool } type existingVariables map[string]existingVariable @@ -146,6 +147,7 @@ func NewCmdVariableImport(ctx *cmd.Context) *cmd.Command { } opts.Client = apiClient + opts.DryRun = ctx.IsDryRun() return runVariableImport(opts) }, @@ -184,6 +186,15 @@ func runVariableImport(opts *ImportOpts) error { target, err := resolveTarget(opts.ShutdownCtx, opts) if err != nil { + if opts.DryRun && opts.VariableSetName != "" { + // Variable set doesn't exist yet; report what would happen. + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s would create variable set %q\n", cs.DryRunLabel(), opts.VariableSetName) + for _, variable := range imported { + fmt.Fprintf(opts.IO.Err(), "%s would create %s variable %q in variable set %q\n", cs.DryRunLabel(), variable.Category, variable.Key, opts.VariableSetName) + } + return nil + } return err } @@ -205,26 +216,41 @@ func runVariableImport(opts *ImportOpts) error { created := 0 updated := 0 + cs := opts.IO.ColorScheme() for _, variable := range imported { if current, ok := existing.Get(variable.Category, variable.Key); ok { + if opts.DryRun { + fmt.Fprintf(opts.IO.Err(), "%s would update %s variable %q in %s\n", cs.DryRunLabel(), variable.Category, variable.Key, target.String()) + updated++ + continue + } if err := target.updateVariable(opts.ShutdownCtx, current.ID, variable); err != nil { return err } updated++ continue } + if opts.DryRun { + fmt.Fprintf(opts.IO.Err(), "%s would create %s variable %q in %s\n", cs.DryRunLabel(), variable.Category, variable.Key, target.String()) + created++ + continue + } if err := target.createVariable(opts.ShutdownCtx, variable); err != nil { return err } created++ } + if opts.DryRun { + return nil + } + fmt.Fprintf(opts.IO.Err(), "%s imported %d variables into %s (%d created, %d updated)", opts.IO.ColorScheme().SuccessIcon(), len(imported), target.String(), created, updated) return nil } func resolveTarget(ctx context.Context, opts *ImportOpts) (*variableTarget, error) { - resolver := client.NewResolver(opts.Client, opts.VariableSetName != "", false) + resolver := client.NewResolver(opts.Client, opts.VariableSetName != "" && !opts.DryRun, false) if opts.VariableSetName != "" { result, err := resolver.VariableSet(ctx, opts.Organization, opts.VariableSetName) diff --git a/internal/pkg/cmd/context.go b/internal/pkg/cmd/context.go index 1b4f00d..11a5c87 100644 --- a/internal/pkg/cmd/context.go +++ b/internal/pkg/cmd/context.go @@ -55,6 +55,7 @@ type GlobalFlags struct { markdown bool noColor bool debug int + dryRun bool // Version indicates the user has requested the version of the CLI Version bool @@ -73,6 +74,11 @@ func (ctx *Context) GetGlobalFlags() GlobalFlags { return ctx.flags } +// IsDryRun returns true when commands should avoid making mutating changes. +func (ctx *Context) IsDryRun() bool { + return ctx.GetGlobalFlags().dryRun +} + // ConfigureRootCommand should be only called on the root command. It configures // global flags and ensures that the context is configured based on any flags // set during a command invocation. @@ -117,6 +123,12 @@ func ConfigureRootCommand(ctx *Context, cmd *Command) { Value: flagvalue.Simple(false, &ctx.flags.markdown), IsBooleanFlag: true, global: true, + }, &Flag{ + Name: "dry-run", + Description: "Shows what would happen without actually changing anything.", + Value: flagvalue.Simple(false, &ctx.flags.dryRun), + IsBooleanFlag: true, + global: true, }, &Flag{ Name: "quiet", Description: "Minimizes output and disables interactive prompting.", diff --git a/internal/pkg/cmd/context_test.go b/internal/pkg/cmd/context_test.go new file mode 100644 index 0000000..c212e79 --- /dev/null +++ b/internal/pkg/cmd/context_test.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "context" + "testing" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestContextDryRunHelper(t *testing.T) { + t.Parallel() + + ctx := &Context{IO: iostreams.Test(), ShutdownCtx: context.Background()} + ctx.flags.parsed = true + ctx.flags.dryRun = true + + if !ctx.IsDryRun() { + t.Fatal("expected dry-run to be enabled") + } +} diff --git a/internal/pkg/iostreams/colorscheme.go b/internal/pkg/iostreams/colorscheme.go index 1247f38..e7082d8 100644 --- a/internal/pkg/iostreams/colorscheme.go +++ b/internal/pkg/iostreams/colorscheme.go @@ -105,6 +105,11 @@ func (cs *ColorScheme) WarningLabel() String { return cs.String("WARNING:").Color(cs.Orange()) } +// DryRunLabel returns a colored dry-run label. +func (cs *ColorScheme) DryRunLabel() String { + return cs.String("DRY RUN:").Color(cs.Green()) +} + // ErrorLabel returns a colored error label. func (cs *ColorScheme) ErrorLabel() String { return cs.String("ERROR:").Color(cs.Red())