Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260616-150518.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: BUG FIXES
body: Disallow api commands containing non-profile hostname URL argument and non-https schemes.
time: 2026-06-16T15:05:18.528781-06:00
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260616-150538.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: BUG FIXES
body: Profile configuration files are now created with owner-rw permissions only.
time: 2026-06-16T15:05:38.766655-06:00
3 changes: 3 additions & 0 deletions .changes/unreleased/BUG FIXES-20260616-150604.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: BUG FIXES
body: hostname telemetry is anonymized when not HCP Terraform.
time: 2026-06-16T15:06:04.201058-06:00
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Comprehensive, official CLI access to the HCP Terraform / Terraform Enterprise p

The `tfctl` CLI provides high-level commands for common workflows, such as managing runs, variables, and workspaces, and direct API access for advanced automation. It supports multiple configuration profiles, allowing you to switch between different HCP Terraform organizations and Terraform Enterprise instances. It also integrates with AI coding agents to facilitate agent-assisted management of Terraform workflows.

![tfctl](assets/hero.png "tfctl")
![tfctl](assets/demo.gif "tfctl demo")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we delete the hero.png file


## Installation
You can install the CLI, command completion utility, and agent skill separately.
Expand Down Expand Up @@ -89,9 +89,9 @@ Verify that the login is successful before leaving the token page in your browse

