Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
71a08b3
limit file permissions to owner-only read
brandonc Jun 16, 2026
68917ce
restrict api command to https & profile host
brandonc Jun 16, 2026
c1bd9a2
update image in README
brandonc Jun 16, 2026
a25fac6
anonymize hostname telemetry when not known
brandonc Jun 16, 2026
baa3c6b
CHANGELOG entries
brandonc Jun 16, 2026
40a7a4b
fix hostname handling- validate and strip scheme
brandonc Jun 17, 2026
d7a1d63
bump go-tfe to v2.0.0-beta1
brandonc Jun 17, 2026
f90ad64
Update BUG FIXES-20260616-150538.yaml
brandonc Jun 17, 2026
a1abc8d
review feedback
brandonc Jun 17, 2026
c3c373b
normalize hostnames from env
brandonc Jun 17, 2026
a4c0568
Update README.md
brandonc Jun 17, 2026
fad7954
add debug clarity about which credential is used
brandonc Jun 17, 2026
70e7655
Update profile.go
brandonc Jun 17, 2026
a02606f
fix URL parse error, credential fallback, more log
brandonc Jun 17, 2026
d3c3fba
rely on go-tfe to not send tokens to unallowed host
brandonc Jun 17, 2026
a0fd0a8
correct help text for --quiet
brandonc Jun 18, 2026
9e40230
accept any response, not just application/vnd.api+json
brandonc Jun 18, 2026
875357e
ensure we don't send tokens to unconfigured hosts
brandonc Jun 18, 2026
20d0f3a
Update README.md
brandonc Jun 18, 2026
03ac9b1
add prediction for agents during harness install
brandonc Jun 18, 2026
ffce635
modify CHANGELOG entries
brandonc Jun 18, 2026
6334890
adds support for IP address hostnames
brandonc Jun 18, 2026
642aa81
reorder imports
brandonc Jun 18, 2026
45e7dfc
bump go-tfe to v2.0.0
brandonc Jun 22, 2026
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-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 read/write permissions for owner 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 configured with a Terraform Enterprise host.
time: 2026-06-16T15:06:04.201058-06:00
2 changes: 1 addition & 1 deletion .changes/unreleased/ENHANCEMENTS-20260618-111524.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
kind: ENHANCEMENTS
body: The `tfctl harness install amp` command now supports installing the `tfctl` skill into the directories that Amp looks for.
body: The `harness install` command supports shell autocompletion for supported coding agents and support for the Amp coding agent has been added.
time: 2026-06-18T11:15:24.079775-04:00
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260618-145700.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: Adds debug logging for token configuration sources.
time: 2026-06-18T14:57:00.037019-06:00
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260618-145810.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: Hostnames are normalized before storage within profiles.
time: 2026-06-18T14:58:10.514394-06:00
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260618-145905.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
kind: ENHANCEMENTS
body: The `api` command now accepts arbitrary URLs, such as Archivist, but does not send tokens to any host except the configured API host.
time: 2026-06-18T14:59:05.436321-06:00
11 changes: 6 additions & 5 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.gif "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 @@ -41,6 +41,7 @@ You can uninstall shell completion with the `tfctl --autocomplete-uninstall` com

The `tfctl` CLI ships with an agent skill that gives AI coding agents access to HCP Terraform through the `tfctl` command, but discourages non-human delete operations. You can install it using the `tfctl harness install` command or NPX. Replace `<agent>` with one of the following supported AI agents:

- `amp`
- `antigravity`
- `bob`
- `claude`
Expand Down Expand Up @@ -89,9 +90,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 +173,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 Expand Up @@ -218,7 +219,7 @@ Use the `--help` flag to print out detailed usage instructions. For example, `tf
- Data type: String
- Optional parameter.

- `--quiet`: Minimizes output and disables interactive prompting.
- `--quiet`: Minimizes output to stdout.
- Data type: Boolean flag
- Defaults to `false`.

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.
Binary file modified assets/tfctl.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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
8 changes: 7 additions & 1 deletion 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 Expand Up @@ -411,8 +416,9 @@ func RunAPI(ctx context.Context, opts *Opts) error {
if contentType != "" && requestHeaders.Get("Content-Type") == "" {
requestHeaders.Set("Content-Type", contentType)
}

if requestHeaders.Get("Accept") == "" {
requestHeaders.Set("Accept", "application/vnd.api+json")
requestHeaders.Set("Accept", "*/*")
}

// Interactive prompt required for DELETE requests to prevent accidental data loss
Expand Down
53 changes: 51 additions & 2 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 @@ -52,7 +54,7 @@ func TestRunAPI_DefaultGet(t *testing.T) {

require.Equal(t, "GET", recorder.Last().Method)
require.Equal(t, "/api/v2/workspaces", recorder.Last().Path)
require.Equal(t, "application/vnd.api+json", recorder.Last().Headers.Get("Accept"))
require.Equal(t, "*/*", recorder.Last().Headers.Get("Accept"))
require.Contains(t, io.Output.String(), "alpha")
require.Empty(t, io.Error.String())
}
Expand Down Expand Up @@ -89,11 +91,39 @@ func TestRunAPI_GetContainingQuotes(t *testing.T) {

require.Equal(t, "GET", recorder.Last().Method)
require.Equal(t, "/api/v2/thing", recorder.Last().Path)
require.Equal(t, "application/vnd.api+json", recorder.Last().Headers.Get("Accept"))
require.Equal(t, "*/*", recorder.Last().Headers.Get("Accept"))
require.Contains(t, io.Output.String(), "big-goose")
require.Empty(t, io.Error.String())
}

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

// Tests that RunAPI can handle URL's that don't match the client's BaseURL and do not send the
// token header to such hosts.
server, recorder := newAPITestServer(map[string]http.HandlerFunc{
"GET /some/path": func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/octet-stream")
w.Write([]byte(`Terraform v1.2.8
on linux_amd64
Initializing plugins and modules...
{"@level":"info","@message":"Terraform 1.2.8","@module":"terraform.ui","@timestamp":"2024-04-20T13:06:29.930400Z","terraform":"1.2.8","type":"version","ui":"1.0"}`))
},
})
defer server.Close()

differentURL := mustResolveTestURL(t, "https://example-tfe-server.hq", "/")

io := iostreams.Test()
err := RunAPI(context.Background(), newTestOpts(t, differentURL.String(), io, func(opts *Opts) {
opts.URL = mustResolveTestURL(t, server.URL, "/some/path")
}))
require.NoError(t, err)

require.Empty(t, recorder.Last().Headers.Get("Authorization"))
require.Contains(t, io.Output.String(), "Terraform 1.2.8")
}

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

Expand Down Expand Up @@ -307,6 +337,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
Loading