diff --git a/.changes/unreleased/BUG FIXES-20260717-154516.yaml b/.changes/unreleased/BUG FIXES-20260717-154516.yaml new file mode 100644 index 0000000..cac19bf --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260717-154516.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Using the --quiet argument no longer suppresses api command rendering for GET requests. +time: 2026-07-17T15:45:16.235312-06:00 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5c33e59..45c5188 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -16,7 +16,7 @@ go run github.com/homeport/termshot/cmd/termshot@v0.6.1 -c -f ~/screenshot.png - - `--json` — Force machine readable output to stdout. Does not apply to stderr. - `--markdown` — Force markdown output to stdout. Does not apply to stderr. - `--dry-run` — Don't make any actual writes or other mutations. Describe what would have changed to stderr. - - `--quiet` — Don't render output to stdout. + - `--quiet` — Only render essential content. - [ ] Get the logging interface from the context and add debug logging for interesting conditions and nonfatal situations. - [ ] Run `make gen/screenshot` if the root command output changes. - [ ] Add the `Autocomplete` field to **positional arguments** and **flags** to assist shell autocomplete. diff --git a/.release/release-metadata.hcl b/.release/release-metadata.hcl index 71e69a8..5b841f0 100644 --- a/.release/release-metadata.hcl +++ b/.release/release-metadata.hcl @@ -4,6 +4,6 @@ url_docker_registry_dockerhub = "https://hub.docker.com/r/hashicorp/tfctl-cli" url_docker_registry_ecr = "https://gallery.ecr.aws/hashicorp/tfctl-cli" url_source_repository = "https://github.com/hashicorp/tfctl-cli" -url_project_website = "https://www.tfctl-cli.io/" +url_project_website = "https://github.com/hashicorp/tfctl-cli" url_license = "https://github.com/hashicorp/tfctl-cli/blob/main/LICENSE" -url_release_notes = "https://www.tfctl-cli.io/docs/release-notes" \ No newline at end of file +url_release_notes = "https://github.com/hashicorp/tfctl-cli/blob/main/CHANGELOG.md" \ No newline at end of file diff --git a/.release/security-scan.hcl b/.release/security-scan.hcl index 81cb6d7..7c8bf03 100644 --- a/.release/security-scan.hcl +++ b/.release/security-scan.hcl @@ -13,4 +13,18 @@ binary { osv = true oss_index = false nvd = false + + triage { + suppress { + vulnerabilities = [ + // golang.org/x/crypto/openpgp is deprecated/unmaintained with no fixed + // version. The provider does not use this package (confirmed via + // `go mod why golang.org/x/crypto/openpgp`); it imports the maintained + // fork github.com/ProtonMail/go-crypto/openpgp instead. x/crypto is + // only pulled in for the unaffected golang.org/x/crypto/cryptobyte + // package. Not reachable in the built binary. + "GO-2026-5932", + ] + } + } } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 728faad..e7f02d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ make check # Verify everything passes before creating PR - `--json` — Force machine readable output to stdout. Does not apply to stderr. - `--markdown` — Force markdown output to stdout. Does not apply to stderr. - `--dry-run` — Don't make any actual writes or other mutations. Describe what would have changed to stderr. - - `--quiet` — Don't render output to stdout. + - `--quiet` — Only render essential content. - [ ] Get the logging interface from the context and add debug logging for interesting conditions and nonfatal situations. - [ ] Run `make gen/screenshot` if the root command output changes. - [ ] Add the `Autocomplete` field to **positional arguments** and **flags** to assist shell autocomplete. diff --git a/Makefile b/Makefile index 87a074a..78f975c 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,11 @@ clean: .PHONY: gen/screenshot gen/screenshot: go/install # Create a screenshot of the tfctl CLI - @go run github.com/homeport/termshot/cmd/termshot@v0.6.1 -c -f $(ASSETS)/tfctl.png -- tfctl + @go run github.com/homeport/termshot/cmd/termshot@v0.6.1 -c -f $(ASSETS)/tfctl.png -- tfctl --help + +.PHONY: gen/logo +gen/logo: logotools + @lolcat -S 26 -f <(figlet -d ./assets -f "Sub-Zero.flf" tfctl) > ./cmd/tfctl/logo.txt .PHONY: go/build go/build: bin @@ -72,6 +76,18 @@ tools: fi; \ } +.PHONY: logotools +logotools: + @command -v lolcat >/dev/null 2>&1 || { \ + echo "Installing lolcat..."; \ + if command -v brew >/dev/null 2>&1; then brew install lolcat; \ + else echo "Could not auto-install lolcat: https://github.com/busyloop/lolcat" && exit 1; \ + fi; \ + } + @command -v figlet >/dev/null 2>&1 || { \ + echo "Install figlet https://www.figlet.org/" && exit 1; \ + } + .PHONY: check check: fmt-check go/lint go/test @@ -83,6 +99,7 @@ help: @echo "Tools:" @echo " tools Install development tools" @echo " gen/screenshot Generate a screenshot of the CLI in $(ASSETS)/" + @echo " gen/logo Generate the ASCII art logo" @echo "" @echo "Build:" @echo " go/install Install tfctl binary to GOPATH/bin" diff --git a/README.md b/README.md index 2986f97..7ed446b 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ The `tfctl` command can manage HCP Terraform runs and variables with the corresp - `--profile=`: The profile to use. If omitted, the CLI uses the current profile. -- `--quiet`: Minimizes output to stdout. +- `--quiet`: Minimizes output, rendering only essential content. - `--version`: Print the version of `tfctl` CLI. @@ -271,16 +271,6 @@ Verify the installation: $ tfctl --version ``` -### Preparing your Change +### Developers -#### PR Preparation Checklist - -1. Run `npx changie new` to prepare a new changelog entry for the next set of release notes. -1. Ensure any command changes are sensitive to these global flags: - - `--json` — Force machine readable output to stdout. Does not apply to stderr. - - `--markdown` — Force markdown output to stdout. Does not apply to stderr. - - `--dry-run` — Don't make any actual writes or other mutations. Describe what would have changed to stderr. - - `--quiet` — Don't render output to stdout. -1. Get the logging interface from the context and add debug logging for interesting conditions and nonfatal situations. -1. Run `make gen/screenshot` if the root command output changes. -1. Add the `Autocomplete` field to positional arguments and flags to assist shell autocomplete. +See [CONTRIBUTING.md](CONTRIBUTING.md) for information about developing tfctl. \ No newline at end of file diff --git a/assets/Sub-Zero.flf b/assets/Sub-Zero.flf new file mode 100644 index 0000000..1120330 --- /dev/null +++ b/assets/Sub-Zero.flf @@ -0,0 +1,629 @@ +flf2a$ 6 5 17 -1 16 + + "Sub-Zero" font by Sub-Zero + ============================== + + +-> Conversion to FigLet font by MEPH. (Part of ASCII Editor Service Pack I) + (http://studenten.freepage.de/meph/ascii/ascii/editor/_index.htm) +-> Defined: ASCII code alphabet +-> Uppercase characters only. + +ScarecrowsASCIIArtArchive1.0.txt +From: "Sub-Zero" +"Here's a font I've been working on lately. Can someone make the V, Q, and X +look better? Also, the B, P, and R could use an improvement too. +Oh, here it is." + +$$$@ +$$$@ +$$$@ +$$$@ +$$$@ +$$$@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ + ______ @ +/\ __ \ @ +\ \ __ \ @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + ______ @ +/\ == \ @ +\ \ __< @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ \____ @ + \ \_____\ @ + \/_____/ @ + @@ + _____ @ +/\ __-. @ +\ \ \/\ \ @ + \ \____- @ + \/____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ __\ @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ __\ @ + \ \_\ @ + \/_/ @ + @@ + ______ @ +/\ ___\ @ +\ \ \__ \ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ \_\ \ @ +\ \ __ \ @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + __ @ +/\ \ @ +\ \ \ @ + \ \_\ @ + \/_/ @ + @@ + __ @ + /\ \ @ + _\_\ \ @ +/\_____\ @ +\/_____/ @ + @@ + __ __ @ +/\ \/ / @ +\ \ _"-. @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + __ @ +/\ \ @ +\ \ \____ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ "-./ \ @ +\ \ \-./\ \ @ + \ \_\ \ \_\ @ + \/_/ \/_/ @ + @@ + __ __ @ +/\ "-.\ \ @ +\ \ \-. \ @ + \ \_\\"\_\ @ + \/_/ \/_/ @ + @@ + ______ @ +/\ __ \ @ +\ \ \/\ \ @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ == \ @ +\ \ _-/ @ + \ \_\ @ + \/_/ @ + @@ + ______ @ +/\ __ \ @ +\ \ \/\_\ @ + \ \___\_\ @ + \/___/_/ @ + @@ + ______ @ +/\ == \ @ +\ \ __< @ + \ \_\ \_\ @ + \/_/ /_/ @ + @@ + ______ @ +/\ ___\ @ +\ \___ \ @ + \/\_____\ @ + \/_____/ @ + @@ + ______ @ +/\__ _\ @ +\/_/\ \/ @ + \ \_\ @ + \/_/ @ + @@ + __ __ @ +/\ \/\ \ @ +\ \ \_\ \ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ \ / / @ +\ \ \'/ @ + \ \__| @ + \/_/ @ + @@ + __ __ @ +/\ \ _ \ \ @ +\ \ \/ ".\ \ @ + \ \__/".~\_\ @ + \/_/ \/_/ @ + @@ + __ __ @ +/\_\_\_\ @ +\/_/\_\/_ @ + /\_\/\_\ @ + \/_/\/_/ @ + @@ + __ __ @ +/\ \_\ \ @ +\ \____ \ @ + \/\_____\ @ + \/_____/ @ + @@ + ______ @ +/\___ \ @ +\/_/ /__ @ + /\_____\ @ + \/_____/ @ + @@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ + ______ @ +/\ __ \ @ +\ \ __ \ @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + ______ @ +/\ == \ @ +\ \ __< @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ \____ @ + \ \_____\ @ + \/_____/ @ + @@ + _____ @ +/\ __-. @ +\ \ \/\ \ @ + \ \____- @ + \/____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ __\ @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ ___\ @ +\ \ __\ @ + \ \_\ @ + \/_/ @ + @@ + ______ @ +/\ ___\ @ +\ \ \__ \ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ \_\ \ @ +\ \ __ \ @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + __ @ +/\ \ @ +\ \ \ @ + \ \_\ @ + \/_/ @ + @@ + __ @ + /\ \ @ + _\_\ \ @ +/\_____\ @ +\/_____/ @ + @@ + __ __ @ +/\ \/ / @ +\ \ _"-. @ + \ \_\ \_\ @ + \/_/\/_/ @ + @@ + __ @ +/\ \ @ +\ \ \____ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ "-./ \ @ +\ \ \-./\ \ @ + \ \_\ \ \_\ @ + \/_/ \/_/ @ + @@ + __ __ @ +/\ "-.\ \ @ +\ \ \-. \ @ + \ \_\\"\_\ @ + \/_/ \/_/ @ + @@ + ______ @ +/\ __ \ @ +\ \ \/\ \ @ + \ \_____\ @ + \/_____/ @ + @@ + ______ @ +/\ == \ @ +\ \ _-/ @ + \ \_\ @ + \/_/ @ + @@ + ______ @ +/\ __ \ @ +\ \ \/\_\ @ + \ \___\_\ @ + \/___/_/ @ + @@ + ______ @ +/\ == \ @ +\ \ __< @ + \ \_\ \_\ @ + \/_/ /_/ @ + @@ + ______ @ +/\ ___\ @ +\ \___ \ @ + \/\_____\ @ + \/_____/ @ + @@ + ______ @ +/\__ _\ @ +\/_/\ \/ @ + \ \_\ @ + \/_/ @ + @@ + __ __ @ +/\ \/\ \ @ +\ \ \_\ \ @ + \ \_____\ @ + \/_____/ @ + @@ + __ __ @ +/\ \ / / @ +\ \ \'/ @ + \ \__| @ + \/_/ @ + @@ + __ __ @ +/\ \ _ \ \ @ +\ \ \/ ".\ \ @ + \ \__/".~\_\ @ + \/_/ \/_/ @ + @@ + __ __ @ +/\_\_\_\ @ +\/_/\_\/_ @ + /\_\/\_\ @ + \/_/\/_/ @ + @@ + __ __ @ +/\ \_\ \ @ +\ \____ \ @ + \/\_____\ @ + \/_____/ @ + @@ + ______ @ +/\___ \ @ +\/_/ /__ @ + /\_____\ @ + \/_____/ @ + @@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ +@ +@ +@ +@ +@ +@@ diff --git a/assets/tfctl.png b/assets/tfctl.png index 7e390a4..3ee26d2 100644 Binary files a/assets/tfctl.png and b/assets/tfctl.png differ diff --git a/cmd/tfctl/logo.txt b/cmd/tfctl/logo.txt new file mode 100644 index 0000000..e607de5 --- /dev/null +++ b/cmd/tfctl/logo.txt @@ -0,0 +1,6 @@ + ______   ______   ______     ______   __         +/\__  _\ /\  ___\ /\  ___\   /\__  _\ /\ \        +\/_/\ \/ \ \  __\ \ \ \____  \/_/\ \/ \ \ \____   +   \ \_\  \ \_\    \ \_____\    \ \_\  \ \_____\  +    \/_/   \/_/     \/_____/     \/_/   \/_____/  +                                                  diff --git a/cmd/tfctl/main.go b/cmd/tfctl/main.go index 90795b3..1bf0e97 100644 --- a/cmd/tfctl/main.go +++ b/cmd/tfctl/main.go @@ -6,10 +6,12 @@ package main import ( "context" + _ "embed" "errors" "fmt" "os" "os/signal" + "strings" "syscall" "github.com/hashicorp/cli" @@ -17,8 +19,10 @@ import ( "github.com/hashicorp/tfctl-cli/internal/commands/profile/profiles" "github.com/hashicorp/tfctl-cli/internal/commands/root" + "github.com/hashicorp/tfctl-cli/internal/pkg/checkpoint" "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" "github.com/hashicorp/tfctl-cli/internal/pkg/format" + "github.com/hashicorp/tfctl-cli/internal/pkg/heredoc" "github.com/hashicorp/tfctl-cli/internal/pkg/iostreams" "github.com/hashicorp/tfctl-cli/internal/pkg/logging" "github.com/hashicorp/tfctl-cli/internal/pkg/profile" @@ -26,6 +30,9 @@ import ( "github.com/hashicorp/tfctl-cli/version" ) +//go:embed logo.txt +var Logo string + func main() { os.Exit(realMain()) } @@ -55,11 +62,17 @@ func realMain() int { } }() + // Explore relevant global args before the command parses them to set up non-command output initialLogLevel := logging.LevelDefault for _, a := range args { if a == "--debug" { initialLogLevel = logging.LevelDebug - break + } + if a == "--no-color" { + io.ForceNoColor() + } + if a == "--quiet" { + io.SetQuiet(true) } } @@ -70,7 +83,9 @@ func realMain() int { // the command execution lifecycle. shutdownCtx = logging.WithLogger(shutdownCtx, logger) - // TODO: check version for updates? + // Run the checkpoint request in a separate goroutine. It's important to always execute + // this without condition because checkForNewVersion will block until it is complete + go checkpoint.Run(shutdownCtx, os.Getenv("CHECKPOINT_DISABLE") != "") // Create the profile loader loader, err := profile.NewLoader() @@ -95,12 +110,13 @@ func realMain() int { if activeProfile != nil { profileTelemetry = activeProfile.GetTelemetry() } + tel := telemetry.Init(shutdownCtx, telemetry.Config{ DeviceID: loader.GetDeviceID(shutdownCtx), Hostname: activeProfile.GetHostname(), ProfileTelemetry: profileTelemetry, Version: version.Version, - ErrWriter: io.Err(), + ErrWriter: io.ErrUnessential(), IsTTY: io.IsOutputTTY(), }) @@ -139,11 +155,30 @@ func realMain() int { }, } + onlyFlagsInArgs := true + for _, arg := range args { + if !strings.HasPrefix(arg, "-") { + onlyFlagsInArgs = false + break + } + } + + // If the user is running the root command, without --help or --version + // show the banner and exit. + if !c.IsVersion() && !c.IsHelp() && onlyFlagsInArgs { + showBanner(io) + return 0 + } + status, err := c.Run() if err != nil { fmt.Fprintf(io.Err(), "Error executing %s: %s\n", version.Name, err.Error()) } + if status == 0 && c.IsVersion() { + checkForNewVersion(io) + } + // Don't worry about telemetry errors at all if err = tel.Shutdown(shutdownCtx, status); err != nil { logger.Debug("Error occurred while shutting down telemetry", "error", err) @@ -152,6 +187,42 @@ func realMain() int { return status } +func showBanner(io iostreams.IOStreams) { + if io.ColorEnabled() && io.IsOutputTTY() { + cs := io.ColorScheme() + // Prepends two spaces before every line of the logo and after the final line + fmt.Fprintf(io.ErrUnessential(), " %s", strings.Join(strings.Split(Logo, "\n"), "\n ")) + fmt.Fprintf(io.ErrUnessential(), "%s\n", cs.String(version.Version).Color(cs.Purple()).Bold()) + fmt.Fprintln(io.ErrUnessential(), "") + } else { + fmt.Fprintln(io.ErrUnessential(), version.Version) + } + + fmt.Fprintln(io.Err(), heredoc.New(io).Mustf(`Get started by running {{ template "mdCodeOrBold" "%s auth login" }} +to authenticate with your user account or run {{ template "mdCodeOrBold" "%s --help" }} for usage +information. Release notes for this version are available at +{{ template "mdCodeOrBold" "https://github.com/hashicorp/tfctl-cli/blob/%s/CHANGELOG.md" }} +`, version.Name, version.Name, version.Version)) + fmt.Fprintln(io.Err(), "") + + checkForNewVersion(io) +} + +func checkForNewVersion(io iostreams.IOStreams) { + cs := io.ColorScheme() + versionInfo := checkpoint.WaitForVersionCheck() + if versionInfo.Outdated { + fmt.Fprintf(io.ErrUnessential(), "A new version of %s is available: %s\n", version.Name, cs.String(fmt.Sprintf("v%s", versionInfo.Latest)).Color(cs.Purple()).Bold()) + } + if len(versionInfo.Alerts) > 0 { + fmt.Fprintln(io.ErrUnessential(), "") + fmt.Fprintf(io.ErrUnessential(), "%s: %s\n", cs.WarningLabel(), "There are alerts regarding your current version.") + for _, alert := range versionInfo.Alerts { + fmt.Fprintln(io.ErrUnessential(), heredoc.New(io, heredoc.WithNoWrap()).Mustf(" - %s", alert)) + } + } +} + // loadActiveProfile loads the active profile. func loadActiveProfile(ctx context.Context, loader *profile.Loader) (*profile.Profile, error) { // Load the active profile diff --git a/go.mod b/go.mod index f412e97..bf627d8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/getkin/kin-openapi v0.137.0 github.com/google/uuid v1.6.0 github.com/hashicorp/cli v1.1.7 + github.com/hashicorp/go-checkpoint v0.5.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-tfe/v2 v2.0.0 @@ -76,6 +77,8 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.0 // indirect + github.com/hashicorp/go-uuid v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0f8c1df..fd6881a 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,10 @@ github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3gh github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= +github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= +github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6KdvN3Gig= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= @@ -121,6 +125,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-tfe/v2 v2.0.0 h1:iyOCu7ZAZ6DfbKcRWrA69gQLoRLubx91RGlt09o4g4M= github.com/hashicorp/go-tfe/v2 v2.0.0/go.mod h1:gosuJ9PH3NLxkCoCW3EIeHHli+5QqLUkboBiUZ1ljCM= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go index 4a81c80..0793fc8 100644 --- a/internal/commands/api/api.go +++ b/internal/commands/api/api.go @@ -269,7 +269,7 @@ func NewCmdAPI(inv *cmd.Invocation) *cmd.Command { opts.URL = resolvedURL opts.Client = apiClient - opts.Quiet = inv.GetGlobalFlags().Quiet + opts.Quiet = inv.IsQuiet() opts.DryRun = inv.IsDryRun() // Allow an active exec session to authorize noninteractive deletes. @@ -533,8 +533,8 @@ func RunAPI(ctx context.Context, opts *Opts) error { defer response.Body.Close() } - if opts.Quiet { - logger.Debug("Quiet mode enabled or no content to display, rendering skipped") + if opts.Quiet && method != http.MethodGet { + logger.Debug("Quiet mode enabled, rendering skipped") return nil } diff --git a/internal/commands/api/api_test.go b/internal/commands/api/api_test.go index 743d072..b6279fd 100644 --- a/internal/commands/api/api_test.go +++ b/internal/commands/api/api_test.go @@ -511,11 +511,11 @@ func TestMergePaginatedBody_UpdatesMeta(t *testing.T) { require.Nil(t, links["next"]) } -func TestRunAPI_QuietSuppressesOutput(t *testing.T) { +func TestRunAPI_QuietSuppressesOutputForPOST(t *testing.T) { t.Parallel() server, _ := newAPITestServer(map[string]http.HandlerFunc{ - "GET /api/v2/workspaces": func(w http.ResponseWriter, r *http.Request) { + "POST /api/v2/workspaces": func(w http.ResponseWriter, r *http.Request) { writeJSONAPIResponse(w, http.StatusOK, map[string]any{ "data": []any{map[string]any{"id": "ws-1", "type": "workspaces", "attributes": map[string]any{"name": "alpha"}}}, }) @@ -525,6 +525,7 @@ func TestRunAPI_QuietSuppressesOutput(t *testing.T) { io := iostreams.Test() err := RunAPI(context.Background(), newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.Method = http.MethodPost opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/workspaces") opts.Quiet = true })) @@ -532,6 +533,27 @@ func TestRunAPI_QuietSuppressesOutput(t *testing.T) { require.Empty(t, io.Output.String()) } +func TestRunAPI_QuietDoesNotSuppressOutputForGet(t *testing.T) { + t.Parallel() + + server, _ := newAPITestServer(map[string]http.HandlerFunc{ + "GET /api/v2/workspaces": func(w http.ResponseWriter, r *http.Request) { + writeJSONAPIResponse(w, http.StatusOK, map[string]any{ + "data": []any{map[string]any{"id": "ws-1", "type": "workspaces", "attributes": map[string]any{"name": "alpha"}}}, + }) + }, + }) + defer server.Close() + + io := iostreams.Test() + err := RunAPI(context.Background(), newTestOpts(t, server.URL, io, func(opts *Opts) { + opts.URL = mustResolveTestURL(t, opts.Client.BaseURL.String(), "/workspaces") + opts.Quiet = true + })) + require.NoError(t, err) + require.NotEmpty(t, io.Output.String()) +} + func TestRunAPI_EmptyBodyNoOutput(t *testing.T) { t.Parallel() diff --git a/internal/commands/auth/login.go b/internal/commands/auth/login.go index 19e4b91..92a1675 100644 --- a/internal/commands/auth/login.go +++ b/internal/commands/auth/login.go @@ -234,7 +234,7 @@ func saveToken(ctx context.Context, opts *LoginOpts, apiClient *client.Client, h return fmt.Errorf("failed to save token to profile: %w", err) } - fmt.Fprintf(opts.IO.Err(), "%s Successfully logged in to %s as %s\n", + fmt.Fprintf(opts.IO.ErrUnessential(), "%s Successfully logged in to %s as %s\n", cs.SuccessIcon(), hostname, cs.String(user).Bold().String()) return nil } diff --git a/internal/commands/create/create.go b/internal/commands/create/create.go index 37595e1..a94de45 100644 --- a/internal/commands/create/create.go +++ b/internal/commands/create/create.go @@ -99,7 +99,7 @@ func NewCmdCreate(inv *cmd.Invocation) *cmd.Command { }, RunF: func(_ *cmd.Command, args []string) error { opts.DryRun = inv.IsDryRun() - opts.Quiet = inv.GetGlobalFlags().Quiet + opts.Quiet = inv.IsQuiet() opts.ProfileOrganization = inv.Profile.DefaultOrganization opts.Args = args diff --git a/internal/commands/get/get.go b/internal/commands/get/get.go index 971679a..5829625 100644 --- a/internal/commands/get/get.go +++ b/internal/commands/get/get.go @@ -118,7 +118,7 @@ func NewCmdGet(inv *cmd.Invocation) *cmd.Command { RunF: func(_ *cmd.Command, args []string) error { opts.DryRun = inv.IsDryRun() opts.ProfileOrganization = inv.Profile.DefaultOrganization - opts.Quiet = inv.GetGlobalFlags().Quiet + opts.Quiet = inv.IsQuiet() opts.Args = args opts.IO = inv.IO opts.Output = inv.Output diff --git a/internal/commands/run/run_start.go b/internal/commands/run/run_start.go index 006be71..1835c95 100644 --- a/internal/commands/run/run_start.go +++ b/internal/commands/run/run_start.go @@ -187,14 +187,14 @@ func runStart(ctx context.Context, opts StartOpts, runOpts CreateOpts) error { newRunID := *response.GetData().GetId() - fmt.Fprintln(io.Err(), heredoc.New(io).Mustf(` + fmt.Fprintln(io.ErrUnessential(), heredoc.New(io).Mustf(` %s %s created. You can monitor the status of the run using: {{ Bold "$ %s run status %s" }} or by visiting {{ Bold "https://%s/app/%s/workspaces/%s/runs/%s" }} `, cs.SuccessIcon(), newRunID, version.Name, newRunID, opts.Profile.GetHostname(), *organizationName, *ws.GetAttributes().GetName(), newRunID)) - fmt.Fprintln(io.Err()) + fmt.Fprintln(io.ErrUnessential()) return nil } diff --git a/internal/pkg/checkpoint/checkpoint.go b/internal/pkg/checkpoint/checkpoint.go new file mode 100644 index 0000000..03f3f82 --- /dev/null +++ b/internal/pkg/checkpoint/checkpoint.go @@ -0,0 +1,86 @@ +// Package checkpoint provides functionality for interacting with HashiCorp's +// Checkpoint service to check for new versions and alerts related to the +// current version of the CLI. +package checkpoint + +import ( + "context" + "path/filepath" + + "github.com/hashicorp/go-checkpoint" + + "github.com/hashicorp/tfctl-cli/internal/pkg/logging" + "github.com/hashicorp/tfctl-cli/internal/pkg/profile" + "github.com/hashicorp/tfctl-cli/version" +) + +func init() { + checkpointResult = make(chan *checkpoint.CheckResponse, 1) +} + +var checkpointResult chan *checkpoint.CheckResponse + +// VersionCheckInfo holds information about the current version of the CLI, including whether it +// is outdated, the latest version available, and any alerts related to the current version. +type VersionCheckInfo struct { + Outdated bool + Latest string + Alerts []string +} + +// Run begins a HashiCorp Checkpoint request, which checks for new versions of the +// CLI using the HashiCorp Checkpoint service. Read about checkpoint +// at https://checkpoint.hashicorp.com/ +func Run(ctx context.Context, disabled bool) { + logger := logging.FromContext(ctx) + + // If the user doesn't want checkpoint at all, then return. + if disabled { + logger.Debug("Checkpoint disabled.") + checkpointResult <- nil + return + } + + configDir, err := profile.ConfigDir() + if err != nil { + logger.Debug("Checkpoint setup error", "error", err) + checkpointResult <- nil + return + } + + resp, err := checkpoint.Check(&checkpoint.CheckParams{ + Product: version.Name, + Version: version.Version, + SignatureFile: filepath.Join(configDir, "checkpoint_signature"), + CacheFile: filepath.Join(configDir, "checkpoint_cache"), + }) + if err != nil { + logger.Debug("Checkpoint error: %s", err) + resp = nil + } + + checkpointResult <- resp +} + +// WaitForVersionCheck waits for the result of a Checkpoint request and returns +// the version information. If the request failed, it returns an empty VersionCheckInfo. +func WaitForVersionCheck() VersionCheckInfo { + // Wait for the result to come through + info := <-checkpointResult + if info == nil { + var zero VersionCheckInfo + return zero + } + + // Build the alerts that we may have received about our version + alerts := make([]string, len(info.Alerts)) + for i, a := range info.Alerts { + alerts[i] = a.Message + } + + return VersionCheckInfo{ + Outdated: info.Outdated, + Latest: info.CurrentVersion, + Alerts: alerts, + } +} diff --git a/internal/pkg/cmd/command_internal.go b/internal/pkg/cmd/command_internal.go index 08f3f9c..1075a7b 100644 --- a/internal/pkg/cmd/command_internal.go +++ b/internal/pkg/cmd/command_internal.go @@ -94,7 +94,7 @@ func (c *Command) Run(args []string, inv *Invocation) int { return cli.RunResultHelp } - fmt.Fprintln(io.Err(), "Command has no run function or children. This is an invalid command") + fmt.Fprintln(io.Err(), "Command has no run function or children. This is an invalid command and is always a tfctl bug.") return 1 } diff --git a/internal/pkg/cmd/invocation.go b/internal/pkg/cmd/invocation.go index bca4a5c..766b2b0 100644 --- a/internal/pkg/cmd/invocation.go +++ b/internal/pkg/cmd/invocation.go @@ -83,6 +83,11 @@ func (i *Invocation) IsDryRun() bool { return i.GetGlobalFlags().dryRun } +// IsQuiet returns true when commands should avoid unessential output. +func (i *Invocation) IsQuiet() bool { + return i.GetGlobalFlags().Quiet +} + // ResolveLogLevel returns the resolved verbosity level, with the --debug // flag taking precedence over the profile setting. func (i *Invocation) ResolveLogLevel() hclog.Level { diff --git a/internal/pkg/iostreams/colorscheme.go b/internal/pkg/iostreams/colorscheme.go index 0dbd9b1..6a753d9 100644 --- a/internal/pkg/iostreams/colorscheme.go +++ b/internal/pkg/iostreams/colorscheme.go @@ -18,6 +18,7 @@ const ( yellow = "#FFD814" gray = "#C2C5CB" lightgreen = "#73DACA" + purple = "#7B5AF0" ) // Emphasis is used to style text. @@ -243,6 +244,13 @@ func (cs *ColorScheme) LightGreen() Color { } } +// Purple is Terraform purple. +func (cs *ColorScheme) Purple() Color { + return Color{ + color: cs.profile.Color(purple), + } +} + // RGB allows setting an RGB color in the format "#". If the terminal does // not support TrueColor, the nearest approximate and supported color will be used. func (cs *ColorScheme) RGB(hex string) Color { diff --git a/internal/pkg/iostreams/io.go b/internal/pkg/iostreams/io.go index 47096b4..b59a26c 100644 --- a/internal/pkg/iostreams/io.go +++ b/internal/pkg/iostreams/io.go @@ -37,6 +37,10 @@ type IOStreams interface { // Err returns an io.Writer for outputting error output Err() io.Writer + // ErrUnessential returns an io.Writer for outputting error, except + // when quiet mode is enabled. + ErrUnessential() io.Writer + // ColorEnabled returns if color is enabled. ColorEnabled() bool @@ -130,6 +134,10 @@ func (s *system) Out() io.Writer { } func (s *system) Err() io.Writer { + return s.err +} + +func (s *system) ErrUnessential() io.Writer { if s.quiet { return io.Discard } diff --git a/internal/pkg/iostreams/testing.go b/internal/pkg/iostreams/testing.go index 45f8697..a89a42f 100644 --- a/internal/pkg/iostreams/testing.go +++ b/internal/pkg/iostreams/testing.go @@ -51,6 +51,9 @@ func Test() *Testing { func (t *Testing) In() io.Reader { return t.Input } func (t *Testing) Out() io.Writer { return t.Output } func (t *Testing) Err() io.Writer { + return t.Error +} +func (t *Testing) ErrUnessential() io.Writer { if t.quiet { return io.Discard } diff --git a/internal/pkg/logging/logging.go b/internal/pkg/logging/logging.go index b3a63d6..d2cdcbf 100644 --- a/internal/pkg/logging/logging.go +++ b/internal/pkg/logging/logging.go @@ -47,7 +47,7 @@ func NewLogger(io iostreams.IOStreams, initialLevel hclog.Level) hclog.Logger { logOpt := &hclog.LoggerOptions{ Name: version.Name, Level: initialLevel, - Output: io.Err(), + Output: io.ErrUnessential(), TimeFn: time.Now, TimeFormat: "15:04:05.000", Color: hclog.ColorOff, // Enabled later, maybe