If the CLI does not find a token configured for the active profile, it checks your Terraform configuration for a matching token. Refer to [Terraform tokens](#terraform-tokens) for more information.

### Set organization
### Set default organization

Run the `tfctl profile set default_organization` command to set the organization. Replace `<name>` with your HCP Terraform or Terraform Enterprise organization name.
Run the `tfctl profile set default_organization` command to set the default organization. Replace `<name>` with your HCP Terraform or Terraform Enterprise organization name.

```bash
$ tfctl profile set default_organization <name>
Expand Down Expand Up @@ -172,7 +172,7 @@ If you have not configured a particular option for the active profile, `tfctl` c

`TFCTL_HOSTNAME`: The Terraform Enterprise or HCP Terraform hostname to use. Defaults to `app.terraform.io`.

`TFCTL_TOKEN`: An HCP Terraform API token to use in conjunction with the default profile.
`TFCTL_TOKEN`: An HCP Terraform API token to use in conjunction with the default profile. This variable is not used in conjunction with any other profile.

`TFCTL_TOKEN_<profile>`: An HCP Terraform API token to use in conjunction with the named profile.

Expand Down
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/hero.png
Binary file not shown.
18 changes: 13 additions & 5 deletions cmd/tfctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,16 @@ func realMain() int {
}
}()

initialLogLevel := logging.LevelDefault
for _, a := range args {
if a == "--debug" {
initialLogLevel = logging.LevelDebug
break
}
}

// The logger level will need to be set by the command after parsing flags.
logger := logging.NewLogger(io)
logger := logging.NewLogger(io, initialLogLevel)

// Add the logger to the shutdown context because this is the context used throughout
// the command execution lifecycle.
Expand All @@ -71,7 +79,7 @@ func realMain() int {
return 1
}

activeProfile, err := loadActiveProfile(loader)
activeProfile, err := loadActiveProfile(shutdownCtx, loader)
if err != nil {
fmt.Fprintln(io.Err(), err)
return 1
Expand Down Expand Up @@ -145,7 +153,7 @@ func realMain() int {
}

// loadActiveProfile loads the active profile.
func loadActiveProfile(loader *profile.Loader) (*profile.Profile, error) {
func loadActiveProfile(ctx context.Context, loader *profile.Loader) (*profile.Profile, error) {
// Load the active profile
activeProfile, err := loader.GetActiveProfile()
if err != nil {
Expand All @@ -157,7 +165,7 @@ func loadActiveProfile(loader *profile.Loader) (*profile.Profile, error) {
return nil, fmt.Errorf("failed to save default active profile config: %w", err)
}

if err := loader.DefaultProfile().Write(); err != nil {
if err := loader.DefaultProfile(ctx).Write(); err != nil {
return nil, fmt.Errorf("failed to save default profile config: %w", err)
}

Expand All @@ -167,7 +175,7 @@ func loadActiveProfile(loader *profile.Loader) (*profile.Profile, error) {
}
}

return loader.LoadProfile(activeProfile.Name)
return loader.LoadProfile(ctx, activeProfile.Name)
}

// IsAutocomplete returns true if the CLI is being run in an autocomplete
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ require (
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/v2 v2.0.0-20260611161741-624e4864f63b
github.com/hashicorp/go-tfe/v2 v2.0.0-beta1
github.com/hashicorp/go-version v1.9.0
github.com/hashicorp/hcl/v2 v2.24.0
github.com/itchyny/gojq v0.12.19
Expand Down Expand Up @@ -86,7 +86,7 @@ require (
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microsoft/kiota-http-go v1.5.6 // 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-json-go v1.1.3 // 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.2.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVH
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/v2 v2.0.0-20260611161741-624e4864f63b h1:l5n1LEe/DByj/2+4TwEbfvbwFf0hu6gZ+HyJM8gykds=
github.com/hashicorp/go-tfe/v2 v2.0.0-20260611161741-624e4864f63b/go.mod h1:gosuJ9PH3NLxkCoCW3EIeHHli+5QqLUkboBiUZ1ljCM=
github.com/hashicorp/go-tfe/v2 v2.0.0-beta1 h1:+PKJssuEaY27h+YV75vubEJSRJc4Qic+in58301ILng=
github.com/hashicorp/go-tfe/v2 v2.0.0-beta1/go.mod h1:gosuJ9PH3NLxkCoCW3EIeHHli+5QqLUkboBiUZ1ljCM=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
Expand Down Expand Up @@ -169,8 +169,8 @@ github.com/microsoft/kiota-http-go v1.5.6 h1:KBdk7sxWYXZnRRExLjIcNt4I7LoOfh/XQJW
github.com/microsoft/kiota-http-go v1.5.6/go.mod h1:bpJkXfBAcnmiXRg03GXdnb/vF3Sqk3+EgLvXXjmzzQM=
github.com/microsoft/kiota-serialization-form-go v1.1.3 h1:eUY8eHXPFe4ma8cAdx0ya3g4NPlZgbPT+GlFC3xcgGY=
github.com/microsoft/kiota-serialization-form-go v1.1.3/go.mod h1:RMO99zyik+NvZjdVcIeyu6ikyfuKhQtzq2RK0fWJJio=
github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k=
github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8=
github.com/microsoft/kiota-serialization-json-go v1.1.3 h1:e9Bx6jXlmDLc/j+9IcMzt2tDrp1EkxNFjEhYteMjKJQ=
github.com/microsoft/kiota-serialization-json-go v1.1.3/go.mod h1:HUTiYs9llTGLjh9+O+yOkBbNEaZ1kxh3sBPU5tPhmeI=
github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4=
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=
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,16 @@ func NewCmdAPI(inv *cmd.Invocation) *cmd.Command {
path = resolvedPath
}

// URL safety validation
resolvedURL, err := client.ResolveURL(*apiClient.BaseURL, path)
if err != nil {
return fmt.Errorf("invalid input path/URL %q", path)
}

if resolvedURL.Scheme != "https" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hostnames are stored exactly as the user entered them (including any scheme), and the client only prepends https:// when no scheme is present also reachable via TFCTL_HOSTNAME. So a profile pointed at an http:// host will now fail every api call, not just absolute http URLs, since relative paths inherit the configured scheme. Is that intended?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a bug in how profile hostnames are parsed and created. "hostname" should not contain a scheme.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree this was a real bug. But the TFCTL_HOSTNAME env path still assigns the raw value without validation in loader.go:319-320 it does hostname = envHostname and stores it directly, bypassing ValidateHostname/SetHostname. So TFCTL_HOSTNAME moves forward without any changes. There's already a normalizeHostname helper at loader.go:338 used for token lookups and routing the env value through validation there would help

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return fmt.Errorf("invalid input path/URL %q: must use https scheme", path)
}

opts.URL = resolvedURL
opts.Client = apiClient
opts.Quiet = inv.GetGlobalFlags().Quiet
Expand Down
21 changes: 21 additions & 0 deletions internal/commands/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import (
"github.com/stretchr/testify/require"

"github.com/hashicorp/tfctl-cli/internal/pkg/client"
"github.com/hashicorp/tfctl-cli/internal/pkg/cmd"
"github.com/hashicorp/tfctl-cli/internal/pkg/format"
"github.com/hashicorp/tfctl-cli/internal/pkg/iostreams"
"github.com/hashicorp/tfctl-cli/internal/pkg/profile"
)

func TestRunAPI_DefaultGet(t *testing.T) {
Expand Down Expand Up @@ -307,6 +309,25 @@ func TestRunAPI_InlineQueryParamsSparseFieldsets(t *testing.T) {
require.Equal(t, "name", req.Query.Get("fields[workspaces]"))
}

func TestNewCmdAPI_NonHTTPSReturnsError(t *testing.T) {
t.Parallel()

io := iostreams.Test()
inv := &cmd.Invocation{
IO: io,
Output: format.New(io),
ShutdownCtx: context.Background(),
Profile: &profile.Profile{
Name: "test",
Hostname: "example.com",
Token: "test-token",
},
}
cmd := NewCmdAPI(inv)
err := cmd.RunF(cmd, []string{"http://example.com/api/v2/things"})
require.ErrorContains(t, err, "must use https scheme")
}

func TestRunAPI_InlineQueryParamsMergedWithFlags(t *testing.T) {
t.Parallel()

Expand Down
Loading