diff --git a/.gitignore b/.gitignore index a3affc3..4e9ac3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ bin/tfcloud +.plans/ +dist/ diff --git a/.golangci.yaml b/.golangci.yaml index 0db3d15..3222dbf 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -14,17 +14,23 @@ linters: - unconvert settings: errcheck: - check-blank: true + check-blank: false misspell: locale: US exclusions: generated: lax rules: + - linters: + - misspell + path: internal/commands/profile/profile_test.go - linters: - bodyclose - errcheck - revive path: _test\.go + - linters: + - revive + path: testing.go - path: (.+)\.go$ text: ifElseChain - path: (.+)\.go$ @@ -39,7 +45,7 @@ formatters: settings: goimports: local-prefixes: - - github.com/hashicorp/hcloud + - github.com/hashicorp/tfcloud exclusions: generated: lax paths: diff --git a/.goreleaser.yml b/.goreleaser.yml index 1e72238..c88a4cf 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -3,10 +3,9 @@ version: 2 before: hooks: - - make go/tidy + - go mod tidy builds: - id: default - main: . env: - CGO_ENABLED=0 mod_timestamp: "{{ .CommitTimestamp }}" diff --git a/Makefile b/Makefile index 44fb3cb..033d2f7 100644 --- a/Makefile +++ b/Makefile @@ -25,9 +25,23 @@ bin: $(BIN_PATH) .PHONY: $(BIN_PATH) $(BIN_PATH): - CGO_ENABLED=0 go build -o $(BIN_PATH) -trimpath -buildvcs=false ./cmd + CGO_ENABLED=0 go build -o $(BIN_PATH) -trimpath -buildvcs=false ./ .PHONY: clean clean: rm -rf $(CURDIR)/$(dir $(BIN_PATH)) +.PHONY: gen/screenshot +gen/screenshot: go/install ## Create a screenshot of the tfcloud CLI + @go run github.com/homeport/termshot/cmd/termshot@v0.6.1 -c -f assets/tfcloud.png -- tfcloud + +.PHONY: go/build +go/build: bin + +.PHONY: go/install +go/install: + @go install + +.PHONY: go/lint +go/lint: + @golangci-lint run \ No newline at end of file diff --git a/README.md b/README.md index 555941a..550208e 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,19 @@ Effectively interact with the HCP Terraform platform. +![tfcloud](assets/tfcloud.png "tfcloud") + #### Quick Start tfcloud uses a host-centric, layered configuration with a logical precedence. Configuration commands -do not yet exist in the CLI, so start by writing this file to `$HOME/.config/tfcloud/tfcloud.hcl` -(or `%AppData%/tfcloud/tfcloud.hcl` on Windows) substituting your own hostname, token, and organization. +do not yet exist in the CLI, so start by writing this file to `$HOME/.config/tfcloud/profiles/default.hcl` +(or `%AppData%/tfcloud/profiles/default.hcl` on Windows) substituting your own hostname, token, and organization. ```hcl -profile "default" "app.terraform.io" { - token = "your-token" - organization = "user-org" -} +name = "default" +organization = "default-organization" +hostname = "app.staging.terraform.io" +token = "TOKEN" ``` ``` @@ -47,81 +49,37 @@ tfcloud api /organizations/acme/workspaces -paginate -method GET -f "sort=-creat **Profile-level Configuration** -Linux/MacOS: `~/.config/tfcloud/tfcloud.hcl` -Windows: `%AppData%/tfcloud/tfcloud.hcl` - -**Working Directory Configuration** - -Working directory config overwrites profile-level config, when available. +The CLI uses a default profile for now, but will eventually support a global --profile named profile. -`.tfcloud.hcl` +Linux/MacOS: `~/.config/tfcloud/profiles/default.hcl` +Windows: `%AppData%/tfcloud/profiles/default.hcl` **Token created by `terraform login`** `~/.terraform.d/credentials.tfrc.json` is checked for the configured hostname if the token is not set by configuration file. -**Token in Environment Variables** +**Environment Variable Configuration** + +If information is not found in the profile, the following environment variables will be used for configuration: + +`TFCLOUD_ORGANIZATION`: The default organization to use, where one might apply. + +`TFCLOUD_HOSTNAME`: The Terraform Enterprise or HCP Terraform hostname to use. (Defaults to `app.terraform.io`) -`TFCLOUD_TOKEN`: An API token to use in conjunction with the default profile, only used if token is not set by any other configuration file. +`TFCLOUD_TOKEN`: An API token to use in conjunction with the default profile. `TFCLOUD_TOKEN_`: Reserved for future use with multiple profiles. `TF_TOKEN_`: An API token to use with the specified hostname with punycode formatting, e.g. `TF_TOKEN_app_terraform_io`, only used if the token is not specified in any other way. - #### Usage -You can use `tfcloud -help` for detailed usage instructions. +You can use `tfcloud --help` for detailed usage instructions. **`tfcloud api [flags]`** -Perform an API request. - -`-H, -header ` - Add a HTTP request header in key:value format - -`-i, -input ` - The file to use as body for the HTTP request (use "-" to read from standard input) - -`-X, -method ` - The HTTP method for the request (default "GET", unless using -a attributes) - -`-t, -type ` - When used with a JSON:API request body for POST/PATCH, the resource type (default to the resource implied by the path) - -`-paginate` - Make additional HTTP requests to fetch all pages of results but emit in a streamable manner - -`-a, -attribute ` - Add a typed resource attribute to the request body in key=value format - -`-f, -field ` - Add a query string parameter to the request URL in key=value format - -`-agent` - Print the raw response body. - -`-json` - Print the raw response body, colorized, if a terminal is attached. - -`-v, -verbose` - Log HTTP request and response details to stderr +Perform an API request. See `tfcloud api --help` for usage and examples. **`tfcloud variable import [tfvars-file] [flags]`** -Import variables from a tfvars file or the process environment into the current workspace or a variable set. - -`-e ` - Import an environment variable by name. Repeat to import multiple values. - -`-variable-set-name ` - Target a variable set by name instead of the current workspace. - -`-organization ` - Organization name. Optional when it can be resolved from the default organization in `tfcloud.hcl` or local Terraform configuration. - -`-workspace ` - Override the target workspace name. - -`-overwrite` - Update matching existing variables instead of failing when duplicates are found. +Import variables from a tfvars file or the process environment into the current workspace or a variable set. See `tfcloud variable import --help` for usage and examples. diff --git a/assets/tfcloud.png b/assets/tfcloud.png new file mode 100644 index 0000000..e71ed9b Binary files /dev/null and b/assets/tfcloud.png differ diff --git a/cmd/tfcloud.go b/cmd/tfcloud.go deleted file mode 100644 index 83e5584..0000000 --- a/cmd/tfcloud.go +++ /dev/null @@ -1,63 +0,0 @@ -// Package main provides the tfcloud CLI entrypoint. -package main - -import ( - "fmt" - "os" - - "github.com/brandonc/tfcloud/internal/command" - "github.com/brandonc/tfcloud/internal/config" - cli "github.com/hashicorp/cli" - "github.com/mattn/go-isatty" -) - -func main() { - if err := realMain(); err != nil { - fmt.Fprintln(os.Stderr, err) - if exitErr, ok := err.(command.ExitError); ok { - os.Exit(exitErr.Code) - } - } -} - -func realMain() error { - stdoutIsTTY := isTerminal(os.Stdout) - stderrIsTTY := isTerminal(os.Stderr) - - ui := &cli.BasicUi{ - Reader: os.Stdin, - Writer: os.Stdout, - ErrorWriter: os.Stderr, - } - - meta := &command.Meta{ - UI: ui, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - StdoutIsTTY: stdoutIsTTY, - StderrIsTTY: stderrIsTTY, - HumanOutput: stdoutIsTTY, - } - - c := cli.NewCLI(config.Name, config.Version) - c.Args = os.Args[1:] - c.HelpWriter = os.Stdout - c.ErrorWriter = os.Stderr - c.Commands = command.Commands(meta) - - exitCode, err := c.Run() - if err != nil { - return err - } - if exitCode != 0 { - return command.ExitError{Code: exitCode} - } - - return nil -} - -func isTerminal(f *os.File) bool { - fd := f.Fd() - return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) -} diff --git a/go.mod b/go.mod index 09a4212..93270bf 100644 --- a/go.mod +++ b/go.mod @@ -1,67 +1,87 @@ -module github.com/brandonc/tfcloud +module github.com/hashicorp/tfcloud go 1.25.5 require ( - github.com/Masterminds/semver/v3 v3.2.0 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/MakeNowJust/heredoc/v2 v2.0.1 + github.com/dustin/go-humanize v1.0.1 + github.com/go-openapi/runtime v0.29.3 github.com/hashicorp/cli v1.1.7 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4 + github.com/hashicorp/go-version v1.9.0 github.com/hashicorp/hcl/v2 v2.24.0 - github.com/mattn/go-isatty v0.0.20 + github.com/lithammer/dedent v1.1.0 github.com/microsoft/kiota-abstractions-go v1.9.4 + github.com/mitchellh/go-homedir v1.1.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 + github.com/muesli/reflow v0.3.0 + github.com/muesli/termenv v0.16.0 + github.com/posener/complete v1.2.3 + github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.11.1 github.com/zclconf/go-cty v1.16.3 - golang.org/x/net v0.43.0 + golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f + golang.org/x/net v0.50.0 + golang.org/x/term v0.40.0 ) require ( + dario.cat/mergo v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/agext/levenshtein v1.2.1 // indirect + github.com/Masterminds/semver/v3 v3.3.1 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/bgentry/speakeasy v0.1.0 // indirect + github.com/bgentry/speakeasy v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/fatih/color v1.16.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fatih/color v1.18.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/strfmt v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/errwrap v1.0.0 // indirect - github.com/hashicorp/go-multierror v1.0.0 // indirect - github.com/huandu/xstrings v1.3.3 // indirect - github.com/imdario/mergo v0.3.11 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microsoft/kiota-http-go v1.5.5 // indirect github.com/microsoft/kiota-serialization-form-go v1.1.3 // indirect github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect - github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/mitchellh/reflectwalk v1.0.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/posener/complete v1.2.3 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/shopspring/decimal v1.2.0 // indirect - github.com/spf13/cast v1.3.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 529089c..2e7b633 100644 --- a/go.sum +++ b/go.sum @@ -1,69 +1,127 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= +github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= -github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= -github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= -github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= -github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= +github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bgentry/speakeasy v0.2.0 h1:tgObeVOf8WAvtuAX6DhJ4xks4CFNwPDZiqzGqIHE51E= +github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= +github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= +github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/strfmt v0.26.0 h1:SDdQLyOEqu8W96rO1FRG1fuCtVyzmukky0zcD6gMGLU= +github.com/go-openapi/strfmt v0.26.0/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= +github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= +github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU= github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +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-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= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4 h1:+jBVHPQheF/zwa5Yi2A7zby+2jl6zV+IiteiOwA36ls= github.com/hashicorp/go-tfe v1.78.1-0.20260401171829-7a49f0cf5cb4/go.mod h1:NCc9n8HN05g6Bu5v0a3JhkOoWN42DF/Jk0nDQSaZwFI= +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/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= -github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= -github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= -github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microsoft/kiota-abstractions-go v1.9.4 h1:VI3UVzSCQHHhRswe3jyaAQHUQWIFhUMp0z5mtZbTbcs= @@ -78,36 +136,49 @@ github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwb github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= -github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= -github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8 h1:gMBdYMTHt2mmTdXW8YfvRjRUZ0GhyGV+IqSH9H15bGw= github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.8/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= @@ -120,54 +191,36 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= +golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/command/api.go b/internal/command/api.go deleted file mode 100644 index ad1c3ad..0000000 --- a/internal/command/api.go +++ /dev/null @@ -1,615 +0,0 @@ -// Package command implements the tfcloud CLI command tree. -package command - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "sort" - "strconv" - "strings" - - "github.com/brandonc/tfcloud/internal/client" - "github.com/brandonc/tfcloud/internal/config" - "github.com/brandonc/tfcloud/internal/render" - "github.com/charmbracelet/lipgloss" -) - -// APICommand performs arbitrary HCP Terraform API requests. -type APICommand struct { - // Meta provides UI and stream access for command execution. - Meta *Meta - loadConfig func() (*config.Config, error) - newClient func(*config.Config) (apiRequester, error) -} - -type apiRequester interface { - Base() *url.URL - RawRequest(context.Context, *client.Request) (*client.Response, error) -} - -type realAPIClient struct { - client *client.Client -} - -func (c *realAPIClient) Base() *url.URL { - return c.client.BaseURL -} - -func (c *realAPIClient) RawRequest(ctx context.Context, req *client.Request) (*client.Response, error) { - return c.client.RawRequest(ctx, req) -} - -type apiOutputMode int - -const ( - apiOutputHuman apiOutputMode = iota - apiOutputMachine -) - -type apiStyles struct { - header lipgloss.Style - label lipgloss.Style - error lipgloss.Style - json lipgloss.Style -} - -const ansiHelpBoldWhite = "\x1b[1;97m" - -// Synopsis returns a short summary of the command. -func (c *APICommand) Synopsis() string { return "Make arbitrary API requests" } - -// Help returns the command help text. -func (c *APICommand) Help() string { - help := strings.TrimSpace(`Usage: tfcloud api [flags] - -Perform an HCP Terraform API v2 request. Table output by default; use -json, --agent or pipe output for JSON. - -Options: - - -H, -header "key: value" Add request header - -i, -input file Read raw JSON request body from file or - for stdin - -X, -method method HTTP method - -t, -type type Resource type for -attribute JSON:API bodies - -paginate Follow links.next and combine up to 1000 resources - -a, -attribute key=value Add typed JSON:API request attribute - -f, -field key=value Add query parameter - -silent Suppress response body output - -agent, -json Print JSON output - -v, -verbose Log request and response metadata to stderr - -(path token) name E.g. -organization myorg to replace {organization} - -Path templates: - - Use tokens like {workspace} or {organization} in paths to have that token - automatically replaced with the corresponding name given by flag or - configuration - -Examples: - - # List workspaces in the default organization - $ tfcloud api /organizations/{organization}/workspaces - - # Create a project using attributes - $ tfcloud api /projects -a name=myproject - - # Create a workspace variable using a JSON:API request body - $ tfcloud api /vars -i '{ "data": { - "type":"vars", - "attributes": { - "key":"AWS_ACCESS_KEY_ID", - "value":"FOOBARBAZQUX", - "category":"env", - "sensitive":true - }, - "relationships": { - "workspace": { - "data": { - "id":"ws-mjAtT5DSQKuY8pAJ", - "type":"workspaces" - } - } - } - }}' -`) - - for _, heading := range []string{"Options:", "Path templates:", "Examples:"} { - help = strings.Replace(help, heading, ansiHelpBoldWhite+heading+"\x1b[0m", 1) - } - return help -} - -// Run executes the API command. -func (c *APICommand) Run(args []string) int { - var headers multiFlag - var attrs multiFlag - var filters multiFlag - var input string - var method string - var resourceType string - var paginate bool - var silent bool - var rawJSON bool - var verbose bool - - fs := flag.NewFlagSet("api", flag.ContinueOnError) - fs.SetOutput(io.Discard) - fs.Var(&headers, "H", "header") - fs.Var(&headers, "header", "header") - fs.StringVar(&input, "input", "", "input") - fs.StringVar(&input, "i", "", "input") - fs.StringVar(&method, "X", "", "method") - fs.StringVar(&method, "method", "", "method") - fs.StringVar(&resourceType, "t", "", "type") - fs.StringVar(&resourceType, "type", "", "type") - fs.BoolVar(&paginate, "paginate", false, "paginate") - fs.Var(&attrs, "a", "attribute") - fs.Var(&attrs, "attribute", "attribute") - fs.Var(&filters, "f", "field") - fs.Var(&filters, "field", "field") - fs.BoolVar(&silent, "silent", false, "silent") - fs.BoolVar(&rawJSON, "agent", false, "agent") - fs.BoolVar(&rawJSON, "json", false, "json") - fs.BoolVar(&verbose, "v", false, "verbose") - fs.BoolVar(&verbose, "verbose", false, "verbose") - - path, err := parseSingleArg(args) - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - if err := fs.Parse(args[1:]); err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - if len(attrs) > 0 && input != "" { - c.Meta.UI.Error("-attribute and -input are mutually exclusive") - return 1 - } - - loadConfig := c.loadConfig - if loadConfig == nil { - loadConfig = config.Load - } - - cfg, err := loadConfig() - if err != nil { - c.emitError(err.Error()) - return 1 - } - - newClient := c.newClient - if newClient == nil { - newClient = func(cfg *config.Config) (apiRequester, error) { - apiClient, err := client.New(cfg) - if err != nil { - return nil, err - } - return &realAPIClient{client: apiClient}, nil - } - } - - apiClient, err := newClient(cfg) - if err != nil { - c.emitError(err.Error()) - return 1 - } - - resolvedURL, err := client.ResolveURL(apiClient.Base(), path) - if err != nil { - c.emitError(err.Error()) - return 1 - } - - for _, item := range filters { - key, value, err := splitPair(item, '=') - if err != nil { - c.emitError(err.Error()) - return 1 - } - query := resolvedURL.Query() - query.Set(key, value) - resolvedURL.RawQuery = query.Encode() - } - - body, contentType, err := buildRequestBody(path, input, attrs, resourceType, c.Meta.Stdin) - if err != nil { - c.emitError(err.Error()) - return 1 - } - - method = inferMethod(method, len(attrs) > 0, input != "") - requestHeaders, err := parseHeaders(headers) - if err != nil { - c.emitError(err.Error()) - return 1 - } - if contentType != "" && requestHeaders.Get("Content-Type") == "" { - requestHeaders.Set("Content-Type", contentType) - } - if requestHeaders.Get("Accept") == "" { - requestHeaders.Set("Accept", "application/vnd.api+json") - } - - response, err := apiClient.RawRequest(context.Background(), &client.Request{ - Method: method, - URL: resolvedURL, - Headers: requestHeaders, - Body: body, - }) - if err != nil { - c.emitError(err.Error()) - return 1 - } - - if verbose { - logRequestResponse(c.Meta.Stderr, method, resolvedURL, requestHeaders, response) - } - - if paginate && response.StatusCode >= 200 && response.StatusCode < 300 { - response, err = paginateResponse(context.Background(), apiClient, response, requestHeaders, verbose, c.Meta.Stderr) - if err != nil { - c.emitError(err.Error()) - return 1 - } - } - - if response.Headers.Get("Content-Type") != "" && strings.Contains(response.Headers.Get("Content-Type"), "text/html") { - c.emitError("HTML response received, likely an error page. Check the URL and try again.") - return 1 - } - - if response.StatusCode < 200 || response.StatusCode >= 300 { - message := summarizeAPIErrors(response.Body) - if message == "" { - message = string(bytes.TrimSpace(response.Body)) - } - if message != "" { - c.emitError(fmt.Sprintf("%s: %s", response.Status, message)) - } else { - c.emitError(response.Status) - } - return 1 - } - - if silent || len(bytes.TrimSpace(response.Body)) == 0 { - return 0 - } - - mode := c.outputMode(rawJSON) - if rawJSON || mode == apiOutputMachine { - c.emitOutput(c.renderJSON(response.Body, mode)) - return 0 - } - - table, ok, err := render.JSONAPITable(response.Body) - if err != nil { - c.emitError(err.Error()) - return 1 - } - if ok { - c.emitOutput(table) - return 0 - } - - c.emitOutput(c.renderJSON(response.Body, mode)) - return 0 -} - -type multiFlag []string - -func (m *multiFlag) String() string { return strings.Join(*m, ",") } -func (m *multiFlag) Set(value string) error { - *m = append(*m, value) - return nil -} - -func inferMethod(explicit string, hasAttributes, hasInput bool) string { - if explicit != "" { - return strings.ToUpper(explicit) - } - if hasAttributes || hasInput { - return http.MethodPost - } - return http.MethodGet -} - -func inferResourceType(path string) string { - segments := strings.FieldsFunc(strings.Trim(path, "/"), func(r rune) bool { return r == '/' }) - if len(segments) == 0 { - return "" - } - last := segments[len(segments)-1] - if len(segments) >= 2 { - prev := segments[len(segments)-2] - if !looksLikeCollection(last) && looksLikeCollection(prev) { - return prev - } - } - return last -} - -func looksLikeCollection(segment string) bool { - return strings.HasSuffix(segment, "s") -} - -func parseTypedValue(raw string) any { - if raw == "null" { - return nil - } - if raw == "true" || raw == "false" { - return raw == "true" - } - if i, err := strconv.ParseInt(raw, 10, 64); err == nil { - return i - } - if f, err := strconv.ParseFloat(raw, 64); err == nil { - return f - } - if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { - var value any - if err := json.Unmarshal([]byte(raw), &value); err == nil { - return value - } - } - return raw -} - -func buildRequestBody(path, input string, attrs multiFlag, resourceType string, stdin io.Reader) ([]byte, string, error) { - if input != "" { - var data []byte - var err error - if input == "-" { - data, err = io.ReadAll(stdin) - } else if strings.HasPrefix(input, "{") || strings.HasPrefix(input, "[") { - data = []byte(input) - } else { - data, err = os.ReadFile(input) - } - if err != nil { - return nil, "", err - } - return data, "application/vnd.api+json", nil - } - - if len(attrs) == 0 { - return nil, "", nil - } - - if resourceType == "" { - resourceType = inferResourceType(path) - } - if resourceType == "" { - return nil, "", errors.New("could not infer resource type from path; use -type") - } - - attributes := make(map[string]any, len(attrs)) - for _, item := range attrs { - key, value, err := splitPair(item, '=') - if err != nil { - return nil, "", err - } - attributes[key] = parseTypedValue(value) - } - - body := map[string]any{ - "data": map[string]any{ - "type": resourceType, - "attributes": attributes, - }, - } - - encoded, err := json.Marshal(body) - if err != nil { - return nil, "", err - } - return encoded, "application/vnd.api+json", nil -} - -func parseHeaders(values []string) (http.Header, error) { - headers := make(http.Header) - for _, item := range values { - key, value, err := splitPair(item, ':') - if err != nil { - return nil, err - } - headers.Add(key, strings.TrimSpace(value)) - } - return headers, nil -} - -func splitPair(item string, sep rune) (string, string, error) { - parts := strings.SplitN(item, string(sep), 2) - if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { - return "", "", fmt.Errorf("invalid pair %q", item) - } - return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil -} - -func paginateResponse(ctx context.Context, apiClient apiRequester, initial *client.Response, headers http.Header, verbose bool, stderr io.Writer) (*client.Response, error) { - combined, nextURL, err := parsePaginationPayload(initial.Body) - if err != nil || nextURL == nil { - return initial, err - } - - for len(combined) < 1000 && nextURL != nil { - resp, reqErr := apiClient.RawRequest(ctx, &client.Request{ - Method: http.MethodGet, - URL: nextURL, - Headers: headers, - }) - if reqErr != nil { - return nil, reqErr - } - if verbose { - logRequestResponse(stderr, http.MethodGet, nextURL, headers, resp) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return resp, nil - } - - pageData, pageNext, pageErr := parsePaginationPayload(resp.Body) - if pageErr != nil { - return nil, pageErr - } - combined = append(combined, pageData...) - nextURL = pageNext - if len(combined) > 1000 { - combined = combined[:1000] - } - initial = resp - } - - merged, err := mergePaginatedBody(initial.Body, combined) - if err != nil { - return nil, err - } - initial.Body = merged - return initial, nil -} - -func (c *APICommand) outputMode(rawJSON bool) apiOutputMode { - if rawJSON { - return apiOutputHuman - } - if c.Meta == nil { - return apiOutputMachine - } - if c.Meta.HumanOutput { - return apiOutputHuman - } - if c.Meta.StdoutIsTTY { - return apiOutputHuman - } - return apiOutputMachine -} - -func (c *APICommand) emitOutput(text string) { - c.Meta.UI.Output(text) -} - -func (c *APICommand) emitError(text string) { - if c.Meta == nil || !c.Meta.HumanOutput || !c.Meta.StderrIsTTY { - c.Meta.UI.Error(text) - return - } - c.Meta.UI.Error(c.styles().error.Render(text)) -} - -func (c *APICommand) renderJSON(body []byte, mode apiOutputMode) string { - pretty := render.PrettyJSON(body) - if mode == apiOutputMachine || c.Meta == nil || !c.Meta.HumanOutput || !c.Meta.StdoutIsTTY { - return pretty - } - return c.styles().json.Render(pretty) -} - -func (c *APICommand) styles() apiStyles { - return apiStyles{ - header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")), - label: lipgloss.NewStyle().Foreground(lipgloss.Color("110")).Bold(true), - error: lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Bold(true), - json: lipgloss.NewStyle().Foreground(lipgloss.Color("252")), - } -} - -func parsePaginationPayload(body []byte) ([]any, *url.URL, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, nil, err - } - data, ok := payload["data"].([]any) - if !ok { - return nil, nil, nil - } - links, ok := payload["links"].(map[string]any) - if !ok { - return data, nil, nil - } - nextRaw, _ := links["next"].(string) - if nextRaw == "" { - return data, nil, nil - } - nextURL, err := url.Parse(nextRaw) - if err != nil { - return nil, nil, err - } - return data, nextURL, nil -} - -func mergePaginatedBody(body []byte, combined []any) ([]byte, error) { - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - return nil, err - } - payload["data"] = combined - if meta, ok := payload["meta"].(map[string]any); ok { - if pagination, ok := meta["pagination"].(map[string]any); ok { - pagination["total-count"] = len(combined) - } - } - if links, ok := payload["links"].(map[string]any); ok { - links["next"] = nil - } - return json.Marshal(payload) -} - -func summarizeAPIErrors(body []byte) string { - var payload struct { - Errors []struct { - Status string `json:"status"` - Title string `json:"title"` - Detail string `json:"detail"` - } `json:"errors"` - Error string `json:"error"` - Message string `json:"message"` - } - if err := json.Unmarshal(body, &payload); err != nil { - return "" - } - if len(payload.Errors) > 0 { - parts := make([]string, 0, len(payload.Errors)) - for _, item := range payload.Errors { - if item.Detail != "" { - parts = append(parts, strings.TrimSpace(item.Title+": "+item.Detail)) - continue - } - if item.Title != "" { - parts = append(parts, item.Title) - } - } - return strings.Join(parts, ", ") - } - if payload.Message != "" { - return payload.Message - } - return payload.Error -} - -func logRequestResponse(w io.Writer, method string, u *url.URL, reqHeaders http.Header, response *client.Response) { - fmt.Fprintf(w, "> %s %s\n", method, u.String()) - writeHeaders(w, reqHeaders) - fmt.Fprintf(w, "< %s\n", response.Status) - writeHeaders(w, response.Headers) -} - -func writeHeaders(w io.Writer, headers http.Header) { - 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), ", ")) - } -} diff --git a/internal/command/api_schema.go b/internal/command/api_schema.go deleted file mode 100644 index 58304d8..0000000 --- a/internal/command/api_schema.go +++ /dev/null @@ -1,21 +0,0 @@ -package command - -// APISchemaCommand displays API schema information. -type APISchemaCommand struct { - // Meta provides UI and stream access for command execution. - Meta *Meta -} - -// Synopsis returns a short summary of the command. -func (c *APISchemaCommand) Synopsis() string { return "Display API schema information" } - -// Help returns the command help text. -func (c *APISchemaCommand) Help() string { - return "" -} - -// Run executes the API schema command. -func (c *APISchemaCommand) Run(_ []string) int { - c.Meta.UI.Error("not implemented") - return 1 -} diff --git a/internal/command/api_test.go b/internal/command/api_test.go deleted file mode 100644 index e50ec70..0000000 --- a/internal/command/api_test.go +++ /dev/null @@ -1,451 +0,0 @@ -package command - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "reflect" - "strconv" - "strings" - "testing" - - "github.com/brandonc/tfcloud/internal/client" - "github.com/brandonc/tfcloud/internal/config" - cli "github.com/hashicorp/cli" -) - -func TestRun(t *testing.T) { - t.Parallel() - - t.Run("returns a single jsonapi resource and renders a vertical table", func(t *testing.T) { - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":{"id":"run-1","type":"runs","attributes":{"message":"deploy","status":"planned_and_finished"}}}`}}, nil) - - if code := cmd.Run([]string{"/runs/run-1"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - got := ui.OutputWriter.String() - if !strings.Contains(got, "message") || !strings.Contains(got, "deploy") { - t.Fatalf("unexpected output %q", got) - } - }) - - t.Run("returns a collection and renders a horizontal table", func(t *testing.T) { - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha","description":"one"}},{"id":"ws-2","type":"workspaces","attributes":{"name":"beta"}}]}`}}, nil) - - if code := cmd.Run([]string{"/workspaces"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - got := ui.OutputWriter.String() - if !strings.Contains(got, "alpha") || !strings.Contains(got, "workspaces") { - t.Fatalf("unexpected output %q", got) - } - }) - - t.Run("prints a useful error message when the api returns an error response", func(t *testing.T) { - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 422, status: "422 Unprocessable Entity", body: `{"errors":[{"title":"invalid attribute","detail":"name is required"}]}`}}, nil) - - if code := cmd.Run([]string{"/projects"}); code != 1 { - t.Fatalf("got exit code %d", code) - } - got := ui.ErrorWriter.String() - if !strings.Contains(got, "422 Unprocessable Entity") || !strings.Contains(got, "name is required") { - t.Fatalf("unexpected error output %q", got) - } - }) - - t.Run("reads request body from stdin with -i dash", func(t *testing.T) { - var captured []byte - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[]}`}}, func(req *client.Request) { - captured = append([]byte(nil), req.Body...) - }) - cmd.Meta.Stdin = strings.NewReader(`{"data":{"type":"vars"}}`) - - if code := cmd.Run([]string{"/vars", "-i", "-"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - if string(captured) != `{"data":{"type":"vars"}}` { - t.Fatalf("got body %q", captured) - } - }) - - t.Run("supports input file, headers, fields, type, method, silent, verbose, and json alias flags", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("X-Test"); got != "yes" { - t.Fatalf("got header %q", got) - } - if r.URL.RawQuery != "page%5Bnumber%5D=2" { - t.Fatalf("got query %q", r.URL.RawQuery) - } - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatal(err) - } - if len(body) == 0 { - t.Fatal("expected request body") - } - w.Header().Set("Content-Type", "application/vnd.api+json") - _, _ = w.Write([]byte(`{"data":[]}`)) - })) - defer server.Close() - - inputFile := t.TempDir() + "/input.json" - if err := os.WriteFile(inputFile, []byte(`{"data":{"type":"vars","attributes":{"key":"AWS_REGION"}}}`), 0o600); err != nil { - t.Fatal(err) - } - - cmd, ui := newTestAPICommandWithServer(t, false, server) - - if code := cmd.Run([]string{"/vars", "-i", inputFile, "-X", "post", "-H", "X-Test: yes", "-f", "page[number]=2", "-v", "-silent"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - if ui.OutputWriter.String() != "" { - t.Fatalf("expected no output, got %q", ui.OutputWriter.String()) - } - if got := ui.ErrorWriter.String(); !strings.Contains(got, "> POST") || !strings.Contains(got, "< 200 OK") { - t.Fatalf("unexpected verbose output %q", got) - } - }) - - t.Run("builds typed jsonapi body with attributes and explicit type", func(t *testing.T) { - var captured []byte - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[]}`}}, func(req *client.Request) { - captured = append([]byte(nil), req.Body...) - }) - - if code := cmd.Run([]string{"/vars", "-t", "vars", "-a", "key=AWS_REGION", "-a", "hcl=false"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - - var payload map[string]any - if err := json.Unmarshal(captured, &payload); err != nil { - t.Fatal(err) - } - data := payload["data"].(map[string]any) - if data["type"] != "vars" { - t.Fatalf("got type %v", data["type"]) - } - }) - - t.Run("paginates and merges resources", func(t *testing.T) { - var serverURL string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/vnd.api+json") - switch r.URL.Path { - case "/api/v2/workspaces": - _, _ = fmt.Fprintf(w, `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}],"links":{"next":%q},"meta":{"pagination":{"total-count":1}}}`, serverURL+"/api/v2/page/2") - case "/api/v2/page/2": - _, _ = w.Write([]byte(`{"data":[{"id":"ws-2","type":"workspaces","attributes":{"name":"beta"}}],"links":{"next":null}}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - serverURL = server.URL - - cmd, ui := newTestAPICommandWithServer(t, false, server) - if code := cmd.Run([]string{"/workspaces", "-paginate"}); code != 0 { - t.Fatalf("got exit code %d: %s", code, ui.ErrorWriter.String()) - } - got := ui.OutputWriter.String() - if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") { - t.Fatalf("unexpected output %q", got) - } - }) - - t.Run("when output is piped to a file the output is raw json instead of a table", func(t *testing.T) { - cmd, ui := newTestAPICommand(t, true, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}]}`}}, nil) - - if code := cmd.Run([]string{"/workspaces"}); code != 0 { - t.Fatalf("got exit code %d", code) - } - if got := strings.TrimSpace(ui.OutputWriter.String()); !strings.HasPrefix(got, "{") { - t.Fatalf("expected json output, got %q", got) - } - }) - - t.Run("agent is an alias for json", func(t *testing.T) { - cmd, ui := newTestAPICommand(t, false, []responseStub{{statusCode: 200, body: `{"data":[{"id":"ws-1","type":"workspaces","attributes":{"name":"alpha"}}]}`}}, nil) - - if code := cmd.Run([]string{"/workspaces", "-agent"}); code != 0 { - t.Fatalf("got exit code %d", code) - } - if got := strings.TrimSpace(ui.OutputWriter.String()); !strings.HasPrefix(got, "{") { - t.Fatalf("expected json output, got %q", got) - } - }) -} - -func TestAPIHelpStylesSectionHeaders(t *testing.T) { - t.Parallel() - - help := (&APICommand{}).Help() - for _, section := range []string{"Options:", "Path templates:", "Examples:"} { - styled := "\x1b[1;97m" + section + "\x1b[0m" - if !strings.Contains(help, styled) { - t.Fatalf("missing section %q in help %q", section, help) - } - } -} - -func TestInferMethod(t *testing.T) { - t.Parallel() - - if got := inferMethod("", false, false); got != "GET" { - t.Fatalf("got %q", got) - } - if got := inferMethod("", true, false); got != "POST" { - t.Fatalf("got %q", got) - } - if got := inferMethod("patch", false, false); got != "PATCH" { - t.Fatalf("got %q", got) - } -} - -func TestInferResourceType(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "/organizations/acme/projects": "projects", - "/runs": "runs", - "/projects/prj-123/tag-bindings": "tag-bindings", - "/organizations/acme/workspaces/ws-1": "workspaces", - "/workspaces/ws-1/vars": "vars", - "/varsets/vs-1/relationships/vars": "vars", - } - - for input, want := range tests { - if got := inferResourceType(input); got != want { - t.Fatalf("inferResourceType(%q) = %q, want %q", input, got, want) - } - } -} - -func TestLooksLikeCollection(t *testing.T) { - t.Parallel() - - tests := map[string]bool{ - "projects": true, - "runs": true, - "workspaces": true, - "vars": true, - "varsets": true, - "policy-sets": true, - "organizations": true, - "ws-1": false, - "relationships": true, - "tag-bindings": true, - } - - for input, want := range tests { - if got := looksLikeCollection(input); got != want { - t.Fatalf("looksLikeCollection(%q) = %v, want %v", input, got, want) - } - } -} - -func TestSplitPair(t *testing.T) { - t.Parallel() - - if gotKey, gotValue, err := splitPair("foo=bar", '='); err != nil || gotKey != "foo" || gotValue != "bar" { - t.Fatalf("got %q %q %v", gotKey, gotValue, err) - } - - if _, _, err := splitPair("missing", '='); err == nil { - t.Fatal("expected error") - } -} - -func TestParseTypedValue(t *testing.T) { - t.Parallel() - - if got := parseTypedValue("true"); got != true { - t.Fatalf("got %#v", got) - } - if got := parseTypedValue("42"); got != int64(42) { - t.Fatalf("got %#v", got) - } - if got := parseTypedValue("3.14"); got != 3.14 { - t.Fatalf("got %#v", got) - } - if got := parseTypedValue("hello"); got != "hello" { - t.Fatalf("got %#v", got) - } -} - -func TestMergePaginatedBody(t *testing.T) { - t.Parallel() - - body := []byte(`{"data":[{"id":"1"}],"links":{"next":"https://example.com/page/2"},"meta":{"pagination":{"total-count":1}}}`) - merged, err := mergePaginatedBody(body, []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}) - if err != nil { - t.Fatal(err) - } - - var payload map[string]any - if err := json.Unmarshal(merged, &payload); err != nil { - t.Fatal(err) - } - data := payload["data"].([]any) - if len(data) != 2 { - t.Fatalf("got %d rows", len(data)) - } - if payload["links"].(map[string]any)["next"] != nil { - t.Fatal("expected next link cleared") - } -} - -func TestBuildRequestBodyInfersTypedJSONAPI(t *testing.T) { - t.Parallel() - - body, contentType, err := buildRequestBody("/workspaces/ws-1/vars", "", multiFlag{"enabled=true", "count=42", "config={\"mode\":\"safe\"}"}, "", nil) - if err != nil { - t.Fatal(err) - } - if contentType != "application/vnd.api+json" { - t.Fatalf("got content type %q", contentType) - } - - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - t.Fatal(err) - } - data := payload["data"].(map[string]any) - if data["type"] != "vars" { - t.Fatalf("got type %v", data["type"]) - } - attrs := data["attributes"].(map[string]any) - if got, want := attrs["enabled"], true; !reflect.DeepEqual(got, want) { - t.Fatalf("enabled = %#v, want %#v", got, want) - } -} - -type responseStub struct { - statusCode int - status string - body string - headers http.Header -} - -type stubAPIClient struct { - baseURL *url.URL - responses []responseStub - requestHandler func(*client.Request) - index int -} - -type serverAPIClient struct { - httpClient *http.Client - baseURL *url.URL -} - -func (c *stubAPIClient) Base() *url.URL { - return c.baseURL -} - -func (c *stubAPIClient) RawRequest(_ context.Context, req *client.Request) (*client.Response, error) { - if c.requestHandler != nil { - c.requestHandler(req) - } - if c.index >= len(c.responses) { - return nil, fmt.Errorf("unexpected request %s %s", req.Method, req.URL) - } - resp := c.responses[c.index] - c.index++ - status := resp.status - if status == "" { - status = strconv.Itoa(resp.statusCode) + " " + http.StatusText(resp.statusCode) - } - headers := resp.headers - if headers == nil { - headers = make(http.Header) - } - return &client.Response{StatusCode: resp.statusCode, Status: status, Headers: headers, Body: []byte(resp.body)}, nil -} - -func (c *serverAPIClient) Base() *url.URL { - return c.baseURL -} - -func (c *serverAPIClient) RawRequest(ctx context.Context, req *client.Request) (*client.Response, error) { - httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL.String(), bytes.NewReader(req.Body)) - if err != nil { - return nil, err - } - for key, values := range req.Headers { - for _, value := range values { - httpReq.Header.Add(key, value) - } - } - httpResp, err := c.httpClient.Do(httpReq) - if err != nil { - return nil, err - } - defer httpResp.Body.Close() - body, err := io.ReadAll(httpResp.Body) - if err != nil { - return nil, err - } - return &client.Response{StatusCode: httpResp.StatusCode, Status: httpResp.Status, Headers: httpResp.Header.Clone(), Body: body}, nil -} - -func newTestAPICommand(t *testing.T, machine bool, responses []responseStub, handler func(*client.Request)) (*APICommand, *cli.MockUi) { - t.Helper() - baseURL, err := url.Parse("https://app.terraform.test/api/v2") - if err != nil { - t.Fatal(err) - } - ui := cli.NewMockUi() - meta := &Meta{ - UI: ui, - Stdin: bytes.NewBuffer(nil), - Stdout: ui.OutputWriter, - Stderr: ui.ErrorWriter, - StdoutIsTTY: !machine, - StderrIsTTY: !machine, - HumanOutput: !machine, - } - cmd := &APICommand{ - Meta: meta, - loadConfig: func() (*config.Config, error) { - return &config.Config{Hostname: "app.terraform.test", Token: "token", DefaultHeaders: make(http.Header)}, nil - }, - newClient: func(*config.Config) (apiRequester, error) { - return &stubAPIClient{baseURL: baseURL, responses: responses, requestHandler: handler}, nil - }, - } - return cmd, ui -} - -func newTestAPICommandWithServer(t *testing.T, machine bool, server *httptest.Server) (*APICommand, *cli.MockUi) { - t.Helper() - baseURL, err := url.Parse(server.URL + "/api/v2") - if err != nil { - t.Fatal(err) - } - ui := cli.NewMockUi() - meta := &Meta{ - UI: ui, - Stdin: bytes.NewBuffer(nil), - Stdout: ui.OutputWriter, - Stderr: ui.ErrorWriter, - StdoutIsTTY: !machine, - StderrIsTTY: !machine, - HumanOutput: !machine, - } - cmd := &APICommand{ - Meta: meta, - loadConfig: func() (*config.Config, error) { - return &config.Config{Hostname: baseURL.Hostname(), Token: "token", DefaultHeaders: make(http.Header)}, nil - }, - newClient: func(cfg *config.Config) (apiRequester, error) { - return &serverAPIClient{httpClient: server.Client(), baseURL: baseURL}, nil - }, - } - return cmd, ui -} diff --git a/internal/command/meta.go b/internal/command/meta.go deleted file mode 100644 index a3ef22c..0000000 --- a/internal/command/meta.go +++ /dev/null @@ -1,111 +0,0 @@ -package command - -import ( - "fmt" - "io" - "log" - - cli "github.com/hashicorp/cli" -) - -// Meta carries UI and stdio details that commands use while running. -type Meta struct { - // UI is the command-line UI implementation. - UI cli.Ui - // Stdin is the command input stream. - Stdin io.Reader - // Stdout is the command output stream. - Stdout io.Writer - // Stderr is the command error stream. - Stderr io.Writer - // StdoutIsTTY reports whether Stdout is attached to a terminal. - StdoutIsTTY bool - // StderrIsTTY reports whether Stderr is attached to a terminal. - StderrIsTTY bool - // HumanOutput reports whether commands should prefer human-oriented rendering. - HumanOutput bool -} - -// ExitError represents a process exit code from the CLI. -type ExitError struct { - // Code is the process exit status. - Code int -} - -// Error returns a message for the exit code. -func (e ExitError) Error() string { - switch e.Code { - case 0: - return "" - case 1: - return "invalid command" - case 2: - return "request error" - case 3: - return "server error" - default: - log.Printf("Exit code %d should be added to the ExitError description", e.Code) - return fmt.Sprintf("command failed (code %d)", e.Code) - } -} - -// Commands returns the CLI command registry. -func Commands(meta *Meta) map[string]cli.CommandFactory { - return map[string]cli.CommandFactory{ - "api": func() (cli.Command, error) { - return &APICommand{Meta: meta}, nil - }, - "api schema": func() (cli.Command, error) { - return &APISchemaCommand{Meta: meta}, nil - }, - "workspace": func() (cli.Command, error) { - return &NamespaceCommand{Meta: meta, Name: "workspace"}, nil - }, - "variable": func() (cli.Command, error) { - return &NamespaceCommand{Meta: meta, Name: "variable"}, nil - }, - "variable import": func() (cli.Command, error) { - return &VariableImportCommand{Meta: meta}, nil - }, - } -} - -// NamespaceCommand groups related subcommands under a shared namespace. -type NamespaceCommand struct { - // Meta provides UI and stream access for command execution. - Meta *Meta - // Name is the namespace name shown in help and synopsis output. - Name string -} - -// Help returns the command help text. -func (c *NamespaceCommand) Help() string { - if c.Name == "workspace" { - return "Usage: tfcloud workspace \n\n vcs Create or update workspace VCS settings" - } - - return "Usage: tfcloud variable \n\n import Import tfvars or environment variables" -} - -// Run executes the namespace command. -func (c *NamespaceCommand) Run(args []string) int { - if len(args) > 0 { - c.Meta.UI.Error("unknown subcommand: " + args[0]) - } - return cli.RunResultHelp -} - -// Synopsis returns a short summary of the command. -func (c *NamespaceCommand) Synopsis() string { - if c.Name == "workspace" { - return "Workspace workflows" - } - return "Variable workflows" -} - -func parseSingleArg(args []string) (string, error) { - if len(args) == 0 { - return "", fmt.Errorf("accepts 1 argument, but got %d. Try using -help", len(args)) - } - return args[0], nil -} diff --git a/internal/command/variable_import.go b/internal/command/variable_import.go deleted file mode 100644 index de55cba..0000000 --- a/internal/command/variable_import.go +++ /dev/null @@ -1,416 +0,0 @@ -package command - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - - "github.com/brandonc/tfcloud/internal/client" - "github.com/brandonc/tfcloud/internal/config" - terraformcfg "github.com/brandonc/tfcloud/internal/terraform" -) - -// VariableImportCommand imports variables into a workspace or variable set. -type VariableImportCommand struct { - // Meta provides UI and stream access for command execution. - Meta *Meta - loadConfig func() (*config.Config, error) -} - -// Synopsis returns a short summary of the command. -func (c *VariableImportCommand) Synopsis() string { - return "Import workspace or variable set variables" -} - -// Help returns the command help text. -func (c *VariableImportCommand) Help() string { - return strings.TrimSpace(`Usage: tfcloud variable import [tfvars-file] [flags] - -Import variables into the current workspace or a variable set. - - -e name Import an environment variable (repeatable) - -variable-set-name name Target variable set by name - -organization string Organization name - -workspace string Workspace name override - -overwrite Update matching existing variables`) -} - -// Run executes the variable import command. -func (c *VariableImportCommand) Run(args []string) int { - var envNames multiFlag - var variableSetName string - var organization string - var workspaceName string - var overwrite bool - - fs := flag.NewFlagSet("variable import", flag.ContinueOnError) - fs.SetOutput(io.Discard) - fs.Var(&envNames, "e", "env") - fs.StringVar(&variableSetName, "variable-set-name", "", "variable set") - fs.StringVar(&organization, "organization", "", "organization") - fs.StringVar(&workspaceName, "workspace", "", "workspace") - fs.BoolVar(&overwrite, "overwrite", false, "overwrite") - - if err := fs.Parse(args); err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - var imported []terraformcfg.ImportedVariable - if fs.NArg() > 1 { - c.Meta.UI.Error("usage: tfcloud variable import [tfvars-file]") - return 1 - } - if fs.NArg() == 1 { - vars, err := terraformcfg.ParseTFVarsFile(fs.Arg(0)) - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - imported = append(imported, vars...) - } - for _, name := range envNames { - value, ok := os.LookupEnv(name) - if !ok { - c.Meta.UI.Error(fmt.Sprintf("environment variable %q is not set", name)) - return 1 - } - imported = append(imported, terraformcfg.ImportedVariable{ - Key: name, - Value: value, - Category: "env", - HCL: false, - Sensitive: true, - }) - } - if len(imported) == 0 { - c.Meta.UI.Error("provide a tfvars file, -e entries, or both") - return 1 - } - - loadConfig := c.loadConfig - if loadConfig == nil { - loadConfig = config.Load - } - - cfg, err := loadConfig() - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - if organization == "" { - organization = cfg.DefaultOrganization - } - - if organization == "" || workspaceName == "" { - cfg, err := terraformcfg.FindCloudConfig(".") - if err == nil { - if organization == "" { - organization = cfg.Organization - } - if workspaceName == "" { - workspaceName = cfg.Workspace - } - } - } - - if variableSetName != "" && organization == "" { - c.Meta.UI.Error("-organization is required when targeting a variable set and no HCP Terraform configuration was found") - return 1 - } - if variableSetName == "" && (organization == "" || workspaceName == "") { - c.Meta.UI.Error("could not resolve target workspace; set -organization and -workspace or run inside a repository with HCP Terraform workspace configuration") - return 1 - } - - apiClient, err := client.New(cfg) - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - target, err := c.resolveTarget(apiClient, organization, workspaceName, variableSetName) - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - existing, err := c.listExistingVariables(apiClient, target) - if err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - - duplicates := make([]string, 0) - for _, variable := range imported { - key := existingKey(variable.Key, variable.Category) - if _, ok := existing[key]; ok && !overwrite { - duplicates = append(duplicates, fmt.Sprintf("%s (%s)", variable.Key, variable.Category)) - } - } - if len(duplicates) > 0 { - c.Meta.UI.Error("variables already exist; rerun with -overwrite to update: " + strings.Join(duplicates, ", ")) - return 1 - } - - created := 0 - updated := 0 - for _, variable := range imported { - key := existingKey(variable.Key, variable.Category) - if current, ok := existing[key]; ok { - if err := c.updateVariable(apiClient, target, current.ID, variable); err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - updated++ - continue - } - if err := c.createVariable(apiClient, target, variable); err != nil { - c.Meta.UI.Error(err.Error()) - return 1 - } - created++ - } - - c.Meta.UI.Output(fmt.Sprintf("imported %d variables into %s (%d created, %d updated)", len(imported), target.DisplayName, created, updated)) - return 0 -} - -type variableTarget struct { - Kind string - ID string - DisplayName string - Path string - ItemPath string -} - -type existingVariable struct { - ID string - Key string - Category string -} - -func (c *VariableImportCommand) resolveTarget(apiClient *client.Client, organization, workspaceName, variableSetName string) (*variableTarget, error) { - if variableSetName != "" { - id, err := c.resolveVariableSet(apiClient, organization, variableSetName) - if err != nil { - return nil, err - } - return &variableTarget{ - Kind: "variable set", - ID: id, - DisplayName: fmt.Sprintf("variable set %q", variableSetName), - Path: fmt.Sprintf("/varsets/%s/relationships/vars", url.PathEscape(id)), - ItemPath: fmt.Sprintf("/varsets/%s/relationships/vars/%%s", url.PathEscape(id)), - }, nil - } - - workspaceID, err := c.resolveWorkspace(apiClient, organization, workspaceName) - if err != nil { - return nil, err - } - return &variableTarget{ - Kind: "workspace", - ID: workspaceID, - DisplayName: fmt.Sprintf("workspace %q", workspaceName), - Path: fmt.Sprintf("/workspaces/%s/vars", url.PathEscape(workspaceID)), - ItemPath: fmt.Sprintf("/workspaces/%s/vars/%%s", url.PathEscape(workspaceID)), - }, nil -} - -func (c *VariableImportCommand) resolveWorkspace(apiClient *client.Client, organization, workspace string) (string, error) { - endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/workspaces/%s", url.PathEscape(organization), url.PathEscape(workspace))) - if err != nil { - return "", err - } - resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) - if err != nil { - return "", err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) - } - var payload struct { - Data struct { - ID string `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(resp.Body, &payload); err != nil { - return "", err - } - if payload.Data.ID == "" { - return "", fmt.Errorf("workspace %q returned no id", workspace) - } - return payload.Data.ID, nil -} - -func (c *VariableImportCommand) resolveVariableSet(apiClient *client.Client, organization, name string) (string, error) { - endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/varsets", url.PathEscape(organization))) - if err != nil { - return "", err - } - query := endpoint.Query() - query.Set("q", name) - endpoint.RawQuery = query.Encode() - - resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) - if err != nil { - return "", err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) - } - - var payload struct { - Data []struct { - ID string `json:"id"` - Attributes struct { - Name string `json:"name"` - } `json:"attributes"` - } `json:"data"` - } - if err := json.Unmarshal(resp.Body, &payload); err != nil { - return "", err - } - for _, item := range payload.Data { - if item.Attributes.Name == name { - return item.ID, nil - } - } - - body := map[string]any{ - "data": map[string]any{ - "type": "varsets", - "attributes": map[string]any{ - "name": name, - }, - }, - } - encoded, err := json.Marshal(body) - if err != nil { - return "", err - } - createResp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: encoded}) - if err != nil { - return "", err - } - if createResp.StatusCode < 200 || createResp.StatusCode >= 300 { - return "", fmt.Errorf("%s: %s", createResp.Status, summarizeAPIErrors(createResp.Body)) - } - var created struct { - Data struct { - ID string `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(createResp.Body, &created); err != nil { - return "", err - } - if created.Data.ID == "" { - return "", fmt.Errorf("created variable set %q returned no id", name) - } - return created.Data.ID, nil -} - -func (c *VariableImportCommand) listExistingVariables(apiClient *client.Client, target *variableTarget) (map[string]existingVariable, error) { - endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) - if err != nil { - return nil, err - } - resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) - } - var payload struct { - Data []struct { - ID string `json:"id"` - Attributes struct { - Key string `json:"key"` - Category string `json:"category"` - } `json:"attributes"` - } `json:"data"` - } - if err := json.Unmarshal(resp.Body, &payload); err != nil { - return nil, err - } - existing := make(map[string]existingVariable, len(payload.Data)) - for _, item := range payload.Data { - existing[existingKey(item.Attributes.Key, item.Attributes.Category)] = existingVariable{ID: item.ID, Key: item.Attributes.Key, Category: item.Attributes.Category} - } - return existing, nil -} - -func (c *VariableImportCommand) createVariable(apiClient *client.Client, target *variableTarget, variable terraformcfg.ImportedVariable) error { - endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) - if err != nil { - return err - } - body, err := json.Marshal(variablePayload(variable)) - if err != nil { - return err - } - resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) - } - return nil -} - -func (c *VariableImportCommand) updateVariable(apiClient *client.Client, target *variableTarget, variableID string, variable terraformcfg.ImportedVariable) error { - endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf(target.ItemPath, url.PathEscape(variableID))) - if err != nil { - return err - } - body, err := json.Marshal(variablePayload(variable)) - if err != nil { - return err - } - resp, err := apiClient.RawRequest(c.background(), &client.Request{Method: http.MethodPatch, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) - if err != nil { - return err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("%s: %s", resp.Status, summarizeAPIErrors(resp.Body)) - } - return nil -} - -func variablePayload(variable terraformcfg.ImportedVariable) map[string]any { - return map[string]any{ - "data": map[string]any{ - "type": "vars", - "attributes": map[string]any{ - "key": variable.Key, - "value": variable.Value, - "category": variable.Category, - "hcl": variable.HCL, - "sensitive": variable.Sensitive, - }, - }, - } -} - -func existingKey(key, category string) string { - return category + "\x00" + key -} - -func jsonAPIHeaders() http.Header { - return http.Header{ - "Accept": []string{"application/vnd.api+json"}, - "Content-Type": []string{"application/vnd.api+json"}, - } -} - -func (c *VariableImportCommand) background() context.Context { return context.Background() } diff --git a/internal/command/variable_import_test.go b/internal/command/variable_import_test.go deleted file mode 100644 index 61049ea..0000000 --- a/internal/command/variable_import_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package command - -import ( - "bytes" - "net/http" - "strings" - "testing" - - "github.com/brandonc/tfcloud/internal/config" - cli "github.com/hashicorp/cli" -) - -func TestVariableImportUsesDefaultOrganizationFromConfig(t *testing.T) { - t.Parallel() - - ui := cli.NewMockUi() - cmd := &VariableImportCommand{ - Meta: &Meta{ - UI: ui, - Stdin: bytes.NewBuffer(nil), - Stdout: ui.OutputWriter, - Stderr: ui.ErrorWriter, - StdoutIsTTY: false, - StderrIsTTY: false, - HumanOutput: true, - }, - loadConfig: func() (*config.Config, error) { - return &config.Config{ - Hostname: "app.terraform.test", - Token: "token", - DefaultOrganization: "config-org", - DefaultHeaders: make(http.Header), - }, nil - }, - } - - code := cmd.Run([]string{"-e", "AWS_REGION", "-variable-set-name", "production"}) - if code != 1 { - t.Fatalf("got exit code %d", code) - } - if got := ui.ErrorWriter.String(); strings.Contains(got, "-organization is required") { - t.Fatalf("expected config default organization to satisfy validation, got %q", got) - } -} diff --git a/internal/commands/api/api.go b/internal/commands/api/api.go new file mode 100644 index 0000000..9d2b7e3 --- /dev/null +++ b/internal/commands/api/api.go @@ -0,0 +1,497 @@ +// Package api implements the tfcloud CLI API command. +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/hashicorp/tfcloud/internal/pkg/client" + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/render" +) + +type apiRequester interface { + Base() *url.URL + RawRequest(context.Context, *client.Request) (*client.Response, error) +} + +type realAPIClient struct { + client *client.Client +} + +func (c *realAPIClient) Base() *url.URL { + return c.client.BaseURL +} + +func (c *realAPIClient) RawRequest(ctx context.Context, req *client.Request) (*client.Response, error) { + return c.client.RawRequest(ctx, req) +} + +// Opts stores the options parsed from flags for the API command. +type Opts struct { + IO iostreams.IOStreams + Headers []string + Attributes map[string]string + Query map[string]string + PathTokens map[string]string + InputRequest string + Method string + ResourceType string + Paginate bool +} + +// NewCmdAPI creates the `tfcloud api` command. +func NewCmdAPI(ctx *cmd.Context) *cmd.Command { + opts := &Opts{ + IO: ctx.IO, + } + + cmd := &cmd.Command{ + Name: "api", + ShortHelp: "Perform any API request", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud api" }} command performs any API v2 request. + `), + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "PATH", + Documentation: "The API path to request, ex. /account/details. Unless -a or -i is used, the command will perform a GET request.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "header", + Shorthand: "H", + DisplayValue: "'name: value'", + Description: "Request header", + Repeatable: true, + Value: flagvalue.SimpleSlice(nil, &opts.Headers), + }, + { + Name: "input", + Shorthand: "i", + DisplayValue: "BODY", + Description: "Raw JSON request body (or - to read from stdin)", + Value: flagvalue.Simple("", &opts.InputRequest), + }, + { + Name: "method", + Shorthand: "X", + DisplayValue: "METHOD", + Description: "HTTP method to use (e.g. GET, POST, etc.)", + Value: flagvalue.Simple("", &opts.Method), + }, + { + Name: "type", + Shorthand: "t", + DisplayValue: "JSON:API TYPE", + Description: "Resource type for --attribute JSON:API request bodies. This value is inferred from the path whenever possible.", + Value: flagvalue.Simple("", &opts.ResourceType), + }, + { + Name: "paginate", + Description: "Automatically paginate through results and stream them, one resource at a time. Only applies to successful responses with JSON:API document bodies.", + Value: flagvalue.Simple(false, &opts.Paginate), + IsBooleanFlag: true, + }, + { + Name: "attribute", + Shorthand: "a", + DisplayValue: "ATTRIBUTE=VALUE", + Description: "Attribute for JSON:API request bodies. Implies POST method.", + Repeatable: true, + Value: flagvalue.SimpleMap(nil, &opts.Attributes), + }, + { + Name: "field", + Shorthand: "f", + DisplayValue: "KEY=VALUE", + Description: "Add a query parameter to the request URL", + Repeatable: true, + Value: flagvalue.SimpleMap(nil, &opts.Query), + }, + { + Name: "pathtoken", + Shorthand: "p", + DisplayValue: "TOKEN=NAME", + Description: "Resolve a path {token} with the given name. For example, --pathtoken 'workspace=foo' would replace {workspace} in the path with the ID of the foo workspace.", + Repeatable: true, + Value: flagvalue.SimpleMap(nil, &opts.PathTokens), + }, + }, + }, + Examples: []cmd.Example{ + { + Preamble: "List workspaces in the default organization", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud api /organizations/{organization}/workspaces`), + }, + { + Preamble: "Create a project using attributes", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud api /projects -a name=myproject`), + }, + { + Preamble: "Add remote state consumer", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud api /workspaces/{workspace}/remote-state-consumers -p 'workspace=my-workspace' -i '{ "data: [ + { + "type":"remote-state-consumers", + "id": "ws-glkT5DSQKuY8pAJ" + } +]}'`), + }, + { + Preamble: "Create a workspace variable using a JSON:API request body", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud api /vars -i '{ "data": { + "type":"vars", + "attributes": { + "key":"AWS_ACCESS_KEY_ID", + "value":"FOOBARBAZQUX", + "category":"env", + "sensitive":true + }, + "relationships": { + "workspace": { + "data": { + "id":"ws-mjAtT5DSQKuY8pAJ", + "type":"workspaces" + } + } + } +}}'`), + }, + }, + RunF: func(_ *cmd.Command, args []string) error { + // TODO: replace `return err` statements with something that can be shown to the user. + if len(args) < 1 { + return cmd.ErrDisplayUsage + } + + path := args[0] + + resolvedURL, err := client.ResolveURL(ctx.APIClient.BaseURL, path) + if err != nil { + return err + } + + for _, item := range opts.Query { + key, value, err := splitPair(item, '=') + if err != nil { + return err + } + query := resolvedURL.Query() + query.Set(key, value) + resolvedURL.RawQuery = query.Encode() + } + + body, contentType, err := buildRequestBody(path, opts.InputRequest, opts.Attributes, opts.ResourceType, ctx.IO.In()) + if err != nil { + return err + } + + method := inferMethod(opts.Method, len(opts.Attributes) > 0, opts.InputRequest != "") + requestHeaders, err := parseHeaders(opts.Headers) + if err != nil { + return err + } + if contentType != "" && requestHeaders.Get("Content-Type") == "" { + requestHeaders.Set("Content-Type", contentType) + } + if requestHeaders.Get("Accept") == "" { + requestHeaders.Set("Accept", "application/vnd.api+json") + } + + response, err := ctx.APIClient.RawRequest(context.Background(), &client.Request{ + Method: method, + URL: resolvedURL, + Headers: requestHeaders, + Body: body, + }) + if err != nil { + return err + } + + verbose := false + if ctx.Profile.GetVerbosity() == "debug" || ctx.Profile.GetVerbosity() == "trace" { + logRequestResponse(ctx.IO.Err(), method, resolvedURL, requestHeaders, response) + verbose = true + } + + if opts.Paginate && response.StatusCode >= 200 && response.StatusCode < 300 { + response, err = paginateResponse(context.Background(), &realAPIClient{client: ctx.APIClient}, response, requestHeaders, verbose, ctx.IO.Err()) + if err != nil { + return err + } + } + + if response.Headers.Get("Content-Type") != "" && strings.Contains(response.Headers.Get("Content-Type"), "text/html") { + return errors.New("an HTML response was received, likely an error page") + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + message := client.SummarizeAPIErrors(response.Body) + if message == "" { + message = string(bytes.TrimSpace(response.Body)) + } + if message != "" { + return fmt.Errorf("%s: %s", response.Status, message) + } + return errors.New(response.Status) + } + + if ctx.Profile.IsQuiet() || len(bytes.TrimSpace(response.Body)) == 0 { + return nil + } + + // TODO: output should be determined by global flags and the ctx should + // contain the displayer output device. This thing shoulld just write a data structure + // to the displayer and let it handle formatting and output. + table, ok, err := render.JSONAPITable(response.Body) + if err != nil { + return err + } + if ok { + _, _ = ctx.IO.Out().Write([]byte(table)) + return nil + } + + _, _ = ctx.IO.Out().Write(response.Body) + return nil + }, + } + + return cmd +} + +func inferMethod(explicit string, hasAttributes, hasInput bool) string { + if explicit != "" { + return strings.ToUpper(explicit) + } + if hasAttributes || hasInput { + return http.MethodPost + } + return http.MethodGet +} + +func inferResourceType(path string) string { + segments := strings.FieldsFunc(strings.Trim(path, "/"), func(r rune) bool { return r == '/' }) + if len(segments) == 0 { + return "" + } + last := segments[len(segments)-1] + if len(segments) >= 2 { + prev := segments[len(segments)-2] + if !looksLikeCollection(last) && looksLikeCollection(prev) { + return prev + } + } + return last +} + +func looksLikeCollection(segment string) bool { + return strings.HasSuffix(segment, "s") +} + +func parseTypedValue(raw string) any { + if raw == "null" { + return nil + } + if raw == "true" || raw == "false" { + return raw == "true" + } + if i, err := strconv.ParseInt(raw, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(raw, 64); err == nil { + return f + } + if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { + var value any + if err := json.Unmarshal([]byte(raw), &value); err == nil { + return value + } + } + return raw +} + +func buildRequestBody(path, input string, attrs map[string]string, resourceType string, stdin io.Reader) ([]byte, string, error) { + if input != "" { + var data []byte + var err error + if input == "-" { + data, err = io.ReadAll(stdin) + } else if strings.HasPrefix(input, "{") || strings.HasPrefix(input, "[") { + data = []byte(input) + } else { + data, err = os.ReadFile(input) + } + if err != nil { + return nil, "", err + } + return data, "application/vnd.api+json", nil + } + + if len(attrs) == 0 { + return nil, "", nil + } + + if resourceType == "" { + resourceType = inferResourceType(path) + } + if resourceType == "" { + return nil, "", errors.New("could not infer resource type from path; use --type") + } + + attributes := make(map[string]any, len(attrs)) + for key, value := range attrs { + attributes[key] = parseTypedValue(value) + } + + body := map[string]any{ + "data": map[string]any{ + "type": resourceType, + "attributes": attributes, + }, + } + + encoded, err := json.Marshal(body) + if err != nil { + return nil, "", err + } + return encoded, "application/vnd.api+json", nil +} + +func parseHeaders(values []string) (http.Header, error) { + headers := make(http.Header) + for _, item := range values { + key, value, err := splitPair(item, ':') + if err != nil { + return nil, err + } + headers.Add(key, strings.TrimSpace(value)) + } + return headers, nil +} + +func splitPair(item string, sep rune) (string, string, error) { + parts := strings.SplitN(item, string(sep), 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + return "", "", fmt.Errorf("invalid pair %q", item) + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil +} + +func paginateResponse(ctx context.Context, apiClient apiRequester, initial *client.Response, headers http.Header, verbose bool, stderr io.Writer) (*client.Response, error) { + combined, nextURL, err := parsePaginationPayload(initial.Body) + if err != nil || nextURL == nil { + return initial, err + } + + for len(combined) < 1000 && nextURL != nil { + resp, reqErr := apiClient.RawRequest(ctx, &client.Request{ + Method: http.MethodGet, + URL: nextURL, + Headers: headers, + }) + if reqErr != nil { + return nil, reqErr + } + if verbose { + logRequestResponse(stderr, http.MethodGet, nextURL, headers, resp) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp, nil + } + + pageData, pageNext, pageErr := parsePaginationPayload(resp.Body) + if pageErr != nil { + return nil, pageErr + } + combined = append(combined, pageData...) + nextURL = pageNext + if len(combined) > 1000 { + combined = combined[:1000] + } + initial = resp + } + + merged, err := mergePaginatedBody(initial.Body, combined) + if err != nil { + return nil, err + } + initial.Body = merged + return initial, nil +} + +func parsePaginationPayload(body []byte) ([]any, *url.URL, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, err + } + data, ok := payload["data"].([]any) + if !ok { + return nil, nil, nil + } + links, ok := payload["links"].(map[string]any) + if !ok { + return data, nil, nil + } + nextRaw, _ := links["next"].(string) + if nextRaw == "" { + return data, nil, nil + } + nextURL, err := url.Parse(nextRaw) + if err != nil { + return nil, nil, err + } + return data, nextURL, nil +} + +func mergePaginatedBody(body []byte, combined []any) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + payload["data"] = combined + if meta, ok := payload["meta"].(map[string]any); ok { + if pagination, ok := meta["pagination"].(map[string]any); ok { + pagination["total-count"] = len(combined) + } + } + if links, ok := payload["links"].(map[string]any); ok { + links["next"] = nil + } + return json.Marshal(payload) +} + +func logRequestResponse(w io.Writer, method string, u *url.URL, reqHeaders http.Header, response *client.Response) { + fmt.Fprintf(w, "> %s %s\n", method, u.String()) + writeHeaders(w, reqHeaders) + fmt.Fprintf(w, "< %s\n", response.Status) + writeHeaders(w, response.Headers) +} + +func writeHeaders(w io.Writer, headers http.Header) { + 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), ", ")) + } +} diff --git a/internal/commands/profile/display.go b/internal/commands/profile/display.go new file mode 100644 index 0000000..4132253 --- /dev/null +++ b/internal/commands/profile/display.go @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdDisplay returns the `tfcloud profile display` command for displaying the active profile. +func NewCmdDisplay(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "display", + ShortHelp: "Display the active profile.", + LongHelp: heredoc.New(ctx.IO).Mustf(` + The {{ template "mdCodeOrBold" "tfcloud profile display" }} command displays the active profile. + `), + RunF: func(_ *cmd.Command, _ []string) error { + return displayRun(&DisplayOpts{ + IO: ctx.IO, + Profile: ctx.Profile, + Format: ctx.Output.GetFormat(), + }) + }, + NoAuthRequired: true, + } + + return cmd +} + +// DisplayOpts defines the options for the `tfcloud profile display` command. +type DisplayOpts struct { + IO iostreams.IOStreams + Profile *profile.Profile + Format format.Format +} + +func displayRun(opts *DisplayOpts) error { + if opts.Format == format.JSON { + data, err := json.MarshalIndent(opts.Profile, "", " ") + if err != nil { + return fmt.Errorf("failed to JSON encode profile: %w", err) + } + + fmt.Fprintln(opts.IO.Out(), string(data)) + } else { + fmt.Fprintln(opts.IO.Out(), opts.Profile.String()) + } + + return nil +} diff --git a/internal/commands/profile/display_test.go b/internal/commands/profile/display_test.go new file mode 100644 index 0000000..5b940db --- /dev/null +++ b/internal/commands/profile/display_test.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestDisplay(t *testing.T) { + t.Parallel() + + io := iostreams.Test() + p := profile.TestProfile(t) + p.Organization = "123" + p.Hostname = "app.eu.terraform.io" + p.NoColor = new(bool) + + t.Run("default", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + opts := &DisplayOpts{ + IO: io, + Profile: p, + } + r.NoError(displayRun(opts)) + r.Contains(io.Output.String(), "hostname") + r.Contains(io.Output.String(), "no_color") + }) + + t.Run("json", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + opts := &DisplayOpts{ + IO: io, + Profile: p, + Format: format.JSON, + } + r.NoError(displayRun(opts)) + r.Contains(io.Output.String(), "hostname") + r.Contains(io.Output.String(), "no_color") + }) +} diff --git a/internal/commands/profile/get.go b/internal/commands/profile/get.go new file mode 100644 index 0000000..ff82fa9 --- /dev/null +++ b/internal/commands/profile/get.go @@ -0,0 +1,135 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/mitchellh/mapstructure" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdGet returns the `tfcloud profile get` command for getting a tfcloud CLI property. +func NewCmdGet(ctx *cmd.Context) *cmd.Command { + opts := &GetOpts{ + Ctx: ctx.ShutdownCtx, + IO: ctx.IO, + Profile: ctx.Profile, + } + + cmd := &cmd.Command{ + Name: "get", + ShortHelp: "Get a tfcloud CLI Property.", + LongHelp: heredoc.New(ctx.IO).Mustf(` + The {{ template "mdCodeOrBold" "tfcloud profile get" }} command gets the specified property in your active profile. + + To view all currently set properties, run {{ template "mdCodeOrBold" "tfcloud profile display" }}. + `), + Args: cmd.PositionalArguments{ + Autocomplete: opts.Profile, + Args: []cmd.PositionalArgument{ + { + Name: "PROPERTY", + Documentation: heredoc.New(ctx.IO).Must(` + Property to be get, such as + {{ template "mdCodeOrBold" "organization" }} and + {{ template "mdCodeOrBold" "hostname" }}. + + Consult the Available Properties section below for a comprehensive list of properties. + `), + }, + }, + }, + AdditionalDocs: []cmd.DocSection{ + availablePropertiesDoc(ctx.IO), + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.Property = args[0] + + return getRun(opts) + }, + } + + return cmd +} + +// GetOpts defines the options for the `tfcloud profile get` command. +type GetOpts struct { + Ctx context.Context + IO iostreams.IOStreams + Profile *profile.Profile + + Property string +} + +func getRun(opts *GetOpts) error { + if err := IsValidProperty(opts.Property); err != nil { + return err + } + + // Decode the existing profile into a map + var data map[string]any + dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + ErrorUnused: true, + Result: &data, + TagName: "hcl", + IgnoreUntaggedFields: true, + }) + if err != nil { + return err + } + + if err := dec.Decode(opts.Profile); err != nil { + return err + } + + // Delete the key from the map + parts := strings.Split(opts.Property, "/") + level := data + var value any + for i, p := range parts { + // This is the final property + if i == len(parts)-1 { + if _, ok := level[p]; !ok { + return fmt.Errorf("property %q is not set", opts.Property) + } + + value = level[p] + break + } + + // Retrieve the component + nested, ok := level[p] + if !ok { + return fmt.Errorf("property %q is not set", opts.Property) + } + + // Check if the retrieved element is a nested object + sub, ok := nested.(map[string]any) + if !ok { + return fmt.Errorf("property %q is not set", opts.Property) + } + + level = sub + } + + v := reflect.ValueOf(value) + if v.Kind() == reflect.Pointer { + value = v.Elem() + if v.IsNil() { + return fmt.Errorf("property %q is not set", opts.Property) + } + } + + fmt.Fprintf(opts.IO.Out(), "%v\n", value) + return nil +} diff --git a/internal/commands/profile/get_test.go b/internal/commands/profile/get_test.go new file mode 100644 index 0000000..6999199 --- /dev/null +++ b/internal/commands/profile/get_test.go @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestGet(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + p := profile.TestProfile(t) + p.Organization = "123" + p.NoColor = new(bool) + + *p.NoColor = true + + expect := map[string]string{ + "organization": "123", + "no_color": "true", + } + + for k, v := range expect { + opts := &GetOpts{ + IO: io, + Profile: p, + Property: k, + } + r.NoError(getRun(opts)) + r.Equal(strings.TrimSpace(io.Output.String()), v) + io.Output.Reset() + } + + // Get an unset property + opts := &GetOpts{ + IO: io, + Profile: p, + Property: "verbosity", + } + r.ErrorContains(getRun(opts), "property \"verbosity\" is not set") +} diff --git a/internal/commands/profile/profile.go b/internal/commands/profile/profile.go new file mode 100644 index 0000000..24e0d82 --- /dev/null +++ b/internal/commands/profile/profile.go @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package profile implements the `tfcloud profile` command group for managing tfcloud CLI profiles +package profile + +import ( + "fmt" + "strings" + + "github.com/muesli/reflow/indent" + "golang.org/x/exp/maps" + + "github.com/hashicorp/tfcloud/internal/commands/profile/profiles" + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/ld" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdProfile returns the `tfcloud profile` command for managing tfcloud CLI profiles. +func NewCmdProfile(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "profile", + ShortHelp: "View and edit tfcloud CLI properties.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile" }} command group lets you initialize, + set, view and unset properties used by the tfcloud CLI. + + A profile is a collection of properties/configuration values that inform the behavior + of {{ template "mdCodeOrBold" "tfcloud" }} CLI. You can create additional profiles + using {{ template "mdCodeOrBold" "tfcloud profile profiles create" }}. + + To switch between profiles, use {{ template "mdCodeOrBold" "tfcloud profile profiles activate" }}. + + {{ 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" }}. + 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 + switch between different projects and settings. + + To run a command using a profile other than the active profile, pass the + {{ template "mdCodeOrBold" "--profile" }} flag to the command. + `), + } + + cmd.AddChild(NewCmdDisplay(ctx)) + cmd.AddChild(NewCmdSet(ctx)) + cmd.AddChild(NewCmdUnset(ctx)) + cmd.AddChild(NewCmdGet(ctx)) + cmd.AddChild(profiles.NewCmdProfiles(ctx)) + return cmd +} + +// IsValidProperty returns an error if the given property is invalid. +func IsValidProperty(property string) error { + valid := profile.PropertyNames() + if _, ok := valid[property]; ok { + return nil + } + + if suggestions := ld.Suggestions(property, maps.Keys(valid), 3, true); len(suggestions) != 0 { + return fmt.Errorf("property with name %q does not exist; did you mean to type one of the following properties: \n\n%s", + property, indent.String(strings.Join(suggestions, "\n"), 2)) + } + + return fmt.Errorf("property with name %q does not exist", property) +} diff --git a/internal/commands/profile/profile_test.go b/internal/commands/profile/profile_test.go new file mode 100644 index 0000000..e832e0d --- /dev/null +++ b/internal/commands/profile/profile_test.go @@ -0,0 +1,17 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProfile_IsValidProperty(t *testing.T) { + t.Parallel() + r := require.New(t) + r.ErrorContains(IsValidProperty("organisation"), "organization") + r.ErrorContains(IsValidProperty("no_colr"), "no_color") +} diff --git a/internal/commands/profile/profiles/activate.go b/internal/commands/profile/profiles/activate.go new file mode 100644 index 0000000..5adeafb --- /dev/null +++ b/internal/commands/profile/profiles/activate.go @@ -0,0 +1,97 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "fmt" + "slices" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdActivate returns the `tfcloud profile profiles activate` command for activating a tfcloud CLI profile. +func NewCmdActivate(ctx *cmd.Context) *cmd.Command { + opts := &ActivateOpts{ + IO: ctx.IO, + } + cmd := &cmd.Command{ + Name: "activate", + ShortHelp: "Activates an existing profile.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles activate" }} command activates an existing profile. + `), + Examples: []cmd.Example{ + { + Preamble: heredoc.New(ctx.IO).Must(` + To active profile {{ template "mdCodeOrBold" "my-profile" }}, run: + `), + Command: "$ tfcloud profile profiles activate my-profile", + }, + }, + Args: cmd.PositionalArguments{ + Autocomplete: predictProfiles(false, false), + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the profile to activate.", + }, + }, + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.Name = args[0] + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + return activateRun(opts) + }, + } + + return cmd +} + +// ActivateOpts defines the options for the `tfcloud profile profiles activate` command. +type ActivateOpts struct { + IO iostreams.IOStreams + Profiles *profile.Loader + Name string +} + +func activateRun(opts *ActivateOpts) error { + // Get the active profile + active, err := opts.Profiles.GetActiveProfile() + if err != nil { + return fmt.Errorf("failed to get active profile: %w", err) + } + + // Ensure the given profile isn't already the active profile + if active.Name == opts.Name { + return fmt.Errorf("profile %q is already the active profile", opts.Name) + } + + // Ensure the given profile exists. + profileNames, err := opts.Profiles.ListProfiles() + if err != nil { + return fmt.Errorf("failed to list profiles: %w", err) + } + + if !slices.Contains(profileNames, opts.Name) { + return fmt.Errorf("profile %q does not exist", opts.Name) + } + + // Save the new active profile + active.Name = opts.Name + if err := active.Write(); err != nil { + return fmt.Errorf("failed to save active profile: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Profile %q activated.\n", + opts.IO.ColorScheme().SuccessIcon(), opts.Name) + return nil +} diff --git a/internal/commands/profile/profiles/activate_test.go b/internal/commands/profile/profiles/activate_test.go new file mode 100644 index 0000000..742cc7f --- /dev/null +++ b/internal/commands/profile/profiles/activate_test.go @@ -0,0 +1,88 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestActivate(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Active string + Create []string + Activate string + Error string + }{ + { + Name: "Activate non-existent profile", + Active: "foo", + Create: []string{"foo", "bar"}, + Activate: "baz", + Error: "profile \"baz\" does not exist", + }, + { + Name: "Activate currently active profile", + Active: "foo", + Create: []string{"foo", "bar"}, + Activate: "foo", + Error: "profile \"foo\" is already the active profile", + }, + { + Name: "Activate good", + Active: "foo", + Create: []string{"foo", "bar"}, + Activate: "bar", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + // Create the profiles + for _, name := range c.Create { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + + // Mark the correct profile as active + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = c.Active + r.NoError(active.Write()) + + opts := &ActivateOpts{ + IO: io, + Profiles: l, + Name: c.Activate, + } + + err = activateRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + r.NoError(err) + + // Check we activated properly + newActive, err := l.GetActiveProfile() + r.NoError(err) + r.Equal(c.Activate, newActive.Name) + }) + } +} diff --git a/internal/commands/profile/profiles/create.go b/internal/commands/profile/profiles/create.go new file mode 100644 index 0000000..f74c6bc --- /dev/null +++ b/internal/commands/profile/profiles/create.go @@ -0,0 +1,146 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "fmt" + + "github.com/posener/complete" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdCreate returns the `tfcloud profile profiles create` command for creating a new tfcloud CLI profile. +func NewCmdCreate(ctx *cmd.Context) *cmd.Command { + opts := &CreateOpts{ + IO: ctx.IO, + } + cmd := &cmd.Command{ + Name: "create", + ShortHelp: "Create a new tfcloud profile.", + LongHelp: heredoc.New(ctx.IO).Mustf(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles create" }} command creates a new named profile. + + Profile names start with a letter and may contain lower case letters a-z, + upper case letters A-Z, digits 0-9, and underscores '_'. The maximum length for + a profile name is 64 characters. + `), + Examples: []cmd.Example{ + { + Preamble: "To create a new profile, run:", + Command: "$ tfcloud profile profiles create my_profile", + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the profile to create.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "no-activate", + Description: "Disables automatic activation of the newly created profile.", + Value: flagvalue.Simple(false, &opts.NoActivate), + IsBooleanFlag: true, + }, + { + Name: "hostname", + DisplayValue: "HOSTNAME", + Description: "HCP Terraform / Terraform Enterprise hostname.", + Value: flagvalue.Simple("", &opts.Hostname), + Autocomplete: complete.PredictSet("app.eu.terraform.io"), + }, + }, + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.Name = args[0] + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + return createRun(opts) + }, + } + + return cmd +} + +// CreateOpts defines the options for the `tfcloud profile profiles create` command. +type CreateOpts struct { + IO iostreams.IOStreams + + Profiles *profile.Loader + Name string + NoActivate bool + Hostname string +} + +func createRun(opts *CreateOpts) error { + // Get the existing profiles + profiles, err := opts.Profiles.ListProfiles() + if err != nil { + return fmt.Errorf("failed to list existing profiles: %w", err) + } + + // Validate a profile with the given name doesn't already exist. + for _, p := range profiles { + if p == opts.Name { + return fmt.Errorf("profile with name %q already exists", opts.Name) + } + } + + // Create the new profile + p, err := opts.Profiles.NewProfile(opts.Name) + if err != nil { + return err + } + + // Set the hostname if provided + if opts.Hostname != "" { + p.Hostname = opts.Hostname + } + + // Save the profile + if err := p.Write(); err != nil { + return fmt.Errorf("failed to save new profile: %w", err) + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s Profile %q created.\n", cs.SuccessIcon(), p.Name) + + if !opts.NoActivate { + // Update the active profile. + active, err := opts.Profiles.GetActiveProfile() + if err != nil { + return fmt.Errorf("failed to retrieve active profile: %w", err) + } + + active.Name = p.Name + if err := active.Write(); err != nil { + return fmt.Errorf("failed to update active profile: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Profile %q activated.\n", cs.SuccessIcon(), p.Name) + } + + fmt.Fprintln(opts.IO.Err()) + fmt.Fprintln(opts.IO.Err(), heredoc.New(opts.IO).Must(` + To initialize the newly created profile, run: + + {{ Bold "$ tfcloud profile init" }} + `)) + fmt.Fprintln(opts.IO.Err()) + + return nil +} diff --git a/internal/commands/profile/profiles/create_test.go b/internal/commands/profile/profiles/create_test.go new file mode 100644 index 0000000..92b2a5c --- /dev/null +++ b/internal/commands/profile/profiles/create_test.go @@ -0,0 +1,47 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestCreate(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + p1, p2 := "test", "test_other" + opts := &CreateOpts{ + IO: io, + Profiles: l, + Name: p1, + NoActivate: false, + } + + r.NoError(createRun(opts)) + r.Contains(io.Error.String(), "created") + r.Contains(io.Error.String(), "activated") + + // Set no activate + opts.Name = p2 + opts.NoActivate = true + io.Error.Reset() + r.NoError(createRun(opts)) + r.Contains(io.Error.String(), "created") + r.NotContains(io.Error.String(), "activated") + + // Get the written profiles + profiles, err := l.ListProfiles() + r.NoError(err) + r.Len(profiles, 2) + r.Contains(profiles, p1) + r.Contains(profiles, p2) +} diff --git a/internal/commands/profile/profiles/delete.go b/internal/commands/profile/profiles/delete.go new file mode 100644 index 0000000..6a6a61e --- /dev/null +++ b/internal/commands/profile/profiles/delete.go @@ -0,0 +1,137 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "fmt" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdDelete returns the `tfcloud profile profiles delete` command for deleting tfcloud CLI profiles. +func NewCmdDelete(ctx *cmd.Context) *cmd.Command { + opts := &DeleteOpts{ + IO: ctx.IO, + } + cmd := &cmd.Command{ + Name: "delete", + ShortHelp: "Delete an existing tfcloud profile.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles delete" }} command + deletes an existing tfcloud profiles. If the profile is the active profile, + it may not be deleted. + + To delete the current active profile, first run {{ template "mdCodeOrBold" "tfcloud profile profiles activate" }} + to active a different profile. + `), + Examples: []cmd.Example{ + { + Preamble: "Delete a profile:", + Command: "$ tfcloud profile profiles delete my-profile", + }, + { + Preamble: "Delete multiple profiles:", + Command: "$ tfcloud profile profiles delete my-profile-1 my-profile-2 my-profile-3", + }, + { + Preamble: "Delete the active profile:", + Command: heredoc.New(ctx.IO).Must(` + $ tfcloud profile profiles active my-other-profile + $ tfcloud profile profiles delete my-profile + `), + }, + }, + NoAuthRequired: true, + Args: cmd.PositionalArguments{ + Autocomplete: predictProfiles(true, false), + Args: []cmd.PositionalArgument{ + { + Name: "PROFILE_NAMES", + Documentation: "The name of the profile to delete. May not be the active profile.", + Repeatable: true, + }, + }, + }, + RunF: func(_ *cmd.Command, args []string) error { + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + opts.Names = args + return deleteRun(opts) + }, + } + + return cmd +} + +// DeleteOpts defines the options for the `tfcloud profile profiles delete` command. +type DeleteOpts struct { + IO iostreams.IOStreams + Profiles *profile.Loader + + Names []string +} + +func deleteRun(opts *DeleteOpts) error { + // Get the active profile + active, err := opts.Profiles.GetActiveProfile() + if err != nil { + return fmt.Errorf("failed to get active profile: %w", err) + } + + profileNames, err := opts.Profiles.ListProfiles() + if err != nil { + return fmt.Errorf("failed to list profiles: %w", err) + } + + // Validate that the given profiles to delete aren't active and that they + // all exist. + existing := make(map[string]struct{}, len(profileNames)) + for _, p := range profileNames { + existing[p] = struct{}{} + } + + cs := opts.IO.ColorScheme() + for _, toDelete := range opts.Names { + if toDelete == active.Name { + return fmt.Errorf("profile %q is the active profile and may not be deleted. Use %s to change the active configuration", + toDelete, cs.String("tfcloud profile profiles activate").Bold()) + } + if _, ok := existing[toDelete]; !ok { + return fmt.Errorf("profile %q does not exist", toDelete) + } + } + + if opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.Err(), "The following profiles will be deleted:") + for _, toDelete := range opts.Names { + fmt.Fprintf(opts.IO.Err(), " - %s\n", toDelete) + } + + fmt.Fprintln(opts.IO.Err()) + ok, err := opts.IO.PromptConfirm("Do you want to continue") + if err != nil { + return err + } + + if !ok { + 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) + } + + fmt.Fprintf(opts.IO.Err(), "%s Profile %q deleted.\n", cs.SuccessIcon(), toDelete) + } + + return nil +} diff --git a/internal/commands/profile/profiles/delete_test.go b/internal/commands/profile/profiles/delete_test.go new file mode 100644 index 0000000..4c2f350 --- /dev/null +++ b/internal/commands/profile/profiles/delete_test.go @@ -0,0 +1,138 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestDelete(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Active string + Create []string + Delete []string + Prompt bool + Confirm bool + Error string + }{ + { + Name: "bad profile name", + Active: "foo", + Create: []string{"foo"}, + Delete: []string{"bar"}, + Error: "profile \"bar\" does not exist", + }, + { + Name: "active profile name", + Active: "foo", + Create: []string{"foo"}, + Delete: []string{"foo"}, + Error: "profile \"foo\" is the active profile and may not be deleted.", + }, + { + Name: "single", + Active: "foo", + Create: []string{"foo", "bar"}, + Delete: []string{"bar"}, + }, + { + Name: "multiple", + Active: "foo", + Create: []string{"foo", "bar", "baz", "bam"}, + Delete: []string{"bar", "baz", "bam"}, + }, + { + Name: "prompt and decline", + Active: "foo", + Create: []string{"foo", "bar", "baz", "bam"}, + Delete: []string{"bar", "baz", "bam"}, + Prompt: true, + Confirm: false, + }, + { + Name: "prompt and accept", + Active: "foo", + Create: []string{"foo", "bar", "baz", "bam"}, + Delete: []string{"bar", "baz", "bam"}, + Prompt: true, + Confirm: true, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + // Create the profiles + for _, name := range c.Create { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + + // Mark the correct profile as active + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = c.Active + r.NoError(active.Write()) + + opts := &DeleteOpts{ + IO: io, + Profiles: l, + Names: c.Delete, + } + + if c.Prompt { + io.InputTTY = true + io.ErrorTTY = true + + resp := 'y' + if !c.Confirm { + resp = 'n' + } + + // Write to stdin + _, err := io.Input.WriteRune(resp) + r.NoError(err) + } + + err = deleteRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + r.NoError(err) + + // Load the profiles that now exist + profiles, err := l.ListProfiles() + r.NoError(err) + + if !c.Prompt || c.Confirm { + // Ensure that any deleted profile does not exist in the set + for _, d := range c.Delete { + r.NotContains(profiles, d) + } + } else if c.Prompt && !c.Confirm { + // If we prompted and didn't accept, ensure we didn't delete + // anything. + for _, p := range c.Create { + r.Contains(profiles, p) + } + } + }) + } +} diff --git a/internal/commands/profile/profiles/list.go b/internal/commands/profile/profiles/list.go new file mode 100644 index 0000000..9ec23c0 --- /dev/null +++ b/internal/commands/profile/profiles/list.go @@ -0,0 +1,119 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "fmt" + "slices" + "strings" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdList returns the `tfcloud profile profiles list` command for listing tfcloud CLI profiles. +func NewCmdList(ctx *cmd.Context) *cmd.Command { + opts := &ListOpts{ + IO: ctx.IO, + Output: ctx.Output, + } + cmd := &cmd.Command{ + Name: "list", + ShortHelp: "List existing tfcloud profiles.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles list" }} command lists existing tfcloud profiles. + `), + Examples: []cmd.Example{ + { + Preamble: "To list existing profiles, run:", + Command: "$ tfcloud profile profiles list", + }, + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, _ []string) error { + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + return listRun(opts) + }, + } + + return cmd +} + +// ListOpts defines the options for the `tfcloud profile profiles list` command. +type ListOpts struct { + IO iostreams.IOStreams + Output *format.Outputter + Profiles *profile.Loader +} + +func listRun(opts *ListOpts) error { + profileNames, err := opts.Profiles.ListProfiles() + if err != nil { + return fmt.Errorf("failed to list profiles: %w", err) + } + + profiles := make([]*profile.Profile, len(profileNames)) + for i, n := range profileNames { + p, err := opts.Profiles.LoadProfile(n) + if err != nil { + return fmt.Errorf("failed to load profile %q: %w", n, err) + } + + profiles[i] = p + } + + // Sort the profiles based on name + slices.SortFunc(profiles, func(p1, p2 *profile.Profile) int { + return strings.Compare(p1.Name, p2.Name) + }) + + // Get the active profile + active, err := opts.Profiles.GetActiveProfile() + if err != nil { + return fmt.Errorf("failed to get active profile: %w", err) + } + + d := &profileDisplayer{ + profiles: profiles, + activeProfile: active.Name, + } + + return opts.Output.Display(d) +} + +type profileDisplayer struct { + profiles []*profile.Profile + activeProfile string +} + +func (p *profileDisplayer) DefaultFormat() format.Format { return format.Table } +func (p *profileDisplayer) Payload() any { return p.profiles } + +func (p *profileDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Name", + ValueFormat: "{{ .Name }}", + }, + { + Name: "Hostname", + ValueFormat: "{{ .Hostname }}", + }, + { + Name: "Active", + ValueFormat: fmt.Sprintf("{{ eq ( .Name ) %q }}", p.activeProfile), + }, + { + Name: "Organization", + ValueFormat: "{{ .Organization }}", + }, + } +} diff --git a/internal/commands/profile/profiles/list_test.go b/internal/commands/profile/profiles/list_test.go new file mode 100644 index 0000000..45c055f --- /dev/null +++ b/internal/commands/profile/profiles/list_test.go @@ -0,0 +1,73 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestList(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + l := profile.TestLoader(t) + output := format.New(io) + + opts := &ListOpts{ + IO: io, + Output: output, + Profiles: l, + } + + // Create a few profiles + p1, err := l.NewProfile("alpha") + r.NoError(err) + p1.Organization = "alpha-org-id" + r.NoError(p1.Write()) + + p2, err := l.NewProfile("beta") + r.NoError(err) + p2.Organization = "beta-org-id" + r.NoError(p2.Write()) + + p3, err := l.NewProfile("zed") + r.NoError(err) + p3.Organization = "zed-org-id" + r.NoError(p3.Write()) + + // Set beta as active + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = "beta" + r.NoError(active.Write()) + + // Call list + r.NoError(listRun(opts)) + + // Check we got the output we expected + expected := [][]string{ + {"Name", "Active", "Organization"}, + {p1.Name, "false", p1.Organization}, + {p2.Name, "true", p2.Organization}, + {p3.Name, "false", p3.Organization}, + } + + lines := strings.Split(io.Output.String(), "\n") + r.Len(lines, 5) + r.Empty(lines[4]) + for i, expectedFields := range expected { + for _, field := range expectedFields { + r.Contains(lines[i], field) + } + } + +} diff --git a/internal/commands/profile/profiles/profiles.go b/internal/commands/profile/profiles/profiles.go new file mode 100644 index 0000000..339be5b --- /dev/null +++ b/internal/commands/profile/profiles/profiles.go @@ -0,0 +1,101 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package profiles implements the `tfcloud profile profiles` command group for managing tfcloud CLI profiles. +package profiles + +import ( + "fmt" + "strings" + + "github.com/muesli/reflow/indent" + "github.com/posener/complete" + "golang.org/x/exp/maps" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/ld" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdProfiles returns the `tfcloud profile profiles` command for managing tfcloud CLI profiles. +func NewCmdProfiles(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "profiles", + ShortHelp: "Manage tfcloud profiles.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles" }} command group manages + the set of named tfcloud profiles. You can create new profiles using + {{ template "mdCodeOrBold" "tfcloud profile profiles create" }} and activate existing + profiles using {{ template "mdCodeOrBold" "tfcloud profile profiles activate" }}. + To run a single command against a profile other than the active profile, + run the command with the flag {{ template "mdCodeOrBold" "--profile" }}. + `), + } + + cmd.AddChild(NewCmdCreate(ctx)) + cmd.AddChild(NewCmdDelete(ctx)) + cmd.AddChild(NewCmdList(ctx)) + cmd.AddChild(NewCmdActivate(ctx)) + cmd.AddChild(NewCmdRename(ctx)) + + return cmd +} + +// IsValidProperty returns an error if the given property is invalid. +func IsValidProperty(property string) error { + valid := profile.PropertyNames() + if _, ok := valid[property]; ok { + return nil + } + + if suggestions := ld.Suggestions(property, maps.Keys(valid), 3, true); len(suggestions) != 0 { + return fmt.Errorf("property with name %q does not exist; did you mean to type one of the following properties: \n\n%s", + property, indent.String(strings.Join(suggestions, "\n"), 2)) + } + + return fmt.Errorf("property with name %q does not exist", property) +} + +// predictProfiles is an argument prediction function that predicts a +// profile name. If repeated is true, multiple profiles will be predicted. This +// is useful for commands that accept lists of profiles. If predictActive is set +// to true, the active profile will be included in the prediction set. +func predictProfiles(repeated, predictActive bool) complete.PredictFunc { + return func(args complete.Args) []string { + if len(args.Completed) >= 1 && !repeated { + return nil + } + + // Get the profile loader + l, err := profile.NewLoader() + if err != nil { + return nil + } + + // Get all the profiles that exist + profiles, err := l.ListProfiles() + if err != nil { + return nil + } + + allProfiles := make(map[string]struct{}, len(profiles)) + for _, p := range profiles { + allProfiles[p] = struct{}{} + } + + // Go through any previously predicted profiles and remove them + for _, p := range args.Completed { + delete(allProfiles, p) + } + + // Get the active profile and delete it + if !predictActive { + if active, err := l.GetActiveProfile(); err == nil { + delete(allProfiles, active.Name) + } + } + + return maps.Keys(allProfiles) + } +} diff --git a/internal/commands/profile/profiles/rename.go b/internal/commands/profile/profiles/rename.go new file mode 100644 index 0000000..88ef711 --- /dev/null +++ b/internal/commands/profile/profiles/rename.go @@ -0,0 +1,144 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "errors" + "fmt" + "slices" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdRename returns the `tfcloud profile profiles rename` command for renaming a tfcloud CLI profile. +func NewCmdRename(ctx *cmd.Context) *cmd.Command { + opts := &RenameOpts{ + IO: ctx.IO, + } + renameCmd := &cmd.Command{ + Name: "rename", + ShortHelp: "Rename an existing profile.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud profile profiles rename" }} command renames an existing profile. + `), + Examples: []cmd.Example{ + { + Preamble: heredoc.New(ctx.IO).Must(` + To rename profile {{ template "mdCodeOrBold" "my-profile" }} to + {{ template "mdCodeOrBold" "new-profile" }}, run: + `), + Command: "$ tfcloud profile profiles rename my-profile --new-name=new_profile", + }, + }, + Args: cmd.PositionalArguments{ + Autocomplete: predictProfiles(false, true), + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the profile to rename.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "new_name", + DisplayValue: "NEW_NAME", + Description: "Specifies the new name of the profile.", + Value: flagvalue.Simple("", &opts.NewName), + Required: true, + }, + }, + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.ExistingName = args[0] + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + return renameRun(opts) + }, + } + + return renameCmd +} + +// RenameOpts defines the options for the `tfcloud profile profiles rename` command. +type RenameOpts struct { + IO iostreams.IOStreams + Profiles *profile.Loader + ExistingName string + NewName string +} + +func renameRun(opts *RenameOpts) error { + if opts.ExistingName == opts.NewName { + return fmt.Errorf("new name must be different from the existing name") + } + + // Validate new name is a valid name. + if _, err := opts.Profiles.NewProfile(opts.NewName); err != nil { + return fmt.Errorf("invalid new name %q: %w", opts.NewName, err) + } + + // Load the existing profile + existing, err := opts.Profiles.LoadProfile(opts.ExistingName) + if err != nil { + if errors.Is(err, profile.ErrNoProfileFilePresent) { + return fmt.Errorf("profile %q does not exist", opts.ExistingName) + } + + return fmt.Errorf("failed to load profile %q: %w", opts.ExistingName, err) + } + + // Ensure we don't clash with an existing profile name. + profileNames, err := opts.Profiles.ListProfiles() + if err != nil { + return fmt.Errorf("failed to list profiles: %w", err) + } + + if slices.Contains(profileNames, opts.NewName) { + return fmt.Errorf("a profile with name %q already exists", opts.NewName) + } + + // Update the name and save. + existing.Name = opts.NewName + if err := existing.Write(); err != nil { + return fmt.Errorf("error saving renamed profile: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Profile %q renamed to %q.\n", + opts.IO.ColorScheme().SuccessIcon(), opts.ExistingName, opts.NewName) + + // Delete the old profile + if err := opts.Profiles.DeleteProfile(opts.ExistingName); err != nil { + 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 { + active.Name = opts.NewName + if err := active.Write(); err != nil { + return fmt.Errorf("failed to save active profile: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Profile %q activated.\n", + opts.IO.ColorScheme().SuccessIcon(), opts.NewName) + } + + return nil +} diff --git a/internal/commands/profile/profiles/rename_test.go b/internal/commands/profile/profiles/rename_test.go new file mode 100644 index 0000000..315fb1e --- /dev/null +++ b/internal/commands/profile/profiles/rename_test.go @@ -0,0 +1,119 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profiles + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestRename(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Active string + Create []string + ExistingName string + NewName string + Error string + }{ + { + Name: "Rename to self", + Active: "foo", + Create: []string{"foo", "bar"}, + ExistingName: "bar", + NewName: "bar", + Error: "new name must be different from the existing name", + }, + { + Name: "Rename to invalid", + Active: "foo", + Create: []string{"foo", "bar"}, + ExistingName: "bar", + NewName: "$ad", + Error: "invalid new name \"$ad\": profile name may only include", + }, + { + Name: "Rename non-existent profile", + Active: "foo", + Create: []string{"foo", "bar"}, + ExistingName: "bad", + NewName: "new_name", + Error: "profile \"bad\" does not exist", + }, + { + Name: "Rename currently active profile", + Active: "foo", + Create: []string{"foo", "bar"}, + ExistingName: "foo", + NewName: "baz", + }, + { + Name: "Rename non-active", + Active: "foo", + Create: []string{"foo", "bar"}, + ExistingName: "bar", + NewName: "baz", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := profile.TestLoader(t) + io := iostreams.Test() + + // Create the profiles + for _, name := range c.Create { + p, err := l.NewProfile(name) + r.NoError(err) + r.NoError(p.Write()) + } + + // Mark the correct profile as active + active, err := l.GetActiveProfile() + r.NoError(err) + active.Name = c.Active + r.NoError(active.Write()) + + opts := &RenameOpts{ + IO: io, + Profiles: l, + ExistingName: c.ExistingName, + NewName: c.NewName, + } + + err = renameRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + r.NoError(err) + + newProfiles, err := l.ListProfiles() + r.NoError(err) + + // Check we deleted the old name + r.NotContains(newProfiles, c.ExistingName) + + // Check the new name exists + r.Contains(newProfiles, c.NewName) + + // If the old was active, check we updated the active + if c.Active == c.ExistingName { + newActive, err := l.GetActiveProfile() + r.NoError(err) + r.Equal(newActive.Name, c.NewName) + } + }) + } +} diff --git a/internal/commands/profile/property_docs.go b/internal/commands/profile/property_docs.go new file mode 100644 index 0000000..414005b --- /dev/null +++ b/internal/commands/profile/property_docs.go @@ -0,0 +1,153 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "bytes" + "fmt" + "slices" + + "github.com/muesli/reflow/indent" + "golang.org/x/exp/maps" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +// availableProperties returns a document section describing all the available +// properties to be set on the profile. +func availablePropertiesDoc(io iostreams.IOStreams) cmd.DocSection { + return cmd.DocSection{ + Title: "Available Properties", + Documentation: availableProperties(io).build(), + } +} + +func availableProperties(io iostreams.IOStreams) *availablePropertiesBuilder { + b := newAvailablePropertiesBuilder(io) + addCoreProperties(b) + return b +} + +func addCoreProperties(b *availablePropertiesBuilder) { + b.AddProperty("", "organization", "Organization of the HCP Terraform or Terraform Enterprise organization to operate on.") + b.AddProperty("", "hostname", ` + Default hostname API endpoints, if different from HCP Terraform (app.terraform.io). This affects which regional + endpoints are used for HCP services. For eu regions, use app.eu.terraform.io.`) + b.AddProperty("", "token", "The API token to use for all requests.") + b.AddProperty("", "no_color", "If True, color will not be used when printing messages in the terminal.") + 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: + {{ template "mdCodeOrBold" "trace" }}, {{ template "mdCodeOrBold" "debug" }}, + {{ template "mdCodeOrBold" "info" }}, {{ template "mdCodeOrBold" "warn" }}, and + {{ template "mdCodeOrBold" "error" }}.`) +} + +type availablePropertiesBuilder struct { + io iostreams.IOStreams + properties map[string]map[string]string +} + +func newAvailablePropertiesBuilder(io iostreams.IOStreams) *availablePropertiesBuilder { + return &availablePropertiesBuilder{ + io: io, + properties: make(map[string]map[string]string), + } +} + +func (b availablePropertiesBuilder) AddProperty(component, property, description string, args ...any) { + c, ok := b.properties[component] + if !ok { + b.properties[component] = make(map[string]string) + c = b.properties[component] + } + + c[property] = heredoc.New(b.io).Mustf(description, args...) +} + +func (b availablePropertiesBuilder) build() string { + if _, ok := b.io.(iostreams.IsMarkdownOutput); ok { + return b.buildMD() + } + + return b.buildCLI() +} + +func (b availablePropertiesBuilder) buildCLI() string { + var buf bytes.Buffer + cs := b.io.ColorScheme() + + // Start with the core section first + topLevel, ok := b.properties[""] + if ok { + keys := maps.Keys(topLevel) + slices.Sort(keys) + for _, k := range keys { + fmt.Fprintln(&buf, k) + fmt.Fprintln(&buf, indent.String(topLevel[k], 2)) + fmt.Fprintln(&buf) + } + } + + allComponents := maps.Keys(b.properties) + slices.Sort(allComponents) + for _, c := range allComponents { + if c == "" { + continue + } + + // Print the component + fmt.Fprintln(&buf, cs.String(c).Underline().String()) + + keys := maps.Keys(b.properties[c]) + slices.Sort(keys) + for _, k := range keys { + fmt.Fprintln(&buf, indent.String(k, 2)) + fmt.Fprintln(&buf, indent.String(b.properties[c][k], 4)) + fmt.Fprintln(&buf) + } + } + + return buf.String() +} + +func (b availablePropertiesBuilder) buildMD() string { + var buf bytes.Buffer + cs := b.io.ColorScheme() + + // Start with the core section first + topLevel, ok := b.properties[""] + if ok { + keys := maps.Keys(topLevel) + slices.Sort(keys) + for _, k := range keys { + fmt.Fprintf(&buf, "* `%s`\n", k) + fmt.Fprintln(&buf, indent.String(fmt.Sprintf("* %s", topLevel[k]), 4)) + fmt.Fprintln(&buf) + } + } + + allComponents := maps.Keys(b.properties) + slices.Sort(allComponents) + for _, c := range allComponents { + if c == "" { + continue + } + + // Print the component + fmt.Fprintf(&buf, "* `%s`\n\n", c) + + keys := maps.Keys(b.properties[c]) + slices.Sort(keys) + for _, k := range keys { + fmt.Fprintln(&buf, indent.String(fmt.Sprintf("* `%s` - %s", cs.String(k), b.properties[c][k]), 4)) + fmt.Fprintln(&buf) + } + } + + return buf.String() +} diff --git a/internal/commands/profile/property_docs_test.go b/internal/commands/profile/property_docs_test.go new file mode 100644 index 0000000..29a2e29 --- /dev/null +++ b/internal/commands/profile/property_docs_test.go @@ -0,0 +1,37 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestProfile_AvailableProperties_Coverage(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + + all := profile.PropertyNames() + delete(all, "name") + b := availableProperties(io) + + for component, properties := range b.properties { + for property := range properties { + name := fmt.Sprintf("%s/%s", component, property) + if component == "" { + name = property + } + + delete(all, name) + } + } + + r.Empty(all, "A property was added to the profile without documentation.") +} diff --git a/internal/commands/profile/set.go b/internal/commands/profile/set.go new file mode 100644 index 0000000..2adb7f0 --- /dev/null +++ b/internal/commands/profile/set.go @@ -0,0 +1,219 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdSet returns the `tfcloud profile set` command for setting a tfcloud CLI property. +func NewCmdSet(ctx *cmd.Context) *cmd.Command { + opts := &SetOpts{ + Ctx: ctx.ShutdownCtx, + IO: ctx.IO, + Profile: ctx.Profile, + } + + cmd := &cmd.Command{ + Name: "set", + ShortHelp: "Set a tfcloud CLI Property.", + LongHelp: heredoc.New(ctx.IO).Mustf(` + The {{ template "mdCodeOrBold" "tfcloud profile set" }} command sets the specified property in your + active profile. A property governs the behavior of a specific aspect of the tfcloud CLI. + This could be setting the hostname and organization to target, or configuring the default + level of logging across commands. + + To view all currently set properties, run {{ template "mdCodeOrBold" "tfcloud profile display" }} + or run {{ template "mdCodeOrBold" "tfcloud profile get" }} to get the value of an individual property. + + To unset properties, use {{ template "mdCodeOrBold" "tfcloud profile unset" }}. + + tfcloud CLI comes with a default profile but supports multiple. To create multiple + configurations, use {{ template "mdCodeOrBold" "tfcloud profile profiles create" }}, + and {{ template "mdCodeOrBold" "tfcloud profile profiles activate" }} to switch between them. + `), + Args: cmd.PositionalArguments{ + Autocomplete: opts.Profile, + Args: []cmd.PositionalArgument{ + { + Name: "PROPERTY", + Documentation: heredoc.New(ctx.IO).Must(` + Property to be set, such as + {{ template "mdCodeOrBold" "organization" }} and + {{ template "mdCodeOrBold" "hostname" }}. + + Consult the Available Properties section below for a comprehensive list of properties. + `), + }, + { + Name: "VALUE", + Documentation: "Value to be set.", + }, + }, + }, + AdditionalDocs: []cmd.DocSection{ + availablePropertiesDoc(ctx.IO), + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.Property = args[0] + opts.Value = args[1] + return setRun(opts) + }, + } + + return cmd +} + +// SetOpts defines the options for the `tfcloud profile set` command. +type SetOpts struct { + Ctx context.Context + IO iostreams.IOStreams + Profile *profile.Profile + + // Arguments + Property string + Value string +} + +func setRun(opts *SetOpts) error { + // Validate we are not changing the name + if opts.Property == "name" { + return fmt.Errorf("to update a profile name use %s", + opts.IO.ColorScheme().String("tfcloud profile profiles rename").Bold()) + } + + // Validate we are setting a valid property + if err := IsValidProperty(opts.Property); err != nil { + return err + } + + p := opts.Profile + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + ErrorUnused: true, + Result: p, + TagName: "hcl", + IgnoreUntaggedFields: true, + }) + if err != nil { + return err + } + + // Build the input + input := map[string]any{} + cur := input + parts := strings.Split(opts.Property, "/") + for i, p := range parts { + if p == "" { + return fmt.Errorf("property name following a \"/\" is required; empty property name is not allowed") + } + + if i == len(parts)-1 { + cur[p] = opts.Value + continue + } + + newLevel := map[string]any{} + cur[p] = newLevel + cur = newLevel + } + + if err := d.Decode(input); err != nil { + return convertDecodeError(err) + } + + if err := p.Validate(); err != nil { + return fmt.Errorf("invalid profile: %w", err) + } + + // Check to see if the property being set is valid + write := true + switch opts.Property { + case "hostname": + write, err = opts.validateHostname() + case "organization": + write, err = opts.validateOrg() + } + if err != nil { + return err + } else if !write { + return nil + } + + // Check if geography was changed and clear org/project if needed + hostnameChanged := false + if opts.Property == "hostname" { + hostnameChanged = true + // Clear organization and token to force re-initialization + p.Organization = "" + p.Token = "" + } + + if err := p.Write(); err != nil { + return err + } + + fmt.Fprintf(opts.IO.Err(), "%s Property %q updated\n", + opts.IO.ColorScheme().SuccessIcon(), opts.Property) + + // Notify user about hostname changes + if hostnameChanged { + fmt.Fprintf(opts.IO.Err(), "\n%s Hostname changed to %q. Organization and token settings have been cleared.\n", + opts.IO.ColorScheme().WarningLabel(), opts.Value) + fmt.Fprintf(opts.IO.Err(), "Please run %s to reconfigure your organization and token for this hostname.\n\n", + opts.IO.ColorScheme().String("tfcloud profile init").Bold()) + } + + return nil +} + +func (o *SetOpts) validateHostname() (bool, error) { + return true, nil +} + +func (o *SetOpts) validateOrg() (bool, error) { + return true, nil +} + +// convertDecodeError converts the mapstructure decode error into a more +// contextual error. +func convertDecodeError(err error) error { + mapErr := &mapstructure.Error{} + if !errors.As(err, &mapErr) { + return err + } + + // We only expect a single error to ever occur + if len(mapErr.Errors) > 1 { + return err + } + + // Parse an invalid key at the top-level + errStr := mapErr.Errors[0] + if strings.HasPrefix(errStr, "'' has invalid keys:") { + parts := strings.Split(errStr, ": ") + return fmt.Errorf("no top-level property with name %q", parts[1]) + } + + // Try to parse invalid keys within a component. This could occur if a user + // runs "set core/bad-key value" + var component, property string + _, scanErr := fmt.Sscanf(strings.ReplaceAll(errStr, "'", ""), "%s has invalid keys: %s", &component, &property) + if scanErr == nil { + return fmt.Errorf("invalid property %q for component %q", property, component) + } + + return errors.New(mapErr.Errors[0]) +} diff --git a/internal/commands/profile/set_test.go b/internal/commands/profile/set_test.go new file mode 100644 index 0000000..3135e63 --- /dev/null +++ b/internal/commands/profile/set_test.go @@ -0,0 +1,153 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestSet(t *testing.T) { + t.Parallel() + cases := []struct { + Name string + Property string + Value string + Error string + SetupProfile func(p *profile.Profile) // Add setup function + CheckProfile func(p *profile.Profile, r *require.Assertions) + }{ + { + Name: "can't set name", + Property: "name", + Value: "test", + Error: "to update a profile name use tfcloud profile profiles rename", + }, + { + Name: "invalid top-level key", + Property: "unknown-top-level", + Value: "test", + Error: "property with name \"unknown-top-level\" does not exist", + }, + { + Name: "basic top-level property", + Property: "organization", + Value: "123", + CheckProfile: func(p *profile.Profile, r *require.Assertions) { + r.Equal("123", p.Organization) + }, + }, + { + Name: "basic core property", + Property: "no_color", + Value: "true", + CheckProfile: func(p *profile.Profile, r *require.Assertions) { + r.True(*p.NoColor) + }, + }, + { + Name: "basic core property - invalid type conversion", + Property: "no_color", + Value: "bad-value", + Error: "cannot parse 'no_color' as bool", + }, + { + Name: "basic core property - invalid value", + Property: "verbosity", + Value: "bad-value", + Error: "invalid verbosity \"bad-value\". Must be one of:", + }, + { + Name: "hostname change clears org and project", + Property: "hostname", + Value: "app.eu.terraform.io", + SetupProfile: func(p *profile.Profile) { + // Set initial org and project values + p.Organization = "test-org-123" + p.Token = "test" + }, + CheckProfile: func(p *profile.Profile, r *require.Assertions) { + // Verify geography is set and org/project are cleared + r.Equal("app.eu.terraform.io", p.Hostname) + r.Equal("", p.Organization) + r.Equal("", p.Token) + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + profile := profile.TestProfile(t) + + // Setup profile if needed + if c.SetupProfile != nil { + c.SetupProfile(profile) + } + + o := &SetOpts{ + IO: io, + Profile: profile, + Property: c.Property, + Value: c.Value, + } + + err := setRun(o) + if c.Error == "" { + r.NoError(err) + if c.CheckProfile != nil { + c.CheckProfile(o.Profile, r) + } + } else { + r.ErrorContains(err, c.Error) + } + }) + } +} + +func TestSet_Organization(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + l := profile.TestLoader(t) + p := l.DefaultProfile() + r.NoError(p.Write()) + o := &SetOpts{ + IO: io, + Profile: p, + Property: "organization", + } + + setup := func(quiet, tty, authed bool, projectID string) { + o.Value = projectID + io.SetQuiet(quiet) + io.InputTTY = tty + io.ErrorTTY = tty + io.Input.Reset() + io.Error.Reset() + io.Output.Reset() + } + + checkOrg := func(expected string) { + loadedProfile, err := l.LoadProfile(p.Name) + r.NoError(err) + r.Equal(expected, loadedProfile.Organization) + } + + // Run with quiet off, TTY's, authenticated, and return that the user has access to the project + { + setup(false, true, true, "123") + r.NoError(setRun(o)) + checkOrg("123") + } + +} diff --git a/internal/commands/profile/unset.go b/internal/commands/profile/unset.go new file mode 100644 index 0000000..b5a1fae --- /dev/null +++ b/internal/commands/profile/unset.go @@ -0,0 +1,171 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "context" + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" + + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// NewCmdUnset returns the `tfcloud profile unset` command for unsetting a tfcloud CLI property. +func NewCmdUnset(ctx *cmd.Context) *cmd.Command { + opts := &UnsetOpts{ + Ctx: ctx.ShutdownCtx, + IO: ctx.IO, + Profile: ctx.Profile, + } + + cmd := &cmd.Command{ + Name: "unset", + ShortHelp: "Unset a tfcloud CLI Property.", + LongHelp: heredoc.New(ctx.IO).Mustf(` + The {{ template "mdCodeOrBold" "tfcloud profile unset" }} command unsets the specified property in your active profile. + + To view all currently set properties, run {{ template "mdCodeOrBold" "tfcloud profile display" }}. + `), + Args: cmd.PositionalArguments{ + Autocomplete: opts.Profile, + Args: []cmd.PositionalArgument{ + { + Name: "PROPERTY", + Documentation: heredoc.New(ctx.IO).Must(` + Property to be unset, such as + {{ template "mdCodeOrBold" "organization" }} and + {{ template "mdCodeOrBold" "hostname" }}. + + Consult the Available Properties section below for a comprehensive list of properties. + `), + }, + }, + }, + AdditionalDocs: []cmd.DocSection{ + availablePropertiesDoc(ctx.IO), + }, + NoAuthRequired: true, + RunF: func(_ *cmd.Command, args []string) error { + opts.Property = args[0] + l, err := profile.NewLoader() + if err != nil { + return err + } + opts.Profiles = l + + return unsetRun(opts) + }, + } + + return cmd +} + +// UnsetOpts defines the options for the `tfcloud profile unset` command. +type UnsetOpts struct { + Ctx context.Context + IO iostreams.IOStreams + Profile *profile.Profile + + Property string + Profiles *profile.Loader +} + +func unsetRun(opts *UnsetOpts) error { + // Validate we are not changing the name + if opts.Property == "name" { + return fmt.Errorf("to update a profile name use %s", + opts.IO.ColorScheme().String("tfcloud profile profiles rename").Bold()) + } + + if err := IsValidProperty(opts.Property); err != nil { + return err + } + + // Decode the existing profile into a map + var data map[string]any + dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + ErrorUnused: true, + Result: &data, + TagName: "hcl", + IgnoreUntaggedFields: true, + }) + if err != nil { + return err + } + + if err := dec.Decode(opts.Profile); err != nil { + return err + } + + // Delete the key from the map + parts := strings.Split(opts.Property, "/") + level := data + didDelete := false + for i, p := range parts { + // This is the final property + if i == len(parts)-1 { + if _, ok := level[p]; !ok { + break + } + + delete(level, p) + didDelete = true + break + } + + // Retrieve the component + nested, ok := level[p] + if !ok { + break + } + + // Check if the retrieved element is a nested object + sub, ok := nested.(map[string]any) + if !ok { + break + } + + level = sub + } + + if didDelete { + p, err := opts.Profiles.NewProfile(opts.Profile.Name) + if err != nil { + return err + } + + dec2, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + WeaklyTypedInput: true, + ErrorUnused: true, + Result: p, + TagName: "hcl", + IgnoreUntaggedFields: true, + }) + if err != nil { + return err + } + + if err := dec2.Decode(data); err != nil { + return convertDecodeError(err) + } + + if err := p.Validate(); err != nil { + return fmt.Errorf("invalid profile: %w", err) + } + + if err := p.Write(); err != nil { + return err + } + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s Property %q unset\n", cs.SuccessIcon(), opts.Property) + return nil +} diff --git a/internal/commands/profile/unset_test.go b/internal/commands/profile/unset_test.go new file mode 100644 index 0000000..ac0071f --- /dev/null +++ b/internal/commands/profile/unset_test.go @@ -0,0 +1,92 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func TestUnset(t *testing.T) { + t.Parallel() + defaultProfile := func(p *profile.Profile) { + p.Organization = "123" + info := "info" + p.Verbosity = &info + } + + cases := []struct { + Name string + Property string + CheckProfile func(p *profile.Profile, r *require.Assertions) + Error string + }{ + { + Name: "can't set name", + Property: "name", + Error: "to update a profile name use tfcloud profile profiles rename", + }, + { + Name: "unset invalid top-level property", + Property: "random", + Error: "property with name \"random\" does not exist", + }, + { + Name: "unset top-level", + Property: "hostname", + CheckProfile: func(p *profile.Profile, r *require.Assertions) { + r.Empty(p.Hostname) + }, + }, + { + Name: "unset basic property", + Property: "verbosity", + CheckProfile: func(p *profile.Profile, r *require.Assertions) { + r.Nil(p.Verbosity) + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a profile loader and generate the starting profile + l := profile.TestLoader(t) + p, err := l.NewProfile("test") + r.NoError(err) + + defaultProfile(p) + r.NoError(p.Write()) + + io := iostreams.Test() + o := &UnsetOpts{ + IO: io, + Profile: p, + Profiles: l, + Property: c.Property, + } + + err = unsetRun(o) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + // Ensure there is no error + r.NoError(err) + + // Load the profile from disk + reread, err := l.LoadProfile("test") + r.NoError(err) + c.CheckProfile(reread, r) + }) + } +} diff --git a/internal/commands/tfcloud/root.go b/internal/commands/tfcloud/root.go new file mode 100644 index 0000000..4c6bf30 --- /dev/null +++ b/internal/commands/tfcloud/root.go @@ -0,0 +1,41 @@ +// Copyright IBM Corp. 2024, 2026 +// SPDX-License-Identifier: MPL-2.0 + +// Package tfcloud implements the root command for the tfcloud CLI. +package tfcloud + +import ( + "github.com/hashicorp/tfcloud/internal/commands/api" + "github.com/hashicorp/tfcloud/internal/commands/profile" + "github.com/hashicorp/tfcloud/internal/commands/variable" + "github.com/hashicorp/tfcloud/internal/config" + "github.com/hashicorp/tfcloud/internal/pkg/cmd" +) + +// NewCmdRoot creates the root command. +func NewCmdRoot(ctx *cmd.Context) *cmd.Command { + c := &cmd.Command{ + Name: config.Name, + ShortHelp: "Interact with HCP Terraform and Terraform Enterprise.", + LongHelp: "The tfcloud command-line interface (CLI) is a unified tool to managing HCP Terraform and Terraform Enterpise from the command line.", + } + + // _ _ ___ _____ _____ + // | \ | |/ _ \_ _| ____| + // | \| | | | || | | _| + // | |\ | |_| || | | |___ + // |_| \_|\___/ |_| |_____| + // + // When adding a top level command group, be sure to regenerate the + // screenshot in the README by running `make gen/screenshot`. + + // Add the subcommands + c.AddChild(api.NewCmdAPI(ctx)) + c.AddChild(variable.NewCmdVariable(ctx)) + c.AddChild(profile.NewCmdProfile(ctx)) + + // Configure the command as the root command. + cmd.ConfigureRootCommand(ctx, c) + + return c +} diff --git a/internal/commands/variable/variable.go b/internal/commands/variable/variable.go new file mode 100644 index 0000000..6111330 --- /dev/null +++ b/internal/commands/variable/variable.go @@ -0,0 +1,26 @@ +// Copyright IBM Corp. 2024, 2026 +// SPDX-License-Identifier: MPL-2.0 + +// Package variable implements the `tfcloud variable` command group. +package variable + +import ( + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" +) + +// NewCmdVariable creates the `tfcloud variable` command. +func NewCmdVariable(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "variable", + ShortHelp: "Manage variables in workspaces or variable sets.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud variable" }} command group lets you manage Terraform or + environment variables belonging to Workspaces or Variable Sets. + `), + } + + cmd.AddChild(NewCmdVariableImport(ctx)) + + return cmd +} diff --git a/internal/commands/variable/variable_import.go b/internal/commands/variable/variable_import.go new file mode 100644 index 0000000..5d251fa --- /dev/null +++ b/internal/commands/variable/variable_import.go @@ -0,0 +1,424 @@ +package variable + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/hashicorp/tfcloud/internal/pkg/client" + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + terraformcfg "github.com/hashicorp/tfcloud/internal/pkg/terraform" +) + +// ImportOpts stores the options parsed from flags for the variable import command. +type ImportOpts struct { + IO iostreams.IOStreams + Env []string + VariableSetName string + Organization string + Workspace string + Overwrite bool +} + +// NewCmdVariableImport creates the `tfcloud variable import` command. +func NewCmdVariableImport(ctx *cmd.Context) *cmd.Command { + opts := &ImportOpts{ + IO: ctx.IO, + } + + cmd := &cmd.Command{ + Name: "import", + ShortHelp: "Import variables from .tfvars or current env into workspaces or variable sets.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "tfcloud variable import" }} command lets you import Terraform + variables from .tfvars files or environment variables from the tfcloud process environment into + Workspaces or Variable Sets. + `), + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "TFVARS_FILE", + Optional: true, + Documentation: "The .tfvars file to import variables from", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "env", + Shorthand: "e", + Description: "Environment variable to import", + Repeatable: true, + Value: flagvalue.SimpleSlice(nil, &opts.Env), + }, + { + Name: "variable-set-name", + Description: "Target Variable Set by name (defaults to workspace if not set)", + Value: flagvalue.Simple("", &opts.VariableSetName), + }, + { + Name: "organization", + Description: "Organization name (defaults to config or terraform cloud config context)", + Value: flagvalue.Simple("", &opts.Organization), + }, + { + Name: "workspace", + Description: "Workspace name override (defaults to terraform cloud config context)", + Value: flagvalue.Simple("", &opts.Workspace), + }, + { + Name: "overwrite", + Description: "Update matching existing variables instead of erroring", + Value: flagvalue.Simple(false, &opts.Overwrite), + IsBooleanFlag: true, + }, + }, + }, + Examples: []cmd.Example{ + { + Preamble: "Import terraform variables from a .tfvars file into the current workspace", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud variable import variables.tfvars`), + }, + { + Preamble: "Import environment variables from the tfcloud process into a variable set", + Command: heredoc.New(ctx.IO, heredoc.WithNoWrap(), heredoc.WithPreserveNewlines()).Must(`$ tfcloud variable import -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY --variable-set-name my-variable-set`), + }, + }, + RunF: func(_ *cmd.Command, args []string) error { + var imported []terraformcfg.ImportedVariable + if len(args) > 1 { + return cmd.ErrDisplayUsage + } + + if len(args) == 1 { + vars, err := terraformcfg.ParseTFVarsFile(args[0]) + if err != nil { + return fmt.Errorf("failed parsing tfvars file: %w", err) + } + imported = append(imported, vars...) + } + + for _, name := range opts.Env { + value, ok := os.LookupEnv(name) + if !ok { + return fmt.Errorf("environment variable %q is not set", name) + } + imported = append(imported, terraformcfg.ImportedVariable{ + Key: name, + Value: value, + Category: "env", + HCL: false, + Sensitive: true, + }) + } + if len(imported) == 0 { + return cmd.ErrDisplayUsage + } + + if opts.Organization == "" { + opts.Organization = ctx.Profile.Organization + } + + if opts.Organization == "" || opts.Workspace == "" { + cfg, err := terraformcfg.FindCloudConfig(".") + if err == nil { + if opts.Organization == "" { + opts.Organization = cfg.Organization + } + if opts.Workspace == "" { + opts.Workspace = cfg.Workspace + } + } + } + + if opts.VariableSetName != "" && opts.Organization == "" { + return errors.New("--organization or profile default organization is required when targeting a variable set and no terraform cloud configuration was found") + } + if opts.VariableSetName == "" && (opts.Organization == "" || opts.Workspace == "") { + return errors.New("could not resolve target workspace; set --organization and --workspace or run inside a repository with terraform cloud configuration") // this should be impossible to hit due to the previous block, but we'll check again before API calls just in case + } + + target, err := resolveTarget(ctx.ShutdownCtx, ctx.APIClient, opts) + if err != nil { + return err + } + + existing, err := listExistingVariables(ctx.ShutdownCtx, ctx.APIClient, target) + if err != nil { + return err + } + + duplicates := make([]string, 0) + for _, variable := range imported { + key := existingKey(variable.Key, variable.Category) + if _, ok := existing[key]; ok && !opts.Overwrite { + duplicates = append(duplicates, fmt.Sprintf("%s (%s)", variable.Key, variable.Category)) + } + } + if len(duplicates) > 0 { + return fmt.Errorf("variables already exist; rerun with --overwrite to update: %s", strings.Join(duplicates, ", ")) + } + + created := 0 + updated := 0 + for _, variable := range imported { + key := existingKey(variable.Key, variable.Category) + if current, ok := existing[key]; ok { + if err := updateVariable(ctx.ShutdownCtx, ctx.APIClient, target, current.ID, variable); err != nil { + return err + } + updated++ + continue + } + if err := createVariable(ctx.ShutdownCtx, ctx.APIClient, target, variable); err != nil { + return err + } + created++ + } + + _, _ = fmt.Fprintf(ctx.IO.Err(), "%s imported %d variables into %s (%d created, %d updated)", opts.IO.ColorScheme().SuccessIcon(), len(imported), target.DisplayName, created, updated) + return nil + }, + } + + return cmd +} + +type variableTarget struct { + Kind string + ID string + DisplayName string + Path string + ItemPath string +} + +type existingVariable struct { + ID string + Key string + Category string +} + +func resolveTarget(ctx context.Context, apiClient *client.Client, opts *ImportOpts) (*variableTarget, error) { + if opts.VariableSetName != "" { + id, err := resolveVariableSet(ctx, apiClient, opts) + if err != nil { + return nil, err + } + return &variableTarget{ + Kind: "variable set", + ID: id, + DisplayName: fmt.Sprintf("variable set %q", opts.VariableSetName), + Path: fmt.Sprintf("/varsets/%s/relationships/vars", url.PathEscape(id)), + ItemPath: fmt.Sprintf("/varsets/%s/relationships/vars/%%s", url.PathEscape(id)), + }, nil + } + + workspaceID, err := resolveWorkspace(ctx, apiClient, opts) + if err != nil { + return nil, err + } + return &variableTarget{ + Kind: "workspace", + ID: workspaceID, + DisplayName: fmt.Sprintf("workspace %q", opts.Workspace), + Path: fmt.Sprintf("/workspaces/%s/vars", url.PathEscape(workspaceID)), + ItemPath: fmt.Sprintf("/workspaces/%s/vars/%%s", url.PathEscape(workspaceID)), + }, nil +} + +func resolveWorkspace(ctx context.Context, apiClient *client.Client, opts *ImportOpts) (string, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/workspaces/%s", url.PathEscape(opts.Organization), url.PathEscape(opts.Workspace))) + if err != nil { + return "", err + } + resp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", resp.Status, client.SummarizeAPIErrors(resp.Body)) + } + var payload struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return "", err + } + if payload.Data.ID == "" { + return "", fmt.Errorf("workspace %q returned no id", opts.Workspace) + } + return payload.Data.ID, nil +} + +func resolveVariableSet(ctx context.Context, apiClient *client.Client, opts *ImportOpts) (string, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf("/organizations/%s/varsets", url.PathEscape(opts.Organization))) + if err != nil { + return "", err + } + query := endpoint.Query() + query.Set("q", opts.VariableSetName) + endpoint.RawQuery = query.Encode() + + resp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", resp.Status, client.SummarizeAPIErrors(resp.Body)) + } + + var payload struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Name string `json:"name"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return "", err + } + for _, item := range payload.Data { + if item.Attributes.Name == opts.VariableSetName { + return item.ID, nil + } + } + + body := map[string]any{ + "data": map[string]any{ + "type": "varsets", + "attributes": map[string]any{ + "name": opts.VariableSetName, + }, + }, + } + encoded, err := json.Marshal(body) + if err != nil { + return "", err + } + createResp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: encoded}) + if err != nil { + return "", err + } + if createResp.StatusCode < 200 || createResp.StatusCode >= 300 { + return "", fmt.Errorf("%s: %s", createResp.Status, client.SummarizeAPIErrors(createResp.Body)) + } + var created struct { + Data struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(createResp.Body, &created); err != nil { + return "", err + } + if created.Data.ID == "" { + return "", fmt.Errorf("created variable set %q returned no id", opts.VariableSetName) + } + return created.Data.ID, nil +} + +func listExistingVariables(ctx context.Context, apiClient *client.Client, target *variableTarget) (map[string]existingVariable, error) { + endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) + if err != nil { + return nil, err + } + resp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodGet, URL: endpoint, Headers: jsonAPIHeaders()}) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("%s: %s", resp.Status, client.SummarizeAPIErrors(resp.Body)) + } + var payload struct { + Data []struct { + ID string `json:"id"` + Attributes struct { + Key string `json:"key"` + Category string `json:"category"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return nil, err + } + existing := make(map[string]existingVariable, len(payload.Data)) + for _, item := range payload.Data { + existing[existingKey(item.Attributes.Key, item.Attributes.Category)] = existingVariable{ID: item.ID, Key: item.Attributes.Key, Category: item.Attributes.Category} + } + return existing, nil +} + +func createVariable(ctx context.Context, apiClient *client.Client, target *variableTarget, variable terraformcfg.ImportedVariable) error { + endpoint, err := client.ResolveURL(apiClient.BaseURL, target.Path) + if err != nil { + return err + } + body, err := json.Marshal(variablePayload(variable)) + if err != nil { + return err + } + resp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodPost, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %s", resp.Status, client.SummarizeAPIErrors(resp.Body)) + } + return nil +} + +func updateVariable(ctx context.Context, apiClient *client.Client, target *variableTarget, variableID string, variable terraformcfg.ImportedVariable) error { + endpoint, err := client.ResolveURL(apiClient.BaseURL, fmt.Sprintf(target.ItemPath, url.PathEscape(variableID))) + if err != nil { + return err + } + body, err := json.Marshal(variablePayload(variable)) + if err != nil { + return err + } + resp, err := apiClient.RawRequest(ctx, &client.Request{Method: http.MethodPatch, URL: endpoint, Headers: jsonAPIHeaders(), Body: body}) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %s", resp.Status, client.SummarizeAPIErrors(resp.Body)) + } + return nil +} + +func variablePayload(variable terraformcfg.ImportedVariable) map[string]any { + return map[string]any{ + "data": map[string]any{ + "type": "vars", + "attributes": map[string]any{ + "key": variable.Key, + "value": variable.Value, + "category": variable.Category, + "hcl": variable.HCL, + "sensitive": variable.Sensitive, + }, + }, + } +} + +func existingKey(key, category string) string { + return category + "\x00" + key +} + +func jsonAPIHeaders() http.Header { + return http.Header{ + "Accept": []string{"application/vnd.api+json"}, + "Content-Type": []string{"application/vnd.api+json"}, + } +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 21e0b8d..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,327 +0,0 @@ -// Package config resolves CLI configuration and build metadata. -package config - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "unicode" - - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclsimple" - "golang.org/x/net/idna" -) - -// DefaultHostname is the hostname used when no default profile is configured. -const DefaultHostname = "app.terraform.io" - -// Config is the fully resolved CLI configuration after all configuration -// layers have been applied. -type Config struct { - // Hostname is the HCP Terraform hostname to target. - Hostname string - // DefaultOrganization is the fallback organization for commands that need one. - DefaultOrganization string - // Token is the resolved API token used for authentication. - Token string - // DefaultHeaders are added to every outgoing API request. - DefaultHeaders http.Header -} - -type credentialsFile struct { - Credentials map[string]struct { - Token string `json:"token"` - } `json:"credentials"` -} - -type hclConfig struct { - Profiles []hclProfile `hcl:"profile,block"` -} - -type hclProfile struct { - Name string `hcl:",label"` - Hostname string `hcl:",label"` - Token *string `hcl:"token,optional"` - Organization *string `hcl:"organization,optional"` -} - -type fileConfig struct { - ProfileName string - Hostname string - Token string - DefaultOrganization string -} - -type resolvedProfile struct { - Name string - Hostname string -} - -// Load resolves CLI configuration from config files, credentials, and -// environment variables using the default profile. -func Load() (*Config, error) { - profile, err := resolveProfile() - if err != nil { - return nil, err - } - - resolved := fileConfig{ProfileName: profile.Name, Hostname: profile.Hostname} - for _, path := range configSearchPaths() { - cfg, err := loadHCLConfig(path, profile) - if err != nil { - return nil, err - } - resolved = mergeConfig(resolved, cfg) - } - - if resolved.Token == "" { - resolved.Token, err = tokenFromCredentials(profile.Hostname) - if err != nil { - return nil, err - } - } - - if envToken := os.Getenv(profileTokenEnvVar(resolved.ProfileName)); envToken != "" { - resolved.Token = envToken - } else if resolved.Token == "" { - resolved.Token = os.Getenv(legacyTokenEnvVar(profile.Hostname)) - } - - if resolved.Token == "" { - return nil, fmt.Errorf("missing token for %s; set %s, add it to tfcloud.hcl, add it to ~/.terraform.d/credentials.tfrc.json, or use terraform-compatible %s", profile.Hostname, profileTokenEnvVar(resolved.ProfileName), legacyTokenEnvVar(profile.Hostname)) - } - - headers := make(http.Header) - headers.Set("User-Agent", fmt.Sprintf("tfcloud CLI %s", Version)) - - return &Config{ - Hostname: profile.Hostname, - DefaultOrganization: resolved.DefaultOrganization, - Token: resolved.Token, - DefaultHeaders: headers, - }, nil -} - -// resolveProfile selects the active default profile for configuration loading. -func resolveProfile() (resolvedProfile, error) { - profile := resolvedProfile{Name: "default"} - for _, path := range configSearchPaths() { - cfg, err := loadDefaultProfile(path) - if err != nil { - return resolvedProfile{}, err - } - if cfg.Hostname != "" { - profile.Name = cfg.ProfileName - profile.Hostname = cfg.Hostname - } - } - if profile.Hostname == "" { - profile.Hostname = DefaultHostname - } - return profile, nil -} - -func configSearchPaths() []string { - paths := make([]string, 0, 2) - if userConfigDir, err := userConfigDir(); err == nil { - paths = append(paths, filepath.Join(userConfigDir, "tfcloud", "tfcloud.hcl")) - } - paths = append(paths, ".tfcloud.hcl") - return paths -} - -// userConfigDir returns the base directory for user-specific configuration files. On -// most platforms, this the os.UserConfigDir(), but on darwin, if XDG_CONFIG_HOME is not set, -// this falls back to $HOME/.config to align with other unix systems. -func userConfigDir() (string, error) { - if runtime.GOOS == "darwin" && os.Getenv("XDG_CONFIG_HOME") == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config"), nil - } - return os.UserConfigDir() -} - -func loadHCLConfig(path string, target resolvedProfile) (fileConfig, error) { - decoded, err := readHCLConfig(path) - if err != nil { - return fileConfig{}, err - } - - selected, err := selectProfileByName(decoded.Profiles, target.Name) - if err != nil { - return fileConfig{}, fmt.Errorf("parse %s: %w", path, err) - } - if selected == nil { - return fileConfig{}, nil - } - - return profileToFileConfig(*selected), nil -} - -func loadDefaultProfile(path string) (fileConfig, error) { - decoded, err := readHCLConfig(path) - if err != nil { - return fileConfig{}, err - } - - selected, err := selectProfileByName(decoded.Profiles, "default") - if err != nil { - return fileConfig{}, fmt.Errorf("parse %s: %w", path, err) - } - if selected == nil { - return fileConfig{}, nil - } - - return profileToFileConfig(*selected), nil -} - -func readHCLConfig(path string) (hclConfig, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return hclConfig{}, nil - } - return hclConfig{}, err - } - - var decoded hclConfig - if err := hclsimple.Decode(path, data, nil, &decoded); err != nil { - return hclConfig{}, formatHCLError(path, err) - } - return decoded, nil -} - -// selectProfileByName returns the matching profile block for name after -// validating supported profile names and normalizing hostnames. -func selectProfileByName(profiles []hclProfile, name string) (*hclProfile, error) { - var selected *hclProfile - for i := range profiles { - profile := profiles[i] - if err := validateProfileName(profile.Name); err != nil { - return nil, err - } - profile.Hostname = normalizeHostname(profile.Hostname) - if profile.Name == name { - selected = &profile - } - } - return selected, nil -} - -// profileToFileConfig converts a decoded HCL profile block into the internal -// file-backed configuration shape used during layering. -func profileToFileConfig(profile hclProfile) fileConfig { - cfg := fileConfig{ProfileName: profile.Name, Hostname: profile.Hostname} - if profile.Token != nil { - cfg.Token = *profile.Token - } - if profile.Organization != nil { - cfg.DefaultOrganization = *profile.Organization - } - return cfg -} - -func mergeConfig(base fileConfig, overlay fileConfig) fileConfig { - if overlay.ProfileName != "" { - base.ProfileName = overlay.ProfileName - } - if overlay.Hostname != "" { - base.Hostname = overlay.Hostname - } - if overlay.Token != "" { - base.Token = overlay.Token - } - if overlay.DefaultOrganization != "" { - base.DefaultOrganization = overlay.DefaultOrganization - } - return base -} - -func validateProfileName(name string) error { - if name == "default" { - return nil - } - for _, r := range name { - if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' { - continue - } - return fmt.Errorf("invalid profile name %q; only letters, digits, and underscores are supported", name) - } - return nil -} - -func formatHCLError(path string, err error) error { - if diags, ok := err.(hcl.Diagnostics); ok { - return fmt.Errorf("parse %s: %s", path, strings.TrimSpace(diags.Error())) - } - return fmt.Errorf("parse %s: %w", path, err) -} - -func normalizeHostname(hostname string) string { - hostname = strings.TrimSpace(hostname) - hostname = strings.TrimPrefix(hostname, "https://") - hostname = strings.TrimPrefix(hostname, "http://") - hostname = strings.TrimRight(hostname, "/") - if asciiHost, err := idna.Lookup.ToASCII(hostname); err == nil { - return asciiHost - } - return hostname -} - -func profileTokenEnvVar(profileName string) string { - if profileName == "" || profileName == "default" { - return "TFCLOUD_TOKEN" - } - return "TFCLOUD_TOKEN_" + profileName -} - -func legacyTokenEnvVar(hostname string) string { - hostname = normalizeHostname(hostname) - - var b strings.Builder - b.WriteString("TF_TOKEN_") - for _, r := range strings.ToUpper(hostname) { - if unicode.IsLetter(r) || unicode.IsDigit(r) { - b.WriteRune(r) - continue - } - b.WriteRune('_') - } - return b.String() -} - -func tokenFromCredentials(hostname string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - - path := filepath.Join(home, ".terraform.d", "credentials.tfrc.json") - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - - var creds credentialsFile - if err := json.Unmarshal(data, &creds); err != nil { - return "", fmt.Errorf("parse %s: %w", path, err) - } - - hostname = normalizeHostname(hostname) - entry, ok := creds.Credentials[hostname] - if !ok { - return "", nil - } - - return entry.Token, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index aa5f30b..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestProfileTokenEnvVar(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "": "TFCLOUD_TOKEN", - "default": "TFCLOUD_TOKEN", - "work": "TFCLOUD_TOKEN_work", - "dev_2026": "TFCLOUD_TOKEN_dev_2026", - } - - for input, want := range tests { - if got := profileTokenEnvVar(input); got != want { - t.Fatalf("profileTokenEnvVar(%q) = %q, want %q", input, got, want) - } - } -} - -func TestLegacyTokenEnvVar(t *testing.T) { - t.Parallel() - - tests := map[string]string{ - "app.terraform.io": "TF_TOKEN_APP_TERRAFORM_IO", - "tfe.example-host.com": "TF_TOKEN_TFE_EXAMPLE_HOST_COM", - "xn--bcher-kva.example": "TF_TOKEN_XN__BCHER_KVA_EXAMPLE", - "xn--caf-dma.fr": "TF_TOKEN_XN__CAF_DMA_FR", - "app.terraform.io:443": "TF_TOKEN_APP_TERRAFORM_IO_443", - } - - for input, want := range tests { - if got := legacyTokenEnvVar(input); got != want { - t.Fatalf("legacyTokenEnvVar(%q) = %q, want %q", input, got, want) - } - } -} - -func TestLoadUsesDefaultProfileAndEnvToken(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.eu.terraform.io" { - token = "user-token" - organization = "ops" -}`) - t.Setenv("TFCLOUD_TOKEN", "env-token") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Hostname != "app.eu.terraform.io" { - t.Fatalf("hostname = %q", cfg.Hostname) - } - if cfg.Token != "env-token" { - t.Fatalf("token = %q", cfg.Token) - } - if cfg.DefaultOrganization != "ops" { - t.Fatalf("default organization = %q", cfg.DefaultOrganization) - } -} - -func TestLoadUsesLayeredHCLPrecedence(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - token = "user-token" - organization = "user-org" -}`) - env.writeLocalConfig(`profile "default" "app.terraform.io" { - organization = "local-org" -}`) - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Hostname != "app.terraform.io" { - t.Fatalf("hostname = %q", cfg.Hostname) - } - if cfg.Token != "user-token" { - t.Fatalf("token = %q", cfg.Token) - } - if cfg.DefaultOrganization != "local-org" { - t.Fatalf("default organization = %q", cfg.DefaultOrganization) - } - if got := cfg.DefaultHeaders.Get("User-Agent"); !strings.HasPrefix(got, "tfcloud CLI ") { - t.Fatalf("user-agent = %q", got) - } -} - -func TestLoadUsesLocalDefaultHostnameWhenNotExplicit(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - token = "user-token" -}`) - env.writeLocalConfig(`profile "default" "app.eu.terraform.io" { - token = "local-token" - organization = "local-org" -}`) - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Hostname != "app.eu.terraform.io" { - t.Fatalf("hostname = %q", cfg.Hostname) - } - if cfg.Token != "local-token" { - t.Fatalf("token = %q", cfg.Token) - } - if cfg.DefaultOrganization != "local-org" { - t.Fatalf("default organization = %q", cfg.DefaultOrganization) - } -} - -func TestLoadFallsBackToCredentialsWhenHCLTokenMissing(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - organization = "from-hcl" -}`) - env.writeCredentials(`{"credentials":{"app.terraform.io":{"token":"cred-token"}}}`) - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Token != "cred-token" { - t.Fatalf("token = %q", cfg.Token) - } - if cfg.DefaultOrganization != "from-hcl" { - t.Fatalf("default organization = %q", cfg.DefaultOrganization) - } -} - -func TestLoadEnvOverridesCredentialsAndHCLToken(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - token = "user-token" -}`) - env.writeCredentials(`{"credentials":{"app.terraform.io":{"token":"cred-token"}}}`) - t.Setenv("TFCLOUD_TOKEN", "env-token") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Token != "env-token" { - t.Fatalf("token = %q", cfg.Token) - } -} - -func TestLoadIgnoresNamedProfileEnvTokenWhenDefaultProfileIsActive(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - organization = "ops" -}`) - t.Setenv("TFCLOUD_TOKEN_work", "env-token") - - _, err := Load() - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "missing token") { - t.Fatalf("error = %v", err) - } -} - -func TestLoadDefaultsHostnameWithoutConfig(t *testing.T) { - env := newConfigTestEnv(t) - t.Setenv("TFCLOUD_TOKEN", "env-token") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Hostname != DefaultHostname { - t.Fatalf("hostname = %q", cfg.Hostname) - } - if cfg.Token != "env-token" { - t.Fatalf("token = %q", cfg.Token) - } - _ = env - if cfg.DefaultOrganization != "" { - t.Fatalf("default organization = %q", cfg.DefaultOrganization) - } -} - -func TestLoadFallsBackToLegacyHostnameEnvVar(t *testing.T) { - env := newConfigTestEnv(t) - t.Setenv("TF_TOKEN_APP_TERRAFORM_IO", "legacy-token") - - cfg, err := Load() - if err != nil { - t.Fatal(err) - } - if cfg.Token != "legacy-token" { - t.Fatalf("token = %q", cfg.Token) - } - _ = env -} - -func TestLoadRejectsMalformedHCL(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" {`) - - _, err := Load() - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "parse") { - t.Fatalf("error = %v", err) - } -} - -func TestLoadRejectsUnsupportedProfileName(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "bad-name" "app.terraform.io" { - token = "token" -}`) - - _, err := Load() - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "invalid profile name") { - t.Fatalf("error = %v", err) - } -} - -func TestLoadErrorsWhenTokenStillMissing(t *testing.T) { - env := newConfigTestEnv(t) - env.writeUserConfig(`profile "default" "app.terraform.io" { - organization = "bcroft" -}`) - - _, err := Load() - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "missing token") { - t.Fatalf("error = %v", err) - } -} - -type configTestEnv struct { - t *testing.T - root string - home string - userConfigDir string - originalWD string -} - -func newConfigTestEnv(t *testing.T) *configTestEnv { - t.Helper() - - root := t.TempDir() - home := filepath.Join(root, "home") - workingDir := filepath.Join(home, "work") - xdgConfigHome := filepath.Join(home, ".config") - for _, dir := range []string{home, workingDir} { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - } - - originalWD, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - if err := os.Chdir(workingDir); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = os.Chdir(originalWD) - }) - - t.Logf("XDG_CONFIG_HOME = %q", os.Getenv("XDG_CONFIG_HOME")) - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) - - userConfigDir, err := userConfigDir() - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(userConfigDir, 0o755); err != nil { - t.Fatal(err) - } - - return &configTestEnv{t: t, root: root, home: home, userConfigDir: userConfigDir, originalWD: originalWD} -} - -func (e *configTestEnv) writeUserConfig(body string) { - e.t.Helper() - path := filepath.Join(e.userConfigDir, "tfcloud", "tfcloud.hcl") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - e.t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - e.t.Fatal(err) - } -} - -func (e *configTestEnv) writeLocalConfig(body string) { - e.t.Helper() - if err := os.WriteFile(".tfcloud.hcl", []byte(body), 0o600); err != nil { - e.t.Fatal(err) - } -} - -func (e *configTestEnv) writeCredentials(body string) { - e.t.Helper() - path := filepath.Join(e.home, ".terraform.d", "credentials.tfrc.json") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - e.t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - e.t.Fatal(err) - } -} diff --git a/internal/config/version.go b/internal/config/version.go index 37fefd8..2713e85 100644 --- a/internal/config/version.go +++ b/internal/config/version.go @@ -1,12 +1,14 @@ // Copyright IBM Corp. 2020, 2026 +// Package config contains runtime configuration related code for the CLI, such as +// version information. package config import ( "fmt" "time" - "github.com/Masterminds/semver/v3" + goversion "github.com/hashicorp/go-version" ) // Name is the application name used throughout the CLI. @@ -59,7 +61,7 @@ func mustParseTime(ts string) time.Time { // version, we will ensure it is prefixed with a `v`. If not, we will leave the // version as is and return it. func publicVersion(v string) string { - sv, err := semver.StrictNewVersion(v) + sv, err := goversion.NewSemver(v) if err != nil { return v } diff --git a/internal/client/client.go b/internal/pkg/client/client.go similarity index 76% rename from internal/client/client.go rename to internal/pkg/client/client.go index 57fb5ad..0b738c5 100644 --- a/internal/client/client.go +++ b/internal/pkg/client/client.go @@ -3,15 +3,17 @@ package client import ( "context" + "encoding/json" "fmt" "io" "net/http" "net/url" "strings" - "github.com/brandonc/tfcloud/internal/config" tfe "github.com/hashicorp/go-tfe" abs "github.com/microsoft/kiota-abstractions-go" + + "github.com/hashicorp/tfcloud/internal/pkg/profile" ) // Client wraps the configured HCP Terraform API clients and request helpers. @@ -53,11 +55,11 @@ type Response struct { } // New constructs a configured API client from CLI configuration. -func New(cfg *config.Config) (*Client, error) { +func New(p *profile.Profile, defaultHeaders http.Header) (*Client, error) { tfeClient, err := tfe.NewClient(&tfe.Config{ - Address: fmt.Sprintf("https://%s", cfg.Hostname), - Token: cfg.Token, - Headers: cfg.DefaultHeaders, + Address: fmt.Sprintf("https://%s", p.Hostname), + Token: p.Token, + Headers: defaultHeaders, }) if err != nil { return nil, err @@ -75,7 +77,7 @@ func New(cfg *config.Config) (*Client, error) { HTTP: native.Client, Adapter: adapter, BaseURL: &baseURL, - DefaultHeaders: cfg.DefaultHeaders, + DefaultHeaders: defaultHeaders, }, nil } @@ -159,3 +161,36 @@ func httpMethod(method string) abs.HttpMethod { return abs.GET } } + +// SummarizeAPIErrors attempts to extract meaningful error messages from typical API error responses. +func SummarizeAPIErrors(body []byte) string { + var payload struct { + Errors []struct { + Status string `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + if len(payload.Errors) > 0 { + parts := make([]string, 0, len(payload.Errors)) + for _, item := range payload.Errors { + if item.Detail != "" { + parts = append(parts, strings.TrimSpace(item.Title+": "+item.Detail)) + continue + } + if item.Title != "" { + parts = append(parts, item.Title) + } + } + return strings.Join(parts, ", ") + } + if payload.Message != "" { + return payload.Message + } + return payload.Error +} diff --git a/internal/pkg/cmd/args.go b/internal/pkg/cmd/args.go new file mode 100644 index 0000000..af69fee --- /dev/null +++ b/internal/pkg/cmd/args.go @@ -0,0 +1,65 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import "fmt" + +// ValidateArgsFunc is a function used to validate a command has received valid +// arguments. +type ValidateArgsFunc func(c *Command, args []string) error + +// NoArgs is a ValidateArgsFunc that validates that no arguments are received. +func NoArgs(_ *Command, args []string) error { + if l := len(args); l > 0 { + return fmt.Errorf("no arguments allowed, but received %d", l) + } + + return nil +} + +// ArbitraryArgs never returns an error and is used to bypass argument +// validation at the command level. +func ArbitraryArgs(_ *Command, _ []string) error { + return nil +} + +// MinimumNArgs returns an error if there is not at least N (inclusive) args. +func MinimumNArgs(n int) ValidateArgsFunc { + return func(_ *Command, args []string) error { + if len(args) < n { + return fmt.Errorf("requires at least %d arg(s), only received %d", n, len(args)) + } + return nil + } +} + +// MaximumNArgs returns an error if there are more than N (inclusive) args. +func MaximumNArgs(n int) ValidateArgsFunc { + return func(_ *Command, args []string) error { + if len(args) > n { + return fmt.Errorf("accepts at most %d arg(s), received %d", n, len(args)) + } + return nil + } +} + +// ExactArgs returns an error if there are not exactly N args. +func ExactArgs(n int) ValidateArgsFunc { + return func(_ *Command, args []string) error { + if len(args) != n { + return fmt.Errorf("accepts %d arg(s), received %d", n, len(args)) + } + return nil + } +} + +// RangeArgs returns an error if the number of args is not within the expected range. +func RangeArgs(minimum int, maximum int) ValidateArgsFunc { + return func(_ *Command, args []string) error { + if len(args) < minimum || len(args) > maximum { + return fmt.Errorf("accepts between %d and %d arg(s), received %d", minimum, maximum, len(args)) + } + return nil + } +} diff --git a/internal/pkg/cmd/args_test.go b/internal/pkg/cmd/args_test.go new file mode 100644 index 0000000..d3fb2cf --- /dev/null +++ b/internal/pkg/cmd/args_test.go @@ -0,0 +1,267 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "testing" + + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestPositionalArgs_validateFunc(t *testing.T) { + t.Parallel() + + argValidationCommand := func(io iostreams.IOStreams, f ValidateArgsFunc) *Command { + return &Command{ + Name: "testing", + ShortHelp: "testing", + RunF: func(c *Command, args []string) error { + return nil + }, + NoAuthRequired: true, + Args: PositionalArguments{Validate: f}, + io: io, + logger: hclog.NewNullLogger(), + } + } + + cases := []struct { + Name string + ValidateF ValidateArgsFunc + Args []string + Error string + }{ + { + Name: "default to no args", + Args: []string{"bad"}, + Error: "no arguments allowed, but received 1\n\nUsage: testing", + }, + { + Name: "explicit no args - bad", + ValidateF: NoArgs, + Args: []string{"bad", "bad"}, + Error: "no arguments allowed, but received 2\n\nUsage: testing", + }, + { + Name: "explicit no args - good", + ValidateF: NoArgs, + Args: []string{}, + }, + { + Name: "arbitrary args", + ValidateF: ArbitraryArgs, + Args: []string{"a", "b", "c", "d", "e"}, + }, + { + Name: "minimum args - bad", + ValidateF: MinimumNArgs(3), + Args: []string{"bad", "bad"}, + Error: "requires at least 3 arg(s), only received 2\n\nUsage: testing", + }, + { + Name: "minimum args - good", + ValidateF: MinimumNArgs(3), + Args: []string{"good", "good", "good", "good"}, + }, + { + Name: "maximum args - bad", + ValidateF: MaximumNArgs(3), + Args: []string{"bad", "bad"}, + }, + { + Name: "maximum args - good", + ValidateF: MaximumNArgs(3), + Args: []string{"good", "good", "good", "good"}, + Error: "accepts at most 3 arg(s), received 4\n\nUsage: testing", + }, + { + Name: "exact args - bad", + ValidateF: ExactArgs(3), + Args: []string{"bad", "bad"}, + Error: "accepts 3 arg(s), received 2\n\nUsage: testing", + }, + { + Name: "exact args - good", + ValidateF: ExactArgs(4), + Args: []string{"good", "good", "good", "good"}, + }, + { + Name: "range args - bad low", + ValidateF: RangeArgs(2, 4), + Args: []string{"bad"}, + Error: "accepts between 2 and 4 arg(s), received 1\n\nUsage: testing", + }, + { + Name: "range args - bad high", + ValidateF: RangeArgs(2, 4), + Args: []string{"1", "2", "3", "4", "5"}, + Error: "accepts between 2 and 4 arg(s), received 5\n\nUsage: testing", + }, + { + Name: "range args - good low inclusive", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good"}, + }, + { + Name: "range args - good middle", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good", "good"}, + }, + { + Name: "range args - good high inclusive", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good", "good", "good"}, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + command := argValidationCommand(io, c.ValidateF) + code := command.Run(c.Args) + if c.Error == "" { + r.Zero(code, io.Error.String()) + return + } + + // Expect an error + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + }) + } +} + +func TestCommand_ArgsValidation(t *testing.T) { + t.Parallel() + + argValidationCommand := func(io iostreams.IOStreams, f ValidateArgsFunc) *Command { + return &Command{ + Name: "testing", + ShortHelp: "testing", + RunF: func(c *Command, args []string) error { + return nil + }, + NoAuthRequired: true, + Args: PositionalArguments{Validate: f}, + io: io, + logger: hclog.NewNullLogger(), + } + } + + cases := []struct { + Name string + ValidateF ValidateArgsFunc + Args []string + Error string + }{ + { + Name: "default to no args", + Args: []string{"bad"}, + Error: "no arguments allowed, but received 1\n\nUsage: testing", + }, + { + Name: "explicit no args - bad", + ValidateF: NoArgs, + Args: []string{"bad", "bad"}, + Error: "no arguments allowed, but received 2\n\nUsage: testing", + }, + { + Name: "explicit no args - good", + ValidateF: NoArgs, + Args: []string{}, + }, + { + Name: "arbitrary args", + ValidateF: ArbitraryArgs, + Args: []string{"a", "b", "c", "d", "e"}, + }, + { + Name: "minimum args - bad", + ValidateF: MinimumNArgs(3), + Args: []string{"bad", "bad"}, + Error: "requires at least 3 arg(s), only received 2\n\nUsage: testing", + }, + { + Name: "minimum args - good", + ValidateF: MinimumNArgs(3), + Args: []string{"good", "good", "good", "good"}, + }, + { + Name: "maximum args - bad", + ValidateF: MaximumNArgs(3), + Args: []string{"bad", "bad"}, + }, + { + Name: "maximum args - good", + ValidateF: MaximumNArgs(3), + Args: []string{"good", "good", "good", "good"}, + Error: "accepts at most 3 arg(s), received 4\n\nUsage: testing", + }, + { + Name: "exact args - bad", + ValidateF: ExactArgs(3), + Args: []string{"bad", "bad"}, + Error: "accepts 3 arg(s), received 2\n\nUsage: testing", + }, + { + Name: "exact args - good", + ValidateF: ExactArgs(4), + Args: []string{"good", "good", "good", "good"}, + }, + { + Name: "range args - bad low", + ValidateF: RangeArgs(2, 4), + Args: []string{"bad"}, + Error: "accepts between 2 and 4 arg(s), received 1\n\nUsage: testing", + }, + { + Name: "range args - bad high", + ValidateF: RangeArgs(2, 4), + Args: []string{"1", "2", "3", "4", "5"}, + Error: "accepts between 2 and 4 arg(s), received 5\n\nUsage: testing", + }, + { + Name: "range args - good low inclusive", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good"}, + }, + { + Name: "range args - good middle", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good", "good"}, + }, + { + Name: "range args - good high inclusive", + ValidateF: RangeArgs(2, 4), + Args: []string{"good", "good", "good", "good"}, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + command := argValidationCommand(io, c.ValidateF) + code := command.Run(c.Args) + if c.Error == "" { + r.Zero(code, io.Error.String()) + return + } + + // Expect an error + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + }) + } +} diff --git a/internal/pkg/cmd/command.go b/internal/pkg/cmd/command.go new file mode 100644 index 0000000..84aec76 --- /dev/null +++ b/internal/pkg/cmd/command.go @@ -0,0 +1,324 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package cmd provides the structures and functions for constructing commands. +package cmd + +import ( + "errors" + "fmt" + "time" + + "github.com/hashicorp/go-hclog" + "github.com/posener/complete" + flag "github.com/spf13/pflag" + + "github.com/hashicorp/tfcloud/internal/config" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +var ( + // ErrDisplayHelp can be returned from a run command to print the help text. + ErrDisplayHelp = errors.New("help") + + // ErrDisplayUsage can be returned from a run command to print the usage text. + ErrDisplayUsage = errors.New("usage") +) + +// Command is used to construct a command. +// +// To create a command that should not be invoked itself but is only used to +// nest sub-commands, construct the Command without a Run function and call +// AddChild to add the child commands. +type Command struct { + // ____ _ _ _ + // | _ \ ___ ___ _ _ _ __ ___ ___ _ __ | |_ __ _| |_(_) ___ _ __ + // | | | |/ _ \ / __| | | | '_ ` _ \ / _ \ '_ \| __/ _` | __| |/ _ \| '_ \ + // | |_| | (_) | (__| |_| | | | | | | __/ | | | || (_| | |_| | (_) | | | | + // |____/ \___/ \___|\__,_|_| |_| |_|\___|_| |_|\__\__,_|\__|_|\___/|_| |_| + + // Name is the name of the command. + Name string + + // Aliases is an array of aliases that can be used instead of the first word + // in Usage. + Aliases []string + + // ShortHelp is the short description shown when listing subcommands or when + // the command is incorrectly invoked. + ShortHelp string + + // LongHelp is the long message shown in the ' --help' output. + LongHelp string + + // Examples is a set of examples of how to use the command. + Examples []Example + + // AdditionalDocs allows adding additional documentation sections. + AdditionalDocs []DocSection + + // ____ _____ + // | _ \ _ _ _ __ | ___| _ _ __ ___ ___ + // | |_) | | | | '_ \ | |_ | | | | '_ \ / __/ __| + // | _ <| |_| | | | | | _|| |_| | | | | (__\__ \ + // |_| \_\\__,_|_| |_| |_| \__,_|_| |_|\___|___/ + + // PersistentPreRun is the set of functions to run for this command and all + // subcommands. + PersistentPreRun func(c *Command, args []string) error + + // RunF is the function that will be run when the command is invoked. It may + // be nil if the command contains children. + RunF func(c *Command, args []string) error + + // ____ __ _ + // / ___|___ _ __ / _(_) __ _ + // | | / _ \| '_ \| |_| |/ _` | + // | |__| (_) | | | | _| | (_| | + // \____\___/|_| |_|_| |_|\__, | + // |___/ + + // NoAuthRequired allows a command to indicate that authentication is not + // required to be invoked. + NoAuthRequired bool + + // Args documents the expected positional arguments. + Args PositionalArguments + + // Flags is the set of flags for this command. + Flags Flags + + // ___ _ _ + // |_ _|_ __ | |_ ___ _ __ _ __ __ _| | + // | || '_ \| __/ _ \ '__| '_ \ / _` | | + // | || | | | || __/ | | | | | (_| | | + // |___|_| |_|\__\___|_| |_| |_|\__,_|_| + // + // ASCII generated with `figlet` + + // parent stores the reference to the parent command + parent *Command + + // children stores child commands. + children []*Command + + // allCommandFlags is full set of flags that apply to this command. It should be + // accessed via the allFlags() method. + allCommandFlags *flag.FlagSet + + // pflags contains persistent flags. It should be accessed via the + // persistentFlags() method. + pflags *flag.FlagSet + + // parentPflags is all inherited persistent flags. It should be accessed via + // the parentPersistentFlags() method. + parentPflags *flag.FlagSet + + // io formats output + io iostreams.IOStreams + + // logger is the logger for this command + logger hclog.Logger +} + +// Example is an example of how to use a given command. +type Example struct { + // Preamble is plaintext displayed before the command. Must be set, start + // with a captital letter, and end with a colon. + Preamble string + + // Command is the command example and any output it may contain + Command string +} + +// PositionalArguments documents a positional argument in a command. +type PositionalArguments struct { + // Preamble allows injecting documentation before individual positional + // arguments. It is optional. + Preamble string + + // Args in an inorder list of arguments. + Args []PositionalArgument + + // Validate if set is invoked to validate the command has received an + // expected set of arguments. If not set, it will be defaulted based on the + // passed Args. If no args are set, NoArgs will be enforced. If all + // arguments are not repeated, ExactArgs will be used, otherwise, + // MinimumNArgs will be set. To bypass argument validation, set this to + // ArbitraryArgs. + Validate ValidateArgsFunc + + // Autocomplete allows configuring autocompletion of arguments. + Autocomplete complete.Predictor +} + +// PositionalArgument documents a positional argument. +type PositionalArgument struct { + // Name is the name of the positional argument + Name string + + // Preamble is plaintext displayed before the command + Documentation string + + // Optional marks the argument as optional. If set, the argument must be the + // last argument or all positional arguments following this must be optional + // as well. + Optional bool + + // Repeatable marks whether the positional argument can be repeated. Only + // the last argument is repeatable. + Repeatable bool +} + +// Flags is the set of flags for a command. It includes both local flags (specific to the command) +// and persistent flags (inherited by child commands). +type Flags struct { + // Local is the set of flags for this command. + Local []*Flag + + // Persistent is the set of flags that exist for this command and all + // its children. + Persistent []*Flag +} + +// Flag instantiates a flag. An example flag is: +// +// Flag{ +// Name: "project", +// Shorthand: "p", +// DisplayValue: "ID", +// Description: "project sets the project ID to target.", +// Value: flagvalue.Simple("", &projectID), +// Required: true, +// } +type Flag struct { + // Name is the name of the flag. The name must be lower case. + Name string + + // Shorthand is an optional shorthand for the flag. Name must still be set + // and shorthand can only be a single, lowercase character. + Shorthand string + + // Description is the description of the flag. + Description string + + // DisplayValue is an optional string that will be used when displaying + // help for using the flag. If set, the displayed value will be + // --Name=DISPLAY_NAME, otherwise it will just be --Name. + // + // As an example, a Flag with the name "project" and display value of "ID", + // would be displayed as "--project=ID". + // + // DisplayValue must be upper case. + DisplayValue string + + // Value is the value that will be set by the flag. The value should be set + // using flagvalue package. + // + // Examples are: + // + // flagvalue.Simple("", &destination) + // flagvalue.Enum[string]([]string{"ONE", "TWO"}, "", &myEnum) + Value flagvalue.Value + + // IsBooleanFlag indicates that the flag is a boolean flag. + IsBooleanFlag bool + + // InvertBooleanNoValue treats the boolean flag as being specified to equal + // the value false. This should be set if the flag indicates the disabling + // of a value (e.g. --no-replication). + InvertBooleanNoValue bool + + // Repeatable marks whether the positional argument can be repeated. + Repeatable bool + + // Required marks whether the flag is required. + Required bool + + // Hidden hides the flag. + Hidden bool + + // Autocomplete is the predictor for this flag. + Autocomplete complete.Predictor + + // global marks the flag as global. + global bool +} + +// DocSection allows adding additional documentation sections. +type DocSection struct { + // The title of the section. + Title string + + // Section documentation. No additional formatting will be applied other + // than indenting. + Documentation string +} + +// AddChild is used to add a child command. +func (c *Command) AddChild(cmd *Command) { + cmd.parent = c + c.children = append(c.children, cmd) +} + +// SetIO sets the commands IO for input and output. +func (c *Command) SetIO(io iostreams.IOStreams) { + c.io = io +} + +// Logger returns a logger named according to the command. +func (c *Command) Logger() hclog.Logger { + if c.logger != nil { + return c.logger + } + + if c.parent != nil { + pl := c.parent.Logger() + c.logger = pl.Named(c.Name) + return c.logger + } + + // Create the logger + io := c.getIO() + logOpt := &hclog.LoggerOptions{ + Name: config.Name, + Level: hclog.Warn, + Output: io.Err(), + TimeFn: time.Now, + Color: hclog.ColorOff, + } + if io.ColorEnabled() { + logOpt.Color = hclog.ForceColor + logOpt.ColorHeaderAndFields = true + } + + c.logger = hclog.New(logOpt) + return c.logger +} + +// ExitCodeError is an error that includes an exit code. If returned by a +// command run, the command will exit using the specified exit code. +type ExitCodeError struct { + Err error + Code int +} + +// NewExitError returns an ExitCodeError. This can be returned to have the +// command exit code be set to a specific value. +func NewExitError(code int, wrapErr error) error { + return &ExitCodeError{ + Err: wrapErr, + Code: code, + } +} + +func (e *ExitCodeError) Error() string { + if e.Err != nil { + return fmt.Sprintf("exit code %d: %v", e.Code, e.Err) + } + + return fmt.Sprintf("exit code %d", e.Code) +} + +func (e *ExitCodeError) Unwrap() error { return e.Err } diff --git a/internal/pkg/cmd/command_internal.go b/internal/pkg/cmd/command_internal.go new file mode 100644 index 0000000..7c7a59b --- /dev/null +++ b/internal/pkg/cmd/command_internal.go @@ -0,0 +1,924 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "os" + "slices" + "strconv" + "strings" + + "github.com/go-openapi/runtime" + "github.com/hashicorp/cli" + "github.com/muesli/reflow/indent" + "github.com/muesli/reflow/wordwrap" + "github.com/posener/complete" + "github.com/spf13/pflag" + + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/ld" +) + +// Run runs the given command. +func (c *Command) Run(args []string) int { + if c.RunF == nil { + if len(c.children) != 0 { + return cli.RunResultHelp + } + + fmt.Println("Command has no run function or children. This is an invalid command") + return 1 + } + + // Get the colorscheme + io := c.getIO() + cs := c.getIO().ColorScheme() + + // Parse the flags + if err := c.parseFlags(args); err != nil { + fmt.Fprintf(io.Err(), "%s %s\n", cs.ErrorLabel(), err) + fmt.Fprintln(io.Err()) + fmt.Fprint(io.Err(), c.usageHelp()) + return 1 + } + + // Ensure all required flags have been set. + var requiredFlags []string + c.allFlags().VisitAll(func(f *pflag.Flag) { + required := isFlagRequired(f.Annotations) + if required && !f.Changed { + requiredFlags = append(requiredFlags, flagString(f)) + } + }) + + if len(requiredFlags) != 0 { + plural := "" + if len(requiredFlags) > 1 { + plural = "s" + } + requiredErr := wordWrap(fmt.Sprintf("missing required flag%s: %s\n", + plural, strings.Join(requiredFlags, ", ")), 80) + requiredErr = strings.TrimSpace(indent.String(requiredErr, 2)) + + fmt.Fprintf(io.Err(), "%s %s\n", cs.ErrorLabel(), requiredErr) + fmt.Fprintln(io.Err()) + fmt.Fprint(io.Err(), c.usageHelp()) + return 1 + } + + // Capture the args after parsing flags + parsedArgs := c.allFlags().Args() + + // Run the prerun functions starting from the root parent and working down. + prerunFuncs := []func(c *Command, args []string) error{} + for cc := c; cc != nil; cc = cc.parent { + if f := cc.PersistentPreRun; f != nil { + prerunFuncs = append(prerunFuncs, f) + } + } + slices.Reverse(prerunFuncs) + for _, f := range prerunFuncs { + if err := f(c, parsedArgs); err != nil { + fmt.Fprintln(io.Err(), err) + return 1 + } + } + + // Validate our arguments. + if err := c.Args.validateFunc()(c, parsedArgs); err != nil { + fmt.Fprintf(io.Err(), "%s %s\n", cs.ErrorLabel(), err) + fmt.Fprintln(io.Err()) + fmt.Fprint(io.Err(), c.usageHelp()) + return 1 + } + + // Run the command + if err := c.RunF(c, parsedArgs); err != nil { + exitCode := 1 + var runtimeErr runtime.ClientResponseStatus + var exitCodeErr *ExitCodeError + if errors.Is(err, ErrDisplayHelp) { + return cli.RunResultHelp + } else if errors.Is(err, ErrDisplayUsage) { + fmt.Fprint(io.Err(), c.usageHelp()) + return 1 + } else if errors.As(err, &runtimeErr) && runtimeErr.IsCode(http.StatusUnauthorized) { + // TODO: This runtimeErr is inaccurate for HCPTF + // Request failed because of authentication issues. + fmt.Fprintf(io.Err(), "%s %s\n\n", cs.ErrorLabel(), authErrorHelp(io, c.commandPath(), args)) + return 1 + } else if errors.As(err, &exitCodeErr) { + exitCode = exitCodeErr.Code + } + + fmt.Fprintf(io.Err(), "%s %s\n", cs.ErrorLabel(), wordWrap(err.Error(), 120)) + return exitCode + } + + return 0 +} + +// authErrorHelp returns a help message for recovering from authentication errors. +func authErrorHelp(io iostreams.IOStreams, commandPath string, args []string) string { + // Build the original command + command := "$ " + commandPath + for _, a := range args { + // If there are spaces in the argument, quote it. + if strings.Contains(a, " ") { + command += fmt.Sprintf(" %s", strconv.Quote(a)) + } else { + command += fmt.Sprintf(" %s", a) + } + } + + // Quote the entire command so we can inject it into the template as a + // variable. + command = strconv.Quote(command) + + // Render the help message to logout, login, and re-run the command. + return heredoc.New(io, heredoc.WithPreserveNewlines(), heredoc.WithWidth(0)).Mustf(` + Unauthorized request. Re-attempt by first logging out and back in, and then re-run the command. + + {{ Bold "$ tfcloud auth logout" }} + {{ Bold "$ tfcloud auth login" }} + {{ with $cmd := %s }}{{ Bold $cmd }}{{ end }} + `, command) +} + +// helpEntry is used to structure help output with titles. +type helpEntry struct { + Title string + Body string +} + +// help prints the long help output for the command. If the command is invoked +// because no valid child command could be matched, a command suggestion is +// made. +func (c *Command) help() string { + // If we have an invalid command, print a suggestion and the usage help. + var buf bytes.Buffer + if help, _ := c.allFlags().GetBool("help"); !help && c.RunF == nil { + invalid := "" + commands := strings.Split(c.commandPath(), " ") + for i, arg := range os.Args { + // Have to check the raw argument as well to handle `tfcloud -h` since + // the flags will not have been parsed. + if arg == "-h" || arg == "--help" { + break + } + + if i >= len(commands) { + invalid = arg + break + } + } + + if invalid != "" { + c.nestedSuggestFunc(&buf, invalid) + _, _ = buf.WriteString(c.usageHelp()) + return buf.String() + } + } + + // Add the command name + cs := c.getIO().ColorScheme() + helpEntries := []helpEntry{} + + // Add the command usage + helpEntries = append(helpEntries, helpEntry{"USAGE", c.useLine()}) + + // Add the description + helpEntries = append(helpEntries, helpEntry{"DESCRIPTION", wordWrap(c.LongHelp, 80)}) + + // Print any available aliases + if len(c.Aliases) > 0 { + usages := c.aliasUsages() + var aliases []string + for a, u := range usages { + aliases = append(aliases, fmt.Sprintf("%s - %s", a, u)) + } + + helpEntries = append(helpEntries, helpEntry{"ALIASES", strings.Join(aliases, "\n")}) + } + + commandHelp := func(group bool) { + // Determine the minimum padding + maxLength := 0 + for _, c := range c.children { + if (group && c.RunF != nil) || (!group && c.RunF == nil) { + continue + } + + maxLength = max(maxLength, len(c.Name)) + } + + namePadding := maxLength + 2 + var names []string + for _, c := range c.children { + if (group && c.RunF != nil) || (!group && c.RunF == nil) { + continue + } + + names = append(names, rpad(c.Name+":", namePadding)+c.ShortHelp) + } + + slices.Sort(names) + if len(names) == 0 { + return + } + + title := "COMMANDS" + if group { + title = "COMMAND GROUPS" + } + + helpEntries = append(helpEntries, helpEntry{ + Title: title, + Body: strings.Join(names, "\n"), + }) + } + + // If we have children, display the available commands + if len(c.children) != 0 { + commandHelp(true) + commandHelp(false) + } + + // Print the examples + if len(c.Examples) != 0 { + var buf bytes.Buffer + for _, e := range c.Examples { + fmt.Fprintln(&buf, e.text(cs)) + } + + helpEntries = append(helpEntries, helpEntry{"EXAMPLES", buf.String()}) + } + + if args := c.Args.text(cs); args != "" { + helpEntries = append(helpEntries, helpEntry{"POSITIONAL ARGUMENTS", args}) + } + + // Print flags only if the command is runnable + helpEntries = append(helpEntries, c.flagsHelpEntry()...) + + // Add any additional documentation provided + for _, d := range c.AdditionalDocs { + helpEntries = append(helpEntries, helpEntry{strings.ToUpper(d.Title), d.Documentation}) + } + + for i, e := range helpEntries { + if e.Title != "" { + // If there is a title, add indentation to each line in the body + fmt.Fprintln(&buf, cs.String(e.Title).Bold()) + fmt.Fprintln(&buf, indent.String(strings.Trim(e.Body, "\r\n"), 2)) + } else { + // If there is no title print the body as is + fmt.Fprintln(&buf, e.Body) + } + + if i != len(helpEntries)-1 { + fmt.Fprintln(&buf) + } + } + + return buf.String() +} + +// flagsHelpEntry returns help entries for the command's flags. +func (c *Command) flagsHelpEntry() []helpEntry { + var helpEntries []helpEntry + + // If we are the root command, just print global flags. + if c.parent == nil && c.RunF == nil { + helpEntries = append(helpEntries, helpEntry{ + Title: "GLOBAL FLAGS", + Body: flagsetUsage(c.globalFlags()), + }) + return helpEntries + } + + // Print flags only if the command is runnable + if c.RunF == nil { + return nil + } + + flagSets := []struct { + flags *pflag.FlagSet + name string + }{ + { + flags: c.localFlags(), + name: "", + }, + { + flags: c.inheritedFlags(), + name: "INHERITED ", + }, + } + + for _, set := range flagSets { + required, optional := splitRequiredFlags(set.flags) + if required.HasFlags() { + flagUsages := flagsetUsage(required) + helpEntries = append(helpEntries, helpEntry{ + Title: fmt.Sprintf("REQUIRED %sFLAGS", set.name), + Body: flagUsages, + }) + + if optional.HasFlags() { + flagUsages := flagsetUsage(optional) + helpEntries = append(helpEntries, helpEntry{ + Title: fmt.Sprintf("OPTIONAL %sFLAGS", set.name), + Body: flagUsages, + }) + } + } else if optional.HasFlags() { + flagUsages := flagsetUsage(optional) + helpEntries = append(helpEntries, helpEntry{ + Title: fmt.Sprintf("%sFLAGS", set.name), + Body: flagUsages, + }) + } + } + + globalFlagUsages := flagsetUsageShort(c.globalFlags(), "For more global flag details, run $ tfcloud --help") + if globalFlagUsages != "" { + helpEntries = append(helpEntries, helpEntry{"GLOBAL FLAGS", globalFlagUsages}) + } + + return helpEntries +} + +// text returns the help text for a given example. +func (e *Example) text(cs *iostreams.ColorScheme) string { + var buf bytes.Buffer + + if e.Preamble != "" { + fmt.Fprintln(&buf, wordWrap(e.Preamble, 80)) + fmt.Fprintln(&buf) + } + if e.Command != "" { + // Use a higher limit for command wrapping since they may include + // potentially long identifiers. + fmt.Fprintln(&buf, cs.String(wordWrap(e.Command, 120)).Color(cs.Green())) + } + + return buf.String() +} + +// text returns the help text for the positional arguments. +func (p PositionalArguments) text(cs *iostreams.ColorScheme) string { + var buf bytes.Buffer + if p.Preamble != "" { + fmt.Fprintln(&buf, p.Preamble) + } + + for _, a := range p.Args { + fmt.Fprintln(&buf, a.text(cs)) + } + + return buf.String() +} + +// text returns the help text for a positional argument. +func (a PositionalArgument) text(cs *iostreams.ColorScheme) string { + var buf bytes.Buffer + + nameUpper := strings.ToUpper(a.Name) + repeatable := "" + if a.Repeatable { + repeatable = fmt.Sprintf(" [%s ...]", nameUpper) + } + fmt.Fprintf(&buf, "%s%s\n", cs.String(nameUpper).Underline(), repeatable) + if a.Optional { + fmt.Fprintln(&buf, indent.String(cs.String("Optional Argument\n").Italic().String(), 2)) + } + fmt.Fprintln(&buf, indent.String(wordWrap(a.Documentation, 80), 2)) + + return buf.String() +} + +// aliasUsages returns a map from the alias to its usage. +func (c *Command) aliasUsages() map[string]string { + aliases := make(map[string]string) + for _, a := range c.Aliases { + var useline string + if c.hasParent() { + useline = c.parent.commandPath() + " " + a + } else { + useline = a + } + if c.RunF == nil { + useline += " " + } + + aliases[a] = useline + } + + return aliases +} + +// usageHelp returns the short usage help that displays the commands usage and +// flags. +func (c *Command) usageHelp() string { + var buf bytes.Buffer + fmt.Fprintf(&buf, "Usage: %s\n", c.useLine()) + fmt.Fprintln(&buf) + + if len(c.children) != 0 { + // Determine the minimum padding + maxLength := 0 + for _, c := range c.children { + maxLength = max(maxLength, len(c.Name)) + } + + namePadding := maxLength + 2 + var names []string + for _, c := range c.children { + names = append(names, rpad(c.Name+":", namePadding)+c.ShortHelp) + } + + // Sort the names + slices.Sort(names) + + fmt.Fprintln(&buf, "Commands:") + fmt.Fprint(&buf, indent.String(strings.Join(names, "\n"), 2)) + return buf.String() + } + + required, optional := splitRequiredFlags(c.nonGlobalFlags()) + if required.HasFlags() { + fmt.Fprintln(&buf, "Required Flags:") + fmt.Fprint(&buf, indent.String(flagsetUsage(required), 2)) + + if optional.HasFlags() { + fmt.Fprintln(&buf) + fmt.Fprintln(&buf, "Optional Flags:") + fmt.Fprint(&buf, indent.String(flagsetUsage(optional), 2)) + } + } else if optional.HasFlags() { + // If all the flags are optional, group them together. + fmt.Fprintln(&buf, "Flags:") + fmt.Fprint(&buf, indent.String(flagsetUsage(optional), 2)) + } + + // Print a smaller help output for global flags. + global := c.globalFlags() + if global.HasFlags() { + fmt.Fprintln(&buf, "Global Flags:") + fmt.Fprint(&buf, indent.String(flagsetUsageShort(global, "For more global flag details, run $ tfcloud --help"), 2)) + } + + return buf.String() +} + +// Display helpful error message in case subcommand name was mistyped. +func (c *Command) nestedSuggestFunc(w io.Writer, arg string) { + fmt.Fprintf(w, "unknown command %q for %q\n", arg, c.commandPath()) + + var candidates []string + if arg == "help" { + candidates = []string{"--help"} + } else { + candidates = c.suggestionsFor(arg) + } + + if len(candidates) > 0 { + fmt.Fprint(w, "\nDid you mean this?\n") + for _, c := range candidates { + fmt.Fprintf(w, " %s\n", c) + } + } + + fmt.Fprintln(w) +} + +// suggestionsFor provides suggestions for the typedName. +func (c *Command) suggestionsFor(typedName string) []string { + options := make([]string, len(c.children)) + for i, c := range c.children { + options[i] = c.Name + } + + typedNameLower := strings.ToLower(typedName) + return ld.SuggestionsWithOverride(typedName, options, 2, true, func(_, option string) bool { + return strings.HasPrefix(strings.ToLower(option), typedNameLower) + }) +} + +// useLine puts out the full usage for a given command (including parents). +func (c *Command) useLine() string { + var useline string + if c.hasParent() { + useline = c.parent.commandPath() + " " + c.Name + } else { + useline = c.Name + } + if c.RunF == nil { + useline += " " + } + + // Add any positional arguments + cs := c.getIO().ColorScheme() + for _, a := range c.Args.Args { + name := cs.String(strings.ToUpper(a.Name)).Underline() + if !a.Optional { + if a.Repeatable { + useline += fmt.Sprintf(" %s [%s ...]", name, name) + } else { + useline += fmt.Sprintf(" %s", name) + } + } else { + if a.Repeatable { + useline += fmt.Sprintf(" [%s ...]", name) + } else { + useline += fmt.Sprintf(" [%s]", name) + } + } + } + + // Add the flags + if c.hasAvailableFlags() { + required, _ := splitRequiredFlags(c.allFlags()) + if required.HasFlags() { + required.VisitAll(func(f *pflag.Flag) { + useline += fmt.Sprintf(" %s", flagString(f)) + }) + + } + useline += " [Optional Flags]" + } + + wrapped := wordWrap(useline, 80) + indented := indent.String(wrapped, 2) + return strings.TrimSpace(indented) +} + +// commandPath returns the full path to this command. +func (c *Command) commandPath() string { + if c.hasParent() { + return c.getParent().commandPath() + " " + c.Name + } + return c.Name +} + +// buildFlags converts the flags from the cmd.Flag format to a pflag.FlagSet. +func (c *Command) buildFlags() { + // We have already built the flags. + if c.allCommandFlags != nil { + return + } + + // Instantiate the various flag sets + c.allCommandFlags = pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + c.allCommandFlags.SetOutput(c.getIO().Err()) + c.pflags = pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + c.pflags.SetOutput(c.getIO().Err()) + c.parentPflags = pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + c.parentPflags.SetOutput(c.getIO().Err()) + + // Convert the flags to a pflag and add them to the correct set + for _, f := range c.Flags.Local { + c.allCommandFlags.AddFlag(f.pflag()) + } + for _, f := range c.Flags.Persistent { + p := f.pflag() + c.allCommandFlags.AddFlag(p) + c.pflags.AddFlag(p) + } + + // Add all parent persistent flags + for parent := c.parent; parent != nil; parent = parent.parent { + parentPFlags := parent.persistentFlags() + c.allCommandFlags.AddFlagSet(parentPFlags) + c.parentPflags.AddFlagSet(parentPFlags) + } +} + +// pflag returns the pflag.Flag representation of the Flag. +func (f *Flag) pflag() *pflag.Flag { + a := newFlagAnnotations() + p := &pflag.Flag{ + Name: f.Name, + Shorthand: f.Shorthand, + Usage: f.Description, + Value: f.Value, + Hidden: f.Hidden, + Annotations: a, + } + + if f.IsBooleanFlag { + p.NoOptDefVal = "true" + if f.InvertBooleanNoValue { + p.NoOptDefVal = "false" + } + } + + if f.Required { + a.Required() + } + + if f.DisplayValue != "" { + a.DisplayValue(f.DisplayValue) + } + + if f.global { + a.Global() + } + + if f.Repeatable { + a.Repeatable() + } + + return p +} + +// allFlags returns the complete FlagSet that applies to this command. The flagset +// will include any persistent flag defined by parents of the given command. +func (c *Command) allFlags() *pflag.FlagSet { + c.buildFlags() + return c.allCommandFlags +} + +// persistentFlags is a flagset for defining flags that should apply to this +// command and all its children. When accessing flags defined here, prefer using +// Flags() as it will contain both flags from this flagset and local flags. +func (c *Command) persistentFlags() *pflag.FlagSet { + c.buildFlags() + return c.pflags +} + +// parentPersistentFlags returns the persistent FlagSet set by parent commands. +func (c *Command) parentPersistentFlags() *pflag.FlagSet { + c.buildFlags() + return c.parentPflags +} + +// localFlags returns all flags defined by this command. +func (c *Command) localFlags() *pflag.FlagSet { + c.buildFlags() + local := pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + local.SetOutput(c.getIO().Err()) + + addToLocal := func(f *pflag.Flag) { + // Add the flag if it is not a parent PFlag, or it shadows a parent PFlag + if local.Lookup(f.Name) == nil && f != c.parentPersistentFlags().Lookup(f.Name) { + local.AddFlag(f) + } + } + c.allFlags().VisitAll(addToLocal) + c.persistentFlags().VisitAll(addToLocal) + return local +} + +// inheritedFlags returns all inherited, non-global flags. +func (c *Command) inheritedFlags() *pflag.FlagSet { + c.buildFlags() + inherited := pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + inherited.SetOutput(c.getIO().Err()) + + addToInherited := func(f *pflag.Flag) { + if _, ok := f.Annotations[flagAnnotationGlobal]; !ok { + inherited.AddFlag(f) + } + } + c.parentPersistentFlags().VisitAll(addToInherited) + return inherited +} + +// globalFlags returns all flags marked as global. +func (c *Command) globalFlags() *pflag.FlagSet { + c.buildFlags() + global := pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + global.SetOutput(c.getIO().Err()) + + addToGlobal := func(f *pflag.Flag) { + if isFlagGlobal(f.Annotations) { + global.AddFlag(f) + } + } + c.allFlags().VisitAll(addToGlobal) + return global +} + +// nonGlobal returns all flags that apply to this command that aren't global. +func (c *Command) nonGlobalFlags() *pflag.FlagSet { + c.buildFlags() + nonglobal := pflag.NewFlagSet(c.Name, pflag.ContinueOnError) + nonglobal.SetOutput(c.getIO().Err()) + + addToNonGlobal := func(f *pflag.Flag) { + if !isFlagGlobal(f.Annotations) { + nonglobal.AddFlag(f) + } + } + c.allFlags().VisitAll(addToNonGlobal) + return nonglobal +} + +// parseFlags parses the flags from the arguments. +func (c *Command) parseFlags(args []string) error { + c.buildFlags() + if err := c.allFlags().Parse(args); err != nil { + return err + } + + return nil +} + +// hasAvailableFlags checks if the command contains any flags (local plus persistent from the entire +// structure) which are not hidden or deprecated. +func (c *Command) hasAvailableFlags() bool { + c.buildFlags() + return c.allFlags().HasAvailableFlags() +} + +// splitRequiredFlags returns two flagset, one that contains the required flags +// and the other that contains optional flags. +func splitRequiredFlags(flagset *pflag.FlagSet) (required, optional *pflag.FlagSet) { + required = pflag.NewFlagSet("tfcloud", pflag.ContinueOnError) + optional = pflag.NewFlagSet("tfcloud", pflag.ContinueOnError) + flagset.VisitAll(func(f *pflag.Flag) { + if _, ok := f.Annotations[flagAnnotationRequired]; ok { + required.AddFlag(f) + } else { + optional.AddFlag(f) + } + }) + + return required, optional +} + +// flagsetUsage returns the usage string for the given flagset. Each flag is +// described on its own line with its description below. For a more compact +// representation, use flagsetUsageShort. +func flagsetUsage(flags *pflag.FlagSet) string { + var buf bytes.Buffer + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + + longDisplay := flagString(flag) + if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { + fmt.Fprintf(&buf, "-%s, %s\n", flag.Shorthand, longDisplay) + } else { + fmt.Fprintf(&buf, "%s\n", longDisplay) + } + + // Add the usage + fmt.Fprintf(&buf, "%s\n\n", indent.String(wordWrap(flag.Usage, 80), 2)) + }) + + return buf.String() +} + +// flagsetUsageShort returns the usage string for the given flagset in a compact +// form where the description is omitted and optional suffix can be provided +// which will be printed on its own line below the flag usage. +func flagsetUsageShort(flags *pflag.FlagSet, suffix string) string { + var names []string + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + + names = append(names, flagString(flag)) + }) + + usage := fmt.Sprintf("%s\n", strings.Join(names, ", ")) + if suffix != "" { + suffix = strings.TrimSpace(suffix) + usage = fmt.Sprintf("%s\n%s\n", usage, suffix) + } + + return wordWrap(usage, 80) +} + +// flagString returns a string representation for the flag. +func flagString(f *pflag.Flag) string { + v := getFlagDisplayValue(f.Annotations) + repeatable := isFlagRepeatable(f.Annotations) + if v != "" { + if repeatable { + return fmt.Sprintf("--%s=%s [Repeatable]", f.Name, v) + } + return fmt.Sprintf("--%s=%s", f.Name, v) + } + + if repeatable { + return fmt.Sprintf("--%s [Repeatable]", f.Name) + } + + return fmt.Sprintf("--%s", f.Name) +} + +// getIO retrieves the IO configured for the command and configures it to output +// to Err even if quiet is specified. To access the raw IOStreams, use +// getRawIO. +func (c *Command) getIO() iostreams.IOStreams { + return iostreams.UseLoud(c.getRawIO()) +} + +// getRawIO gets the configured IO for the command. +func (c *Command) getRawIO() iostreams.IOStreams { + if c.io != nil { + return c.io + } + + return c.parent.getRawIO() +} + +// hasParent determines if the command is a child command. +func (c *Command) hasParent() bool { + return c.parent != nil +} + +// getParent returns a commands parent command. +func (c *Command) getParent() *Command { + return c.parent +} + +// getAutocompleteFlags builds the complete Flags, supporting both the long and +// short declerations. +func (c *Command) getAutocompleteFlags() complete.Flags { + // Get all flag predictors from this command to the root + allPredictors := make(map[string]complete.Predictor) + for c := c; c != nil; c = c.parent { + for _, flag := range c.Flags.Local { + allPredictors[flag.Name] = flag.Autocomplete + } + + for _, flag := range c.Flags.Persistent { + allPredictors[flag.Name] = flag.Autocomplete + } + } + + flagPredictors := make(map[string]complete.Predictor) + c.allFlags().VisitAll(func(f *pflag.Flag) { + p, ok := allPredictors[f.Name] + if !ok { + return + } + + flagPredictors["--"+f.Name] = p + if f.Shorthand != "" { + flagPredictors["-"+f.Shorthand] = p + } + }) + + return flagPredictors +} + +// validateFunc returns the set validation function or a default argument +// validation function based on the documented arguments. +func (p PositionalArguments) validateFunc() ValidateArgsFunc { + if p.Validate != nil { + return p.Validate + } + + numArgs := len(p.Args) + if numArgs == 0 { + return NoArgs + } + + optional := 0 + for _, a := range p.Args { + if a.Optional { + optional++ + } + + if a.Repeatable { + return MinimumNArgs(numArgs - optional) + } + } + + if optional > 0 { + return RangeArgs(numArgs-optional, numArgs) + } + + return ExactArgs(numArgs) +} + +// rpad adds padding to the right of a string. +func rpad(s string, padding int) string { + template := fmt.Sprintf("%%-%ds ", padding) + return fmt.Sprintf(template, s) +} + +// wordWrap wraps an input at the given wrap length. It uses a customized +// wordwrap.Writer that is more appropriate for splitting command line flags. +func wordWrap(input string, wrap int) string { + w := wordwrap.NewWriter(wrap) + w.Breakpoints = []rune{} + _, _ = w.Write([]byte(input)) + _ = w.Close() + return w.String() +} diff --git a/internal/pkg/cmd/command_internal_test.go b/internal/pkg/cmd/command_internal_test.go new file mode 100644 index 0000000..dc50b06 --- /dev/null +++ b/internal/pkg/cmd/command_internal_test.go @@ -0,0 +1,25 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestAuthErrorHelp(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + + commandPath := "tfcloud example" + args := []string{"simple", "'single-quote'", `escaped \"inner\"`} + + // Get the help text + helpText := authErrorHelp(io, commandPath, args) + r.Contains(helpText, `$ tfcloud example simple 'single-quote' "escaped \\\"inner\\\""`) +} diff --git a/internal/pkg/cmd/command_test.go b/internal/pkg/cmd/command_test.go new file mode 100644 index 0000000..a23d9de --- /dev/null +++ b/internal/pkg/cmd/command_test.go @@ -0,0 +1,134 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "fmt" + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestCommand_PersistentPrerun(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create the command tree + io := iostreams.Test() + root := &Command{ + Name: "root", + io: io, + } + child := &Command{ + Name: "child", + RunF: func(c *Command, args []string) error { + return nil + }, + } + childContainer := &Command{Name: "child-group"} + grandchild := &Command{ + Name: "grandchild", + RunF: func(c *Command, args []string) error { + return nil + }, + } + root.AddChild(child) + root.AddChild(childContainer) + childContainer.AddChild(grandchild) + + // Add the persistent preruns + rootPreRunCount := 0 + containerPreRunCount := 0 + root.PersistentPreRun = func(c *Command, args []string) error { + rootPreRunCount++ + return nil + } + childContainer.PersistentPreRun = func(c *Command, args []string) error { + containerPreRunCount++ + return nil + } + + // Run the grandchild and the child + r.Zero(grandchild.Run(nil)) + r.Zero(child.Run(nil)) + + // Expect the prerun commmands were called + r.Equal(2, rootPreRunCount) + r.Equal(1, containerPreRunCount) +} + +func TestCommand_Flags(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create the command tree + io := iostreams.Test() + root := &Command{ + Name: "root", + io: io, + } + rootFlag := root.persistentFlags().String("root-flag", "", "testing") + + seenFlags := 0 + child := &Command{ + Name: "child", + RunF: func(c *Command, args []string) error { + c.allFlags().VisitAll(func(_ *pflag.Flag) { + seenFlags++ + }) + return nil + }, + } + root.AddChild(child) + childFlag := child.allFlags().String("child-flag", "", "testing") + + r.Zero(child.Run([]string{"--root-flag=root-set", "--child-flag=child-set"})) + r.Equal(2, seenFlags) + r.Equal("root-set", *rootFlag) + r.Equal("child-set", *childFlag) +} + +func TestCommand_Logger(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create the command tree + io := iostreams.Test() + root := &Command{ + Name: "root", + io: io, + } + child := &Command{ + Name: "child", + RunF: func(c *Command, args []string) error { + c.Logger().Error("hello, world!") + return nil + }, + } + root.AddChild(child) + r.Zero(child.Run([]string{})) + r.Contains(io.Error.String(), "tfcloud.child: hello, world!") +} + +func TestCommand_ExitCode(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create the command tree + io := iostreams.Test() + code := 42 + err := fmt.Errorf("bad bad bad") + root := &Command{ + Name: "root", + io: io, + RunF: func(c *Command, args []string) error { + return NewExitError(code, err) + }, + } + r.Equal(code, root.Run([]string{})) + r.Contains(io.Error.String(), err.Error()) +} diff --git a/internal/pkg/cmd/compat.go b/internal/pkg/cmd/compat.go new file mode 100644 index 0000000..be5f4de --- /dev/null +++ b/internal/pkg/cmd/compat.go @@ -0,0 +1,97 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "fmt" + + "github.com/hashicorp/cli" + "github.com/posener/complete" +) + +// Ensure we meet the cli interfaces. +var _ cli.Command = &CompatibleCommand{} +var _ cli.CommandAutocomplete = &CompatibleCommand{} +var _ cli.CommandHelpTemplate = &CompatibleCommand{} + +// CompatibleCommand is a compatibility layer for interopability with the `cli` +// package. +type CompatibleCommand struct { + c *Command +} + +// HelpTemplate implements cli.CommandHelpTemplate. +func (cc *CompatibleCommand) HelpTemplate() string { + return `{{.Help}}` +} + +// AutocompleteArgs implements cli.CommandAutocomplete. +func (cc *CompatibleCommand) AutocompleteArgs() complete.Predictor { + return cc.c.Args.Autocomplete +} + +// AutocompleteFlags implements cli.CommandAutocomplete. +func (cc *CompatibleCommand) AutocompleteFlags() complete.Flags { + return cc.c.getAutocompleteFlags() +} + +// Help implements cli.Command. +func (cc *CompatibleCommand) Help() string { + return cc.c.help() +} + +// Synopsis implements cli.Command. +func (cc *CompatibleCommand) Synopsis() string { + return cc.c.ShortHelp +} + +// Run implements cli.Command. +func (cc *CompatibleCommand) Run(args []string) int { + return cc.c.Run(args) +} + +// ToCommandMap converts a Command and its children to a hashicorp/cli command +// factory map. The passed Command should be the +// root command. +func ToCommandMap(c *Command) map[string]cli.CommandFactory { + m := make(map[string]cli.CommandFactory) + for _, child := range c.children { + toCommandMap("", child, m) + } + + return m +} + +func toCommandMap(parent string, c *Command, m map[string]cli.CommandFactory) { + // allNames is the commands name and all aliases. + allNames := map[string]struct{}{c.Name: {}} + for _, a := range c.Aliases { + allNames[a] = struct{}{} + } + + for name := range allNames { + path := name + if parent != "" { + path = fmt.Sprintf("%s %s", parent, name) + } + + m[path] = func() (cli.Command, error) { + return &CompatibleCommand{ + c: c, + }, nil + } + + for _, child := range c.children { + toCommandMap(path, child, m) + } + } +} + +// RootHelpFunc returns a help function that meets the hashicorp/cli interface +// for help functions. +func RootHelpFunc(c *Command) func(map[string]cli.CommandFactory) string { + return func(map[string]cli.CommandFactory) string { + return c.help() + } +} diff --git a/internal/pkg/cmd/compat_test.go b/internal/pkg/cmd/compat_test.go new file mode 100644 index 0000000..573e987 --- /dev/null +++ b/internal/pkg/cmd/compat_test.go @@ -0,0 +1,117 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +func TestToCommandMap(t *testing.T) { + r := require.New(t) + t.Parallel() + + // Create a command tree + root := &Command{ + Name: "tfcloud", + } + c1 := &Command{ + Name: "child1", + Aliases: []string{"c1", "childOne"}, + } + c2 := &Command{ + Name: "child2", + Aliases: []string{"c2", "childTwo"}, + } + + c1n1 := &Command{ + Name: "nested1", + Aliases: []string{"n1", "nestedOne"}, + } + c1n2 := &Command{ + Name: "nested2", + Aliases: []string{"n2", "nestedTwo"}, + } + + c2n1 := &Command{ + Name: "nested1", + Aliases: []string{"n1", "nestedOne"}, + } + c2n2 := &Command{ + Name: "nested2", + } + + root.AddChild(c1) + root.AddChild(c2) + c1.AddChild(c1n1) + c1.AddChild(c1n2) + c2.AddChild(c2n1) + c2.AddChild(c2n2) + + // Build the command map + m := ToCommandMap(root) + + // Expected values + expectedCommands := []string{ + "child1", + "c1", + "childOne", + + "child2", + "c2", + "childTwo", + + "child1 nested1", + "c1 nested1", + "childOne nested1", + + "child1 n1", + "c1 n1", + "childOne n1", + + "child1 nestedOne", + "c1 nestedOne", + "childOne nestedOne", + + "child1 nested2", + "c1 nested2", + "childOne nested2", + + "child1 n2", + "c1 n2", + "childOne n2", + + "child1 nestedTwo", + "c1 nestedTwo", + "childOne nestedTwo", + + "child2 nested1", + "c2 nested1", + "childTwo nested1", + + "child2 n1", + "c2 n1", + "childTwo n1", + + "child2 nestedOne", + "c2 nestedOne", + "childTwo nestedOne", + + "child2 nested2", + "c2 nested2", + "childTwo nested2", + } + + // Sort all + slices.Sort(expectedCommands) + + actualCommands := maps.Keys(m) + slices.Sort(actualCommands) + + // Check the actual and expected match + r.Equal(expectedCommands, actualCommands, "commands") +} diff --git a/internal/pkg/cmd/context.go b/internal/pkg/cmd/context.go new file mode 100644 index 0000000..a788d01 --- /dev/null +++ b/internal/pkg/cmd/context.go @@ -0,0 +1,293 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/hashicorp/go-hclog" + "github.com/posener/complete" + + "github.com/hashicorp/tfcloud/internal/config" + "github.com/hashicorp/tfcloud/internal/pkg/client" + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +// Context passes global objects for constructing and invoking a command. +type Context struct { + // IO is used to interact directly with IO or the terminal. + IO iostreams.IOStreams + + // Output is used to print structured output. + Output *format.Outputter + + // ShutdownCtx is a context that is canceled if the user requests the + // command to be shutdown. If a command can block for an extended amount of + // time, the context should be used to exit early. + ShutdownCtx context.Context + + // flags stores our global flags. Access must go through GetGlobalFlags() + // which ensures flags are only accessed after the flags have been parsed + // from the arguments. + flags GlobalFlags + + Profile *profile.Profile + + APIClient *client.Client +} + +// GlobalFlags contains the global flags. +type GlobalFlags struct { + // parsed stores if the flags have been parsed yet + parsed bool + + // Unexported global flags. These should generally be access via other + // helpers exported in the Context. + profile string + json bool + agent bool + debug int + + // Version indicates the user has requested the version of the CLI + Version bool + + // Quiet indicates the user has requested minimal output + Quiet bool +} + +// GetGlobalFlags returns the global flags. It panics if the flags have not been +// parsed yet, which should only be the case if they are accessed outside of a run command. +func (ctx *Context) GetGlobalFlags() GlobalFlags { + if !ctx.flags.parsed { + panic("This is a programmer error. Only access global flags from within a run command. Otherwise flags haven't been parsed yet.") + } + + return ctx.flags +} + +// 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. +func ConfigureRootCommand(ctx *Context, cmd *Command) { + // Store the IO on the command, making it available to the entire tree. + cmd.io = ctx.IO + + cmd.Flags.Persistent = append(cmd.Flags.Persistent, &Flag{ + Name: "profile", + DisplayValue: "NAME", + Description: "The profile to use. If omitted, the currently selected profile will be used.", + Value: flagvalue.Simple("", &ctx.flags.profile), + global: true, + Autocomplete: complete.PredictFunc(func(_ complete.Args) []string { + l, err := profile.NewLoader() + if err != nil { + return nil + } + + profiles, err := l.ListProfiles() + if err != nil { + return nil + } + + return profiles + }), + }, &Flag{ + Name: "json", + Description: "Sets the output format.", + Value: flagvalue.Simple(false, &ctx.flags.json), + global: true, + }, &Flag{ + Name: "agent", + Description: "Sets the output format.", + Value: flagvalue.Simple(false, &ctx.flags.agent), + global: true, + }, &Flag{ + Name: "quiet", + Description: "Minimizes output and disables interactive prompting.", + Value: flagvalue.Simple(false, &ctx.flags.Quiet), + IsBooleanFlag: true, + global: true, + }, &Flag{ + Name: "debug", + Description: "Enable debug output.", + Value: flagvalue.Counter(0, &ctx.flags.debug), + IsBooleanFlag: true, + global: true, + }, &Flag{ + Name: "version", + Description: "Print the version of tfcloud CLI.", + Value: flagvalue.Simple(false, &ctx.flags.Version), + IsBooleanFlag: true, + global: true, + }) + + // Setup the pre-run command + cmd.PersistentPreRun = func(c *Command, args []string) error { + // Setup the HTTP logger. We retrieve the commands logger so the API + // logger is named with the subcommand. + // ctx.HCP.SetLogger(newAPILogger(c.Logger())) + // ctx.HCP.Debug = true + + if err := ctx.applyGlobalFlags(c); err != nil { + return err + } + + c.io = ctx.IO + + err := isAuthenticated(ctx, c, args) + if err != nil { + return err + } + + client, err := ctx.newAPIClient() + if err != nil && !c.NoAuthRequired { + return err + } + ctx.APIClient = client + return nil + } +} + +func (ctx *Context) newAPIClient() (*client.Client, error) { + apiClient, err := client.New(ctx.Profile, http.Header{ + "User-Agent": []string{fmt.Sprintf("tfcloud-cli/%s", config.Version)}, + }) + if err != nil { + return nil, err + } + return apiClient, nil +} + +// applyGlobalFlags applies the global flags. +func (ctx *Context) applyGlobalFlags(c *Command) error { + // Mark that we have parsed flags + ctx.flags.parsed = true + + // Parse the profile first + if p := ctx.flags.profile; p != "" { + l, err := profile.NewLoader() + if err != nil { + return err + } + + p, err := l.LoadProfile(ctx.flags.profile) + if err != nil { + return err + } + + *ctx.Profile = *p + } + + // Set the verbosity if the flag is set. + verbosity := ctx.Profile.GetVerbosity() + switch ctx.flags.debug { + case 0: + // nothing + case 1: + verbosity = "debug" + default: + verbosity = "trace" + } + + if verbosity != "" { + l := hclog.LevelFromString(verbosity) + if l == hclog.NoLevel { + return fmt.Errorf("invalid log level: %q", verbosity) + } + + c.Logger().SetLevel(l) + } + + // Set the output format if the flag is set. + // f := ctx.flags.format + // if f == "" { + // f = ctx.Profile.Core.GetOutputFormat() + // } + // if f != "" { + // format, err := format.FromString(f) + // if err != nil { + // return err + // } + + // ctx.Output.SetFormat(format) + // } + + // Disable color if set + if ctx.Profile != nil && ctx.Profile.NoColor != nil && *ctx.Profile.NoColor { + ctx.IO.ForceNoColor() + } + + // Set quiet on the IOStream if enabled by the flag or profile + if ctx.flags.Quiet || ctx.Profile.IsQuiet() { + ctx.IO.SetQuiet(true) + } + + return nil +} + +// ParseFlags can be used to parse the flags for a given command before it is +// run. This can be helpful in very specific cases such as accessing flags +// during autocompletion. The return args are the non-flag arguments. +func (ctx *Context) ParseFlags(c *Command, args []string) ([]string, error) { + if err := c.parseFlags(args); err != nil { + return nil, err + } + + if err := ctx.applyGlobalFlags(c); err != nil { + return nil, err + } + + return c.allCommandFlags.Args(), nil +} + +func isAuthenticated(ctx *Context, c *Command, args []string) error { + if isTopLevelCmd(args) || c.NoAuthRequired { + return nil + } + + if ctx.Profile.Token == "" { + return authHelp(c.io) + } + + return nil +} + +func authHelp(io iostreams.IOStreams) error { + cs := io.ColorScheme() + help := heredoc.Docf(` +No authentication detected. To get started with tfcloud CLI, please run: %s`, + cs.String("tfcloud auth login").Bold().String()) + + return errors.New(help) +} + +// Used to parse commands and skip loading tfcloud profile. +func isTopLevelCmd(args []string) bool { + if len(args) != 1 { + return false + } + + switch args[0] { + case "version": + return true + case "-v": + return true + case "--version": + return true + case "-version": + return true + case "-h": + return true + case "--help": + return true + } + return false +} diff --git a/internal/pkg/cmd/flags.go b/internal/pkg/cmd/flags.go new file mode 100644 index 0000000..f90926c --- /dev/null +++ b/internal/pkg/cmd/flags.go @@ -0,0 +1,80 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +const ( + // flagAnnotationGlobal is an annotation key that marks the flag as a + // global flag. + flagAnnotationGlobal = "cmd:global" + + // flagAnnotationRequired is an annotation key that marks the flag as a + // required flag. + flagAnnotationRequired = "cmd:required" + + // flagAnnotationRepeatable is an annotation key that marks the flag as a + // repeatable flag. + flagAnnotationRepeatable = "cmd:repeatable" + + // flagAnnotationDisplayValue is an annotation key that stores the optional + // display value. + flagAnnotationDisplayValue = "cmd:display_value" +) + +// flagAnnotations is a set of annotations on flags to customize output. +type flagAnnotations map[string][]string + +// newFlagAnnotations returns a new set of flag annotations. +func newFlagAnnotations() flagAnnotations { + return make(map[string][]string) +} + +// Global marks a flag as global. +func (a flagAnnotations) Global() flagAnnotations { + a[flagAnnotationGlobal] = nil + return a +} + +// isFlagGlobal returns whether the flag is global. +func isFlagGlobal(a flagAnnotations) bool { + _, ok := a[flagAnnotationGlobal] + + return ok +} + +// Required marks a flag as required. +func (a flagAnnotations) Required() { + a[flagAnnotationRequired] = nil +} + +// isFlagRequired returns whether the flag is required. +func isFlagRequired(a flagAnnotations) bool { + _, ok := a[flagAnnotationRequired] + return ok +} + +// DisplayValue stores the display value for the flag. +func (a flagAnnotations) DisplayValue(v string) { + a[flagAnnotationDisplayValue] = []string{v} +} + +// getFlagDisplayValue returns the display value for the flag or an empty string +// if it wasn't set. +func getFlagDisplayValue(a flagAnnotations) string { + set, ok := a[flagAnnotationDisplayValue] + if !ok { + return "" + } + return set[0] +} + +// Repeatable marks a flag as repeatable. +func (a flagAnnotations) Repeatable() { + a[flagAnnotationRepeatable] = nil +} + +// isFlagRepeatable returns whether the flag is repeatable. +func isFlagRepeatable(a flagAnnotations) bool { + _, ok := a[flagAnnotationRepeatable] + return ok +} diff --git a/internal/pkg/cmd/gen_md.go b/internal/pkg/cmd/gen_md.go new file mode 100644 index 0000000..fde9835 --- /dev/null +++ b/internal/pkg/cmd/gen_md.go @@ -0,0 +1,246 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/spf13/pflag" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +const markdownExtension = ".mdx" + +// LinkHandler is a function that can be used to modify the links in the +// generated markdown. The path string is the unmodified path to the file. +type LinkHandler func(path string) string + +// GenMarkdownTree creates a markdown file for the command and all of its children. +func GenMarkdownTree(c *Command, dir string, link LinkHandler) error { + // Create the directory if it doesn't exist + if err := os.MkdirAll(dir, 0766); err != nil { + return err + } + + // Determine the filename. If the command is a command group parent and has + // no run function, we create an index file, otherwise we name the file + // after the command name. + filename := "index" + markdownExtension + if c.RunF != nil { + filename = c.Name + markdownExtension + } + + // Create the file + f, err := os.Create(filepath.Join(dir, filename)) + if err != nil { + return err + } + defer f.Close() + + // Generate the markdown + if err := GenMarkdown(c, f, link); err != nil { + return err + } + + for _, c := range c.children { + dir := dir + if len(c.children) > 0 { + dir = filepath.Join(dir, c.Name) + } + + if err := GenMarkdownTree(c, dir, link); err != nil { + return err + } + } + + return nil +} + +// GenMarkdown creates custom markdown output. +func GenMarkdown(c *Command, w io.Writer, link LinkHandler) error { + mdIO, ok := c.getRawIO().(iostreams.IsMarkdownOutput) + if !ok { + return fmt.Errorf("IOStream instance must be configured for markdown output") + } + + buf := new(bytes.Buffer) + name := c.commandPath() + + buf.WriteString("---\n") + fmt.Fprintf(buf, "page_title: %s\n", name) + fmt.Fprintf(buf, "description: |-\n The \"%s\" command lets you %s\n", name, (strings.ToLower(c.ShortHelp[:1]) + c.ShortHelp[1:])) + buf.WriteString("---\n\n") + + _, err := buf.WriteTo(w) + if err != nil { + return fmt.Errorf("error writing header: %w", err) + } + + buf.WriteString("# " + name + "\n\n") + fmt.Fprintf(buf, "Command: `%s` \n\n", name) + + // Description + buf.WriteString(c.LongHelp + "\n\n") + + // Disable markdown escaping and then re-enable. This is needed because + // the flags / args would otherwise generate markdown that will not render + // because we are using a code block. + mdIO.SetMD(false) + buf.WriteString("## Usage\n\n") + fmt.Fprintf(buf, "```shell-session\n$ %s\n```\n\n", c.useLine()) + mdIO.SetMD(true) + + // Aliases + if len(c.Aliases) > 0 { + buf.WriteString("## Aliases\n\n") + for a, u := range c.aliasUsages() { + fmt.Fprintf(buf, "- `%s`. For example: `%s`\n", a, u) + } + buf.WriteString("\n") + } + + // Examples + if len(c.Examples) > 0 { + buf.WriteString("## Examples\n\n") + + for _, e := range c.Examples { + fmt.Fprintf(buf, "%s\n\n", e.Preamble) + fmt.Fprintf(buf, "```shell-session\n%s\n```\n\n", e.Command) + } + } + + // Children commands + if len(c.children) > 0 { + var commands, groups []string + for _, c := range c.children { + path := strings.ReplaceAll(c.commandPath(), " ", "/") + entry := fmt.Sprintf("- [`%s`](%s) - %s", c.Name, link(path), c.ShortHelp) + + if c.RunF != nil { + commands = append(commands, entry) + } else { + groups = append(groups, entry) + } + } + if len(groups) > 0 { + buf.WriteString("## Command groups\n\n") + buf.WriteString(strings.Join(groups, "\n") + "\n\n") + } + + if len(commands) > 0 { + buf.WriteString("## Commands\n\n") + buf.WriteString(strings.Join(commands, "\n") + "\n\n") + } + } + + // Positional arguments + genMarkdownPositionalArgs(c, buf) + + // Print flags + genMarkdownFlags(c, buf) + + // Additional docs + for _, d := range c.AdditionalDocs { + fmt.Fprintf(buf, "## %s\n", d.Title) + fmt.Fprintf(buf, "%s\n", d.Documentation) + } + + _, err = buf.WriteTo(w) + return err +} + +func genMarkdownPositionalArgs(c *Command, buf *bytes.Buffer) { + if len(c.Args.Args) == 0 { + return + } + + cs := c.getIO().ColorScheme() + buf.WriteString("## Positional arguments\n\n") + p := c.Args + if p.Preamble != "" { + fmt.Fprintln(buf, p.Preamble) + } + + for _, a := range p.Args { + nameUpper := strings.ToUpper(a.Name) + repeatable := "" + if a.Repeatable { + repeatable = fmt.Sprintf(" [%s ...]", nameUpper) + } + fmt.Fprintf(buf, "- `%s%s` - ", nameUpper, repeatable) + + if a.Optional { + fmt.Fprintln(buf, cs.String("Optional argument\n").Italic().String()) + } + fmt.Fprintln(buf, strings.ReplaceAll(a.Documentation, "\n", "\n\t")) + fmt.Fprintln(buf) + } +} + +func genMarkdownFlags(c *Command, buf *bytes.Buffer) { + // If we are the root command, just print global flags. + if c.parent == nil && c.RunF == nil { + buf.WriteString("## Global flags\n\n") + genMarkdownFlagsetUsage(c.globalFlags(), buf) + } + + // Print flags only if the command is runnable + if c.RunF == nil { + return + } + + flagSets := []struct { + flags *pflag.FlagSet + name string + }{ + { + flags: c.localFlags(), + name: "", + }, + { + flags: c.inheritedFlags(), + name: "Inherited ", + }, + } + + for _, set := range flagSets { + required, optional := splitRequiredFlags(set.flags) + if required.HasFlags() { + fmt.Fprintf(buf, "## Required %sflags\n\n", set.name) + genMarkdownFlagsetUsage(required, buf) + + if optional.HasFlags() { + fmt.Fprintf(buf, "## Optional %sflags\n\n", set.name) + genMarkdownFlagsetUsage(optional, buf) + } + } else if optional.HasFlags() { + fmt.Fprintf(buf, "## %sFlags\n\n", set.name) + genMarkdownFlagsetUsage(optional, buf) + } + } +} + +func genMarkdownFlagsetUsage(flags *pflag.FlagSet, buf *bytes.Buffer) { + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + + longDisplay := flagString(flag) + if flag.Shorthand != "" && flag.ShorthandDeprecated == "" { + fmt.Fprintf(buf, "- `-%s, %s` - ", flag.Shorthand, longDisplay) + } else { + fmt.Fprintf(buf, "- `%s` - ", longDisplay) + } + + // Add the usage + fmt.Fprintf(buf, "%s\n\n", strings.ReplaceAll(flag.Usage, "\n", "\n\t")) + }) +} diff --git a/internal/pkg/cmd/gen_nav_json.go b/internal/pkg/cmd/gen_nav_json.go new file mode 100644 index 0000000..958810b --- /dev/null +++ b/internal/pkg/cmd/gen_nav_json.go @@ -0,0 +1,84 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "encoding/json" + "io" + "path/filepath" + "slices" + "strings" +) + +// DocNavItem is a single item in the navigation JSON. +type DocNavItem struct { + Title string `json:"title"` + Href string `json:"href,omitempty"` + Path string `json:"path,omitempty"` + Routes []*DocNavItem `json:"routes,omitempty"` +} + +// GenNavJSON generates the navigation JSON in the format that web-unified-docs expects, +// for the command structure. +func GenNavJSON(c *Command, w io.Writer) error { + + root := &DocNavItem{} + genNavJSON(c, root, "cli/commands") + + // Create the top level nav item + nav := &DocNavItem{ + Title: "Commands (CLI)", + Routes: root.Routes[0].Routes, + } + + // Serialize the JSON + e := json.NewEncoder(w) + e.SetIndent("", " ") + if err := e.Encode(nav); err != nil { + return err + } + + return nil +} + +// genNavJSON is a recursive function that generates the navigation JSON for +// the command structure. +func genNavJSON(c *Command, nav *DocNavItem, path string) { + // Generate a new nav item for this command + var self *DocNavItem + + if c.parent != nil { + path = filepath.Join(path, c.Name) + } + + // Handle being a command group + if len(c.children) > 0 { + self = &DocNavItem{ + Title: c.Name, + Routes: []*DocNavItem{ + { + Title: "Overview", + Path: path, + }, + }, + } + } else { + self = &DocNavItem{ + Title: c.Name, + Path: path, + } + } + + // Sort the children by name + slices.SortFunc(c.children, func(i, j *Command) int { + return strings.Compare(i.Name, j.Name) + }) + + // If we have children, create a new nav item for each child + for _, child := range c.children { + genNavJSON(child, self, path) + } + + nav.Routes = append(nav.Routes, self) +} diff --git a/internal/pkg/cmd/standard_errors.go b/internal/pkg/cmd/standard_errors.go new file mode 100644 index 0000000..2f7a63f --- /dev/null +++ b/internal/pkg/cmd/standard_errors.go @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "errors" + + "github.com/MakeNowJust/heredoc/v2" +) + +// RequireOrganization requires that the profile has a set organization. +func RequireOrganization(ctx *Context) error { + if ctx.Profile.Organization != "" { + return nil + } + + cs := ctx.IO.ColorScheme() + help := heredoc.Docf(`%v + + Please run %v to interactively set the Organization, or run: + + %v`, + cs.String("Organization must be configured before running the command.").Color(cs.Orange()), + cs.String("tfcloud config init").Bold(), + cs.String("$ tfcloud config set organization ").Bold(), + ) + + return errors.New(help) +} + +// RequireOrg requires that the profile has a set organization. +func RequireOrg(ctx *Context) error { + if ctx.Profile.Organization != "" { + return nil + } + + cs := ctx.IO.ColorScheme() + help := heredoc.Docf(`%v + + Please run %s to interactively set the Organization, or run: + + %v`, + cs.String("Organization must be configured before running the command.").Color(cs.Orange()), + cs.String("tfcloud config init").Bold(), + cs.String("$ tfcloud config set organization ").Bold(), + ) + + return errors.New(help) +} diff --git a/internal/pkg/cmd/validate.go b/internal/pkg/cmd/validate.go new file mode 100644 index 0000000..b0c6187 --- /dev/null +++ b/internal/pkg/cmd/validate.go @@ -0,0 +1,390 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/spf13/pflag" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +const ( + // shortHelpMaxLength is the maximum length of the short help text. + shortHelpMaxLength = 60 +) + +var ( + // commandNameRegex is used to validate the command names. It enforces that + // the command name is lower case and contains only letters and hyphens. + commandNameRegex = regexp.MustCompile(`^[a-z]+([-][a-z]+)*$`) + + // errCommandNameInvalid is returned when the command name is invalid. + errCommandNameInvalid = fmt.Errorf("only lower case names with hyphens are allowed") + + // shortHelpRegex is used to validate the short help text. It enforces that + // the short help text starts with a capital letter, ends with a period, and + // contains only letters, apostrophes, hyphens, and spaces. + shortHelpRegex = regexp.MustCompile(`^[A-Z][a-zA-Z-\s']+\.$`) + + // errShortHelpInvalid is returned when the short help text is invalid. + errShortHelpInvalid = fmt.Errorf("short help text must start with a capital letter, end with a period, and contain only letters, apostrophes, hyphens, and spaces") + + // flagNameRegex is used to validate the flag names. It enforces that the flag + // name is lower case and contains only letters and hyphens. + flagNameRegex = regexp.MustCompile(`^[a-z0-9]+([-][a-z0-9]+)*$`) + + // errFlagNameInvalid is returned when the flag name is invalid. + errFlagNameInvalid = fmt.Errorf("only lower case letters, numbers, and hyphens are allowed") + + // flagDescriptionRegex is used to validate the flag descriptions. It enforces that the + // flag description starts with a capital letter and ends with a period. + flagDescriptionRegex = regexp.MustCompile(`(?s)^[A-Z].+\.$`) + + // errFlagDescriptionInvalid is returned when the flag description is invalid. + errFlagDescriptionInvalid = fmt.Errorf("description must start with a capital letter and end with a period") + + // argsPreambleRegex is used to validate the preamble of the positional + // arguments. It enforces that the preamble starts with a capital letter and + // ends with a period. + argsPreambleRegex = regexp.MustCompile(`(?s)^[A-Z].+\.$`) + + // errArgsPreambleInvalid is returned when the preamble of the positional + // arguments is invalid. + errArgsPreambleInvalid = fmt.Errorf("preable must start with a capital letter and end with a period") + + // examplePreambleRegex is used to validate the preamble of the examples. It + // enforces that the preamble starts with a capital letter and ends with a + // colon. + examplePreambleRegex = regexp.MustCompile(`(?s)^[A-Z].+:$`) + + // eaxmplePreambleInvalidError is returned when the preamble of the example + // is invalid. + errExamplePreambleInvalid = fmt.Errorf("preamble must start with a capital letter and end with a colon") + + // errCommandLongHelpPrefixInvalid is returned when the long help prefix is + // invalid for a command. + errCommandLongHelpPrefixInvalid = func(c *Command) error { + template, plaintext := expectedLongHelpPrefix(c) + + got := c.LongHelp + if len(got) > 100 { + got = got[:100] + "..." + } + + return fmt.Errorf("invalid command long help prefix.\n\nWANT: %q\nGOT: %q\nREPLACE WITH: %q", plaintext, got, template) + } +) + +// Validate validates the command and all of its children. +func (c *Command) Validate() error { + var validationErr error + + // Validate ourselves and then the children. + if err := c.validate(); err != nil { + validationErr = errors.Join(validationErr, err) + } + + namesAndAliases := make(map[string]struct{}, len(c.children)) + for _, child := range c.children { + if err := child.Validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating command %s: %w", child.Name, err)) + continue + } + + // Ensure the child name and its aliases are unique. + if _, ok := namesAndAliases[child.Name]; ok { + validationErr = errors.Join(validationErr, fmt.Errorf("child command name %q used by a sibling name or alias", child.Name)) + } else { + namesAndAliases[child.Name] = struct{}{} + } + + for _, alias := range child.Aliases { + if _, ok := namesAndAliases[alias]; ok { + validationErr = errors.Join(validationErr, fmt.Errorf("child command %q has alias %q already used by a sibling name or alias", child.Name, alias)) + } else { + namesAndAliases[alias] = struct{}{} + } + } + } + + return validationErr +} + +func (c *Command) validate() error { + var validationErr error + + // Validate the name + if c.Name == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("command name cannot be empty")) + } else if !commandNameRegex.MatchString(c.Name) { + validationErr = errors.Join(validationErr, errCommandNameInvalid) + } + + // Ensure the aliases are valid and there are no duplicates in the aliases + aliases := make(map[string]struct{}, len(c.Aliases)) + for _, alias := range c.Aliases { + // Ensure the alias is not the name + if alias == c.Name { + validationErr = errors.Join(validationErr, fmt.Errorf("command name cannot be an alias")) + continue + } + + // Check for duplicates + if _, ok := aliases[alias]; ok { + validationErr = errors.Join(validationErr, fmt.Errorf("duplicate alias %q found", alias)) + } + aliases[alias] = struct{}{} + + // Validate the alias + if alias == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("alias name is empty")) + } else if !commandNameRegex.MatchString(alias) { + validationErr = errors.Join(validationErr, fmt.Errorf("alias %q: %w", alias, errCommandNameInvalid)) + } + } + + // Validate that the help text is set + if c.ShortHelp == "" || c.LongHelp == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("short and long help text must be set")) + } + + // Validate the short help. + if len(c.ShortHelp) > shortHelpMaxLength { + validationErr = errors.Join(validationErr, + fmt.Errorf("short help text is too long. Max length is %d; got %q (%d)", + shortHelpMaxLength, c.ShortHelp, len(c.ShortHelp))) + } else if !shortHelpRegex.MatchString(c.ShortHelp) { + validationErr = errors.Join(validationErr, fmt.Errorf("%w; got %q", errShortHelpInvalid, c.ShortHelp)) + } + + // Validate the long help. Since the LongHelp is rendered, we can't fully + // validate that the template is correct. Instead, we validate that the + // plaintext output is correct. + _, longHelpPrefix := expectedLongHelpPrefix(c) + if c.parent != nil && !strings.HasPrefix(strings.TrimSpace(c.LongHelp), longHelpPrefix) { + validationErr = errors.Join(validationErr, errCommandLongHelpPrefixInvalid(c)) + } + + // Validate the additional documentation sections + for i, d := range c.AdditionalDocs { + if err := d.validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating documentation section %d: %w", i, err)) + } + } + + // Validate the examples + for i, e := range c.Examples { + if err := e.validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating example %d: %w", i, err)) + } + } + + // Validate IO is set + if err := c.validateIO(); err != nil { + validationErr = errors.Join(validationErr, err) + + // Inject a test io so we can continue validation + c.io = iostreams.Test() + } + + // Validate the Flags + if err := c.validateFlags(); err != nil { + validationErr = errors.Join(validationErr, err) + } + + // validate the positional arguments + if err := c.Args.validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating positional arguments: %w", err)) + } + + // Validate that either RunF or Children are set, but not both. + if c.RunF == nil && len(c.children) == 0 { + validationErr = errors.Join(validationErr, fmt.Errorf("either RunF or Children must be set")) + } else if c.RunF != nil && len(c.children) > 0 { + validationErr = errors.Join(validationErr, fmt.Errorf("both RunF and Children cannot be set")) + } + + return validationErr +} + +// expectedLongHelpPrefix returns the expected long help prefix for the command +// that should be present in the template, and the plaintext version to test +// against. +func expectedLongHelpPrefix(c *Command) (templated, plaintext string) { + group := " group" + if c.RunF != nil { + group = "" + } + + templated = fmt.Sprintf(`The {{ template "mdCodeOrBold" %q }} command%s`, c.commandPath(), group) + plaintext = fmt.Sprintf("The %s command%s", c.commandPath(), group) + return +} + +// validateIO checks that the io is set on the command or any parent command. +func (c *Command) validateIO() error { + for c := c; c != nil; c = c.parent { + if c.io != nil { + return nil + } + } + + return fmt.Errorf("io not set on command or any parent command") +} + +func (c *Command) validateFlags() error { + var validationErr error + defer func() { + if err := recover(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("panic validating flags: %v", err)) + } + }() + + for _, flag := range c.Flags.Local { + if err := flag.validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating local flag %q: %w", flag.Name, err)) + } + } + + for _, flag := range c.Flags.Persistent { + if err := flag.validate(); err != nil { + validationErr = errors.Join(validationErr, fmt.Errorf("error validating persistent flag %q: %w", flag.Name, err)) + } + } + + // Return early since visiting invalid flags can cause a panic. + if validationErr != nil { + return validationErr + } + + // Ensure local flags do not override parent persistent flags + var flagErr error + localFlags, inheritedFlags := c.localFlags(), c.parentPersistentFlags() + localFlags.VisitAll(func(f *pflag.Flag) { + if flagErr != nil { + return + } + + if inheritedFlags.Lookup(f.Name) != nil { + flagErr = fmt.Errorf("local flag %q overrides inherited persistent flag", f.Name) + } + }) + if flagErr != nil { + validationErr = errors.Join(validationErr, flagErr) + } + + return validationErr +} + +func (f *Flag) validate() error { + var validationErr error + + if f.Name == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("name cannot be empty")) + } else if !flagNameRegex.MatchString(f.Name) { + validationErr = errors.Join(validationErr, fmt.Errorf("%w; got %q", errFlagNameInvalid, f.Name)) + } + if f.Shorthand != strings.ToLower(f.Shorthand) { + validationErr = errors.Join(validationErr, fmt.Errorf("shorthand %q is not lowercase", f.Shorthand)) + } else if len(f.Shorthand) > 1 { + validationErr = errors.Join(validationErr, fmt.Errorf("shorthand %q must be a single character", f.Shorthand)) + } + if f.DisplayValue != strings.ToUpper(f.DisplayValue) { + validationErr = errors.Join(validationErr, fmt.Errorf("display value %q is not uppercase", f.DisplayValue)) + } + if !flagDescriptionRegex.MatchString(f.Description) { + validationErr = errors.Join(validationErr, fmt.Errorf("%w; got %q", errFlagDescriptionInvalid, f.Description)) + } + if f.Value == nil { + validationErr = errors.Join(validationErr, fmt.Errorf("value cannot be nil")) + } + + return validationErr +} + +// validate validates the documentation section. +func (d *DocSection) validate() error { + var validationErr error + + if d.Title == "" { + return fmt.Errorf("title cannot be empty") + } else if strings.HasSuffix(d.Title, ".") { + return fmt.Errorf("title cannot end with a period") + } + if d.Documentation == "" { + return fmt.Errorf("documentation cannot be empty") + } + + return validationErr +} + +// validate validates the positional arguments. +func (p *PositionalArguments) validate() error { + var validationErr error + + // Start capital and end with a period if set. + if p.Preamble != "" && !argsPreambleRegex.MatchString(p.Preamble) { + return errArgsPreambleInvalid + } + + l := len(p.Args) + for i, p := range p.Args { + if err := p.validate(i == l-1); err != nil { + return fmt.Errorf("error validating positional argument %d: %w", i, err) + } + } + + return validationErr +} + +// validate validates the positional argument. isLast indicates if the positional +// argument is the last argument. +func (a *PositionalArgument) validate(isLast bool) error { + var validationErr error + + if a.Name == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("name cannot be empty")) + } else if a.Name != strings.ToUpper(a.Name) { + validationErr = errors.Join(validationErr, fmt.Errorf("name %q is not uppercase", a.Name)) + } + + if a.Documentation == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("documentation cannot be empty")) + } else if !strings.HasSuffix(a.Documentation, ".") { + validationErr = errors.Join(validationErr, fmt.Errorf("documentation must end with a period")) + } + + if a.Optional && !isLast { + validationErr = errors.Join(validationErr, fmt.Errorf("optional positional argument %q must be the last argument", a.Name)) + } + if a.Repeatable && !isLast { + validationErr = errors.Join(validationErr, fmt.Errorf("repeatable positional argument %q must be the last argument", a.Name)) + } + + return validationErr +} + +// validate validates the example. +func (e *Example) validate() error { + var validationErr error + + if e.Preamble == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("preamble cannot be empty")) + } else if !examplePreambleRegex.MatchString(e.Preamble) { + validationErr = errors.Join(validationErr, errExamplePreambleInvalid) + } + + if e.Command == "" { + validationErr = errors.Join(validationErr, fmt.Errorf("command cannot be empty")) + } else if !strings.HasPrefix(e.Command, "$ ") && !strings.HasPrefix(e.Command, "#") { + validationErr = errors.Join(validationErr, fmt.Errorf("example command must start with $ or #")) + } + + return validationErr +} diff --git a/internal/pkg/cmd/validate_test.go b/internal/pkg/cmd/validate_test.go new file mode 100644 index 0000000..05efe72 --- /dev/null +++ b/internal/pkg/cmd/validate_test.go @@ -0,0 +1,456 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func getGoodCommand() *Command { + var projectID string + parent := &Command{ + Name: "parent-cmd", + Aliases: []string{"parent"}, + ShortHelp: "This is a short help message.", + LongHelp: `The parent-cmd command group lets you do things.`, + Flags: Flags{ + Persistent: []*Flag{ + { + Name: "project", + Shorthand: "p", + Description: "Project ID.", + DisplayValue: "ID", + Value: flagvalue.Simple("", &projectID), + }, + }, + }, + io: iostreams.Test(), + } + + // Add a child command + var count int + child := &Command{ + Name: "child-cmd", + Aliases: []string{"child"}, + ShortHelp: "This is a short help message.", + LongHelp: `The parent-cmd child-cmd command lets you do things.`, + Flags: Flags{ + Local: []*Flag{ + { + Name: "count", + Description: "Count of things to print.", + DisplayValue: "N", + Value: flagvalue.Simple(0, &count), + }, + }, + }, + Args: PositionalArguments{ + Preamble: "This is a preamble.", + Args: []PositionalArgument{ + { + Name: "TEXT", + Documentation: "Text to repeatedly print.", + Optional: false, + Repeatable: false, + }, + { + Name: "PREFIX", + Documentation: "Prefix to prepend to the text.", + Optional: false, + Repeatable: false, + }, + }, + }, + Examples: []Example{ + { + Preamble: "This is an example invocation:", + Command: "$ tfcloud parent child --count 5", + }, + }, + AdditionalDocs: []DocSection{ + { + Title: "More Details", + Documentation: "This will explain everything.", + }, + }, + RunF: func(cmd *Command, args []string) error { + return nil + }, + } + parent.AddChild(child) + + return parent +} + +func TestCommand_Validate(t *testing.T) { + t.Parallel() + cases := []struct { + name string + command func(c *Command) + error string + }{ + { + name: "good", + command: func(c *Command) {}, + error: "", + }, + { + name: "no io", + command: func(c *Command) { + c.io = nil + }, + error: "io not set on command or any parent command", + }, + { + name: "no runf or children", + command: func(c *Command) { + c.children[0].RunF = nil + }, + error: "either RunF or Children must be set", + }, + { + name: "both runf and children", + command: func(c *Command) { + c.RunF = func(cmd *Command, args []string) error { return nil } + }, + error: "both RunF and Children cannot be set", + }, + { + name: "command group has bad long help", + command: func(c *Command) { + // Force a parent since LongHelp verification is disabled for + // the root command. + c.parent = &Command{ + Name: "tfcloud", + } + c.LongHelp = "Bad prefix" + }, + error: "invalid command long help prefix.\n\nWANT: \"The tfcloud parent-cmd command group\"\nGOT:", + }, + { + name: "command has bad long help", + command: func(c *Command) { + c.children[0].LongHelp = "Bad prefix" + }, + error: "invalid command long help prefix.\n\nWANT: \"The parent-cmd child-cmd command\"\nGOT:", + }, + { + name: "siblings have conflicting names", + command: func(c *Command) { + child2 := *c.children[0] + c.AddChild(&child2) + }, + error: "child command name \"child-cmd\" used by a sibling name or alias", + }, + { + name: "siblings have conflicting aliases", + command: func(c *Command) { + child2 := *c.children[0] + child2.Name = "child-two" + child2.Aliases = []string{c.children[0].Name} + child2.LongHelp = `The parent-cmd child-two command lets you do things.` + c.AddChild(&child2) + }, + error: "child command \"child-two\" has alias \"child-cmd\" already used by a sibling name or alias", + }, + { + name: "no name", + command: func(c *Command) { + c.Name = "" + }, + error: "command name cannot be empty", + }, + { + name: "bad name characters", + command: func(c *Command) { + c.Name = "ThisIsBad" + }, + error: "only lower case names with hyphens are allowed", + }, + { + name: "bad alias characters", + command: func(c *Command) { + c.Aliases = append(c.Aliases, "ThisIsBad") + }, + error: "only lower case names with hyphens are allowed", + }, + { + name: "duplicate aliases", + command: func(c *Command) { + c.Aliases = append(c.Aliases, "good", "good") + }, + error: "duplicate alias \"good\" found", + }, + { + name: "duplicate name and alias", + command: func(c *Command) { + c.Aliases = append(c.Aliases, c.Name) + }, + error: "command name cannot be an alias", + }, + { + name: "no short help", + command: func(c *Command) { + c.ShortHelp = "" + }, + error: "short and long help text must be set", + }, + { + name: "no long help", + command: func(c *Command) { + c.LongHelp = "" + }, + error: "short and long help text must be set", + }, + { + name: "short help is too long", + command: func(c *Command) { + c.ShortHelp = "This is a very long help message that is too long to be valid." + }, + error: "short help text is too long. Max length is 60; got", + }, + { + name: "short help doesn't start with capital", + command: func(c *Command) { + c.ShortHelp = "bad short." + }, + error: "short help text must start with a capital letter, end with a period, and contain only letters, apostrophes, hyphens, and spaces", + }, + { + name: "short help doesn't end with a period", + command: func(c *Command) { + c.ShortHelp = "Bad short" + }, + error: "short help text must start with a capital letter, end with a period, and contain only letters, apostrophes, hyphens, and spaces", + }, + { + name: "short help has bad char", + command: func(c *Command) { + c.ShortHelp = "Bad $hort." + }, + error: "short help text must start with a capital letter, end with a period, and contain only letters, apostrophes, hyphens, and spaces", + }, + { + name: "additional docs has title", + command: func(c *Command) { + c.children[0].AdditionalDocs[0].Title = "" + }, + error: "error validating documentation section 0: title cannot be empty", + }, + { + name: "additional docs has no period", + command: func(c *Command) { + c.children[0].AdditionalDocs[0].Title = "test." + }, + error: "error validating documentation section 0: title cannot end with a period", + }, + { + name: "additional docs has no docs", + command: func(c *Command) { + c.children[0].AdditionalDocs[0].Documentation = "" + }, + error: "error validating documentation section 0: documentation cannot be empty", + }, + { + name: "example preamble set", + command: func(c *Command) { + c.children[0].Examples[0].Preamble = "" + }, + error: "error validating example 0: preamble cannot be empty", + }, + { + name: "example preamble start with capital", + command: func(c *Command) { + c.children[0].Examples[0].Preamble = "bad preamble:" + }, + error: "error validating example 0: preamble must start with a capital letter and end with a colon", + }, + { + name: "example preamble end with colon", + command: func(c *Command) { + c.children[0].Examples[0].Preamble = "Bad preamble" + }, + error: "error validating example 0: preamble must start with a capital letter and end with a colon", + }, + { + name: "examples start with a $", + command: func(c *Command) { + c.children[0].Examples[0].Command = "tfcloud parent child --count 5" + }, + error: "error validating example 0: example command must start with $ or #", + }, + { + name: "flag name is set", + command: func(c *Command) { + c.Flags.Persistent[0].Name = "" + }, + error: "error validating persistent flag \"\": name cannot be empty", + }, + { + name: "flag name must be lower", + command: func(c *Command) { + c.Flags.Persistent[0].Name = "BAD" + }, + error: "error validating persistent flag \"BAD\": only lower case letters, numbers, and hyphens are allowed", + }, + { + name: "flag name can't end in hyphen", + command: func(c *Command) { + c.Flags.Persistent[0].Name = "test-" + }, + error: "error validating persistent flag \"test-\": only lower case letters, numbers, and hyphens are allowed", + }, + { + name: "flag name no underscores", + command: func(c *Command) { + c.Flags.Persistent[0].Name = "test_flag" + }, + error: "error validating persistent flag \"test_flag\": only lower case letters, numbers, and hyphens are allowed", + }, + { + name: "flag name no special", + command: func(c *Command) { + c.Flags.Persistent[0].Name = "test!" + }, + error: "error validating persistent flag \"test!\": only lower case letters, numbers, and hyphens are allowed", + }, + { + name: "flag shorthand must be lower", + command: func(c *Command) { + c.Flags.Persistent[0].Shorthand = "B" + }, + error: "error validating persistent flag \"project\": shorthand \"B\" is not lowercase", + }, + { + name: "flag shorthand too long", + command: func(c *Command) { + c.Flags.Persistent[0].Shorthand = "bbb" + }, + error: "error validating persistent flag \"project\": shorthand \"bbb\" must be a single character", + }, + { + name: "flag display value must be upper case", + command: func(c *Command) { + c.Flags.Persistent[0].DisplayValue = "id" + }, + error: "error validating persistent flag \"project\": display value \"id\" is not uppercase", + }, + { + name: "flag description lowercase start", + command: func(c *Command) { + c.Flags.Persistent[0].Description = "this is a description." + }, + error: "error validating persistent flag \"project\": description must start with a capital letter and end with a period", + }, + { + name: "flag description end with period", + command: func(c *Command) { + c.Flags.Persistent[0].Description = "This is a description" + }, + error: "error validating persistent flag \"project\": description must start with a capital letter and end with a period", + }, + { + name: "flag description no value", + command: func(c *Command) { + c.Flags.Persistent[0].Value = nil + }, + error: "error validating persistent flag \"project\": value cannot be nil", + }, + { + name: "flags don't override parent persistent", + command: func(c *Command) { + c.children[0].Flags.Local[0].Name = "project" + }, + error: "local flag \"project\" overrides inherited persistent flag", + }, + { + name: "PositionalArgs preamble is valid", + command: func(c *Command) { + c.children[0].Args.Preamble = "bad preamble." + }, + error: "error validating positional arguments: preable must start with a capital letter and end with a period", + }, + { + name: "PositionalArg name is set", + command: func(c *Command) { + c.children[0].Args.Args[0].Name = "" + }, + error: "error validating positional argument 0: name cannot be empty", + }, + { + name: "PositionalArg name must be uppercase", + command: func(c *Command) { + c.children[0].Args.Args[0].Name = "bad" + }, + error: "error validating positional argument 0: name \"bad\" is not uppercase", + }, + { + name: "PositionalArg documentation must be set", + command: func(c *Command) { + c.children[0].Args.Args[0].Documentation = "" + }, + error: "error validating positional argument 0: documentation cannot be empty", + }, + { + name: "PositionalArg documentation must end with a period", + command: func(c *Command) { + c.children[0].Args.Args[0].Documentation = "bad docs" + }, + error: "error validating positional argument 0: documentation must end with a period", + }, + { + name: "PositionalArg optional must be last", + command: func(c *Command) { + c.children[0].Args.Args[0].Optional = true + }, + error: "error validating positional argument 0: optional positional argument \"TEXT\" must be the last argument", + }, + { + name: "PositionalArg repeated must be last", + command: func(c *Command) { + c.children[0].Args.Args[0].Repeatable = true + }, + error: "error validating positional argument 0: repeatable positional argument \"TEXT\" must be the last argument", + }, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Get and modify the good command + cmd := getGoodCommand() + c.command(cmd) + + err := cmd.Validate() + if c.error != "" { + r.ErrorContains(err, c.error) + } else { + r.NoError(err) + } + }) + } +} + +func TestCommand_Validate_MultiError(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Get and modify the good command + cmd := getGoodCommand() + cmd.Name = "ThisIsBad" + cmd.Aliases = append(cmd.Aliases, "ThisIsBad") + cmd.Flags.Persistent[0].Name = "test_flag" + + err := cmd.Validate() + r.ErrorContains(err, "only lower case names with hyphens are allowed") + r.ErrorContains(err, "command name cannot be an alias") + r.ErrorContains(err, "error validating persistent flag \"test_flag\": only lower case letters, numbers, and hyphens are allowed") +} diff --git a/internal/pkg/flagvalue/counter.go b/internal/pkg/flagvalue/counter.go new file mode 100644 index 0000000..7f50954 --- /dev/null +++ b/internal/pkg/flagvalue/counter.go @@ -0,0 +1,39 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "fmt" + + "github.com/spf13/pflag" +) + +type counterValue struct { + value *int +} + +// Counter returns a pflags.Value that sets the value at p with the default +// val or the value provided via a flag. +func Counter(val int, p *int) Value { + v := new(counterValue) + v.value = p + *p = val + return v +} + +func (i *counterValue) Set(_ string) error { + *i.value++ + return nil +} + +func (i *counterValue) Type() string { + return "int" +} + +func (i *counterValue) String() string { + return fmt.Sprintf("%d", *i.value) +} + +// Ensure we meet the interface. +var _ pflag.Value = &counterValue{} diff --git a/internal/pkg/flagvalue/doc.go b/internal/pkg/flagvalue/doc.go new file mode 100644 index 0000000..083b282 --- /dev/null +++ b/internal/pkg/flagvalue/doc.go @@ -0,0 +1,2 @@ +// Package flagvalue provides custom pflag.Value implementations for common flag types. +package flagvalue diff --git a/internal/pkg/flagvalue/duration.go b/internal/pkg/flagvalue/duration.go new file mode 100644 index 0000000..6793a67 --- /dev/null +++ b/internal/pkg/flagvalue/duration.go @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "time" +) + +type durationValue time.Duration + +// Duration returns a pflag.Value that sets the duration at p with the default +// val or the value provided via a flag. +func Duration(val time.Duration, p *time.Duration) Value { + *p = val + return (*durationValue)(p) +} + +// Set implements the pflag.Value interface. +func (d *durationValue) Set(s string) error { + v, err := time.ParseDuration(s) + *d = durationValue(v) + return err +} + +// Type implements the pflag.Value interface. +func (d *durationValue) Type() string { + return "duration" +} + +// String implements the pflag.Value interface. +func (d *durationValue) String() string { + return (*time.Duration)(d).String() +} diff --git a/internal/pkg/flagvalue/duration_test.go b/internal/pkg/flagvalue/duration_test.go new file mode 100644 index 0000000..72d3cc4 --- /dev/null +++ b/internal/pkg/flagvalue/duration_test.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue_test + +import ( + "testing" + "time" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" +) + +func ExampleDuration() { + var sleep time.Duration + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "wait", + Usage: "wait specifies the time to sleep before taking an action", + DefValue: "5s", + Value: flagvalue.Duration(5*time.Second, &sleep), + }) + + time.Sleep(sleep) + + // ... Take an action +} + +func TestDuration(t *testing.T) { + t.Parallel() + r := require.New(t) + + var dur time.Duration + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "dur", + Value: flagvalue.Duration(time.Second, &dur), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(time.Second, dur) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--dur", "2m"})) + r.Equal(2*time.Minute, dur) +} diff --git a/internal/pkg/flagvalue/enum.go b/internal/pkg/flagvalue/enum.go new file mode 100644 index 0000000..3bb08ec --- /dev/null +++ b/internal/pkg/flagvalue/enum.go @@ -0,0 +1,54 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "fmt" + "reflect" + "slices" + + "github.com/spf13/pflag" +) + +type enumValue[T comparable] struct { + allowed []T + value *T +} + +// Enum returns a pflags.Value that sets the value at p with the default +// val or the value provided via a flag. The provided value must be in the +// allowed list or an error is returned. +func Enum[T comparable](allowed []T, val T, p *T) Value { + v := new(enumValue[T]) + v.allowed = allowed + v.value = p + *p = val + return v +} + +func (i *enumValue[T]) Set(s string) error { + var v T + if _, err := fmt.Sscanf(s, "%v", &v); err != nil { + return err + } + + // Check the value is allowed + if !slices.Contains(i.allowed, v) { + return fmt.Errorf("must be one of %v", i.allowed) + } + + *i.value = v + return nil +} + +func (i *enumValue[T]) Type() string { + return reflect.TypeOf(*i.value).Name() +} + +func (i *enumValue[T]) String() string { + return fmt.Sprintf("%v", *i.value) +} + +// Ensure we meet the interface. +var _ pflag.Value = &enumValue[bool]{} diff --git a/internal/pkg/flagvalue/enum_test.go b/internal/pkg/flagvalue/enum_test.go new file mode 100644 index 0000000..1c83b57 --- /dev/null +++ b/internal/pkg/flagvalue/enum_test.go @@ -0,0 +1,82 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue_test + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" +) + +func ExampleEnum() { + var logLevel string + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "log-level", + Usage: "log-level specifies the verbosity to log with.", + DefValue: "warn", + Value: flagvalue.Enum([]string{"trace", "debug", "info", "warn", "error"}, "warn", &logLevel), + }) + + // Setup logger + // logger := hclog.Default().SetLevel(hclog.LevelFromString(logLevel)) + // logger.Warn("we are using flags!") +} + +func TestEnum_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + var level string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "log-level", + Value: flagvalue.Enum([]string{"trace", "debug", "info", "warn", "error"}, "warn", &level), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal("warn", level) + + // Parse with the flag set to a valid enum + r.NoError(f.Parse([]string{"--log-level", "trace"})) + r.Equal("trace", level) + + // Parse with the flag set to an invalid enum + err := f.Parse([]string{"--log-level", "random"}) + r.Error(err) + r.ErrorContains(err, "must be one of [trace debug info warn error]") +} + +func TestEnum_Int(t *testing.T) { + t.Parallel() + r := require.New(t) + + var level int + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "level", + Value: flagvalue.Enum([]int{0, 10, 100}, 10, &level), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(10, level) + + // Parse with the flag set to a valid enum + r.NoError(f.Parse([]string{"--level", "100"})) + r.Equal(100, level) + + // Parse with the flag set to an invalid enum + err := f.Parse([]string{"--level", "101"}) + r.Error(err) + r.ErrorContains(err, "must be one of [0 10 100]") +} diff --git a/internal/pkg/flagvalue/map.go b/internal/pkg/flagvalue/map.go new file mode 100644 index 0000000..2d77c42 --- /dev/null +++ b/internal/pkg/flagvalue/map.go @@ -0,0 +1,74 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "fmt" + "reflect" + "strings" + + "github.com/spf13/pflag" +) + +type simpleMapValue[K, V SimpleValue] struct { + value *map[K]V + changed bool +} + +// SimpleMap returns a pflags.Value that sets the map at p with the default +// val or the value(s) provided via a flag. +func SimpleMap[K, V SimpleValue](val map[K]V, p *map[K]V) Value { + isv := new(simpleMapValue[K, V]) + isv.value = p + *isv.value = val + return isv +} + +func (m *simpleMapValue[K, V]) Set(s string) error { + // Split the string, KEY=VALUE, into its parts + parts := strings.SplitN(s, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("expected key=value, got %q", s) + } + + // Parse the key + var key K + _, err := fmt.Sscanf(parts[0], "%v", &key) + if err != nil { + return fmt.Errorf("failed to parse key: %w", err) + } + + // Parse the value + var value V + _, err = fmt.Sscanf(parts[1], "%v", &value) + if err != nil { + return fmt.Errorf("failed to parse value: %w", err) + } + + if !m.changed { + *m.value = map[K]V{ + key: value, + } + } else { + (*m.value)[key] = value + } + + m.changed = true + return nil +} + +func (m *simpleMapValue[K, V]) Type() string { + return reflect.TypeOf(*m.value).String() +} + +func (m *simpleMapValue[K, V]) String() string { + out := make([]string, 0, len(*m.value)) + for k, v := range *m.value { + out = append(out, fmt.Sprintf("%v=%v", k, v)) + } + return "[" + strings.Join(out, ",") + "]" +} + +// Ensure we meet the interface. +var _ pflag.Value = &simpleMapValue[string, string]{} diff --git a/internal/pkg/flagvalue/map_test.go b/internal/pkg/flagvalue/map_test.go new file mode 100644 index 0000000..711fc4f --- /dev/null +++ b/internal/pkg/flagvalue/map_test.go @@ -0,0 +1,138 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue_test + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" +) + +func ExampleSimpleMap() { + var headers map[string]string + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "headers", + Usage: "headers is a set of headers to send with the request. May be specified multiple times in the form of KEY=VALUE.", + Value: flagvalue.SimpleMap(nil, &headers), + }) + + // Make the request +} + +func TestSimpleMap_StringToString(t *testing.T) { + t.Parallel() + r := require.New(t) + + var m map[string]string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "values", + Value: flagvalue.SimpleMap(map[string]string{"test": "value"}, &m), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(map[string]string{"test": "value"}, m) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--values", "hello=world", "--values", "false=123"})) + r.EqualValues(map[string]string{ + "hello": "world", + "false": "123", + }, m) +} + +func TestSimpleMap_StringToInt(t *testing.T) { + t.Parallel() + r := require.New(t) + + var m map[string]int + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "values", + Value: flagvalue.SimpleMap(map[string]int{"test": 22}, &m), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(map[string]int{"test": 22}, m) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--values", "hello=49", "--values", "123=123"})) + r.EqualValues(map[string]int{ + "hello": 49, + "123": 123, + }, m) +} + +func TestSimpleMap_StringToBool(t *testing.T) { + t.Parallel() + r := require.New(t) + + var m map[string]bool + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "values", + Value: flagvalue.SimpleMap(map[string]bool{"test": true}, &m), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(map[string]bool{"test": true}, m) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--values", "hello=true", "--values", "test=false"})) + r.EqualValues(map[string]bool{ + "hello": true, + "test": false, + }, m) +} + +func TestSimpleMap_IntToString(t *testing.T) { + t.Parallel() + r := require.New(t) + + var m map[int]string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "values", + Value: flagvalue.SimpleMap(map[int]string{49: "test"}, &m), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(map[int]string{49: "test"}, m) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--values", "49=other", "--values", "123=123"})) + r.EqualValues(map[int]string{ + 49: "other", + 123: "123", + }, m) +} + +func TestSimpleMap(t *testing.T) { + t.Parallel() + r := require.New(t) + + var stringToString map[string]string + var stringToInt map[string]int + var i8Tof64 map[int8]float64 + + r.Equal("map[string]string", flagvalue.SimpleMap(nil, &stringToString).Type()) + r.Equal("map[string]int", flagvalue.SimpleMap(nil, &stringToInt).Type()) + r.Equal("map[int8]float64", flagvalue.SimpleMap(nil, &i8Tof64).Type()) +} diff --git a/internal/pkg/flagvalue/simple.go b/internal/pkg/flagvalue/simple.go new file mode 100644 index 0000000..24fda4a --- /dev/null +++ b/internal/pkg/flagvalue/simple.go @@ -0,0 +1,84 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "fmt" + "reflect" + "strconv" + + "github.com/spf13/pflag" + "golang.org/x/exp/constraints" +) + +// Value is the interface to the dynamic value stored in a flag. +type Value = pflag.Value + +// SimpleValue is a constraint that includes all the types supported by the +// Simple flag value. +type SimpleValue interface { + constraints.Float | constraints.Integer | ~string | ~bool | + *string | *bool +} + +type simpleValue[T any] struct { + value *T +} + +// Simple returns a pflags.Value that sets the value at p with the default +// val or the value provided via a flag. +// +// If the type of the value is a boolean, set the NoOptDefVal to "true", on the +// Flag. Otherwise the flag will have to have a value set to be parsed. As an +// example if the boolean flag had the name "force" and NoOptDefVal is not set, +// the flag will have to be set as --force=true. +func Simple[T SimpleValue](val T, p *T) Value { + v := new(simpleValue[T]) + v.value = p + *p = val + return v +} + +// Set implements the pflag.Value interface. +func (i *simpleValue[T]) Set(s string) error { + var err error + switch v := any(i.value).(type) { + case **string: + *v = new(string) + **v = s + case *string: + *v = s + case **bool: + *v = new(bool) + err = parseBool[bool](s, *v) + case *bool: + err = parseBool[bool](s, v) + default: + _, err = fmt.Sscanf(s, "%v", v) + } + return err +} + +func parseBool[T bool](s string, v *T) error { + b, err := strconv.ParseBool(s) + if err != nil { + return fmt.Errorf("failed to parse %q as a boolean", s) + } + + *v = T(b) + return nil +} + +// Type implements the pflag.Value interface. +func (i *simpleValue[T]) Type() string { + return reflect.TypeOf(*i.value).Name() +} + +// String implements the pflag.Value interface. +func (i *simpleValue[T]) String() string { + return fmt.Sprintf("%v", *i.value) +} + +// Ensure we meet the interface. +var _ pflag.Value = &simpleValue[bool]{} diff --git a/internal/pkg/flagvalue/simple_slice.go b/internal/pkg/flagvalue/simple_slice.go new file mode 100644 index 0000000..a51c513 --- /dev/null +++ b/internal/pkg/flagvalue/simple_slice.go @@ -0,0 +1,104 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue + +import ( + "fmt" + "reflect" + "strings" + + "github.com/spf13/pflag" +) + +type simpleSliceValue[T any] struct { + value *[]T + changed bool +} + +// SimpleSlice returns a pflags.Value that sets the slice at p with the default +// val or the value(s) provided via a flag. +func SimpleSlice[T SimpleValue](val []T, p *[]T) Value { + isv := new(simpleSliceValue[T]) + isv.value = p + *isv.value = val + return isv +} + +func (s *simpleSliceValue[T]) Set(val string) error { + ss := strings.Split(val, ",") + out := make([]T, len(ss)) + for i, val := range ss { + _, err := fmt.Sscanf(val, "%v", &out[i]) + if err != nil { + return err + } + } + if !s.changed { + *s.value = out + } else { + *s.value = append(*s.value, out...) + } + s.changed = true + return nil +} + +func (s *simpleSliceValue[T]) Type() string { + return fmt.Sprintf("%sSlice", reflect.TypeOf(*s.value).Elem().Name()) +} + +func (s *simpleSliceValue[T]) String() string { + out := make([]string, len(*s.value)) + for i, val := range *s.value { + out[i] = fmt.Sprintf("%v", val) + } + return "[" + strings.Join(out, ",") + "]" +} + +func (s *simpleSliceValue[T]) Append(val string) error { + i, err := s.fromString(val) + if err != nil { + return err + } + *s.value = append(*s.value, i) + return nil +} + +func (s *simpleSliceValue[T]) Replace(val []string) error { + out := make([]T, len(val)) + for i, d := range val { + var err error + out[i], err = s.fromString(d) + if err != nil { + return err + } + } + *s.value = out + return nil +} + +func (s *simpleSliceValue[T]) GetSlice() []string { + out := make([]string, len(*s.value)) + for i, d := range *s.value { + out[i] = s.toString(d) + } + return out +} + +func (s *simpleSliceValue[T]) fromString(val string) (T, error) { + var out T + _, err := fmt.Sscanf(val, "%v", &out) + if err != nil { + return out, err + } + + return out, nil +} + +func (s *simpleSliceValue[T]) toString(val T) string { + return fmt.Sprintf("%v", val) +} + +// Ensure we meet the interface. +var _ pflag.Value = &simpleSliceValue[string]{} +var _ pflag.SliceValue = &simpleSliceValue[string]{} diff --git a/internal/pkg/flagvalue/simple_slice_test.go b/internal/pkg/flagvalue/simple_slice_test.go new file mode 100644 index 0000000..5863fe4 --- /dev/null +++ b/internal/pkg/flagvalue/simple_slice_test.go @@ -0,0 +1,143 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue_test + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" +) + +func ExampleSimpleSlice() { + var secrets []string + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "secret", + Usage: "secret is a secret to read. Multiple values may be specified.", + Value: flagvalue.SimpleSlice[string]([]string{}, &secrets), + }) + + // Fetch the secrets +} + +func TestSimpleSlice_Type(t *testing.T) { + t.Parallel() + r := require.New(t) + var i8 []int8 + var i16 []int16 + var i32 []int32 + var i64 []int64 + var ui8 []uint8 + var ui16 []uint16 + var ui32 []uint32 + var ui64 []uint64 + var f32 []float32 + var f64 []float64 + var b []bool + var s []string + + r.Equal("int8Slice", flagvalue.SimpleSlice[int8](nil, &i8).Type()) + r.Equal("int16Slice", flagvalue.SimpleSlice[int16](nil, &i16).Type()) + r.Equal("int32Slice", flagvalue.SimpleSlice[int32](nil, &i32).Type()) + r.Equal("int64Slice", flagvalue.SimpleSlice[int64](nil, &i64).Type()) + r.Equal("uint8Slice", flagvalue.SimpleSlice[uint8](nil, &ui8).Type()) + r.Equal("uint16Slice", flagvalue.SimpleSlice[uint16](nil, &ui16).Type()) + r.Equal("uint32Slice", flagvalue.SimpleSlice[uint32](nil, &ui32).Type()) + r.Equal("uint64Slice", flagvalue.SimpleSlice[uint64](nil, &ui64).Type()) + r.Equal("float32Slice", flagvalue.SimpleSlice[float32](nil, &f32).Type()) + r.Equal("float64Slice", flagvalue.SimpleSlice[float64](nil, &f64).Type()) + r.Equal("boolSlice", flagvalue.SimpleSlice[bool](nil, &b).Type()) + r.Equal("stringSlice", flagvalue.SimpleSlice[string](nil, &s).Type()) +} + +func TestSimpleSlice_Bool(t *testing.T) { + t.Parallel() + r := require.New(t) + + var b []bool + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "bools", + Value: flagvalue.SimpleSlice[bool]([]bool{}, &b), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal([]bool{}, b) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--bools", "true", "--bools", "false", "--bools", "true"})) + r.Equal([]bool{true, false, true}, b) +} + +func TestSimpleSlice_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + var s []string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "strings", + Value: flagvalue.SimpleSlice[string]([]string{"test"}, &s), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal([]string{"test"}, s) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--strings", "hello", "--strings", "false", "--strings", "123"})) + r.Equal([]string{"hello", "false", "123"}, s) +} + +func TestSimpleSlice_Int(t *testing.T) { + t.Parallel() + r := require.New(t) + + var i []int + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "ints", + Value: flagvalue.SimpleSlice[int]([]int{12}, &i), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal([]int{12}, i) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--ints", "123", "--ints", "1", "--ints", "-123"})) + r.Equal([]int{123, 1, -123}, i) +} + +func TestSimpleSlice_Float64(t *testing.T) { + t.Parallel() + r := require.New(t) + + var floats []float64 + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "floats", + Value: flagvalue.SimpleSlice[float64]([]float64{12.12}, &floats), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal([]float64{12.12}, floats) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--floats", "123.123", "--floats", "1", "--floats", "-123.123"})) + r.Equal([]float64{123.123, 1, -123.123}, floats) +} diff --git a/internal/pkg/flagvalue/simple_test.go b/internal/pkg/flagvalue/simple_test.go new file mode 100644 index 0000000..ff0e61c --- /dev/null +++ b/internal/pkg/flagvalue/simple_test.go @@ -0,0 +1,284 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package flagvalue_test + +import ( + "testing" + + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/flagvalue" +) + +func ExampleSimple() { + var projectID string + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "project", + Usage: "project specifies the HCP Project ID to use.", + Value: flagvalue.Simple[string]("", &projectID), + }) +} + +func ExampleSimple_boolean() { + var force bool + f := pflag.NewFlagSet("example", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "force", + Shorthand: "f", + Usage: "force force deletes without confirmation.", + Value: flagvalue.Simple[bool](false, &force), + + // Critical to set for boolean values. Otherwise -f, --force will not + // set force to true. Instead the flag parsing will error expecting a + // value to be set for the flag. + NoOptDefVal: "true", + }) +} + +func TestSimple_Type(t *testing.T) { + t.Parallel() + r := require.New(t) + var i8 int8 + var i16 int16 + var i32 int32 + var i64 int64 + var ui8 uint8 + var ui16 uint16 + var ui32 uint32 + var ui64 uint64 + var f32 float32 + var f64 float64 + var b bool + var s string + + r.Equal("int8", flagvalue.Simple[int8](10, &i8).Type()) + r.Equal("int16", flagvalue.Simple[int16](10, &i16).Type()) + r.Equal("int32", flagvalue.Simple[int32](10, &i32).Type()) + r.Equal("int64", flagvalue.Simple[int64](10, &i64).Type()) + r.Equal("uint8", flagvalue.Simple[uint8](10, &ui8).Type()) + r.Equal("uint16", flagvalue.Simple[uint16](10, &ui16).Type()) + r.Equal("uint32", flagvalue.Simple[uint32](10, &ui32).Type()) + r.Equal("uint64", flagvalue.Simple[uint64](10, &ui64).Type()) + r.Equal("float32", flagvalue.Simple[float32](10.21, &f32).Type()) + r.Equal("float64", flagvalue.Simple[float64](10.21, &f64).Type()) + r.Equal("bool", flagvalue.Simple[bool](false, &b).Type()) + r.Equal("string", flagvalue.Simple[string]("foo", &s).Type()) +} + +func TestSimple_Bool(t *testing.T) { + t.Parallel() + r := require.New(t) + + var b bool + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "bool", + Value: flagvalue.Simple[bool](false, &b), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(false, b) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--bool", "true"})) + r.Equal(true, b) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--bool=what"}) + r.Error(err) + r.ErrorContains(err, `failed to parse "what" as a boolean`) +} + +func TestSimple_Bool_Ptr(t *testing.T) { + t.Parallel() + r := require.New(t) + + var b *bool + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "bool", + Value: flagvalue.Simple[*bool]((*bool)(nil), &b), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Nil(b) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--bool", "true"})) + r.NotNil(b) + r.Equal(true, *b) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--bool=what"}) + r.Error(err) + r.ErrorContains(err, `failed to parse "what" as a boolean`) +} + +func TestSimple_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + var s string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "string", + Value: flagvalue.Simple[string]("foo", &s), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal("foo", s) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--string", "hello"})) + r.Equal("hello", s) + + // Parse with a long string + r.NoError(f.Parse([]string{"--string", "hello, world!"})) + r.Equal("hello, world!", s) +} + +func TestSimple_String_Ptr(t *testing.T) { + t.Parallel() + r := require.New(t) + + var s *string + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "string", + Value: flagvalue.Simple((*string)(nil), &s), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Nil(s) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--string", "hello"})) + r.NotNil(s) + r.Equal("hello", *s) + + // Parse with a long string + r.NoError(f.Parse([]string{"--string", "hello, world!"})) + r.NotNil(s) + r.Equal("hello, world!", *s) +} + +func TestSimple_Int(t *testing.T) { + t.Parallel() + r := require.New(t) + + var i int + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "int", + Value: flagvalue.Simple[int](10, &i), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(int(10), i) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--int", "42"})) + r.Equal(int(42), i) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--int=what"}) + r.Error(err) + r.Error(err) +} + +func TestSimple_Int8(t *testing.T) { + t.Parallel() + r := require.New(t) + + var i int8 + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "int", + Value: flagvalue.Simple[int8](10, &i), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(int8(10), i) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--int", "42"})) + r.Equal(int8(42), i) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--int=what"}) + r.Error(err) +} + +func TestSimple_Uint64(t *testing.T) { + t.Parallel() + r := require.New(t) + + var i uint64 + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "int", + Value: flagvalue.Simple[uint64](10, &i), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(uint64(10), i) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--int", "42"})) + r.Equal(uint64(42), i) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--int=what"}) + r.Error(err) +} + +func TestSimple_Float32(t *testing.T) { + t.Parallel() + r := require.New(t) + + var i float32 + defVal := float32(10.3) + f := pflag.NewFlagSet("test", pflag.ContinueOnError) + f.AddFlag(&pflag.Flag{ + Name: "number", + Value: flagvalue.Simple[float32](defVal, &i), + }) + + // Parse an empty set of args + r.NoError(f.Parse([]string{})) + + // Expect the default + r.Equal(defVal, i) + + // Parse with the flag set + r.NoError(f.Parse([]string{"--number", "42.42"})) + r.Equal(float32(42.42), i) + + // Parse with the flag set to an invalid value + err := f.Parse([]string{"--int=what"}) + r.Error(err) +} diff --git a/internal/pkg/format/displayers_test.go b/internal/pkg/format/displayers_test.go new file mode 100644 index 0000000..4b6582f --- /dev/null +++ b/internal/pkg/format/displayers_test.go @@ -0,0 +1,99 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "time" + + "github.com/dustin/go-humanize" + + "github.com/hashicorp/tfcloud/internal/pkg/format" +) + +type Complex struct { + Name string + Description string + Version int + CreatedAt time.Time + UpdatedAt time.Time +} + +func (c *Complex) CreatedAtHumanized() string { + return humanize.Time(c.CreatedAt) +} + +func (c *Complex) UpdatedAtHumanized() string { + return humanize.Time(c.UpdatedAt) +} + +type ComplexDisplayer struct { + Data []*Complex + Default format.Format +} + +func (d *ComplexDisplayer) DefaultFormat() format.Format { return d.Default } + +func (d *ComplexDisplayer) Payload() any { + if len(d.Data) == 1 { + return d.Data[0] + } + + return d.Data +} + +func (d *ComplexDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Name", + ValueFormat: "{{ .Name }}", + }, + { + Name: "Description", + ValueFormat: "{{ .Description }}", + }, + { + Name: "Version", + ValueFormat: "v{{ .Version }}", + }, + { + Name: "Created At", + ValueFormat: "{{ .CreatedAtHumanized }}", + }, + { + Name: "Updated At", + ValueFormat: "{{ .UpdatedAtHumanized }}", + }, + } +} + +type KV struct { + Key, Value string +} + +type KVDisplayer struct { + KVs []*KV + Default format.Format +} + +func (d *KVDisplayer) DefaultFormat() format.Format { return d.Default } + +func (d *KVDisplayer) Payload() any { + if len(d.KVs) == 1 { + return d.KVs[0] + } + + return d.KVs +} +func (d *KVDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Key", + ValueFormat: "{{ .Key }}", + }, + { + Name: "Value", + ValueFormat: "{{ .Value }}", + }, + } +} diff --git a/internal/pkg/format/example_test.go b/internal/pkg/format/example_test.go new file mode 100644 index 0000000..abae3ef --- /dev/null +++ b/internal/pkg/format/example_test.go @@ -0,0 +1,142 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "fmt" + "strings" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func ExampleOutputter() { + // Create the outputter. This is typically passed to the command. + io := iostreams.Test() + outputter := format.New(io) + + // Resource is an example resource that we want to display + type Metadata struct { + Owner string + CreatedAt string + } + + type Resource struct { + Name string + ID string + Description string + Bytes int + Metadata Metadata + } + + // Build our mock resources. Typically this is the response payload from an API + // request. + payload := []Resource{ + { + Name: "hello", + ID: "123", + Description: "world", + Bytes: 100, + Metadata: Metadata{ + Owner: "Bob Builder", + CreatedAt: "2021-01-01", + }, + }, + { + Name: "another", + ID: "456", + Description: "example", + Bytes: 1024 * 1024, + Metadata: Metadata{ + Owner: "Jeff Bezos", + CreatedAt: "2023-02-04", + }, + }, + } + + // For displaying a table of the exact values, Show can be used: + _ = outputter.Show(payload, format.Table) + + // For displaying a table with a subset of the fields, list the fields as + // such: + // _ = outputter.Show(payload, format.Table, "Name", "ID", "Metadata.Owner") + + // Since the IO is a test io, manually print it. + // We trim the lines to make examples testing pass correctly. + lines := strings.Split(io.Output.String(), "\n") + for i, l := range lines { + lines[i] = strings.TrimSpace(l) + } + fmt.Println(strings.Join(lines, "\n")) + + // Output: + // Name ID Description Bytes Metadata Owner Metadata Created At + // hello 123 world 100 Bob Builder 2021-01-01 + // another 456 example 1048576 Jeff Bezos 2023-02-04 + // + +} + +func ExampleDisplayer() { + // Create the outputter. This is typically passed to the command. + io := iostreams.Test() + outputter := format.New(io) + + // Resource is an example resource that we want to display + type Resource struct { + Name string + ID string + Description string + Bytes int + } + + // Build our mock resources. Typically this is the response payload from an API + // request. + payload := []Resource{ + { + Name: "hello", + ID: "123", + Description: "world", + Bytes: 100, + }, + { + Name: "another", + ID: "456", + Description: "example", + Bytes: 1024 * 1024, + }, + } + + // If you wish to format the values differently, use a Displayer: + + // Define the fields that we want to ExampleDisplayer + var fields = []format.Field{ + format.NewField("Name", "{{ .Name }}"), + format.NewField("ID", "{{ .ID }}"), + format.NewField("Description", "{{ .Description }}"), + format.NewField("Bytes", "{{ .Bytes }} bytes"), + } + + // Build the displayer + d := format.NewDisplayer(payload, format.Pretty, fields) + + // Run the displayer + if err := outputter.Display(d); err != nil { + fmt.Printf("error displaying resources: %s\n", err) + } + + // Since the IO is a test io, manually print it + fmt.Println(io.Output.String()) + + // Output: + // Name: hello + // ID: 123 + // Description: world + // Bytes: 100 bytes + // --- + // Name: another + // ID: 456 + // Description: example + // Bytes: 1048576 bytes +} diff --git a/internal/pkg/format/format.go b/internal/pkg/format/format.go new file mode 100644 index 0000000..792995c --- /dev/null +++ b/internal/pkg/format/format.go @@ -0,0 +1,51 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package format provides utilities for handling output formats. +package format + +import ( + "fmt" + "strings" + + "golang.org/x/exp/maps" +) + +// Format captures the output format to use. +type Format int + +const ( + // Unset is the default value for Format and indicates that no format has been set. + Unset Format = iota + + // Pretty is used to output the payload in a key/value format where each + // pair is outputted on a new line. + Pretty Format = iota + + // Table outputs the payload as a table. + Table Format = iota + + // JSON outputs the values in raw JSON. + JSON Format = iota +) + +var ( + // formatStrings is used to convert from the canonical string representation + // to the Format enum. + formatStrings = map[string]Format{ + "pretty": Pretty, + "table": Table, + "json": JSON, + } +) + +// FromString converts a string representation of a format to a Format. +func FromString(s string) (Format, error) { + s = strings.ToLower(s) + f, ok := formatStrings[s] + if !ok { + return Pretty, fmt.Errorf("invalid format %q. Must be one of %q", s, maps.Keys(formatStrings)) + } + + return f, nil +} diff --git a/internal/pkg/format/infer_fields_test.go b/internal/pkg/format/infer_fields_test.go new file mode 100644 index 0000000..882fcf6 --- /dev/null +++ b/internal/pkg/format/infer_fields_test.go @@ -0,0 +1,116 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInferFields(t *testing.T) { + t.Parallel() + + r := require.New(t) + + s1 := struct { + Name string + }{ + Name: "s1", + } + + r.Equal([]Field{ + {Name: "Name", ValueFormat: "{{ .Name }}"}, + }, inferFields(s1, nil)) + + s3 := struct { + Name string + Age int + }{ + Name: "s3", + } + + r.Equal([]Field{ + {Name: "Name", ValueFormat: "{{ .Name }}"}, + }, inferFields(s3, []string{"Name"})) + + // Shows that the json tag wins as the title even if it's specified as + // by struct field name + r.Equal([]Field{ + {Name: "Name", ValueFormat: "{{ .Name }}"}, + }, inferFields(s3, []string{"Name"})) + + s4 := []struct { + Name string + Age int + }{ + { + Name: "s3", + }, + } + + r.Equal([]Field{ + {Name: "Name", ValueFormat: "{{ .Name }}"}, + }, inferFields(s4, []string{"Name"})) + + r.Equal([]Field{ + {Name: "Value", ValueFormat: "{{ . }}"}, + }, inferFields(1, nil)) + + s5 := struct { + Name string + max int + }{ + Name: "s2", + max: 10, + } + + r.Equal([]Field{ + {Name: "Name", ValueFormat: "{{ .Name }}"}, + }, inferFields(s5, nil)) + + s6 := struct { + CreatedAt string + max int + }{ + CreatedAt: "s2", + max: 10, + } + + r.Equal([]Field{ + {Name: "Created At", ValueFormat: "{{ .CreatedAt }}"}, + }, inferFields(s6, nil)) + + type nested struct { + Test string + max int + } + + s7 := struct { + Metadata nested + }{ + Metadata: nested{ + Test: "s7", + max: 10, + }, + } + + r.Equal([]Field{ + {Name: "Metadata Test", ValueFormat: "{{ with .Metadata }}{{ .Test }}{{ end }}"}, + }, inferFields(s7, nil)) + + s8 := struct { + Metadata *nested + }{ + Metadata: &nested{ + Test: "s8", + max: 10, + }, + } + + r.Equal([]Field{ + {Name: "Metadata Test", ValueFormat: "{{ with .Metadata }}{{ .Test }}{{ end }}"}, + }, inferFields(s8, nil)) + +} diff --git a/internal/pkg/format/json_test.go b/internal/pkg/format/json_test.go new file mode 100644 index 0000000..7a76eb6 --- /dev/null +++ b/internal/pkg/format/json_test.go @@ -0,0 +1,152 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestJSON_KV_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + { + Key: "Another", + Value: "Test", + }, + }, + Default: format.JSON, + } + + // Display the table + r.NoError(out.Display(d)) + + var parsed []*KV + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + r.Equal(d.KVs, parsed) +} + +func TestJSON_KV_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + }, + Default: format.JSON, + } + + // Display the table + r.NoError(out.Display(d)) + + var parsed *KV + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + r.Equal(d.KVs[0], parsed) +} + +func TestJSON_Complex_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + { + Name: "Other", + Description: "Other description", + Version: 15, + CreatedAt: time.Now().Add(-10 * time.Minute), + UpdatedAt: time.Now().Add(-3 * time.Second), + }, + }, + Default: format.JSON, + } + + // Display the table + r.NoError(out.Display(d)) + + var parsed []*Complex + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + + // Check the timestamps are equal and then clear + for i, d := range d.Data { + r.True(d.CreatedAt.Equal(parsed[i].CreatedAt)) + r.True(d.UpdatedAt.Equal(parsed[i].UpdatedAt)) + d.CreatedAt = time.Time{} + d.UpdatedAt = time.Time{} + parsed[i].CreatedAt = time.Time{} + parsed[i].UpdatedAt = time.Time{} + } + + r.Equal(d.Data, parsed) +} + +func TestJSON_Complex_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + }, + Default: format.JSON, + } + + // Display the table + r.NoError(out.Display(d)) + + var parsed *Complex + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + + // Check the timestamps are equal and then clear + r.True(d.Data[0].CreatedAt.Equal(parsed.CreatedAt)) + r.True(d.Data[0].UpdatedAt.Equal(parsed.UpdatedAt)) + d.Data[0].CreatedAt = time.Time{} + d.Data[0].UpdatedAt = time.Time{} + parsed.CreatedAt = time.Time{} + parsed.UpdatedAt = time.Time{} + + r.Equal(d.Data[0], parsed) +} diff --git a/internal/pkg/format/output.go b/internal/pkg/format/output.go new file mode 100644 index 0000000..c7eb1b2 --- /dev/null +++ b/internal/pkg/format/output.go @@ -0,0 +1,416 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "text/tabwriter" + "text/template" + "unicode" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +// Displayer is the interface for displaying a given payload. By implementing +// this interface, the payload can be outputted in any of the given Formats. +type Displayer interface { + // DefaultFormat returns the Format in which to display the payload if the + // user does not specify a Format override. + DefaultFormat() Format + + // Payload is the object to display. Payload may return a single object or a + // slice of objects. + Payload() any + + // FieldTemplates returns a slice of Fields. Each Field represents an field + // based on the payload to display to the user. It is common that the Field + // is simply a specific field of the payload struct being outputted. + FieldTemplates() []Field +} + +// NewDisplayer creates a new Displayer with the given payload, default format, +// and fields. +func NewDisplayer[T any](payload T, defaultFormat Format, fields []Field) Displayer { + return &internalDisplayer[T]{ + payload: payload, + fields: fields, + defaultFormat: defaultFormat, + } +} + +// DisplayFields displays the given fields about the given payload. If no fields are +// provided, then all fields are displayed. The fields can be specified using +// the direct struct field name. If specifying a nested nested field, use a dot +// to separate (SubStruct.FieldA). +func DisplayFields[T any](payload T, format Format, fields ...string) Displayer { + return NewDisplayer[T](payload, format, inferFields(payload, fields)) +} + +func formatName(name string) string { + var sb strings.Builder + + spaced := true + + for i, r := range name { + if i == 0 { + sb.WriteRune(r) + continue + } + + if !spaced && unicode.IsUpper(r) { + sb.WriteRune(' ') + spaced = true + } else { + spaced = false + } + + sb.WriteRune(r) + } + + return sb.String() +} + +func inferFields[T any](payload T, columns []string) []Field { + rv := reflect.ValueOf(payload) + + for rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + + var ret []Field + + if rv.Kind() == reflect.Slice { + if rv.Len() == 0 { + return ret + } + rv = rv.Index(0) + + for rv.Kind() == reflect.Pointer { + rv = rv.Elem() + } + } + + if rv.Kind() != reflect.Struct { + return []Field{NewField("Value", "{{ . }}")} + } + + toField := map[string]int{} + + for i, col := range columns { + toField[col] = i + } + + st := rv.Type() + all := len(toField) == 0 + if !all { + ret = make([]Field, len(toField)) + } + + // fieldNames takes a slice of strings that represent the nesting of a field + // (e.g. ["ClusterInfo", "Name"] indicates Name is a field of the struct + // ClusterInfo) and returns the dotted and spaced versions of the parts. + // The dotted version is the parts joined by a dot (e.g. "ClusterInfo.Name") + // and the spaced version is the parts joined by a space (e.g. "Cluster Info + // Name") and each part spaced on camel case words. + fieldNames := func(parts []string) (dotted, spaced string) { + dotted = strings.Join(parts, ".") + for i, part := range parts { + if i != 0 { + spaced += " " + } + + spaced += formatName(part) + } + return + } + + var getFields func(st reflect.Type, namePrefix []string) + getFields = func(st reflect.Type, namePrefix []string) { + exportedFields := 0 + for i := 0; i < st.NumField(); i++ { + f := st.Field(i) + if !f.IsExported() { + continue + } + exportedFields++ + + parts := append(slices.Clone(namePrefix), f.Name) + + // If the field is a struct, we need to recurse into it + if f.Type.Kind() == reflect.Struct || f.Type.Kind() == reflect.Ptr && f.Type.Elem().Kind() == reflect.Struct { + t := f.Type + if f.Type.Kind() == reflect.Ptr { + t = f.Type.Elem() + } + getFields(t, parts) + } else { + dotted, formatted := fieldNames(parts) + df := NewField(formatted, fmt.Sprintf("{{ .%s }}", dotted)) + if all { + ret = append(ret, df) + } else if idx, ok := toField[dotted]; ok { + ret[idx] = df + } + } + } + + // Handle the case where the struct has no exported fields such as + // time.Time. In this case, we display the struct directly, defering to + // any String() method on the struct. + if exportedFields == 0 { + dotted, formatted := fieldNames(namePrefix) + ret = append(ret, NewField(formatted, fmt.Sprintf("{{ .%s}}", dotted))) + } + } + + // Gather the fields + getFields(st, nil) + for i := range ret { + ret[i].ValueFormat = convertToScopedWithBlocks(ret[i].ValueFormat) + } + return ret +} + +// convertToScopedWithBlocks converts a full dot-path into nested `with` blocks using local field scope. +// {{ .Request.Agent.Op.ActionRunID }} => {{ with .Request }}{{ with .Agent }}{{ with .Op }}{{ .ActionRunID }}{{ end }}{{ end }}{{ end }}. +func convertToScopedWithBlocks(input string) string { + input = strings.TrimSpace(input) + // Remove surrounding {{ and }} if present + if strings.HasPrefix(input, "{{") && strings.HasSuffix(input, "}}") { + input = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(input, "{{"), "}}")) + } + // Only process if input starts with "." + if !strings.HasPrefix(input, ".") { + return input + } + // Split by "." and ignore the leading empty string from input[1:] + parts := strings.Split(input[1:], ".") + if len(parts) < 2 { + return "{{ " + input + " }}" + } + var builder strings.Builder + // Open with blocks + for _, part := range parts[:len(parts)-1] { + fmt.Fprintf(&builder, "{{ with .%s }}", part) + } + // Final value + fmt.Fprintf(&builder, "{{ .%s }}", parts[len(parts)-1]) + // Close all with blocks + builder.WriteString(strings.Repeat("{{ end }}", len(parts)-1)) + return strings.TrimSpace(builder.String()) +} + +type internalDisplayer[T any] struct { + payload T + fields []Field + defaultFormat Format +} + +func (i *internalDisplayer[T]) DefaultFormat() Format { return i.defaultFormat } +func (i *internalDisplayer[T]) FieldTemplates() []Field { return i.fields } +func (i *internalDisplayer[T]) Payload() any { return i.payload } + +// TemplatedPayload allows a Displayer to return a different payload if the +// output will be templated using the field templates. This can be useful when +// raw output (e.g. JSON) requires a specific payload but the templated output +// (e.g. table/pretty) would benefit from a different payload type. +type TemplatedPayload interface { + TemplatedPayload() any +} + +// Field represents a field to output. +type Field struct { + // Name is the displayed name of the field to the user. + Name string + + // ValueFormat is a text/template that controls how the value will be + // displayed. If the payload is a struct with the following structure: + // + // type Cluster struct { + // Name string + // Description string + // CloudProvider string + // Region string + // CreatedAt time.Time + // } + // + // Example ValueFormat's would be: + // '{{ . Name }}' -> "Example" + // '{{ .CloudProvider }}/{{ . Region }}' -> "aws/us-east-1" + // + // A more advanced example would be using the text/template to invoke a + // function. This can be done by implementing a function on the Payload type + // that can be invoked. Function definitions will shadow fields in the + // returned Payload. + // + // func (c *Cluster) CreatedAt() string { + // return humanize.Time(d.cluster.CreatedAt) + // } + // + // A ValueFormat of '{{ .CreatedAt }}' will now invoke this function. If the + // cluster was recently created an output may display "4s ago". + ValueFormat string +} + +// NewField creates a new Field with the given name and value format string. See +// the Field struct for more information. +func NewField(name, valueFormat string) Field { + return Field{Name: name, ValueFormat: valueFormat} +} + +// Outputter is used to output data to users in a consistent manner. The +// outputter supports outputting data in a number of Formats. +// +// To output data, the Display function should be called with a Displayer. A +// Displayer has a default format. The outputter will use this format unless a +// format has previously been set which overrides the default. +type Outputter struct { + // io is the iostream to output to. + io iostreams.IOStreams + + // forcedFormat is the format to output with regardless of the DefaultFormat + // of the passed Displayer. + forcedFormat Format +} + +// New returns an new outputter that will write to the provided IOStreams. +func New(io iostreams.IOStreams) *Outputter { + if io == nil { + panic("io stream must be specified") + } + + return &Outputter{ + io: io, + } +} + +// SetFormat sets the format to output with regardless of the DefaultFormat +// returned by the displayer. +func (o *Outputter) SetFormat(f Format) { + o.forcedFormat = f +} + +// GetFormat returns the format if set. +func (o *Outputter) GetFormat() Format { + return o.forcedFormat +} + +// Display displays the passed Displayer. The format used is the DefaultFormat +// unless the outputter has had a Format set which overrides the default. +func (o *Outputter) Display(d Displayer) error { + // Determine what format to use + format := d.DefaultFormat() + if o.forcedFormat != Unset { + format = o.forcedFormat + } + + // Display the payload based on the selected format. + switch format { + case Pretty: + return o.outputPretty(d) + case Table: + return o.outputTable(d) + case JSON: + return o.outputJSON(d) + } + + return fmt.Errorf("invalid output format") +} + +// Show outputs the given val using the DisplayFields function. +// If fields are specified, only those fields are shown, otherwise +// all fields are shown. If specifying a nested nested field, use a dot +// to separate (SubStruct.FieldA). +// +// This is a simplified version of using .Display, which should be used for all more +// advanced cases that require formatting fields differently. +// +// This function can accept a slice of values as well and formats them correctly. +// If the value being considered (directly or within in a slice) is not a struct, +// it is displayed as is under the field named 'Value'. +func (o *Outputter) Show(val any, format Format, fields ...string) error { + return o.Display(DisplayFields(val, format, fields...)) +} + +// outputJSON outputs the payload in JSON. +func (o *Outputter) outputJSON(d Displayer) error { + data, err := json.MarshalIndent(d.Payload(), "", " ") + if err != nil { + return fmt.Errorf("failed to marshall result to JSON: %w", err) + } + + fmt.Fprintln(o.io.Out(), string(data)) + return nil +} + +// outputPretty outputs the payload using a key/value format where each field +// occupies a single row. +func (o *Outputter) outputPretty(d Displayer) error { + var p any + if tp, ok := d.(TemplatedPayload); ok { + p = tp.TemplatedPayload() + } else { + p = d.Payload() + } + + tmpl, err := template.New("tfcloud").Parse(prettyPrintTemplate(d)) + if err != nil { + return err + } + + rv := reflect.ValueOf(p) + if rv.Kind() == reflect.Slice { + if rv.Len() == 0 { + fmt.Fprintln(o.io.Out(), "Listed 0 items.") + return nil + } + + for i := 0; i < rv.Len(); i++ { + vf := rv.Index(i) + if err := tmpl.Execute(o.io.Out(), vf.Interface()); err != nil { + return err + } + + fmt.Fprintln(o.io.Out()) + if i != rv.Len()-1 { + fmt.Fprintln(o.io.Out(), "---") + } + } + } else { + if err := tmpl.Execute(o.io.Out(), p); err != nil { + return err + } + + fmt.Fprintln(o.io.Out()) + } + + return nil +} + +// prettyPrintTemplate returns a text/template string for pretty printing the +// given payload. The template will align the values so they are easily scannable. +func prettyPrintTemplate(d Displayer) string { + // Write to the buffer using a tabwriter. The Tabwriter will ensure that + // each key/value is aligned. + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 0, 1, ' ', 0) + + // Go through each field and output a new line + fields := d.FieldTemplates() + for i, f := range fields { + fmt.Fprintf(w, "%s:\t%s", f.Name, f.ValueFormat) + if i != len(fields)-1 { + fmt.Fprintln(w) + } + } + + // Ignore the error + _ = w.Flush() + return buf.String() +} diff --git a/internal/pkg/format/output_test.go b/internal/pkg/format/output_test.go new file mode 100644 index 0000000..7144668 --- /dev/null +++ b/internal/pkg/format/output_test.go @@ -0,0 +1,243 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + example "github.com/hashicorp/go-tfe/api/account" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestOutputter_SetFormat(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer default to pretty printing + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + }, + Default: format.Pretty, + } + + // Force the format to JSON + out.SetFormat(format.JSON) + + // Display the table + r.NoError(out.Display(d)) + + // Ensure we can unmarshal the output as JSON + var parsed *KV + r.NoError(json.Unmarshal(io.Output.Bytes(), &parsed)) + r.Equal(d.KVs[0], parsed) +} + +type InnerL2Struct struct { + Name string +} + +type InnerL1Struct struct { + Name string + Inner *InnerL2Struct +} + +type OuterStruct struct { + Name string + Inner *InnerL1Struct +} + +func TestNilInnerStruct(t *testing.T) { + t.Parallel() + r := require.New(t) + + kv := &OuterStruct{ + Name: "OuterStruct", + // we leave inner nil on purpose + } + + io := iostreams.Test() + out := format.New(io) + err := out.Show(kv, format.Pretty) + + r.NoError(err) + fmt.Println(io.Output.String()) + r.Equal("Name: OuterStruct\nInner Name: \nInner Inner Name: \n", io.Output.String()) +} + +func TestNilInnerL2Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + + kv := &OuterStruct{ + Name: "OuterStruct", + Inner: &InnerL1Struct{ + Name: "InnerL1Struct", + // we leave inner nil on purpose + }, + } + + io := iostreams.Test() + out := format.New(io) + err := out.Show(kv, format.Pretty) + + r.NoError(err) + fmt.Println(io.Output.String()) + r.Equal("Name: OuterStruct\nInner Name: InnerL1Struct\nInner Inner Name: \n", io.Output.String()) +} + +func TestNonNilInnerStruct(t *testing.T) { + t.Parallel() + r := require.New(t) + + kv := &OuterStruct{ + Name: "OuterStruct", + Inner: &InnerL1Struct{ + Name: "InnerL1Struct", + Inner: &InnerL2Struct{ + Name: "InnerStruct", + }, + }, + } + + io := iostreams.Test() + out := format.New(io) + err := out.Show(kv, format.Pretty) + + r.NoError(err) + fmt.Println(io.Output.String()) + r.Equal("Name: OuterStruct\nInner Name: InnerL1Struct\nInner Inner Name: InnerStruct\n", io.Output.String()) +} + +func TestWithSlice(t *testing.T) { + t.Skip("Do we even want this type of formatting?") + t.Parallel() + r := require.New(t) + j := `{ + "data": { + "id": "user-V3R563qtJNcExAkN", + "type": "users", + "attributes": { + "username": "admin", + "is-service-account": false, + "auth-method": "tfc", + "avatar-url": "https://www.gravatar.com/avatar/9babb00091b97b9ce9538c45807fd35f?s=100&d=mm", + "v2-only": false, + "is-site-admin": true, + "is-sso-login": false, + "email": "admin@hashicorp.com", + "unconfirmed-email": null, + "permissions": { + "can-create-organizations": true, + "can-change-email": true, + "can-change-username": true + } + }, + "relationships": { + "authentication-tokens": { + "links": { + "related": "/api/v2/users/user-V3R563qtJNcExAkN/authentication-tokens" + } + }, + "authenticated-resource": { + "data": { + "id": "user-V3R563qtJNcExAkN", + "type": "users" + }, + "links": { + "related": "/api/v2/users/user-V3R563qtJNcExAkN" + } + } + }, + "links": { + "self": "/api/v2/users/user-V3R563qtJNcExAkN" + } + } +}` + thing := example.DetailsGetResponse{} + err := json.Unmarshal([]byte(j), &thing) + r.NoError(err) + io := iostreams.Test() + out := format.New(io) + err = out.Show(thing.GetData().GetAttributes(), format.Pretty) + + r.NoError(err) + fmt.Println(io.Output.String()) + + expected := `Action UR L: +Created At: 2024-08-16T18:11:19.777Z +Description: test description +ID: 00000000-0000-0000-0000-000000000000 +Name: Agent Smith +Request Agent Op Action Run ID: +Request Agent Op Body: +Request Agent Op Group: Enforcements +Request Agent Op ID: Agent Smith +Request Custom Body: +Request Custom Headers: +Request Custom Method: +Request Custom UR L: +Request Github Enable Debug Log: +Request Github Gh Enabled Workflow Param: +Request Github Git Ref: +Request Github Inputs: +Request Github Install Name: +Request Github Repository: +Request Github Workflow ID: +--- +Action UR L: +Created At: 2024-06-13T17:31:17.436Z +Description: Runs an action against https://hashicorp.com +ID: 11111111-1111-1111-1111-111111111111 +Name: Example +Request Agent Op Action Run ID: +Request Agent Op Body: +Request Agent Op Group: +Request Agent Op ID: +Request Custom Body: +Request Custom Headers: [] +Request Custom Method: GET +Request Custom UR L: https://hashicorp.com +Request Github Enable Debug Log: +Request Github Gh Enabled Workflow Param: +Request Github Git Ref: +Request Github Inputs: +Request Github Install Name: +Request Github Repository: +Request Github Workflow ID: +--- +Action UR L: +Created At: 2024-08-07T21:56:00.043Z +Description: An action to test the variables feature. +ID: 22222222-2222-2222-2222-222222222222 +Name: Variables +Request Agent Op Action Run ID: +Request Agent Op Body: +Request Agent Op Group: +Request Agent Op ID: +Request Custom Body: +Request Custom Headers: [] +Request Custom Method: GET +Request Custom UR L: https://${var.company}.com +Request Github Enable Debug Log: +Request Github Gh Enabled Workflow Param: +Request Github Git Ref: +Request Github Inputs: +Request Github Install Name: +Request Github Repository: +Request Github Workflow ID: +` + r.Equal(expected, io.Output.String()) +} diff --git a/internal/pkg/format/pretty_test.go b/internal/pkg/format/pretty_test.go new file mode 100644 index 0000000..8e6873e --- /dev/null +++ b/internal/pkg/format/pretty_test.go @@ -0,0 +1,297 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "bufio" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestPretty_KV_Slice_Empty(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{}, + Default: format.Pretty, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + r.Equal("Listed 0 items.\n", io.Output.String()) +} + +func TestPretty_KV_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + { + Key: "Another", + Value: "Test", + }, + }, + Default: format.Pretty, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Key:", "Hello"}, + {"Value:", "World!"}, + {"---"}, + {"Key:", "Another"}, + {"Value:", "Test"}, + } + + previousAlignment := -1 + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + + // Skip blank lines when checking for alignment + if len(row) == 1 { + continue + } + + // Determine whether we are aligning + alignment := charactersToValue(scanner.Text()) + if previousAlignment == -1 { + previousAlignment = alignment + } + + r.Equal(previousAlignment, alignment) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestPretty_KV_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + }, + Default: format.Pretty, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Key:", "Hello"}, + {"Value:", "World!"}, + } + + previousAlignment := -1 + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + + // Skip blank lines when checking for alignment + if len(row) == 1 { + continue + } + + // Determine whether we are aligning + alignment := charactersToValue(scanner.Text()) + if previousAlignment == -1 { + previousAlignment = alignment + } + + r.Equal(previousAlignment, alignment) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestPretty_Complex_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + { + Name: "Other", + Description: "Other description", + Version: 15, + CreatedAt: time.Now().Add(-10 * time.Minute), + UpdatedAt: time.Now().Add(-3 * time.Second), + }, + }, + Default: format.Pretty, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Name:", "Test"}, + {"Description:", "Test", "description"}, + {"Version:", "v12"}, + {"Created", "At:", "5", "seconds", "ago"}, + {"Updated", "At:", "1", "second", "ago"}, + {"---"}, + {"Name:", "Other"}, + {"Description:", "Other", "description"}, + {"Version:", "v15"}, + {"Created", "At:", "10", "minutes", "ago"}, + {"Updated", "At:", "3", "seconds", "ago"}, + } + + previousAlignment := -1 + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + + // Skip blank lines when checking for alignment + if len(row) == 1 { + continue + } + + // Determine whether we are aligning + alignment := charactersToValue(scanner.Text()) + if previousAlignment == -1 { + previousAlignment = alignment + } + + r.Equal(previousAlignment, alignment) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestPretty_Complex_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + }, + Default: format.Pretty, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Name:", "Test"}, + {"Description:", "Test", "description"}, + {"Version:", "v12"}, + {"Created", "At:", "5", "seconds", "ago"}, + {"Updated", "At:", "1", "second", "ago"}, + } + + previousAlignment := -1 + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + + // Skip blank lines when checking for alignment + if len(row) == 1 { + continue + } + + // Determine whether we are aligning + alignment := charactersToValue(scanner.Text()) + if previousAlignment == -1 { + previousAlignment = alignment + } + + r.Equal(previousAlignment, alignment) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +// charactersToValue returns the number of characters to the first value. +// For an input of "My Key: My Value" the returned value will be 10. +// "My Key" (6) + ":" (1) + " " (3) = 10. +func charactersToValue(line string) int { + // Split on the colon + parts := strings.Split(line, ":") + label := parts[0] + prefixedValue := parts[1] + + // Determine the number of spaces to the first non-space character + spaces := 0 + for i, c := range prefixedValue { + spaces = i + if c != ' ' { + break + } + } + + // Determine whether we are aligning + return len(label) + 1 + spaces +} diff --git a/internal/pkg/format/table.go b/internal/pkg/format/table.go new file mode 100644 index 0000000..64c38f8 --- /dev/null +++ b/internal/pkg/format/table.go @@ -0,0 +1,127 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format + +import ( + "bytes" + "fmt" + "reflect" + "strings" + "text/template" + + "github.com/hashicorp/tfcloud/internal/pkg/table" +) + +// TableFormatter is an optional interface to implement to customize how a table +// is outputted. +type TableFormatter interface { + HeaderFormatter(input string) string + FirstColumnFormatter(input string) string +} + +// outputTable outputs the payload as a table. +func (o *Outputter) outputTable(d Displayer) error { + // Gather the headers and the row template + fields := d.FieldTemplates() + headers := make([]interface{}, len(fields)) + for i, f := range fields { + headers[i] = f.Name + } + + // Create the table outputter + tbl := table.Table{ + LineLength: uint(o.io.TerminalWidth()), + Wrap: true, + SeparatorSpaces: 3, + HeaderFormatter: o.defaultHeaderFormatter, + FirstColumnFormatter: o.defaultFirstColumnFormatter, + } + + // If the displayer has implemented the table formatter, then use it. + if formatter, ok := d.(TableFormatter); ok { + tbl.HeaderFormatter = formatter.HeaderFormatter + tbl.FirstColumnFormatter = formatter.FirstColumnFormatter + } + + // Add the headers + tbl.AddRow(headers...) + + // Get the payload + var p any + if tp, ok := d.(TemplatedPayload); ok { + p = tp.TemplatedPayload() + } else { + p = d.Payload() + } + + // Build the rows + var rows [][]interface{} + rv := reflect.ValueOf(p) + + // If the payload is a slice, render each row and add it to the table. + if rv.Kind() == reflect.Slice { + for i := 0; i < rv.Len(); i++ { + vf := rv.Index(i) + row, err := renderRow(vf.Interface(), fields) + if err != nil { + return err + } + + rows = append(rows, row) + } + } else { + // Render the payload as a row and add it to the table. + row, err := renderRow(p, fields) + if err != nil { + return err + } + + rows = append(rows, row) + } + + for _, row := range rows { + tbl.AddRow(row...) + } + + // Output the table + fmt.Fprintln(o.io.Out(), tbl.String()) + return nil +} + +// defaultHeaderFormatter is the default header formatter which prints the +// header in green. +func (o *Outputter) defaultHeaderFormatter(input string) string { + nonPadded := strings.TrimRight(input, " ") + cs := o.io.ColorScheme() + return cs.String(nonPadded).Color(cs.Green()).Underline().String() + + strings.Repeat(" ", len(input)-len(nonPadded)) +} + +// defaultFirstColumnFormatter is the default first column formatter which +// prints the column in yellow. +func (o *Outputter) defaultFirstColumnFormatter(input string) string { + cs := o.io.ColorScheme() + return cs.String(input).Color(cs.Yellow()).String() +} + +// renderRow renders each field by executing the text/template given the +// payload. +func renderRow(p any, fields []Field) ([]interface{}, error) { + renderedFields := make([]interface{}, len(fields)) + for i, f := range fields { + tmpl, err := template.New("tfcloud").Parse(f.ValueFormat) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, p); err != nil { + return nil, err + } + + renderedFields[i] = buf.String() + } + + return renderedFields, nil +} diff --git a/internal/pkg/format/table_test.go b/internal/pkg/format/table_test.go new file mode 100644 index 0000000..65a3033 --- /dev/null +++ b/internal/pkg/format/table_test.go @@ -0,0 +1,239 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package format_test + +import ( + "bufio" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestTable_KV_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + { + Key: "Another", + Value: "Test", + }, + }, + Default: format.Table, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Key", "Value"}, + {"Hello", "World!"}, + {"Another", "Test"}, + } + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestTable_KV_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVDisplayer{ + KVs: []*KV{ + { + Key: "Hello", + Value: "World!", + }, + }, + Default: format.Table, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Key", "Value"}, + {"Hello", "World!"}, + } + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestTable_Complex_Slice(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + { + Name: "Other", + Description: "Other description", + Version: 15, + CreatedAt: time.Now().Add(-10 * time.Minute), + UpdatedAt: time.Now().Add(-3 * time.Second), + }, + }, + Default: format.Table, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Name", "Description", "Version", "Created", "At", "Updated", "At"}, + {"Test", "Test", "description", "v12", "5", "seconds", "ago", "1", "second", "ago"}, + {"Other", "Other", "description", "v15", "10", "minutes", "ago", "3", "seconds", "ago"}, + } + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +func TestTable_Complex_Struct(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &ComplexDisplayer{ + Data: []*Complex{ + { + Name: "Test", + Description: "Test description", + Version: 12, + CreatedAt: time.Now().Add(-5 * time.Second), + UpdatedAt: time.Now().Add(-1 * time.Second), + }, + }, + Default: format.Table, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"Name", "Description", "Version", "Created", "At", "Updated", "At"}, + {"Test", "Test", "description", "v12", "5", "seconds", "ago", "1", "second", "ago"}, + } + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + } + + // There should be no more text + r.False(scanner.Scan()) +} + +type KVTableFormatter struct { + KVDisplayer +} + +func (f *KVTableFormatter) HeaderFormatter(input string) string { + return strings.ToUpper(input) +} + +func (f *KVTableFormatter) FirstColumnFormatter(input string) string { + return strings.ToLower(input) +} + +func TestTable_TableFormatter(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + out := format.New(io) + + // Create our displayer + d := &KVTableFormatter{ + KVDisplayer{ + KVs: []*KV{ + { + Key: "HELLO", + Value: "World!", + }, + { + Key: "ANOTHER", + Value: "Test", + }, + }, + Default: format.Table, + }, + } + + // Display the table + r.NoError(out.Display(d)) + + // Create a scanner to check the output + scanner := bufio.NewScanner(io.Output) + + // Check the output is expected + expected := [][]string{ + {"KEY", "VALUE"}, + {"hello", "World!"}, + {"another", "Test"}, + } + for _, row := range expected { + r.True(scanner.Scan()) + r.Equal(row, strings.Fields(scanner.Text())) + } + + // There should be no more text + r.False(scanner.Scan()) +} diff --git a/internal/git/git.go b/internal/pkg/git/git.go similarity index 100% rename from internal/git/git.go rename to internal/pkg/git/git.go diff --git a/internal/pkg/heredoc/example_test.go b/internal/pkg/heredoc/example_test.go new file mode 100644 index 0000000..06c8081 --- /dev/null +++ b/internal/pkg/heredoc/example_test.go @@ -0,0 +1,154 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package heredoc_test + +import ( + "context" + "fmt" + + "github.com/hashicorp/tfcloud/internal/pkg/heredoc" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func Example() { + io, _ := iostreams.System(context.Background()) + out := heredoc.New(io).Mustf(` + This is an example of documenting a command. You can format in values %s you want. + + Really long lines will automatically get wrapped for you. So you don't need to worry about being super rigorous about where you wrap a line. + + However if you have a block of text that you want to preserve the formatting of, you can use the PreserveNewLines function as shown below. + + {{ PreserveNewLines }} + { + "description": "JSON block to preserve the formatting of", + "cool": true + } + {{ PreserveNewLines }} + + You can also colorize and stylize text. This is useful if you want to highlight a command. + + Such as, run {{ Bold "tfcloud your command" }} to do awesome things. + + The available style functions are: Bold, Faint, Italic, Underline, Blink, CrossOut. + + You can color output with the Color function. It is invoked as Color "text". + + The valdid colors are: red, green, yellow, orange, gray, white, black, or #. The case doesn't matter. + + For example, {{ Color "Red" "this could be an error" }}. + + You may have noticed that all these lines have an indent. heredoc will automatically dedent for you. + + But you can still further indent and it will be maintained for you. + + Lastly, blank spaces at the start and end will be stripped so that you can start your text on a new line and end it like this. + `, "wherever") + + fmt.Fprintln(io.Out(), out) + // Output: + // This is an example of documenting a command. You can format in values wherever + // you want. + // + // Really long lines will automatically get wrapped for you. So you don't need to + // worry about being super rigorous about where you wrap a line. + // + // However if you have a block of text that you want to preserve the formatting of, + // you can use the PreserveNewLines function as shown below. + // + // { + // "description": "JSON block to preserve the formatting of", + // "cool": true + // } + // + // You can also colorize and stylize text. This is useful if you want to highlight + // a command. + // + // Such as, run tfcloud your command to do awesome things. + // + // The available style functions are: Bold, Faint, Italic, Underline, Blink, + // CrossOut. + // + // You can color output with the Color function. It is invoked as Color "text". + // + // The valdid colors are: red, green, yellow, orange, gray, white, black, or + // #. The case doesn't matter. + // + // For example, this could be an error. + // + // You may have noticed that all these lines have an indent. heredoc will + // automatically dedent for you. + // + // But you can still further indent and it will be maintained for you. + // + // Lastly, blank spaces at the start and end will be stripped so that you can start + // your text on a new line and end it like this. +} + +// This example demonstrates how to use codeblocks. +func Example_second() { + io, _ := iostreams.System(context.Background()) + out := heredoc.New(io).Must(` +When displaying code blocks,the heredoc should have no indentation. + +The code block gets defined then passed to the code block function. + +{{ define "example" -}} { + "bindings": [ + { + "role_id": "ROLE_ID", + "members": [ + { + "member_id": "PRINCIPAL_ID", + "member_type": "USER" | "GROUP" | "SERVICE_PRINCIPAL", + } + ] + } + ], + "etag": "ETAG", +} {{- end }} +{{- CodeBlock "example" "json" | Color "green" }} + `) + + fmt.Fprintln(io.Out(), out) + // Output: + // When displaying code blocks,the heredoc should have no indentation. + // + // The code block gets defined then passed to the code block function. + // + //{ + // "bindings": [ + // { + // "role_id": "ROLE_ID", + // "members": [ + // { + // "member_id": "PRINCIPAL_ID", + // "member_type": "USER" | "GROUP" | "SERVICE_PRINCIPAL", + // } + // ] + // } + // ], + // "etag": "ETAG", + //} +} + +// This example demonstrates how to use the mdCodeOrBold template. +func Example_third() { + io, _ := iostreams.System(context.Background()) + out := heredoc.New(io).Must(` + To display text as bold for non-markdown output and as a code block for + markdown output, use the mdCodeOrBold template. + + The {{ template "mdCodeOrBold" "tfcloud api" }} command + is used to perform any api operation. + `) + + fmt.Fprintln(io.Out(), out) + // Output: + // To display text as bold for non-markdown output and as a code block for + // markdown output, use the mdCodeOrBold template. + // + // The tfcloud api command is used to perform any api operation. +} diff --git a/internal/pkg/heredoc/heredoc.go b/internal/pkg/heredoc/heredoc.go new file mode 100644 index 0000000..e1dca2d --- /dev/null +++ b/internal/pkg/heredoc/heredoc.go @@ -0,0 +1,227 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package heredoc provides utilities for formatting text that is intended to be +// output to users and includes text/template formatting and word-wrapping. +package heredoc + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + "github.com/lithammer/dedent" + "github.com/muesli/reflow/wordwrap" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +// Config stores configuration for the formatter. The values can be set by +// constructing the formatter with ConfigOptions. +type Config struct { + width int + autoParagraph bool +} + +// defaultConfig returns the default configuration. +func defaultConfig() *Config { + return &Config{ + width: 80, + autoParagraph: true, + } +} + +// ConfigOption allow configuring the formatters configuration. +type ConfigOption func(c *Config) + +// WithWidth sets the maximum width at which lines are word-wrapped. +func WithWidth(w int) ConfigOption { + return func(c *Config) { + c.width = w + } +} + +// WithNoWrap does not wrap the line. This is useful when the output will be +// passed to other functions that have their own wrapping. +func WithNoWrap() ConfigOption { + return func(c *Config) { + c.width = -1 + } +} + +// WithPreserveNewlines is used to disable the auto-formation of paragraphs for +// the entire template. To preserve new lines over just a section of the +// template, use the {{ PreserveNewLines }} template function. +func WithPreserveNewlines() ConfigOption { + return func(c *Config) { + c.autoParagraph = false + } +} + +// Formatter is used to format a template such that it can be outputted to +// users. The formatter wraps lines and ignores any initial indentation. +// Further, it supplements the text/template string with functions for +// colorizing and styling output. +type Formatter struct { + c *Config + io iostreams.IOStreams +} + +// New returns a new formatter given the IOStreams and passed options. +func New(io iostreams.IOStreams, options ...ConfigOption) *Formatter { + f := &Formatter{ + io: io, + c: defaultConfig(), + } + + for _, o := range options { + o(f.c) + } + + return f +} + +// Must invokes Doc and panics if an error occurs. +func (f *Formatter) Must(tmpl string) string { + doc, err := f.Doc(tmpl) + if err != nil { + panic(err) + } + + return doc +} + +// Mustf invokes Docf and panics if an error occurs. +func (f *Formatter) Mustf(tmpl string, args ...any) string { + doc, err := f.Docf(tmpl, args...) + if err != nil { + panic(err) + } + + return doc +} + +// Docf takes a text/template string and a series of arguments that are +// fmt.Sprintf into the template before the interpolatted string is passed to +// Doc function. To see the format of the tmpl, see Doc's documentation. +func (f *Formatter) Docf(tmpl string, args ...any) (string, error) { + interpolatted := fmt.Sprintf(tmpl, args...) + return f.Doc(interpolatted) +} + +// Doc takes a text/template string and renders it. The formatter adds the +// following functions that are available to the template string. +// +// - PreserveNewLines: Must be paired. Any text between the two calls will have +// new lines preserved. +// - Color "string" +// - Color "string" +// - Bold "string" +// - Italic "string" +// - Faint "string" +// - Underline "string" +// - Blink "string" +// - CrossOut "string" +// - Link "name" "url" {{ Link " +// - Code "string" +// - CodeBlock "string" "extension" (shell-session, json, go) +// - IsMD: Returns true if the output is markdown. +// +// Valid Color values are: "red", "green", "yellow", "orange", "gray", white", +// "black" (case insensitive), or "#". +// +// These functions can be chained such as: +// {{ Color "Red" ( Italic (CrossOut "example" ) ) }} +// +// Additionally, the following templates are made available: +// +// - mdCodeOrBold: If the output is markdown, it will return the string in a +// code stanza. Otherwise, it will return the string in bold. +// An example usage is: +// {{ template "mdCodeOrBold" "tfcloud projects iam read-policy --format=json" }} +// +// After rendering the template following manipulations are made: +// - The text is dedented. This allows you to use a Go string literal and not +// worry about the indentation. e.g. ` your docs`. -> `your docs` +// - Long lines are wrapped at word boundaries. The default wrapping length can +// be overridden using WithWidth. +// - Starting and ending blank spaces are stripped. +func (f *Formatter) Doc(tmpl string) (string, error) { + // Replace tabs with spaces + tmpl = strings.ReplaceAll(tmpl, "\t", " ") + + // Parse the string as template + tpl := template.New("tpl") + tpl.Funcs(f.templateFuncs(tpl)) + tpl, err := tpl.Parse(tmpl) + if err != nil { + return "", fmt.Errorf("failed to parse input as a text/template: %w", err) + } + + if err := addTemplates(tpl); err != nil { + return "", fmt.Errorf("failed to add templates: %w", err) + } + + // Run the template + var buf bytes.Buffer + if err := tpl.Execute(&buf, nil); err != nil { + return "", fmt.Errorf("failed executing text/template: %w", err) + } + + dedented := dedent.Dedent(buf.String()) + + // Form paragraphs automatically + chunked := dedented + if f.c.autoParagraph { + // Look for any preserve line sentinels + preserveGroups := strings.Split(dedented, preserveNewLinesToken) + if len(preserveGroups)%2 != 1 { + return "", fmt.Errorf("{{ PreserveNewLines }} calls are not balanced") + } + + // [Normal, Preserve, Normal, Preserve, ...] + text := "" + for i, group := range preserveGroups { + if i%2 == 1 { + // Should be preserved but strip the new lines that come from + // the {{ PreserveNewLines }} call. + group, _ = strings.CutPrefix(group, "\n") + group, _ = strings.CutSuffix(group, "\n") + text += group + continue + } + + // Drop new lines within the paragraph. + paragraphs := strings.Split(group, "\n\n") + for i, p := range paragraphs { + paragraphs[i] = strings.ReplaceAll(p, "\n", " ") + } + + text += strings.Join(paragraphs, "\n\n") + } + + chunked = text + } + + // Word wrap + wrapped := chunked + if f.c.width > 0 { + wrapped = wordWrap(chunked, f.c.width) + } + + // Strip any whitespace at the start/end. + stripped := strings.TrimSpace(wrapped) + + return stripped, nil +} + +// wordWrap wraps an input at the given wrap length. It uses a customized +// wordwrap.Writer that is more appropriate for splitting command line flags. +func wordWrap(input string, wrap int) string { + w := wordwrap.NewWriter(wrap) + w.Breakpoints = []rune{} + _, _ = w.Write([]byte(input)) + _ = w.Close() + return w.String() +} diff --git a/internal/pkg/heredoc/heredoc_test.go b/internal/pkg/heredoc/heredoc_test.go new file mode 100644 index 0000000..9fc3178 --- /dev/null +++ b/internal/pkg/heredoc/heredoc_test.go @@ -0,0 +1,333 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package heredoc + +import ( + "strings" + "testing" + + "github.com/muesli/termenv" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +func TestTemplate_NoWrap(t *testing.T) { + t.Parallel() + r := require.New(t) + + input := ` + This is a line in + a longer paragraph. + + Versus this is a new paragraph since it is separated by a blank line. + ` + expected := "This is a line in a longer paragraph.\n\nVersus this is a new paragraph since it is separated by a blank line." + + io := iostreams.Test() + f := New(io, WithNoWrap()) + + out, err := f.Doc(input) + r.NoError(err) + r.Equal(expected, out) +} + +func TestTemplate_AutoParagraph(t *testing.T) { + t.Parallel() + r := require.New(t) + + input := ` + This is a line in + a longer paragraph. + + Versus this is a new paragraph since it is separated by a blank line. + ` + expectedAuto := "This is a line in a longer paragraph.\n\nVersus this is a new paragraph since it is separated by a blank line." + expectedPreserve := "This is a line in\na longer paragraph.\n\nVersus this is a new paragraph since it is separated by a blank line." + + io := iostreams.Test() + f := New(io) + + out, err := f.Doc(input) + r.NoError(err) + r.Equal(expectedAuto, out) + + f = New(io, WithPreserveNewlines()) + out, err = f.Doc(input) + r.NoError(err) + r.Equal(expectedPreserve, out) +} + +func TestTemplate_PreserveNewLine(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Input string + Expected string + }{ + { + Name: "Longer example", + Input: ` + Input before preserve new lines. + + {{ PreserveNewLines }} + This is a line in + a longer, preserved paragraph. + {{ PreserveNewLines }} + + Versus this is a new paragraph since it is separated by a blank line. But since it is long, it gets split. + `, + Expected: "Input before preserve new lines.\n\nThis is a line in\na longer, preserved paragraph.\n\nVersus this is a new paragraph since it is separated by a blank line. But since\nit is long, it gets split.", + }, + { + Name: "Bullets", + Input: ` +The name of the group to delete. The name may be specified as either: + +{{ PreserveNewLines }} + * The group's resource name. Formatted as: + {{ Italic "iam/organization/ORG_ID/group/GROUP_NAME" }} + * The resource name suffix, GROUP_NAME. +{{ PreserveNewLines }}`, + Expected: `The name of the group to delete. The name may be specified as either: + + * The group's resource name. Formatted as: + iam/organization/ORG_ID/group/GROUP_NAME + * The resource name suffix, GROUP_NAME.`, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + io := iostreams.Test() + f := New(io) + + out, err := f.Doc(c.Input) + r.NoError(err) + r.Equal(c.Expected, out) + }) + } +} + +func TestTemplate_Format(t *testing.T) { + t.Parallel() + r := require.New(t) + + input := `lets %s and a number %d` + expected := `lets format a string and a number 42` + + io := iostreams.Test() + f := New(io) + out, err := f.Docf(input, "format a string", 42) + r.NoError(err) + r.Equal(expected, out) +} + +func TestTemplate_StripWhitespace(t *testing.T) { + t.Parallel() + r := require.New(t) + + input := ` +start after first line + +` + expected := `start after first line` + + io := iostreams.Test() + f := New(io) + out, err := f.Doc(input) + r.NoError(err) + r.Equal(expected, out) +} + +func TestTemplate_Wrapping(t *testing.T) { + t.Parallel() + cases := []struct { + Name string + Input string + Expected string + Width int + }{ + { + Name: "Wrap long", + Width: 11, + Input: `this is too long by a bit.`, + Expected: `this is too +long by a +bit.`, + }, + { + Name: "Wrap at the word that would push the line over rather than cutting it.", + Width: 15, + Input: `this is too long by a bit.`, + Expected: `this is too +long by a bit.`, + }, + { + Name: "Realistic Example", + Width: 80, + Input: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`, + Expected: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor +incididunt ut labore et dolore magna aliqua. + +Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut +aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in +voluptate velit esse cillum dolore eu fugiat nulla pariatur. + +Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia +deserunt mollit anim id est laborum.`, + }, + + { + // TODO + Name: "Wrap a long single line", + Width: 80, + Input: `Really long lines will automatically get wrapped for you. So you don't need to worry about being super rigorous about where you wrap a line.`, + Expected: `Really long lines will automatically get wrapped for you. So you don't need to +worry about being super rigorous about where you wrap a line.`, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + f := New(io, WithWidth(c.Width)) + out, err := f.Doc(c.Input) + r.NoError(err) + r.Equal(c.Expected, out) + }) + } +} + +func TestTemplate_Dedent(t *testing.T) { + t.Parallel() + cases := []struct { + Name string + Input string + Expected string + }{ + { + Name: "Dedent all", + Input: ` + Has starting spaces. + Has starting spaces. + Has starting spaces. + `, + Expected: `Has starting spaces. +Has starting spaces. +Has starting spaces.`, + }, + { + Name: "Mixed indent", + Input: ` + Has starting spaces. + Further Indent. + Has starting spaces. + Further Indent. + More Further Indent. + `, + Expected: `Has starting spaces. + Further Indent. +Has starting spaces. + Further Indent. + More Further Indent.`, + }, + { + Name: "Shared prefix has mixed tabs and spaces", + Input: ` + Has starting tabs. +` + strings.Repeat(" ", 6) + `Has starting spaces. + `, + Expected: `Has starting tabs. +Has starting spaces.`, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + f := New(io, WithPreserveNewlines()) + out, err := f.Doc(c.Input) + r.NoError(err) + r.Equal(c.Expected, out) + }) + } +} + +func TestTemplate_Colors(t *testing.T) { + t.Parallel() + tmpl := `{{ Color "red" "test" }} +{{ Color "ReD" "test" }} +{{ Color "white" "orange" "White on orange" }} +{{ Color "#ff00aa" "rgb color" }} +{{ Bold "Bold" }} +{{ Faint "Faint" }} +{{ Italic "Italic" }} +{{ Underline "Underline" }} +{{ Blink "Blink" }} +{{ CrossOut "CrossOut" }} +{{ Color "ReD" (Bold (Italic "chained")) }}` + + // Test acts more as a detector for breaking changes. Important part is that + // Ascii has no escape sequences, and between Ansi, 256, TrueColor, they are + // different and contain escape sequences. + cases := []struct { + Name string + Profile termenv.Profile + Expected string + }{ + { + Name: "No color", + Profile: termenv.Ascii, + Expected: "test\ntest\nWhite on orange\nrgb color\nBold\nFaint\nItalic\nUnderline\nBlink\nCrossOut\nchained", + }, + { + Name: "ANSI", + Profile: termenv.ANSI, + Expected: "\x1b[91mtest\x1b[0m\n\x1b[91mtest\x1b[0m\n\x1b[37;101mWhite on orange\x1b[0m\n\x1b[95mrgb color\x1b[0m\n\x1b[1mBold\x1b[0m\n\x1b[2mFaint\x1b[0m\n\x1b[3mItalic\x1b[0m\n\x1b[4mUnderline\x1b[0m\n\x1b[5mBlink\x1b[0m\n\x1b[9mCrossOut\x1b[0m\n\x1b[91m\x1b[1m\x1b[3mchained\x1b[0m\x1b[0m\x1b[0m", + }, + { + Name: "256", + Profile: termenv.ANSI256, + Expected: "\x1b[38;5;160mtest\x1b[0m\n\x1b[38;5;160mtest\x1b[0m\n\x1b[37;48;5;130mWhite on orange\x1b[0m\n\x1b[38;5;199mrgb color\x1b[0m\n\x1b[1mBold\x1b[0m\n\x1b[2mFaint\x1b[0m\n\x1b[3mItalic\x1b[0m\n\x1b[4mUnderline\x1b[0m\n\x1b[5mBlink\x1b[0m\n\x1b[9mCrossOut\x1b[0m\n\x1b[38;5;160m\x1b[1m\x1b[3mchained\x1b[0m\x1b[0m\x1b[0m", + }, + { + Name: "TrueColor", + Profile: termenv.TrueColor, + Expected: "\x1b[38;2;229;34;40mtest\x1b[0m\n\x1b[38;2;229;34;40mtest\x1b[0m\n\x1b[37;48;2;187;89;0mWhite on orange\x1b[0m\n\x1b[38;2;255;0;170mrgb color\x1b[0m\n\x1b[1mBold\x1b[0m\n\x1b[2mFaint\x1b[0m\n\x1b[3mItalic\x1b[0m\n\x1b[4mUnderline\x1b[0m\n\x1b[5mBlink\x1b[0m\n\x1b[9mCrossOut\x1b[0m\n\x1b[38;2;229;34;40m\x1b[1m\x1b[3mchained\x1b[0m\x1b[0m\x1b[0m", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + io.ForcedColorProfile(c.Profile) + + f := New(io, WithPreserveNewlines()) + out, err := f.Doc(tmpl) + r.NoError(err) + r.Equal(c.Expected, out) + }) + } +} diff --git a/internal/pkg/heredoc/template_funcs.go b/internal/pkg/heredoc/template_funcs.go new file mode 100644 index 0000000..ce28c1a --- /dev/null +++ b/internal/pkg/heredoc/template_funcs.go @@ -0,0 +1,122 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package heredoc + +import ( + "fmt" + "strings" + "text/template" + + "golang.org/x/exp/maps" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +const ( + // preserveNewLinesToken is a token that can be used in a template to + // preserve new lines. It is expected to be paired around lines that have + // their new lines preserved. + preserveNewLinesToken = "__preserveNewLines__" +) + +// templateFuncs returns template helpers based on the IOStreams. +func (f *Formatter) templateFuncs(t *template.Template) template.FuncMap { + cs := f.io.ColorScheme() + + return template.FuncMap{ + "Color": func(values ...interface{}) (string, error) { + s := cs.String(values[len(values)-1].(string)) + switch len(values) { + case 2: + c, err := getColor(cs, values[0].(string)) + if err != nil { + return "", err + } + + s = s.Color(c) + case 3: + foregroundColor, err := getColor(cs, values[0].(string)) + if err != nil { + return "", fmt.Errorf("invalid foreground color: %w", err) + } + + backgroundColor, err := getColor(cs, values[1].(string)) + if err != nil { + return "", fmt.Errorf("invalid background color: %w", err) + } + + s = s. + Color(foregroundColor). + Background(backgroundColor) + } + + return s.String(), nil + }, + "Bold": styleFunc(cs, iostreams.String.Bold), + "Faint": styleFunc(cs, iostreams.String.Faint), + "Italic": styleFunc(cs, iostreams.String.Italic), + "Underline": styleFunc(cs, iostreams.String.Underline), + "Blink": styleFunc(cs, iostreams.String.Blink), + "CrossOut": styleFunc(cs, iostreams.String.CrossOut), + "Code": styleFunc(cs, iostreams.String.Code), + "CodeBlock": func(name, extension string) (string, error) { + var buf strings.Builder + if err := t.ExecuteTemplate(&buf, name, nil); err != nil { + return "", err + } + + return preserveNewLinesToken + + cs.String(buf.String()).CodeBlock(extension).String() + + preserveNewLinesToken, + nil + }, + + "PreserveNewLines": func() string { return preserveNewLinesToken }, + "IsMD": func() bool { + if _, ok := f.io.(iostreams.IsMarkdownOutput); ok { + return true + } + + return false + }, + "Link": func(text, url string) string { + if _, ok := f.io.(iostreams.IsMarkdownOutput); ok { + return fmt.Sprintf("[%s](%s)", text, url) + } + + return fmt.Sprintf("%s (%s)", text, url) + }, + } +} + +func getColor(cs *iostreams.ColorScheme, c string) (iostreams.Color, error) { + c = strings.ToLower(c) + valid := map[string]func() iostreams.Color{ + "white": cs.White, + "black": cs.Black, + "red": cs.Red, + "green": cs.Green, + "orange": cs.Orange, + "yellow": cs.Yellow, + "gray": cs.Gray, + } + + if strings.HasPrefix(c, "#") { + return cs.RGB(c), nil + } + + color, ok := valid[c] + if ok { + return color(), nil + } + + return cs.Black(), fmt.Errorf("unknown color. Must either be an RGB value (#) or one of %v", maps.Keys(valid)) +} + +func styleFunc(cs *iostreams.ColorScheme, f func(iostreams.String) iostreams.String) func(input string) string { + return func(input string) string { + s := cs.String(input) + return f(s).String() + } +} diff --git a/internal/pkg/heredoc/templates.go b/internal/pkg/heredoc/templates.go new file mode 100644 index 0000000..f7f9fb9 --- /dev/null +++ b/internal/pkg/heredoc/templates.go @@ -0,0 +1,23 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package heredoc + +import "text/template" + +// addTemplates adds the custom templates to the given template. +func addTemplates(t *template.Template) error { + _, err := t.Parse(` + {{- define "mdCodeOrBold" -}} + {{- if IsMD -}} + {{ Code . }} + {{- else -}} + {{ Bold . }} + {{- end}} + {{- end}}`) + if err != nil { + return err + } + + return nil +} diff --git a/internal/pkg/iostreams/colorscheme.go b/internal/pkg/iostreams/colorscheme.go new file mode 100644 index 0000000..11aae8f --- /dev/null +++ b/internal/pkg/iostreams/colorscheme.go @@ -0,0 +1,239 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package iostreams + +import ( + "github.com/muesli/termenv" +) + +const ( + // Each constant defines the color code for the given color. A # prefix + // indicates RGB colors, and a number indicates ANSI colors. + black = "0" + white = "7" + red = "#E52228" + green = "#008A22" + orange = "#BB5A00" + yellow = "#FFD814" + gray = "#C2C5CB" +) + +// Emphasis is used to style text. +type Emphasis int + +const ( + // EmphasisBold is used to bold text. + EmphasisBold Emphasis = iota + + // EmphasisItalic is used to italic text. + EmphasisItalic + + // EmphasisUnderline is used to underline text. + EmphasisUnderline + + // EmphasisCrossOut is used to cross out text. + EmphasisCrossOut + + // EmphasisCode is used to format text as inline code in markdown output. + EmphasisCode + + // EmphasisCodeBlock is used to format text as a code block in markdown output. + EmphasisCodeBlock +) + +// ColorScheme is used to style and color text according to the capabilities of +// the current terminal. It will automatically degrade the requested styling to +// what the system is capable for outputting. +type ColorScheme struct { + profile termenv.Profile + + // md marks that we are emitting markdown + md bool +} + +// String is a stylized string. +type String struct { + style termenv.Style + + // md marks that we are emitting markdown + md bool + + // rawString is the underlying string with no styling applied + rawString string + + // emphases is the set of emphases being applied to the String + emphases []Emphasis + + // codeBlockExtension is the extension for the code block. + codeBlockExtension string +} + +// String wraps the given string. The wrapped String can then have style or +// color applied to it. +func (cs *ColorScheme) String(s string) String { + return String{ + style: cs.profile.String(s), + md: cs.md, + rawString: s, + } +} + +// String implements the fmt.Stringer interface. The wrapped string will be +// printed with the appropriate control sequences applied to have the string +// stylized as requested. +func (s String) String() string { + if s.md { + return s.markdownString() + } + + return s.style.String() +} + +// SuccessIcon returns a success icon. +func (cs *ColorScheme) SuccessIcon() String { + return cs.String("✓").Color(cs.Green()) +} + +// FailureIcon returns a failure icon. +func (cs *ColorScheme) FailureIcon() String { + return cs.String("X").Color(cs.Red()) +} + +// WarningLabel returns a colored warning label. +func (cs *ColorScheme) WarningLabel() String { + return cs.String("WARNING:").Color(cs.Orange()) +} + +// ErrorLabel returns a colored error label. +func (cs *ColorScheme) ErrorLabel() String { + return cs.String("ERROR:").Color(cs.Red()) +} + +// Color applies the given color to the texts foreground. +func (s String) Color(c Color) String { + s.style = s.style.Foreground(c.color) + return s +} + +// Background applies the given color to the texts background. +func (s String) Background(c Color) String { + s.style = s.style.Background(c.color) + return s +} + +// Bold makes the string bold. +func (s String) Bold() String { + s.style = s.style.Bold() + s.emphases = append(s.emphases, EmphasisBold) + return s +} + +// Faint makes the text faint. +func (s String) Faint() String { + s.style = s.style.Faint() + return s +} + +// Italic makes the text italic. +func (s String) Italic() String { + s.style = s.style.Italic() + s.emphases = append(s.emphases, EmphasisItalic) + return s +} + +// Underline makes the text underlined. +func (s String) Underline() String { + s.style = s.style.Underline() + s.emphases = append(s.emphases, EmphasisUnderline) + return s +} + +// CrossOut makes the text have a cross through it middle height wise. +func (s String) CrossOut() String { + s.style = s.style.CrossOut() + s.emphases = append(s.emphases, EmphasisCrossOut) + return s +} + +// Blink makes the text blink. +func (s String) Blink() String { + s.style = s.style.Blink() + return s +} + +// Code makes the text output as code. Only applies to markdown output. +func (s String) Code() String { + s.emphases = append(s.emphases, EmphasisCode) + return s +} + +// CodeBlock makes the text output as a code block and sets the extension for +// highlighting. Only applies to markdown output. +func (s String) CodeBlock(extension string) String { + s.emphases = append(s.emphases, EmphasisCodeBlock) + s.codeBlockExtension = extension + return s +} + +// Color is represents a color. +type Color struct { + color termenv.Color +} + +// White is the color white. +func (cs *ColorScheme) White() Color { + return Color{ + color: cs.profile.Color(white), + } +} + +// Black is the color black. +func (cs *ColorScheme) Black() Color { + return Color{ + color: cs.profile.Color(black), + } +} + +// Red is the color red. +func (cs *ColorScheme) Red() Color { + return Color{ + color: cs.profile.Color(red), + } +} + +// Green is the color green. +func (cs *ColorScheme) Green() Color { + return Color{ + color: cs.profile.Color(green), + } +} + +// Orange is the color orange. +func (cs *ColorScheme) Orange() Color { + return Color{ + color: cs.profile.Color(orange), + } +} + +// Yellow is the color yellow. +func (cs *ColorScheme) Yellow() Color { + return Color{ + color: cs.profile.Color(yellow), + } +} + +// Gray is the color gray. +func (cs *ColorScheme) Gray() Color { + return Color{ + color: cs.profile.Color(gray), + } +} + +// 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 { + return Color{ + color: cs.profile.Color(hex), + } +} diff --git a/internal/pkg/iostreams/example_test.go b/internal/pkg/iostreams/example_test.go new file mode 100644 index 0000000..4ae948f --- /dev/null +++ b/internal/pkg/iostreams/example_test.go @@ -0,0 +1,62 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package iostreams_test + +import ( + "fmt" + + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" +) + +// Example_output shows how text can be outputted with color and style. +func Example() { + // Use iostreams.System for real usage. + io := iostreams.Test() + + cs := io.ColorScheme() + fmt.Fprintln(io.Out(), cs.String("Applying Style").Bold()) + fmt.Fprintln(io.Out(), cs.String("Chaining Styles").Bold().Italic()) + + fmt.Fprintln(io.Out(), cs.String("Applying Color").Color(cs.Orange())) + fmt.Fprintln(io.Out(), cs.String("Applying Color and Style").Bold().Color(cs.Orange())) + + // Changing the background + fmt.Fprintln(io.Out(), cs.String("WARNING").Bold().Background(cs.Orange()).Color(cs.Black())) + + // Print the test output + fmt.Print(io.Output.String()) + + // Output: + // Applying Style + // Chaining Styles + // Applying Color + // Applying Color and Style + // WARNING +} + +// Example_secrets shows how a secret can be retrieved. +func ExampleIOStreams_ReadSecret() { + // Use iostreams.System for real usage. + io := iostreams.Test() + io.InputTTY = true + io.ErrorTTY = true + + // Mock stdin to demonstrate reading from stdin. + io.Input.WriteString("pa$$w0rd") + + fmt.Fprintln(io.Err(), "Whats your password?") + data, err := io.ReadSecret() + if err != nil { + panic(err) + } + fmt.Fprintf(io.Out(), "%q is a terrible password now!", string(data)) + + // Print the test output + fmt.Print(io.Error.String()) + fmt.Print(io.Output.String()) + + // Output: + // Whats your password? + // "pa$$w0rd" is a terrible password now! +} diff --git a/internal/pkg/iostreams/io.go b/internal/pkg/iostreams/io.go new file mode 100644 index 0000000..344a13e --- /dev/null +++ b/internal/pkg/iostreams/io.go @@ -0,0 +1,306 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package iostreams provides access to the terminal outputs and inputs in a +// centralized and mockable fashion. All terminal access should happen using +// this package. +package iostreams + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/muesli/termenv" + "golang.org/x/term" +) + +const ( + // TerminalDefaultWidth is the default width of the terminal if the width + // cannot be determined. + TerminalDefaultWidth = 80 +) + +// IOStreams is an interface for interacting with IO and general terminal +// output. Commands should not directly interact with os.Stdout/Stderr/Stdin but +// utilize the passed IOStreams. +type IOStreams interface { + // In returns an io.Reader for reading input + In() io.Reader + + // Out returns an io.Writer for outputting non-error output + Out() io.Writer + + // Err returns an io.Writer for outputting error output + Err() io.Writer + + // ColorEnabled returns if color is enabled. + ColorEnabled() bool + + // ForceNoColor forces no color output + ForceNoColor() + + // ColorScheme returns a ColorScheme for coloring and formatting output. + ColorScheme() *ColorScheme + + // RestoreConsole should be called the console to its original state. This + // should be called in a defer method after retrieving an IOStream. + RestoreConsole() error + + // ReadSecret reads a line of input without local echo. The returned data + // does not include the \n delimeter. + ReadSecret() ([]byte, error) + + // PromptConfirm prompts for a confirmation from the user. + PromptConfirm(prompt string) (bool, error) + + // IsInputTTY returns whether the input is a TTY + IsInputTTY() bool + + // IsOutputTTY returns whether the output is a TTY + IsOutputTTY() bool + + // IsErrorTTY returns whether the error output is a TTY + IsErrorTTY() bool + + // TerminalWidth returns the width of the terminal that controls the process + TerminalWidth() int + + // CanPrompt returns true if prompting is available. Both the input and + // error output must be a TTY for this to return true as well as SetQuiet + // must not be true. + CanPrompt() bool + + // SetQuiet updates the iostream to disable Err output and prompting. + SetQuiet(quiet bool) +} + +// system is the IOStreams for interacting with an actual terminal. +type system struct { + in *os.File + out *termenv.Output + err *termenv.Output + + // Capture the restore functions + restoreConsoleFn []func() error + + // forceNoColor forces no color output + forceNoColor bool + + // quiet stores if quiet mode has been enabled. + quiet bool + + // ctx is used to cancel any blocking read in the event the context is + // canceled. + ctx context.Context +} + +// System returns an IOStreams meant to interact with the systems terminal. +func System(ctx context.Context) (IOStreams, error) { + io := &system{ + in: os.Stdin, + out: termenv.NewOutput(os.Stdout), + err: termenv.NewOutput(os.Stderr), + ctx: ctx, + } + + restoreOut, err := termenv.EnableVirtualTerminalProcessing(io.out) + if err != nil { + return nil, fmt.Errorf("failed to enable virtual terminal processing: %w", err) + } + + restoreErr, err := termenv.EnableVirtualTerminalProcessing(io.err) + if err != nil { + return nil, fmt.Errorf("failed to enable virtual terminal processing: %w", err) + } + io.restoreConsoleFn = []func() error{restoreOut, restoreErr} + + return io, nil +} + +func (s *system) In() io.Reader { + return s.in +} + +func (s *system) Out() io.Writer { + return s.out +} + +func (s *system) Err() io.Writer { + if s.quiet { + return io.Discard + } + + return s.err +} + +func (s *system) SetQuiet(quiet bool) { + s.quiet = quiet +} + +func (s *system) ColorEnabled() bool { + if s.forceNoColor { + return false + } + + return !s.out.EnvNoColor() +} + +func (s *system) ForceNoColor() { + s.forceNoColor = true +} + +func (s *system) ColorScheme() *ColorScheme { + if !s.ColorEnabled() { + return &ColorScheme{profile: termenv.Ascii} + } + return &ColorScheme{profile: s.out.EnvColorProfile()} +} + +func (s *system) ReadSecret() ([]byte, error) { + if !s.CanPrompt() { + return nil, fmt.Errorf("prompting is disabled") + } + + fd := int(s.in.Fd()) + + // Store and restore the terminal status on interruptions to + // avoid that the terminal remains in the password state + // This is necessary as for https://github.com/golang/go/issues/31180 + oldState, err := term.GetState(fd) + if err != nil { + return nil, err + } + + type Buffer struct { + Buffer []byte + Error error + } + errorChannel := make(chan Buffer, 1) + doneChannel := make(chan struct{}) + defer close(doneChannel) + + // Canceled context restores the terminal, otherwise the no-echo mode would remain intact + go func() { + select { + case <-doneChannel: + return + case <-s.ctx.Done(): + } + + if oldState != nil { + _ = term.Restore(fd, oldState) + } + errorChannel <- Buffer{Buffer: make([]byte, 0), Error: context.Cause(s.ctx)} + }() + + go func() { + buf, err := term.ReadPassword(fd) + errorChannel <- Buffer{Buffer: buf, Error: err} + }() + + buf := <-errorChannel + + return buf.Buffer, buf.Error +} + +func (s *system) PromptConfirm(prompt string) (confirmed bool, err error) { + if !s.CanPrompt() { + return false, fmt.Errorf("prompting is disabled") + } + + // Prompt + fmt.Fprintf(s.err, "%s (y/n)? ", prompt) + + // Read the input in a goroutine so we can handle the command be signaled + // or STDIN being closed. + doneCh := make(chan bool, 1) + go func() { + defer close(doneCh) + r := bufio.NewReader(s.in) + for { + read, readErr := r.ReadString('\n') + if readErr != nil { + err = readErr + return + } + + read = strings.ToLower(strings.TrimSpace(read)) + switch read { + case "y", "yes": + confirmed = true + fmt.Fprintln(s.err) + return + case "n", "no": + confirmed = false + fmt.Fprintln(s.err) + return + default: + fmt.Fprint(s.err, "Please enter 'y' or 'n': ") + } + } + }() + + select { + case <-s.ctx.Done(): + // If we are canceled, try to extract the reason. + if cause := context.Cause(s.ctx); cause != nil { + err = cause + } else { + err = s.ctx.Err() + } + case <-doneCh: + } + + if err != nil { + // Print new lines to separate the error from any user input. + fmt.Fprintln(s.err) + fmt.Fprintln(s.err) + return false, err + } + + return confirmed, nil +} + +func (s *system) IsInputTTY() bool { + return term.IsTerminal(int(s.in.Fd())) +} + +func (s *system) IsOutputTTY() bool { + return term.IsTerminal(int(s.out.TTY().Fd())) // nolint:staticcheck // Need a Fd for term.IsTerminal +} + +func (s *system) IsErrorTTY() bool { + return term.IsTerminal(int(s.err.TTY().Fd())) // nolint:staticcheck // Need a Fd for term.IsTerminal +} + +func (s *system) CanPrompt() bool { + return !s.quiet && s.IsErrorTTY() && s.IsInputTTY() +} + +// TerminalWidth returns the width of the terminal that controls the process. +func (s *system) TerminalWidth() int { + if !s.IsOutputTTY() { + return TerminalDefaultWidth + } + + width, _, err := term.GetSize(int(s.out.TTY().Fd())) // nolint:staticcheck // Need a Fd for term.IsTerminal + if err != nil { + return TerminalDefaultWidth + } + + return width +} + +func (s *system) RestoreConsole() error { + var returnErr error + for _, f := range s.restoreConsoleFn { + if err := f(); err != nil { + returnErr = err + } + } + return returnErr +} diff --git a/internal/pkg/iostreams/loud.go b/internal/pkg/iostreams/loud.go new file mode 100644 index 0000000..a3e6689 --- /dev/null +++ b/internal/pkg/iostreams/loud.go @@ -0,0 +1,47 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package iostreams + +import "io" + +// Loud is unimpacted by quiet being set on the iostreams. +type Loud interface { + IOStreams + + LoudErr() io.Writer +} + +// LoudErr returns the writer to use for loud error output. +func (s *system) LoudErr() io.Writer { + return s.err +} + +// LoudErr returns the writer to use for loud error output in testing. +func (t *Testing) LoudErr() io.Writer { + return t.Error +} + +// UseLoud takes an IOStream and if it implements the Load interfaces, it will +// be used instead of the quiet alternatives. +func UseLoud(io IOStreams) IOStreams { + l, ok := io.(Loud) + if !ok { + return io + } + + return &loudWrap{ + IOStreams: l, + l: l, + } +} + +type loudWrap struct { + IOStreams + l Loud +} + +// Err returns the loud error writer instead of the quiet one. +func (l *loudWrap) Err() io.Writer { + return l.l.LoudErr() +} diff --git a/internal/pkg/iostreams/md.go b/internal/pkg/iostreams/md.go new file mode 100644 index 0000000..1f54c3c --- /dev/null +++ b/internal/pkg/iostreams/md.go @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package iostreams + +import ( + "fmt" + + "github.com/muesli/termenv" +) + +// IsMarkdownOutput is an interface that when met, indicates that the IOStreams +// instance is configured to output markdown. +type IsMarkdownOutput interface { + IOStreams + + // SetMD sets the IOStreams instance to output markdown or not. This can be + // useful to temporarily change the output format. + SetMD(bool) +} + +type mdStream struct { + *Testing + + // enabled is true if the IOStreams instance is configured to output markdown. + enabled bool +} + +// MD returns a new IOStreams instance that is configured to output markdown. +func MD() IOStreams { + return &mdStream{ + Testing: Test(), + enabled: true, + } +} + +// SetMD sets the IOStreams instance to output markdown or not. +func (m *mdStream) SetMD(enable bool) { + m.enabled = enable +} + +func (m *mdStream) ColorScheme() *ColorScheme { + return &ColorScheme{ + profile: termenv.Ascii, + md: m.enabled, + } +} + +// markdownString returns a string that is formatted for markdown. +func (s String) markdownString() string { + mdText := s.rawString + + for _, e := range s.emphases { + switch e { + case EmphasisBold: + mdText = "**" + mdText + "**" + case EmphasisItalic: + mdText = "_" + mdText + "_" + case EmphasisUnderline: + mdText = "" + mdText + "" + case EmphasisCrossOut: + mdText = "~~" + mdText + "~~" + case EmphasisCode: + mdText = "`" + mdText + "`" + case EmphasisCodeBlock: + mdText = fmt.Sprintf("```%s\n%s\n```", s.codeBlockExtension, mdText) + } + } + + return mdText +} diff --git a/internal/pkg/iostreams/testing.go b/internal/pkg/iostreams/testing.go new file mode 100644 index 0000000..1108251 --- /dev/null +++ b/internal/pkg/iostreams/testing.go @@ -0,0 +1,162 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package iostreams + +import ( + "bytes" + "fmt" + "io" + + "github.com/muesli/termenv" +) + +// Testing implements the IOStreams interface and provides programtic access to +// both input and output. +type Testing struct { + // Input should be written to simulate Stdin. + Input *bytes.Buffer + + // Output contains all data emitted to the Out stream. + Output *bytes.Buffer + + // Error contains all data emitted to the Err stream. + Error *bytes.Buffer + + // InputTTY, OutputTTY, and ErrorTTY control whether the testing IOStreams + // treats each respective FD as a TTY. + InputTTY bool + OutputTTY bool + ErrorTTY bool + + // profile is the terminal color profile to use. + profile termenv.Profile + + // quiet stores if quiet mode has been enabled. + quiet bool +} + +// Test returns a new IOStreams for testing. +func Test() *Testing { + t := &Testing{ + Input: &bytes.Buffer{}, + Output: &bytes.Buffer{}, + Error: &bytes.Buffer{}, + profile: termenv.Ascii, // Default to no color + } + + return t +} + +func (t *Testing) In() io.Reader { return t.Input } +func (t *Testing) Out() io.Writer { return t.Output } +func (t *Testing) Err() io.Writer { + if t.quiet { + return io.Discard + } + + return t.Error +} + +func (t *Testing) ColorEnabled() bool { return false } +func (t *Testing) ForceNoColor() {} +func (t *Testing) RestoreConsole() error { return nil } +func (t *Testing) SetQuiet(quiet bool) { t.quiet = quiet } + +func (t *Testing) ColorScheme() *ColorScheme { + return &ColorScheme{profile: t.profile} +} + +// ForcedColorProfile allows forcing a specific color profile for testing. +func (t *Testing) ForcedColorProfile(profile termenv.Profile) { + t.profile = profile +} + +func (t *Testing) ReadSecret() ([]byte, error) { + if !t.CanPrompt() { + return nil, fmt.Errorf("prompting is disabled") + } + + var buf [1]byte + var ret []byte + + for { + n, err := t.Input.Read(buf[:]) + if n > 0 { + switch buf[0] { + case '\b': + if len(ret) > 0 { + ret = ret[:len(ret)-1] + } + case '\n', '\r': + return ret, nil + default: + ret = append(ret, buf[0]) + } + continue + } + if err != nil { + if err == io.EOF && len(ret) > 0 { + return ret, nil + } + return ret, err + } + } +} + +// PromptConfirm for testing attempts to read a single byte from stdin. If the +// byte is y, true is returned, if it is n, false is returned. Any other value +// is an error. +func (t *Testing) PromptConfirm(prompt string) (bool, error) { + if !t.CanPrompt() { + return false, fmt.Errorf("prompting is disabled") + } + + // Output the prompt + fmt.Fprintf(t.Error, "%s (y/n)? ", prompt) + + // Try to read a single byte from stdin. + b, err := t.Input.ReadByte() + if err != nil { + return false, err + } + + switch b { + case byte('y'): + return true, nil + case byte('n'): + return false, nil + default: + return false, fmt.Errorf("invalid character: %v", b) + } +} + +func (t *Testing) IsInputTTY() bool { + return t.InputTTY +} + +func (t *Testing) IsOutputTTY() bool { + return t.OutputTTY +} + +func (t *Testing) IsErrorTTY() bool { + return t.ErrorTTY +} + +func (t *Testing) CanPrompt() bool { + return !t.quiet && t.IsErrorTTY() && t.IsInputTTY() +} + +func (t *Testing) TerminalWidth() int { + return TerminalDefaultWidth +} + +// nopCloser wraps an io.Writer with a Close method. +type nopCloser struct{ io.Writer } + +// NopWriteCloser wraps an io.Writer with a Close method. +func NopWriteCloser(w io.Writer) io.WriteCloser { return nopCloser{w} } +func (nopCloser) Close() error { return nil } + +// Ensure we meet the interface. +var _ IOStreams = &Testing{} diff --git a/internal/pkg/ld/suggest.go b/internal/pkg/ld/suggest.go new file mode 100644 index 0000000..f53ffab --- /dev/null +++ b/internal/pkg/ld/suggest.go @@ -0,0 +1,70 @@ +// Copyright IBM Corp. 2026 + +// Package ld implements levenshtein distance in order to provide suggestions +// based on similarity. +package ld + +import "strings" + +// Distance compares two strings and returns the levenshtein distance between them. +func Distance(s, t string, ignoreCase bool) int { + if ignoreCase { + s = strings.ToLower(s) + t = strings.ToLower(t) + } + d := make([][]int, len(s)+1) + for i := range d { + d[i] = make([]int, len(t)+1) + } + for i := range d { + d[i][0] = i + } + for j := range d[0] { + d[0][j] = j + } + for j := 1; j <= len(t); j++ { + for i := 1; i <= len(s); i++ { + if s[i-1] == t[j-1] { + d[i][j] = d[i-1][j-1] + } else { + minD := d[i-1][j] + if d[i][j-1] < minD { + minD = d[i][j-1] + } + if d[i-1][j-1] < minD { + minD = d[i-1][j-1] + } + d[i][j] = minD + 1 + } + } + + } + return d[len(s)][len(t)] +} + +// Suggestions takes in an input and a set of valid options. It returns a set of +// suggested values based on the levenshtein distance between them. The +// distanceCutoff can be used to control the required similarity of the option +// to the input for it to be included in the suggestions. +func Suggestions(input string, options []string, distanceCutoff int, ignoreCase bool) []string { + return SuggestionsWithOverride(input, options, distanceCutoff, ignoreCase, nil) +} + +// SuggestionsWithOverride is similar to Suggestions, except it takes an +// optional include function. If the distance is less than the cutoff, the +// passed override function will be invoked, and if it returns true, the option +// will be added to the suggestions list. This allows custom suggestion logic to +// be added. +func SuggestionsWithOverride(input string, options []string, distanceCutoff int, ignoreCase bool, override func(input, option string) bool) []string { + suggestions := []string{} + for _, o := range options { + d := Distance(input, o, ignoreCase) + if d <= distanceCutoff { + suggestions = append(suggestions, o) + } else if override != nil && override(input, o) { + suggestions = append(suggestions, o) + } + } + + return suggestions +} diff --git a/internal/pkg/profile/doc.go b/internal/pkg/profile/doc.go new file mode 100644 index 0000000..0c38fc4 --- /dev/null +++ b/internal/pkg/profile/doc.go @@ -0,0 +1,9 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package profile stores the CLI configuration for the named profile. +// +// The profile stores common configuration values such as the organization and +// hostname when running commands. It also stores user settable values +// that customize CLI output, like disabling color. +package profile diff --git a/internal/pkg/profile/loader.go b/internal/pkg/profile/loader.go new file mode 100644 index 0000000..8e01df3 --- /dev/null +++ b/internal/pkg/profile/loader.go @@ -0,0 +1,364 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode" + + "github.com/hashicorp/hcl/v2/hclsimple" + "github.com/mitchellh/go-homedir" + "golang.org/x/net/idna" +) + +const ( + // ConfigDir is the directory that contains TFCloud CLI configuration. + ConfigDir = "~/.config/tfcloud/" + + // ProfileDir is the directory that contains TFCloud CLI profiles. + ProfileDir = "profiles/" + + // ProfileNameDefault is the default profile name. + ProfileNameDefault = "default" + + // TerraformCredentialsPath is the path to the terraform credentials file that we will check for + // tokens if they're not set in the profiler. + TerraformCredentialsPath = "~/.terraform.d/credentials.tfrc.json" +) + +var ( + // ErrNoActiveProfileFilePresent is returned if no active profile file + // exists. + ErrNoActiveProfileFilePresent = errors.New("active profile file doesn't exist") + + // ErrActiveProfileFileEmpty is returned if the active profile file is + // empty. + ErrActiveProfileFileEmpty = errors.New("active profile is unset") +) + +// Loader is used to load and interact with profiles on disk. +type Loader struct { + // configDir is the configuration directory. + configDir string + + // profilesDir is the directory containing profiles. + profilesDir string +} + +// NewLoader returns a new loader or an error if the loader can't be +// instantiated. +func NewLoader() (*Loader, error) { + return newLoader(ConfigDir) +} + +// newLoader returns a new loader for the given config directory. +func newLoader(dir string) (*Loader, error) { + path, err := homedir.Expand(dir) + if err != nil { + return nil, fmt.Errorf("error expanding TFCloud config directory path %q: %w", dir, err) + } + + // Ensure the config directory exists. + _, err = os.Stat(path) + if err != nil { + // If the directory doesn't exist, create it. + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(path, 0766); err != nil { + return nil, fmt.Errorf("failed to created TFCloud config directory %q: %w", path, err) + } + } else { + return nil, fmt.Errorf("failed to check if TFCloud config directory exists: %w", err) + } + } + + // Ensure the profiles directory exists. + profilesDir := filepath.Join(path, ProfileDir) + _, err = os.Stat(profilesDir) + if err != nil { + // If the directory doesn't exist, create it. + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(profilesDir, 0766); err != nil { + return nil, fmt.Errorf("failed to created TFCloud profiles directory %q: %w", profilesDir, err) + } + } else { + return nil, fmt.Errorf("failed to check if TFCloud profiles directory exists: %w", err) + } + } + + return &Loader{ + configDir: path, + profilesDir: profilesDir, + }, nil +} + +// GetActiveProfile returns the current profile. +func (l *Loader) GetActiveProfile() (*ActiveProfile, error) { + // Expand the active profile path. + path := filepath.Join(l.configDir, ActiveProfileFileName) + + // Check if the file exists. + _, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNoActiveProfileFilePresent + } + + return nil, err + } + + // Decode the file + var c ActiveProfile + if err := hclsimple.DecodeFile(path, nil, &c); err != nil { + return nil, err + } + + // Check if no profile has been set. + if c.Name == "" { + return nil, ErrActiveProfileFileEmpty + } + + c.dir = l.configDir + return &c, nil +} + +// DefaultActiveProfile returns an active profile set to default. +func (l *Loader) DefaultActiveProfile() *ActiveProfile { + return &ActiveProfile{ + Name: ProfileNameDefault, + dir: l.configDir, + } +} + +// ListProfiles returns the available profile names. +func (l *Loader) ListProfiles() ([]string, error) { + files, err := os.ReadDir(l.profilesDir) + if err != nil { + return nil, fmt.Errorf("unable to list profiles: %w", err) + } + + profiles := make([]string, 0, len(files)) + for _, file := range files { + n := file.Name() + if file.IsDir() { + return nil, fmt.Errorf("unexpected directory %q in profile %q directory. Please delete to recover", n, l.configDir) + } + + if !strings.HasSuffix(n, ".hcl") { + return nil, fmt.Errorf("unexpected non-hcl file %q in profile %q directory. Please delete to recover", n, l.configDir) + } + + profiles = append(profiles, strings.TrimSuffix(n, ".hcl")) + } + + return profiles, nil +} + +// LoadProfile loads a profile given its name. If the profile can not be found, +// ErrNoProfileFilePresent will be returned. Otherwise, an error will be +// returned if the profile is invalid. +func (l *Loader) LoadProfile(name string) (*Profile, error) { + // Expand the directory. + path := filepath.Join(l.profilesDir, fmt.Sprintf("%s.hcl", name)) + + // Check that the profile exists. + _, err := os.Stat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNoProfileFilePresent + } + + return nil, err + } + + // Decode the profile. + var c Profile + if err := hclsimple.DecodeFile(path, nil, &c); err != nil { + return nil, fmt.Errorf("failed to decode profile: %w", err) + } + + // Validate the name matches in the path and file. + if name != c.Name { + return nil, fmt.Errorf("profile path name does not match name in file. %q versus %q. Please rename file or name within the profile file to reconcile", name, c.Name) + } + + // If there's no default organization set, use the environment variable if it's set. + if c.Organization == "" { + if orgID, ok := os.LookupEnv(envVarTFCloudOrganization); ok && orgID != "" { + c.Organization = orgID + } + } + + // If there's no token set, check the credentials file and environment variables. + if c.Token == "" { + c.Token, err = tokenFromCredentials(c.Hostname) + if err != nil { + return nil, err + } + } + + if c.Token == "" { + if envToken := os.Getenv(profileTokenEnvVar(c.Name)); envToken != "" { + c.Token = envToken + } + } + + if c.Token == "" { + c.Token = os.Getenv(legacyTokenEnvVar(c.Hostname)) + } + + c.dir = l.profilesDir + return &c, nil +} + +// LoadProfiles loads all the available profiles. +func (l *Loader) LoadProfiles() ([]*Profile, error) { + profileNames, err := l.ListProfiles() + if err != nil { + return nil, err + } + + var profiles []*Profile + for _, n := range profileNames { + p, err := l.LoadProfile(n) + if err != nil { + return nil, fmt.Errorf("failed to load profile %q: %w", n, err) + } + profiles = append(profiles, p) + } + + return profiles, nil +} + +// DeleteProfile deletes the profile with the given name. If the profile can not be found, +// ErrNoProfileFilePresent will be returned. Otherwise, an error will be +// returned if the profile can not be deleted for any other reason.. +func (l *Loader) DeleteProfile(name string) error { + // Expand the directory. + path := filepath.Join(l.profilesDir, fmt.Sprintf("%s.hcl", name)) + + // Try to delete the file + err := os.Remove(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return ErrNoProfileFilePresent + } + + return err + } + + return nil +} + +const ( + envVarTFCloudHostname = "TFCLOUD_HOSTNAME" + envVarTFCloudOrganization = "TFCLOUD_ORGANIZATION" + envVarTFCloudToken = "TFCLOUD_TOKEN" + envVarTFCloudTokenProfileFormat = "TFCLOUD_TOKEN_%s" +) + +// DefaultProfile returns the minimal default profile. If environment +// variables related to organization and project are set, they are honored here. +func (l *Loader) DefaultProfile() *Profile { + profile, err := l.NewProfile(ProfileNameDefault) + if err != nil { + panic("The default profile should always be valid. This is always a developer error: " + err.Error()) + } + + org, orgOK := os.LookupEnv(envVarTFCloudOrganization) + if orgOK { + profile.Organization = org + } + + hostname := "app.terraform.io" + if envHostname, ok := os.LookupEnv(envVarTFCloudHostname); ok && envHostname != "" { + hostname = envHostname + } + + profile.Hostname = hostname + + return profile +} + +// NewProfile returns an new profile with defaults. +func (l *Loader) NewProfile(name string) (*Profile, error) { + p := &Profile{ + Name: name, + dir: l.profilesDir, + } + + return p, p.Validate() +} + +func normalizeHostname(hostname string) string { + hostname = strings.TrimSpace(hostname) + hostname = strings.TrimPrefix(hostname, "https://") + hostname = strings.TrimPrefix(hostname, "http://") + hostname = strings.TrimRight(hostname, "/") + if asciiHost, err := idna.Lookup.ToASCII(hostname); err == nil { + return asciiHost + } + return hostname +} + +func profileTokenEnvVar(profileName string) string { + if profileName == "" || profileName == "default" { + return envVarTFCloudToken + } + return fmt.Sprintf(envVarTFCloudTokenProfileFormat, profileName) +} + +func legacyTokenEnvVar(hostname string) string { + hostname = normalizeHostname(hostname) + + var b strings.Builder + b.WriteString("TF_TOKEN_") + for _, r := range strings.ToUpper(hostname) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + continue + } + b.WriteRune('_') + } + return b.String() +} + +type credentialsFile struct { + Credentials map[string]struct { + Token string `json:"token"` + } `json:"credentials"` +} + +func tokenFromCredentials(hostname string) (string, error) { + path, err := homedir.Expand(TerraformCredentialsPath) + if err != nil { + return "", fmt.Errorf("error expanding TFCloud config directory path %q: %w", TerraformCredentialsPath, err) + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + var creds credentialsFile + if err := json.Unmarshal(data, &creds); err != nil { + return "", fmt.Errorf("parse %s: %w", path, err) + } + + hostname = normalizeHostname(hostname) + entry, ok := creds.Credentials[hostname] + if !ok { + return "", nil + } + + return entry.Token, nil +} diff --git a/internal/pkg/profile/loader_test.go b/internal/pkg/profile/loader_test.go new file mode 100644 index 0000000..a5e1379 --- /dev/null +++ b/internal/pkg/profile/loader_test.go @@ -0,0 +1,338 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoader_New(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Test that we create the directory if it doesn't yet exist. + dir := filepath.Join(t.TempDir(), "tfcloud") + l, err := newLoader(dir) + r.NoError(err) + r.NotNil(l) + + // Check the directory and the profiles sub-dir was created. + r.DirExists(dir) + r.DirExists(filepath.Join(dir, ProfileDir)) +} + +func TestLoader_GetActiveProfile(t *testing.T) { + t.Parallel() + + t.Run("no active profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l, err := newLoader(t.TempDir()) + r.NoError(err) + active, err := l.GetActiveProfile() + r.Nil(active) + r.ErrorIs(err, ErrNoActiveProfileFilePresent) + }) + + t.Run("empty active profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + active := l.DefaultActiveProfile() + active.Name = "" + r.NoError(active.Write()) + + p, err := l.GetActiveProfile() + r.Nil(p) + r.ErrorIs(err, ErrActiveProfileFileEmpty) + }) + + t.Run("malformed active profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Write a bad active profile + r.NoError(os.WriteFile(filepath.Join(l.configDir, ActiveProfileFileName), []byte("invalid!"), 0x777)) + + // Read the malformed profile + p, err := l.GetActiveProfile() + r.Nil(p) + r.Error(err) + }) + + t.Run("valid active profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + active := l.DefaultActiveProfile() + active.Name = t.Name() + r.NoError(active.Write()) + + p, err := l.GetActiveProfile() + r.NoError(err) + r.Equal(t.Name(), p.Name) + }) + +} + +func TestLoader_ListProfiles(t *testing.T) { + t.Parallel() + + validProfileNames := []string{"bar", "baz", "foo"} + slices.Sort(validProfileNames) + t.Run("empty profiles directory", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + profiles, err := l.ListProfiles() + r.Empty(profiles) + r.NoError(err) + }) + + t.Run("valid profiles", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Create some profiles + for _, n := range validProfileNames { + p, err := l.NewProfile(n) + r.NoError(err) + r.NoError(p.Write()) + } + + profiles, err := l.ListProfiles() + slices.Sort(profiles) + r.Equal(profiles, validProfileNames) + r.NoError(err) + }) + + t.Run("one invalid profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Create some profiles + for _, n := range validProfileNames { + p, err := l.NewProfile(n) + r.NoError(err) + r.NoError(p.Write()) + } + + // Write an invalid file + r.NoError(os.WriteFile(filepath.Join(l.configDir, ProfileDir, "not_a_profile.json"), []byte("invalid!"), 0x777)) + + profiles, err := l.ListProfiles() + r.Empty(profiles) + r.ErrorContains(err, "unexpected non-hcl file") + }) +} + +func TestLoader_LoadProfile(t *testing.T) { + t.Parallel() + + t.Run("no profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + p, err := l.LoadProfile("test") + r.Nil(p) + r.ErrorIs(err, ErrNoProfileFilePresent) + }) + + t.Run("invalid profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Write an invalid profile to disk + name := "test" + path := filepath.Join(l.configDir, ProfileDir, fmt.Sprintf("%s.hcl", name)) + r.NoError(os.WriteFile(path, []byte("invalid!"), 0x777)) + + p, err := l.LoadProfile(name) + r.Nil(p) + r.ErrorContains(err, "failed to decode profile") + }) + + t.Run("mismatched profile name", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Write an invalid profile to disk + name := "test" + path := filepath.Join(l.configDir, ProfileDir, fmt.Sprintf("%s.hcl", name)) + r.NoError(os.WriteFile(path, []byte(`name = "other" +organization = "123"`, + ), 0x777)) + + p, err := l.LoadProfile(name) + r.Nil(p) + r.ErrorContains(err, "profile path name does not match name in file") + }) + + t.Run("valid profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "123" + r.NoError(p.Write()) + + out, err := l.LoadProfile(p.Name) + r.NotNil(out) + r.Equal(p.Name, out.Name) + r.Equal(p.Organization, out.Organization) + r.NoError(err) + }) + + t.Run("invalid profile name", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + _, err := l.NewProfile("test!@#$") + r.ErrorContains(err, "profile name may only include") + }) +} + +//nolint:paralleltest +func TestLoader_LoadProfileEnv(t *testing.T) { + + // These tests aren't parallel because they manipulate the environment + // and can't run concurrently. + + //nolint:paralleltest + t.Run("default profile, env set", func(t *testing.T) { + defer os.Unsetenv(envVarTFCloudOrganization) + + os.Setenv(envVarTFCloudOrganization, "xyz") + + r := require.New(t) + l, err := newLoader(t.TempDir()) + r.NoError(err) + prof := l.DefaultProfile() + + r.Equal("xyz", prof.Organization) + }) + + //nolint:paralleltest + t.Run("valid active profile, env set", func(t *testing.T) { + r := require.New(t) + l := TestLoader(t) + + defer os.Unsetenv(envVarTFCloudOrganization) + + p, err := l.NewProfile("test") + r.NoError(err) + r.NoError(p.Write()) + + os.Setenv(envVarTFCloudOrganization, "xyz") + + out, err := l.LoadProfile(p.Name) + r.NoError(err) + r.NotNil(out) + r.Equal("xyz", out.Organization) + }) +} + +func TestLoader_LoadProfiles(t *testing.T) { + t.Parallel() + + t.Run("no profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + profiles, err := l.LoadProfiles() + r.Nil(profiles) + r.NoError(err) + }) + + t.Run("valid profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "123" + r.NoError(p.Write()) + + out, err := l.LoadProfiles() + r.NoError(err) + r.Len(out, 1) + r.Equal(p.Name, out[0].Name) + r.Equal(p.Organization, out[0].Organization) + r.NoError(err) + }) + + t.Run("valid profiles", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "123" + r.NoError(p.Write()) + + p2, err := l.NewProfile("test2") + r.NoError(err) + p2.Organization = "456" + r.NoError(p2.Write()) + + out, err := l.LoadProfiles() + r.NoError(err) + r.NotNil(out) + r.Equal(p.Name, out[0].Name) + r.Equal(p.Organization, out[0].Organization) + r.Equal(p2.Name, out[1].Name) + r.Equal(p2.Organization, out[1].Organization) + }) +} + +func TestLoader_DeleteProfile(t *testing.T) { + t.Parallel() + + t.Run("no profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + p, err := l.NewProfile("test") + r.NoError(err) + p.Organization = "123" + r.NoError(p.Write()) + + r.NoError(l.DeleteProfile("test")) + }) + + t.Run("existing profile", func(t *testing.T) { + t.Parallel() + r := require.New(t) + l := TestLoader(t) + + // Write an invalid profile to disk + name := "test" + path := filepath.Join(l.configDir, ProfileDir, fmt.Sprintf("%s.hcl", name)) + r.NoError(os.WriteFile(path, []byte("invalid!"), 0x777)) + + p, err := l.LoadProfile(name) + r.Nil(p) + r.ErrorContains(err, "failed to decode profile") + }) +} diff --git a/internal/pkg/profile/profile.go b/internal/pkg/profile/profile.go new file mode 100644 index 0000000..22cf1c9 --- /dev/null +++ b/internal/pkg/profile/profile.go @@ -0,0 +1,241 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "slices" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/posener/complete" +) + +const ( + // ActiveProfileFileName is the file name of the active profile stored in + // the ConfigDir. + ActiveProfileFileName = "active_profile.hcl" +) + +var ( + // ErrNoProfileFilePresent is returned when the requested profile does not + // exist. + ErrNoProfileFilePresent = errors.New("profile configuration file doesn't exist") + + // ErrInvalidProfileName is returned if a profile is created with an invalid + // profile name. + ErrInvalidProfileName = errors.New("profile name may only include a-z, A-Z, 0-9, or '_', must start with a letter, and can be no longer than 64 characters") +) + +// ActiveProfile stores the active profile. +type ActiveProfile struct { + Name string `hcl:"name"` + + // dir is the directory the active profile should be written to. + dir string +} + +// Write writes the active profile to disk. +func (c *ActiveProfile) Write() error { + path := filepath.Join(c.dir, ActiveProfileFileName) + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(c, f.Body()) + return os.WriteFile(path, f.Bytes(), 0o666) +} + +// Profile is a named set of configuration for the tfcloud CLI. It captures common +// configuration values such as the organization and project being interacted +// with, but also allows storing service specific configuration. +type Profile struct { + // Name is the name of the profile + Name string `hcl:"name"` + + // Organization stores the organization to make requests against. + Organization string `hcl:"organization"` + + // NoColor disables color output + NoColor *bool `hcl:"no_color,optional" json:",omitempty"` + + // Verbosity is the default verbosity to log at + Verbosity *string `hcl:"verbosity,optional" json:",omitempty"` + + // Quiet is whether the CLI should minimize output + Quiet *bool `hcl:"quiet,optional" json:",omitempty"` + + // Hostname is the profile's configured hostname for API requests. If not set, the default is app.terraform.io. + Hostname string `hcl:"hostname,optional" json:",omitempty"` + + // Token is the API token to use for API requests. If not set, the CLI will look for the token in the environment or terraform credentials. + Token string `hcl:"token,optional" json:",omitempty"` + + // dir is the directory the profile should write to. + dir string +} + +// Predict predicts the HCL key names and basic settable values. +func (p *Profile) Predict(args complete.Args) []string { + properties := map[string][]string{ + "no_color": {"true", "false"}, + "verbosity": {"trace", "debug", "info", "warn", "error"}, + "quiet": {"true", "false"}, + } + + // If the property has been specified, return possible values. + if len(args.All) >= 1 { + prediction, ok := properties[args.All[0]] + if ok { + return prediction + } + } + + // predicting the property + if len(args.All) == 1 { + return []string{"organization", "no_color", "verbosity", "quiet", "hostname", "token"} + } + + return nil +} + +// Validate validates that the set values are valid. It validates parameters +// that do not require any communication with HCP. +func (p *Profile) Validate() error { + err := &multierror.Error{} + + const nameRegex = "^[A-Za-z][A-Za-z0-9_]{0,63}$" + if matched, _ := regexp.MatchString(nameRegex, p.Name); !matched { + err = multierror.Append(err, ErrInvalidProfileName) + } + + allowedVerbosities := []string{"trace", "debug", "info", "warn", "error"} + if f := p.GetVerbosity(); f != "" && !slices.Contains(allowedVerbosities, f) { + err = multierror.Append(err, fmt.Errorf("invalid verbosity %q. Must be one of: %q", f, allowedVerbosities)) + } + + err.ErrorFormat = func(errors []error) string { + if len(errors) == 1 { + return errors[0].Error() + } + + numErrors := len(errors) + var buf bytes.Buffer + fmt.Fprintln(&buf) + fmt.Fprintln(&buf) + for i, e := range errors { + fmt.Fprintf(&buf, " * %s", e) + if i != numErrors-1 { + fmt.Fprintln(&buf) + } + } + return buf.String() + } + + return err.ErrorOrNil() +} + +// Clean nils any empty component. +func (p *Profile) Clean() { +} + +// Write writes the profile to disk. +func (p *Profile) Write() error { + // Remove any empty components before writing + p.Clean() + + path := fmt.Sprintf("%s/%s.hcl", p.dir, p.Name) + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(p, f.Body()) + return os.WriteFile(path, f.Bytes(), 0o666) +} + +// String returns an HCL formatted string representation of the profile. +func (p Profile) String() string { + f := hclwrite.NewEmptyFile() + p.Token = "(sensitive)" + gohcl.EncodeIntoBody(p, f.Body()) + return strings.TrimSpace(string(f.Bytes())) +} + +// PropertyNames returns the name of the properties in a profile. If the +// property is in a struct, such as Core, the property name will be +// /, such as "core/no_color". +func PropertyNames() map[string]struct{} { + keys := make(map[string]struct{}) + var p Profile + doWalkStructElements("", reflect.TypeOf(p), keys) + return keys +} + +func doWalkStructElements(path string, t reflect.Type, keys map[string]struct{}) { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // Get the tag + name := field.Tag.Get("hcl") + if name == "" { + continue + } + + name = strings.Split(name, ",")[0] + if path != "" { + name = fmt.Sprintf("%s/%s", path, name) + } + + v := field.Type + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() == reflect.Struct { + doWalkStructElements(name, v, keys) + } else { + keys[name] = struct{}{} + } + + } +} + +// GetVerbosity returns the set verbosity or an empty string if it has not been +// configured. +func (p *Profile) GetVerbosity() string { + if p == nil { + return "" + } + + if p.Verbosity == nil { + return "" + } + + return *p.Verbosity +} + +// SetOrg sets the Organization. +func (p *Profile) SetOrg(name string) *Profile { + if p == nil { + return nil + } + + p.Organization = name + return p +} + +// IsQuiet returns whether the quiet property has been configured to be quiet. +func (p *Profile) IsQuiet() bool { + if p == nil { + return false + } + + if p.Quiet == nil { + return false + } + + return *p.Quiet +} diff --git a/internal/pkg/profile/profile_test.go b/internal/pkg/profile/profile_test.go new file mode 100644 index 0000000..cb9072c --- /dev/null +++ b/internal/pkg/profile/profile_test.go @@ -0,0 +1,134 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import ( + "strings" + "testing" + + "github.com/posener/complete" + "github.com/stretchr/testify/require" +) + +func TestPropertyNames(t *testing.T) { + t.Parallel() + r := require.New(t) + properties := PropertyNames() + r.NotEmpty(properties) + r.Contains(properties, "name") + r.Contains(properties, "organization") + r.Contains(properties, "token") + r.Contains(properties, "hostname") +} + +func TestProfile_Validate(t *testing.T) { + t.Parallel() + + badVerbosity := "invalid" + + cases := []struct { + Name string + Profile *Profile + Error string + }{ + { + Name: "empty", + Profile: &Profile{}, + Error: "profile name may only include", + }, + { + Name: "name too long", + Profile: &Profile{ + Name: strings.Repeat("test", 100), + }, + Error: "profile name may only include", + }, + { + Name: "invalid core", + Profile: &Profile{ + Name: "test", + Organization: "123", + Verbosity: &badVerbosity, + }, + Error: "invalid verbosity", + }, + { + Name: "valid", + Profile: &Profile{ + Name: "test", + Organization: "123", + }, + Error: "", + }, + } + + for _, c := range cases { + // Capture the test case + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + err := c.Profile.Validate() + if c.Error == "" { + r.NoError(err) + } else { + r.ErrorContains(err, c.Error) + } + }) + } +} + +func TestProfile_Predict(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args complete.Args + Expected []string + }{ + { + Name: "empty", + Args: complete.Args{ + All: []string{""}, + }, + Expected: []string{"organization", "no_color", "verbosity", "quiet", "hostname", "token"}, + }, + { + Name: "specific field", + Args: complete.Args{ + All: []string{"org"}, + }, + Expected: []string{"organization", "no_color", "verbosity", "quiet", "hostname", "token"}, + }, + } + + for _, c := range cases { + // Capture the test case + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a profile + p := &Profile{} + + // Predict + out := p.Predict(c.Args) + r.Equal(c.Expected, out) + }) + } +} + +func TestCore_Getters(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Instantiate a non-empty profile + v := true + p := &Profile{ + NoColor: &v, + } + r.Equal(v, *p.NoColor) +} diff --git a/internal/pkg/profile/testing.go b/internal/pkg/profile/testing.go new file mode 100644 index 0000000..34f8ce3 --- /dev/null +++ b/internal/pkg/profile/testing.go @@ -0,0 +1,27 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package profile + +import "testing" + +// TestProfile returns a profile appropriate for use during testing. If +// interacting with more than one profile, prefer using TestLoader. +func TestProfile(t *testing.T) *Profile { //nolint:paralleltest + return TestLoader(t).DefaultProfile() +} + +// TestLoader returns a Loader suitable for testing. All profiles that are +// accessed will be in the context of a temporary directory. +func TestLoader(t *testing.T) *Loader { //nolint:paralleltest + l, err := newLoader(t.TempDir()) + if err != nil { + t.Fatalf("failed to create profile loader: %v", err) + } + + if err := l.DefaultActiveProfile().Write(); err != nil { + t.Fatalf("failed to create default active profile file: %v", err) + } + + return l +} diff --git a/internal/render/json.go b/internal/pkg/render/json.go similarity index 100% rename from internal/render/json.go rename to internal/pkg/render/json.go diff --git a/internal/render/jsonapi_table.go b/internal/pkg/render/jsonapi_table.go similarity index 100% rename from internal/render/jsonapi_table.go rename to internal/pkg/render/jsonapi_table.go diff --git a/internal/render/jsonapi_table_test.go b/internal/pkg/render/jsonapi_table_test.go similarity index 100% rename from internal/render/jsonapi_table_test.go rename to internal/pkg/render/jsonapi_table_test.go diff --git a/internal/render/type_columns.go b/internal/pkg/render/type_columns.go similarity index 100% rename from internal/render/type_columns.go rename to internal/pkg/render/type_columns.go diff --git a/internal/pkg/table/LICENSE b/internal/pkg/table/LICENSE new file mode 100644 index 0000000..e436d90 --- /dev/null +++ b/internal/pkg/table/LICENSE @@ -0,0 +1,10 @@ +MIT License +=========== + +Copyright (c) 2015, Greg Osuri + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/internal/pkg/table/table.go b/internal/pkg/table/table.go new file mode 100644 index 0000000..5ede950 --- /dev/null +++ b/internal/pkg/table/table.go @@ -0,0 +1,245 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +// Package table outputs data in a table format. The package is heavily inspired by +// github.com/gosuri/uitable. +package table + +import ( + "fmt" + "strings" + + "github.com/muesli/ansi" + "github.com/muesli/reflow/padding" + "github.com/muesli/reflow/truncate" + "github.com/muesli/reflow/wrap" +) + +// Table represents a decorator that renders the data in formatted in a table. +type Table struct { + // LineLength is the maximum allowed length of a line in the Table + LineLength uint + + // Wrap when set to true wraps the contents of the columns when the length exceeds the MaxColWidth + Wrap bool + + // SeparatorSpaces is the number of spaces between columns + SeparatorSpaces uint + + // HeaderFormatter is a function that formats the header of the table + HeaderFormatter func(input string) string + + // FirstColumnFormatter is a function that formats the first column of the table + FirstColumnFormatter func(input string) string + + // rows is the collection of rows in the table + rows []*row + + // maxColWidth is the maximum allowed width for cells in the table + maxColWidth uint + + // separator is the separator for columns in the table. Default is "\t" + separator string +} + +// AddRow adds a new row to the table. +func (t *Table) AddRow(data ...interface{}) *Table { + r := newRow(data...) + t.rows = append(t.rows, r) + return t +} + +// String returns the string value of table. +func (t *Table) String() string { + if len(t.rows) == 0 { + return "" + } + + // Set the separator string + t.separator = strings.Repeat(" ", int(t.SeparatorSpaces)) + + // Determine the maximum column width by subtracting from the total line LineLength + // the width of column separators and then dividing by the number of columns. + numHeaders := len(t.rows[0].cells) + headerSpacing := uint((numHeaders - 1)) * t.SeparatorSpaces + t.maxColWidth = (t.LineLength - headerSpacing) / uint(numHeaders) + + // determine the width for each column (cell in a row) + var colWidths []uint + var rawColWidths []uint + for _, row := range t.rows { + for i, cell := range row.cells { + // resize colwidth array + if i+1 > len(colWidths) { + colWidths = append(colWidths, 0) + rawColWidths = append(rawColWidths, 0) + } + cellwidth := cell.lineWidth() + if cellwidth > rawColWidths[i] { + rawColWidths[i] = cellwidth + } + + if t.maxColWidth != 0 && cellwidth > t.maxColWidth { + cellwidth = t.maxColWidth + } + if cellwidth > colWidths[i] { + colWidths[i] = cellwidth + } + } + } + + // If the total width of the table is less than the LineLength, distribute + // the remaining width to the columns whose colwidth is less than the + // rawColWidths. + if t.LineLength > 0 { + totalWidth := uint(int(t.SeparatorSpaces) * (len(colWidths) - 1)) + for _, w := range colWidths { + totalWidth += w + } + + // Determine the remaining width to distribute. + remainingWidth := t.LineLength - totalWidth + if remainingWidth > 0 { + for i, w := range colWidths { + if desiredWidth := rawColWidths[i]; w < desiredWidth { + add := desiredWidth - w + if add > remainingWidth { + add = remainingWidth + } + colWidths[i] += add + remainingWidth -= add + } + } + } + } + + var lines []string + for i, row := range t.rows { + row.separator = t.separator + if i == 0 { + row.headerFormatter = t.HeaderFormatter + } else { + row.firstColumnFormatter = t.FirstColumnFormatter + } + for i, cell := range row.cells { + cell.width = colWidths[i] + cell.wrap = t.Wrap + } + lines = append(lines, row.string()) + } + return strings.Join(lines, "\n") +} + +// row represents a row in a table. +type row struct { + // cells is the group of cell for the row + cells []*cell + + // separator for tabular columns + separator string + + // headerFormatter is a function that formats the header of the table + headerFormatter func(input string) string + + // firstColumnFormatter is a function that formats the first column of the table + firstColumnFormatter func(input string) string +} + +// newRow returns a new Row and adds the data to the row. +func newRow(data ...interface{}) *row { + r := &row{cells: make([]*cell, len(data))} + for i, d := range data { + r.cells[i] = &cell{data: d} + } + return r +} + +// string returns the string representation of the row. +func (r *row) string() string { + // get the max number of lines for each cell + var lc int // line count + for _, cell := range r.cells { + if clc := len(strings.Split(cell.string(), "\n")); clc > lc { + lc = clc + } + } + + // allocate a two-dimensional array of cells for each line and add size them + cells := make([][]*cell, lc) + for x := 0; x < lc; x++ { + cells[x] = make([]*cell, len(r.cells)) + for y := 0; y < len(r.cells); y++ { + cells[x][y] = &cell{width: r.cells[y].width, wrap: r.cells[y].wrap} + } + } + + // insert each line in a cell as new cell in the cells array + for y, cell := range r.cells { + lines := strings.Split(cell.string(), "\n") + for x, line := range lines { + cells[x][y].data = line + } + } + + // format each line + lines := make([]string, lc) + for x := range lines { + line := make([]string, len(cells[x])) + for y := range cells[x] { + val := cells[x][y].string() + if r.headerFormatter != nil { + val = r.headerFormatter(val) + } + if y == 0 && r.firstColumnFormatter != nil { + val = r.firstColumnFormatter(val) + } + + line[y] = val + } + lines[x] = strings.Join(line, r.separator) + } + return strings.Join(lines, "\n") +} + +// cell represents a column in a row. +type cell struct { + // width is the width of the cell + width uint + + // wrap when true wraps the contents of the cell when the length exceeds the width + wrap bool + + // data is the cell data + data interface{} +} + +// lineWidth returns the max width of all the lines in a cell. +func (c *cell) lineWidth() uint { + width := 0 + for _, s := range strings.Split(c.string(), "\n") { + w := ansi.PrintableRuneWidth(s) + if w > width { + width = w + } + } + return uint(width) +} + +// string returns the string formatted representation of the cell. +func (c *cell) string() string { + if c.data == nil { + return padding.String(" ", c.width) + } + s := fmt.Sprint(c.data) + if c.width > 0 { + if c.wrap && uint(ansi.PrintableRuneWidth(s)) > c.width { + return wrap.String(s, int(c.width)) + } else if !c.wrap && uint(ansi.PrintableRuneWidth(s)) > c.width { + return truncate.StringWithTail(s, c.width, "...") + } else if len(s) != 0 { + return padding.String(s, c.width) + } + return strings.Repeat(" ", int(c.width)) + } + return s +} diff --git a/internal/pkg/table/table_test.go b/internal/pkg/table/table_test.go new file mode 100644 index 0000000..2d198fa --- /dev/null +++ b/internal/pkg/table/table_test.go @@ -0,0 +1,100 @@ +// Copyright IBM Corp. 2024, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package table + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// This test ensures that we expand columns to fill the width of the line if the +// total output is less than the line width. +func TestTable_LessThanLineWidth(t *testing.T) { + t.Parallel() + r := require.New(t) + tbl := &Table{ + LineLength: 22, + Wrap: true, + SeparatorSpaces: 2, + } + + tbl.AddRow("header1", "header2") // 7 + 2 + 7 = 16 + tbl.AddRow("short", "alongervalue") // 5 + 2 + 12 = 19 + tbl.AddRow("medium", "medium") // 6 + 2 + 6 = 14 + + // Only have two rows and expect no extra lines. + out := tbl.String() + r.Len(strings.Split(out, "\n"), 3, out) + r.Len(tbl.rows, 3) + + // Expect the cell width to be equal to the header for the first column and + // the length of the longest value for the second. + r.Equal(uint(7), tbl.rows[0].cells[0].width) + r.Equal(uint(12), tbl.rows[1].cells[1].width) +} + +// This test ensures that column width is evenly distributed when the overall +// output exceeds the line length. +func TestTable_MoreThanLineWidth(t *testing.T) { + t.Parallel() + r := require.New(t) + tbl := &Table{ + LineLength: 18, + Wrap: true, + SeparatorSpaces: 2, + } + + tbl.AddRow("header1", "header2") // 7 + 2 + 7 = 16 + tbl.AddRow("short", "alongervalue") // 5 + 2 + 12 = 19 + tbl.AddRow("alongervalue", "alongervalue") // 12 + 2 + 12 = 26 + + // Expect both rows to be wrapped. + // header1 header2 + // short alongerv + // alue + // alongerv alongerv + // alue alue + out := tbl.String() + r.Len(strings.Split(out, "\n"), 5, out) + r.Len(tbl.rows, 3) + + // Expect the cell width to be an equal distribution across the line width. + // (18 - 2) / 2 = 8 + r.Equal(uint(8), tbl.rows[0].cells[0].width) + r.Equal(uint(8), tbl.rows[1].cells[1].width) +} + +func TestCell(t *testing.T) { + t.Parallel() + r := require.New(t) + c := &cell{ + data: "foo bar", + width: 5, + } + + got := c.string() + r.Equal("fo...", got) + r.EqualValues(5, c.lineWidth()) + + c.wrap = true + got = c.string() + r.Equal("foo b\nar", got) + r.EqualValues(5, c.lineWidth()) +} + +func TestRow(t *testing.T) { + t.Parallel() + r := require.New(t) + row := &row{ + separator: " ", + cells: []*cell{ + {data: "foo", width: 3, wrap: true}, + {data: "bar baz", width: 3, wrap: true}, + }, + } + need := "foo bar\n baz" + r.Equal(need, row.string()) +} diff --git a/internal/terraform/workspace.go b/internal/pkg/terraform/workspace.go similarity index 100% rename from internal/terraform/workspace.go rename to internal/pkg/terraform/workspace.go diff --git a/internal/terraform/workspace_test.go b/internal/pkg/terraform/workspace_test.go similarity index 100% rename from internal/terraform/workspace_test.go rename to internal/pkg/terraform/workspace_test.go diff --git a/main.go b/main.go new file mode 100644 index 0000000..99d0ac2 --- /dev/null +++ b/main.go @@ -0,0 +1,155 @@ +// Package main provides the tfcloud CLI entrypoint. +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/hashicorp/cli" + "github.com/posener/complete" + + "github.com/hashicorp/tfcloud/internal/commands/tfcloud" + "github.com/hashicorp/tfcloud/internal/config" + "github.com/hashicorp/tfcloud/internal/pkg/cmd" + "github.com/hashicorp/tfcloud/internal/pkg/format" + "github.com/hashicorp/tfcloud/internal/pkg/iostreams" + "github.com/hashicorp/tfcloud/internal/pkg/profile" +) + +func main() { + os.Exit(realMain()) +} + +func realMain() int { + args := os.Args[1:] + + // Listen for interrupts + shutdownCtx, shutdown := context.WithCancelCause(context.Background()) + defer shutdown(nil) + go func() { + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) + sig := <-signalCh + shutdown(fmt.Errorf("command received signal: %s", sig)) + }() + + // Create our iostreams + io, err := iostreams.System(shutdownCtx) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to configure iostreams: %v\n", err) + return 1 + } + defer func() { + if err := io.RestoreConsole(); err != nil { + fmt.Fprintf(os.Stderr, "failed to restore console output: %v\n", err) + } + }() + + // TODO: check version for updates? + + activeProfile, err := loadProfile(shutdownCtx) + if err != nil { + fmt.Fprintln(io.Err(), err) + return 1 + } + + // If the profile has disabled color, disable on the iostream. + if activeProfile != nil && activeProfile.NoColor != nil && *activeProfile.NoColor { + io.ForceNoColor() + } + + // Create the command context + cCtx := &cmd.Context{ + IO: io, + Profile: activeProfile, + Output: format.New(io), + ShutdownCtx: shutdownCtx, + } + + // Get the HCP Root command + tfcloudCmd := tfcloud.NewCmdRoot(cCtx) + cmdMap := cmd.ToCommandMap(tfcloudCmd) + + c := cli.CLI{ + Version: config.Version, + Name: config.Name, + Args: args, + Commands: cmdMap, + HelpFunc: cmd.RootHelpFunc(tfcloudCmd), + Autocomplete: true, + AutocompleteNoDefaultFlags: true, + AutocompleteGlobalFlags: map[string]complete.Predictor{ + "--help": complete.PredictNothing, + "--version": complete.PredictNothing, + "--json": complete.PredictAnything, + "--quiet": complete.PredictAnything, + "--agent": complete.PredictAnything, + }, + } + + status, err := c.Run() + if err != nil { + fmt.Fprintf(io.Err(), "Error executing tfcloud: %s\n", err.Error()) + } + + return status +} + +// loadActiveProfile loads the active profile. +func loadActiveProfile() (*profile.Profile, error) { + // Create the profile loader + loader, err := profile.NewLoader() + if err != nil { + return nil, fmt.Errorf("failed to create profile loader: %w", err) + } + + // Load the active profile + activeProfile, err := loader.GetActiveProfile() + if err != nil { + if !errors.Is(err, profile.ErrNoActiveProfileFilePresent) && !errors.Is(err, profile.ErrActiveProfileFileEmpty) { + return nil, fmt.Errorf("failed to read active profile: %w", err) + } + + if err := loader.DefaultActiveProfile().Write(); err != nil { + return nil, fmt.Errorf("failed to save default active profile config: %w", err) + } + + if err := loader.DefaultProfile().Write(); err != nil { + return nil, fmt.Errorf("failed to save default profile config: %w", err) + } + + activeProfile, err = loader.GetActiveProfile() + if err != nil { + return nil, fmt.Errorf("failed to save default active profile config: %w", err) + } + } + + return loader.LoadProfile(activeProfile.Name) +} + +// loadProfile loads the active profile and if one doesn't exist, a default +// profile is created. +func loadProfile(_ context.Context) (*profile.Profile, error) { + // Get the active profile + p, err := loadActiveProfile() + if err != nil { + return nil, err + } + + // Save the profile. + if err := p.Write(); err != nil { + return nil, fmt.Errorf("failed to save default profile: %w", err) + } + + return p, nil +} + +// IsAutocomplete returns true if the CLI is being run in an autocomplete +// context. +func IsAutocomplete() bool { + return os.Getenv("COMP_LINE") != "" && os.Getenv("COMP_POINT") != "" +}