From d35768d4513aee8a5b3bb7468ff9d27b4f154a2c Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 29 Jul 2026 10:17:48 -0600 Subject: [PATCH] fixes: auth login, profile display, profiles list - Using `auth login --dry-run` no longer opens a web browser. --dry-run now appears in argument autocomplete lists. - Using `profile display --markdown` no longer produces an error. - Removed Token property from json output when using `profile profiles list --json`, preventing accidental exposure. --- .../unreleased/BUG FIXES-20260729-100121.yaml | 3 + .../unreleased/BUG FIXES-20260729-100210.yaml | 3 + .../unreleased/BUG FIXES-20260729-101327.yaml | 3 + AGENTS.md | 118 ++++++++++-- README.md | 2 +- cmd/tfctl/main.go | 1 + internal/commands/auth/login.go | 101 ++++++---- internal/commands/auth/login_test.go | 179 ++++++------------ internal/commands/profile/display.go | 4 +- internal/commands/profile/profiles/list.go | 30 ++- internal/pkg/cmd/invocation.go | 16 +- 11 files changed, 277 insertions(+), 183 deletions(-) create mode 100644 .changes/unreleased/BUG FIXES-20260729-100121.yaml create mode 100644 .changes/unreleased/BUG FIXES-20260729-100210.yaml create mode 100644 .changes/unreleased/BUG FIXES-20260729-101327.yaml diff --git a/.changes/unreleased/BUG FIXES-20260729-100121.yaml b/.changes/unreleased/BUG FIXES-20260729-100121.yaml new file mode 100644 index 0000000..17cbdf0 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260729-100121.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Using `auth login --dry-run` no longer opens a web browser. --dry-run now appears in argument autocomplete lists. +time: 2026-07-29T10:01:21.23869-06:00 diff --git a/.changes/unreleased/BUG FIXES-20260729-100210.yaml b/.changes/unreleased/BUG FIXES-20260729-100210.yaml new file mode 100644 index 0000000..3c7d85e --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260729-100210.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Using `profile display --markdown` no longer produces an error. +time: 2026-07-29T10:02:10.946104-06:00 diff --git a/.changes/unreleased/BUG FIXES-20260729-101327.yaml b/.changes/unreleased/BUG FIXES-20260729-101327.yaml new file mode 100644 index 0000000..8fd96a6 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20260729-101327.yaml @@ -0,0 +1,3 @@ +kind: BUG FIXES +body: Removed Token property from json output when using `profile profiles list --json`, preventing accidental exposure. +time: 2026-07-29T10:13:27.549029-06:00 diff --git a/AGENTS.md b/AGENTS.md index 00704b5..7508beb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,110 @@ # tfctl CLI -A CLI for interacting with HCP Terraform and Terraform Enterprise in several ways, featuring a low-level API helper, high level commands, and interactive commands. +`tfctl` is a Go CLI for HCP Terraform and Terraform Enterprise. It provides raw API access, high-level workflows, and commands for humans and coding agents. -## Contributing -**MUST follow these rules:** +## Planning and Communication + +- Use ASD-STE100 Simplified Technical English in plans and documentation. +- Make the smallest correct change. Follow the established package boundaries and command patterns. +- Treat the rules in this file as requirements for new and substantially changed code. The known deviations below are technical debt, not examples to copy. + +## Development Setup + +- Use Go 1.26.4, git, bash, and make. +- Run `scripts/setup.sh` to install development tools and the `tfctl` binary. The script does not install Go. +- Run `make bin` to build the binary. +- Run `make check` for formatting checks, lint, and standard tests. This target does not run the race detector. + +## Repository Architecture + +- `cmd/tfctl/main.go` is the process entry point. It creates I/O, logging, profiles, telemetry, the shared invocation, and the command tree. +- `internal/commands/` contains command behavior. The top-level groups are `api`, `get`, `create`, `run`, `auth`, `variable`, `profile`, and `harness`. +- `internal/pkg/` contains reusable infrastructure. Important packages include `cmd`, `client`, `format`, `iostreams`, `logging`, `telemetry`, `profile`, `openapi`, and `execsession`. +- `internal/commands/*` can depend on `internal/pkg/*`. Do not add dependencies from infrastructure packages to command packages. +- `skills/` contains embedded coding-agent skills. + +The CLI uses a custom command model in `internal/pkg/cmd` and adapts it to `github.com/hashicorp/cli`. `cmd.Invocation` carries shared I/O, output, profile, shutdown context, and parsed global state to command constructors. + +For runnable leaf commands, persistent pre-run applies global flags, configures the context logger, starts a telemetry span, and checks authentication. Group help, flag parse errors, and required-argument errors can return before persistent pre-run. + +## Command Design - Use TDD. -- Respect the global --dry-run flag - when dry-run is enabled, don't change any data or execute any mutations. -- For stdout rendering, create a displayer that can render --json, --markdown, and default (pretty) output. -- Use ColorScheme formatting for stderr rendering. -- When authoring a new command, pass an XXXOpts type value to a private runXXX function. Don't share the entire command context. Test functions should test the behavior of the runXXX function by varying the options passed to it. -- Use the command Logger() to produce appropriate debug output. - -## Testing instructions -- Test: `go test ./... -run ""` -- Lint: `golangci-lint run` -- Test for regressions: `go test ./... -race` +- Keep command declaration and flag wiring in `NewCmdXxx`. +- Put command behavior in a private `runXxx` function. Pass an `XxxOpts` value that contains only the required dependencies and values. +- Do not pass `*cmd.Invocation` to `runXxx`. Resolve invocation state and construct clients in command wiring, then pass explicit dependencies in the options value. +- Test `runXxx` directly by varying its options. Add `Command.Run` tests when flag parsing, argument validation, autocomplete, or exit behavior needs coverage. +- Group commands with no `RunF` do not need an options value or behavior function. +- Keep shared behavior private unless another command package has a concrete need to call it. + +## Global Flags + +Command changes must account for these global flags: + +- `--dry-run` must prevent remote mutations and command-specific state changes. Report the skipped action to stderr. +- `--quiet` suppresses `IOStreams.ErrUnessential()` and disables prompts. It does not automatically suppress stdout or `IOStreams.Err()`. +- `--no-color` disables command-facing color and styling. +- `--debug` controls the context logger level. +- `--profile` replaces the active profile for the invocation. + +## Mutation Safety + +- Check dry-run state before every write, mutation request, browser launch, child process, or other command-specific side effect. +- In dry-run mode, do not mutate shared in-memory values as a substitute for avoiding a persisted write. Use a copy when validation needs a proposed value. +- Render dry-run details to `IOStreams.Err()` with `ColorScheme.DryRunLabel()`. +- Do not rely on `client.Resolver.dryRun` to block creation. The field is not enforced. Callers must set `createIfNotFound` to false or guard the mutation before calling the resolver. +- Keep destructive API operations behind the existing confirmation and exec-session checks. Harness exec-session permission applies only to selected API deletes and is not a general mutation permission. + +## Output and Diagnostics + +- Send structured stdout through `format.Outputter` with a `format.Displayer`. +- A displayer must provide a default format, a payload, and field templates that work with forced JSON and Markdown output. The outputter also supports pretty and table output. `format.Agent` currently renders as JSON. +- Use direct stdout only for an intentional raw byte stream or child-process pass-through. Document why global format conversion does not apply. +- Never include credentials or sensitive values in a displayer payload. JSON output serializes the full payload, not only the displayed field templates. +- Use `IOStreams.Err()` for essential diagnostics that must remain visible with `--quiet`. +- Use `IOStreams.ErrUnessential()` for progress, guidance, and routine success messages that `--quiet` can suppress. +- Use `IOStreams.ColorScheme()` for command-facing stderr styling. Logging has separate hclog color handling. +- A command that must suppress stdout in quiet mode must implement that behavior explicitly. + +## Logging and Telemetry + +- Get the logger with `logging.FromContext(ctx)`. +- Add debug logs for useful decisions, fallback behavior, ignored nonfatal errors, and external operations. +- Do not log tokens, credentials, sensitive variable values, or request bodies that can contain secrets. +- Pass the command context to API and other blocking calls so cancellation and telemetry propagate. +- Telemetry command spans exist only for runnable commands that reach persistent pre-run. Do not assume that help and parse-error paths have a command span. + +## Arguments, Flags, and Help + +- Set `Command.Args.Autocomplete` for positional-argument completion. `PositionalArgument` does not have an `Autocomplete` field. +- Set `Flag.Autocomplete` for flags that accept values. Use an appropriate `complete.Predictor`. +- If autocomplete would be incorrect, omit it and add a short comment. `harness exec` is an example because its trailing arguments belong to another executable. +- Add examples and clear help for user-facing behavior. +- Run `make gen/screenshot` when root command output changes. + +## Profiles and Configuration + +- Profiles are HCL files under the `profiles/` configuration directory. The configuration root also contains `active_profile.hcl`, `device_id`, and host caches. +- `Profile.Predict` completes profile property names. Profile-name completion uses `Loader.ListProfiles`. +- Hostname helpers default, normalize, and validate hostnames. They do not classify HCP Terraform and Terraform Enterprise. +- Selected commands can use local Terraform configuration as an organization or workspace fallback. +- `auth login --token` reads a token from stdin. It does not accept the token as the flag value. + +## Testing and Release Checks + +- Write a failing test before the implementation change. +- Run a focused test with `go test ./... -run ''`. +- Run lint with `golangci-lint run`. +- Run regression and race tests with `go test ./... -race`. +- Use `cmdtest.NewServer` for routed HTTP test servers and `cmdtest.WriteJSONAPI` when a handler needs a JSON:API response. +- Format tests use inline expected output rather than golden files. +- Run `changie new` to prepare a changelog entry for a user-visible change. + +## Known Architecture Deviations + +Do not reproduce these patterns in new code. Fix a deviation when it is in the direct scope of the change. + +- `internal/commands/profile/set.go` decodes proposed values directly into the shared profile before the dry-run check. +- `client.Resolver` stores `dryRun` but does not read it. Creation safety depends on each caller. +- Some ignored nonfatal errors have no debug log. For example, `auth status` suppresses token-expiration lookup failures. +- Telemetry shutdown runs after normal CLI dispatch, but early returns such as the root banner path bypass it. diff --git a/README.md b/README.md index 7ed446b..db064f4 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ The `tfctl` command can manage HCP Terraform runs and variables with the corresp - `--debug`: Enable debug output. -- `--dry-run`: Creates a preview of the proposed changes. +- `--dry-run`: Creates a preview of the proposed changes without making them. - `--json`: Sets the output format to JSON. diff --git a/cmd/tfctl/main.go b/cmd/tfctl/main.go index 1bf0e97..2c11987 100644 --- a/cmd/tfctl/main.go +++ b/cmd/tfctl/main.go @@ -152,6 +152,7 @@ func realMain() int { "--no-color": complete.PredictAnything, "--profile": profiles.PredictProfiles(false, true), "--quiet": complete.PredictAnything, + "--dry-run": complete.PredictAnything, }, } diff --git a/internal/commands/auth/login.go b/internal/commands/auth/login.go index 92a1675..175aebb 100644 --- a/internal/commands/auth/login.go +++ b/internal/commands/auth/login.go @@ -33,13 +33,31 @@ const ( // non-error exit rather than a failure. var errLoginCanceled = errors.New("login canceled") +// LoginOpts defines the options for the `auth login` command. +type LoginOpts struct { + IO iostreams.IOStreams + Hostname string + Output *format.Outputter + NewAPIClient func(token string) (*client.Client, error) + Profile *profile.Profile + + // OpenBrowser opens a URL in the user's default browser. When nil, the + // package default openBrowser is used. Tests inject a no-op opener to avoid + // launching a real browser and to keep parallel tests free of shared state. + OpenBrowser func(url string) error + + Name string + TokenFromStdin bool + DryRun bool +} + // NewCmdLogin returns the `auth login` command for authenticating. func NewCmdLogin(inv *cmd.Invocation) *cmd.Command { - opts := &LoginOpts{ + opts := LoginOpts{ IO: inv.IO, - Profile: inv.Profile, Output: inv.Output, OpenBrowser: openBrowser, + Profile: inv.Profile, } cmd := &cmd.Command{ @@ -72,7 +90,7 @@ func NewCmdLogin(inv *cmd.Invocation) *cmd.Command { { Name: "token", Description: "Read the token from standard input instead of prompting.", - Value: flagvalue.Simple(false, &opts.Token), + Value: flagvalue.Simple(false, &opts.TokenFromStdin), IsBooleanFlag: true, }, }, @@ -80,39 +98,36 @@ func NewCmdLogin(inv *cmd.Invocation) *cmd.Command { NoAuthRequired: true, RunF: func(_ *cmd.Command, _ []string) error { opts.DryRun = inv.IsDryRun() - return loginRun(inv.ShutdownCtx, inv, opts) + opts.Hostname = inv.Profile.GetHostname() + opts.NewAPIClient = func(token string) (*client.Client, error) { + return inv.NewAPIClientForHost(inv.Profile.GetHostname(), token) + } + if opts.DryRun { + opts.OpenBrowser = func(url string) error { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Err(), "%s would open the web browser to URL %s\n", + cs.DryRunLabel(), url) + return fmt.Errorf("can't open web browser when --dry-run is enabled") + } + } + + return loginRun(inv.ShutdownCtx, opts) }, } return cmd } -// LoginOpts defines the options for the `auth login` command. -type LoginOpts struct { - IO iostreams.IOStreams - Profile *profile.Profile - Output *format.Outputter - - // OpenBrowser opens a URL in the user's default browser. When nil, the - // package default openBrowser is used. Tests inject a no-op opener to avoid - // launching a real browser and to keep parallel tests free of shared state. - OpenBrowser func(url string) error - - Name string - Token bool - DryRun bool -} - -func loginRun(ctx context.Context, inv *cmd.Invocation, opts *LoginOpts) error { - hostname := opts.Profile.GetHostname() +func loginRun(ctx context.Context, opts LoginOpts) error { + hostname := opts.Hostname logger := logging.FromContext(ctx) - logger.Debug("starting login process", "hostname", hostname, "token_from_stdin", opts.Token) + logger.Debug("Starting login process", "hostname", hostname, "token_from_stdin", opts.TokenFromStdin) // Read the token. var token string var err error - if opts.Token { + if opts.TokenFromStdin { token, err = readTokenFromStdin(opts) } else { token, err = readTokenInteractive(opts, hostname) @@ -126,18 +141,15 @@ func loginRun(ctx context.Context, inv *cmd.Invocation, opts *LoginOpts) error { } // Set the token on the profile and create a client to verify it. - opts.Profile.Token = token - logger.Debug("verifying token", "hostname", hostname) - apiClient, err := inv.NewAPIClient() + err = saveToken(ctx, opts, hostname, token) if err != nil { - return fmt.Errorf("failed to create API client: %w", err) + return err } - - return saveToken(ctx, opts, apiClient, hostname, token) + return nil } // readTokenFromStdin reads a token from stdin. -func readTokenFromStdin(opts *LoginOpts) (string, error) { +func readTokenFromStdin(opts LoginOpts) (string, error) { scanner := bufio.NewScanner(opts.IO.In()) if !scanner.Scan() { if err := scanner.Err(); err != nil { @@ -157,7 +169,7 @@ func readTokenFromStdin(opts *LoginOpts) (string, error) { // readTokenInteractive explains the flow, asks the user to confirm, opens the // browser to the token page, and prompts for the generated token. The user must // confirm before the browser is opened; declining is a clean exit. -func readTokenInteractive(opts *LoginOpts, hostname string) (string, error) { +func readTokenInteractive(opts LoginOpts, hostname string) (string, error) { if !opts.IO.CanPrompt() { return "", fmt.Errorf("interactive login requires a terminal; use --token to read from stdin") } @@ -190,9 +202,13 @@ func readTokenInteractive(opts *LoginOpts, hostname string) (string, error) { openURL = openBrowser } if err := openURL(tokenURL); err != nil { + reason := "" + if opts.DryRun { + reason = " when --dry-run is enabled" + } fmt.Fprintf(opts.IO.Err(), - "%s Could not open the browser automatically. Open the URL above manually.\n\n", - cs.WarningLabel()) + "%s Could not open the browser automatically%s. Visit the URL above to continue.\n\n", + cs.WarningLabel(), reason) } // Prompt for the token and read it without echoing. @@ -215,22 +231,31 @@ func readTokenInteractive(opts *LoginOpts, hostname string) (string, error) { } // saveToken verifies the token via the API and persists it to the profile. -func saveToken(ctx context.Context, opts *LoginOpts, apiClient *client.Client, hostname, token string) error { - cs := opts.IO.ColorScheme() +func saveToken(ctx context.Context, opts LoginOpts, hostname, token string) error { + logger := logging.FromContext(ctx) + + apiClient, err := opts.NewAPIClient(token) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + logger.Debug("Verifying token", "hostname", hostname) user, err := verifyToken(ctx, apiClient) if err != nil { return fmt.Errorf("failed to verify token: %w", err) } + cs := opts.IO.ColorScheme() if opts.DryRun { fmt.Fprintf(opts.IO.Err(), "%s would save token to profile %q for host %s (user: %s)\n", cs.DryRunLabel(), opts.Profile.Name, hostname, user) return nil } - opts.Profile.Token = token - if err := opts.Profile.Write(); err != nil { + profile := opts.Profile + + profile.Token = token + if err := profile.Write(); err != nil { return fmt.Errorf("failed to save token to profile: %w", err) } diff --git a/internal/commands/auth/login_test.go b/internal/commands/auth/login_test.go index 9d159d7..45dd520 100644 --- a/internal/commands/auth/login_test.go +++ b/internal/commands/auth/login_test.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -12,6 +13,7 @@ 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/iostreams" "github.com/hashicorp/tfctl-cli/internal/pkg/profile" @@ -40,17 +42,44 @@ func newFakeTFE(t *testing.T, username string) *httptest.Server { // It injects a no-op browser opener into the options so tests never launch a // real browser and never mutate shared package state (keeping them race-free // under t.Parallel()). -func runLogin(t *testing.T, opts *LoginOpts) error { +func runLogin(t *testing.T, opts LoginOpts) error { t.Helper() if opts.OpenBrowser == nil { opts.OpenBrowser = func(string) error { return nil } } - inv := &cmd.Invocation{ - IO: opts.IO, - Profile: opts.Profile, + return loginRun(context.Background(), opts) +} + +func createLoginOpts(t *testing.T, profile *profile.Profile, tokenFromStdin, dryRun bool) LoginOpts { + t.Helper() + return createLoginOptsWithIO(t, iostreams.Test(), profile, tokenFromStdin, dryRun) +} + +func createLoginOptsWithIO(t *testing.T, io iostreams.IOStreams, profile *profile.Profile, tokenFromStdin, dryRun bool) LoginOpts { + t.Helper() + + inv := cmd.Invocation{ + IO: io, + Profile: profile, ShutdownCtx: context.Background(), } - return loginRun(inv.ShutdownCtx, inv, opts) + + return LoginOpts{ + IO: io, + Profile: profile, + TokenFromStdin: tokenFromStdin, + NewAPIClient: func(token string) (*client.Client, error) { + return inv.NewAPIClientForHost(profile.GetHostname(), token) + }, + Hostname: profile.GetHostname(), + OpenBrowser: func(string) error { + if dryRun { + return errors.New("dry run") + } + return nil + }, + DryRun: dryRun, + } } func TestLoginFromStdin(t *testing.T) { @@ -66,13 +95,7 @@ func TestLoginFromStdin(t *testing.T) { io := iostreams.Test() io.Input.WriteString("my-test-token\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - r.NoError(runLogin(t, opts)) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p, true, false))) r.Contains(io.Error.String(), "Successfully logged in") r.Contains(io.Error.String(), "testuser") @@ -93,12 +116,7 @@ func TestLoginFromStdin_CustomHostname(t *testing.T) { io := iostreams.Test() io.Input.WriteString("custom-token\n") - - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } + opts := createLoginOptsWithIO(t, io, p, true, false) r.NoError(runLogin(t, opts)) r.Contains(io.Error.String(), "Successfully logged in") @@ -116,13 +134,7 @@ func TestLoginFromStdin_EmptyToken(t *testing.T) { io := iostreams.Test() io.Input.WriteString("\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - err := runLogin(t, opts) + err := runLogin(t, createLoginOptsWithIO(t, io, p, true, false)) r.Error(err) r.Contains(err.Error(), "token is empty") } @@ -135,15 +147,7 @@ func TestLoginFromStdin_NoInput(t *testing.T) { p := l.DefaultProfile(context.Background()) r.NoError(p.Write()) - io := iostreams.Test() - - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - err := runLogin(t, opts) + err := runLogin(t, createLoginOpts(t, p, true, false)) r.Error(err) r.Contains(err.Error(), "no token provided on stdin") } @@ -159,13 +163,7 @@ func TestLoginFromStdin_WhitespaceToken(t *testing.T) { io := iostreams.Test() io.Input.WriteString(" \n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - err := runLogin(t, opts) + err := runLogin(t, createLoginOptsWithIO(t, io, p, true, false)) r.Error(err) r.Contains(err.Error(), "token is empty") } @@ -183,13 +181,7 @@ func TestLoginFromStdin_TokenWithWhitespace(t *testing.T) { io := iostreams.Test() io.Input.WriteString(" my-token-with-spaces \n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - r.NoError(runLogin(t, opts)) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p, true, false))) loaded, err := l.LoadProfile(context.Background(), p.Name) r.NoError(err) @@ -204,15 +196,7 @@ func TestLoginInteractive_NoTTY(t *testing.T) { p := l.DefaultProfile(context.Background()) r.NoError(p.Write()) - io := iostreams.Test() - - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: false, - } - - err := runLogin(t, opts) + err := runLogin(t, createLoginOpts(t, p, false, false)) r.Error(err) r.Contains(err.Error(), "interactive login requires a terminal") } @@ -235,13 +219,7 @@ func TestLoginInteractive_Success(t *testing.T) { io.Input.WriteString("y") io.Input.WriteString("interactive-token\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: false, - } - - r.NoError(runLogin(t, opts)) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p, false, false))) r.Contains(io.Error.String(), "Opening your browser") r.Contains(io.Error.String(), "Successfully logged in") r.Contains(io.Error.String(), "interactive-user") @@ -272,12 +250,12 @@ func TestLoginFromStdin_DifferentProfile(t *testing.T) { // Login to production io := iostreams.Test() io.Input.WriteString("prod-token\n") - r.NoError(runLogin(t, &LoginOpts{IO: io, Profile: p1, Token: true})) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p1, true, false))) // Login to staging io = iostreams.Test() io.Input.WriteString("staging-token\n") - r.NoError(runLogin(t, &LoginOpts{IO: io, Profile: p2, Token: true})) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p2, true, false))) // Verify tokens were saved to the correct profiles loadedProd, err := l.LoadProfile(context.Background(), "production") @@ -306,13 +284,7 @@ func TestLoginFromStdin_DryRun(t *testing.T) { io := iostreams.Test() io.Input.WriteString("my-new-token\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - DryRun: true, - } - + opts := createLoginOptsWithIO(t, io, p, true, true) r.NoError(runLogin(t, opts)) r.Contains(io.Error.String(), "would save token") r.Contains(io.Error.String(), p.Name) @@ -343,14 +315,7 @@ func TestLoginInteractive_DryRun(t *testing.T) { io.Input.WriteString("y") io.Input.WriteString("interactive-token\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: false, - DryRun: true, - } - - r.NoError(runLogin(t, opts)) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p, false, true))) r.Contains(io.Error.String(), "would save token") loaded, err := l.LoadProfile(context.Background(), p.Name) @@ -373,11 +338,7 @@ func TestLoginFromStdin_QuietMode(t *testing.T) { io.Input.WriteString("my-token\n") io.SetQuiet(true) - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } + opts := createLoginOptsWithIO(t, io, p, true, false) r.NoError(runLogin(t, opts)) r.Empty(io.Error.String()) @@ -400,13 +361,7 @@ func TestLoginFromStdin_VerifyFails(t *testing.T) { io := iostreams.Test() io.Input.WriteString("bad-token\n") - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - } - - err := runLogin(t, opts) + err := runLogin(t, createLoginOptsWithIO(t, io, p, true, false)) r.Error(err) r.Contains(err.Error(), "failed to verify token") @@ -435,15 +390,12 @@ func TestLoginInteractive_ConfirmOpensBrowserWithSource(t *testing.T) { var openedURL string opened := false - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: false, - OpenBrowser: func(u string) error { - opened = true - openedURL = u - return nil - }, + + opts := createLoginOptsWithIO(t, io, p, false, false) + opts.OpenBrowser = func(u string) error { + opened = true + openedURL = u + return nil } r.NoError(runLogin(t, opts)) @@ -477,14 +429,10 @@ func TestLoginInteractive_DeclineDoesNotOpenBrowser(t *testing.T) { io.Input.WriteString("n") opened := false - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: false, - OpenBrowser: func(string) error { - opened = true - return nil - }, + opts := createLoginOptsWithIO(t, io, p, false, false) + opts.OpenBrowser = func(string) error { + opened = true + return nil } // Declining is a clean, non-error exit and must not open the browser or @@ -512,17 +460,8 @@ func TestLoginFromStdin_DoesNotPromptOrOpenBrowser(t *testing.T) { io.Input.WriteString("my-test-token\n") opened := false - opts := &LoginOpts{ - IO: io, - Profile: p, - Token: true, - OpenBrowser: func(string) error { - opened = true - return nil - }, - } - r.NoError(runLogin(t, opts)) + r.NoError(runLogin(t, createLoginOptsWithIO(t, io, p, true, false))) r.False(opened, "stdin/--token mode must not open a browser") r.NotContains(io.Error.String(), "Do you want to proceed") } diff --git a/internal/commands/profile/display.go b/internal/commands/profile/display.go index 719ebdb..4ea1a8a 100644 --- a/internal/commands/profile/display.go +++ b/internal/commands/profile/display.go @@ -73,8 +73,8 @@ func (p *displayDisplayer) FieldTemplates() []format.Field { ValueFormat: "{{ .Name }}", }, { - Name: "Organization", - ValueFormat: "{{ .Organization }}", + Name: "Default Organization", + ValueFormat: "{{ .DefaultOrganization }}", }, { Name: "Hostname", diff --git a/internal/commands/profile/profiles/list.go b/internal/commands/profile/profiles/list.go index 2e90bf8..cb41850 100644 --- a/internal/commands/profile/profiles/list.go +++ b/internal/commands/profile/profiles/list.go @@ -84,15 +84,39 @@ func listRun(ctx context.Context, opts *ListOpts) error { } d := &profileDisplayer{ - profiles: profiles, + profiles: profileToProfileForDisplay(active.Name, profiles), activeProfile: active.Name, } return opts.Output.Display(d) } +func profileToProfileForDisplay(activeName string, profiles []*profile.Profile) []profileForDisplay { + result := make([]profileForDisplay, len(profiles)) + for i, p := range profiles { + result[i] = profileForDisplay{ + Name: p.Name, + Hostname: p.Hostname, + DefaultOrganization: p.DefaultOrganization, + Telemetry: p.Telemetry, + NoColor: p.NoColor, + Active: activeName == p.Name, + } + } + return result +} + +type profileForDisplay struct { + Name string `json:"Name"` + Hostname string `json:"Hostname,omitempty"` + DefaultOrganization string `json:"DefaultOrganization,omitempty"` + Telemetry *string `json:"Telemetry,omitempty"` + NoColor *bool `json:"NoColor,omitempty"` + Active bool `json:"Active"` +} + type profileDisplayer struct { - profiles []*profile.Profile + profiles []profileForDisplay activeProfile string } @@ -111,7 +135,7 @@ func (p *profileDisplayer) FieldTemplates() []format.Field { }, { Name: "Active", - ValueFormat: fmt.Sprintf("{{ eq ( .Name ) %q }}", p.activeProfile), + ValueFormat: "{{ .Active }}", }, { Name: "Default Organization", diff --git a/internal/pkg/cmd/invocation.go b/internal/pkg/cmd/invocation.go index 766b2b0..f820776 100644 --- a/internal/pkg/cmd/invocation.go +++ b/internal/pkg/cmd/invocation.go @@ -303,15 +303,14 @@ func (i *Invocation) applyGlobalFlags(_ *Command) error { return nil } -// NewAPIClient returns a new API Client configured using the invocation Profile. -// When debug output is enabled and a non-nil logger is provided, the client's -// HTTP transport is wrapped to log requests and responses. -func (i *Invocation) NewAPIClient() (*client.Client, error) { - address := i.Profile.GetHostname() +// NewAPIClientForHost returns a new API Client configured using the specificed +// hostname and token. +func (i *Invocation) NewAPIClientForHost(hostname, token string) (*client.Client, error) { + address := hostname if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") { address = "https://" + address } - apiClient, err := client.New(i.ShutdownCtx, address, i.Profile.GetToken(), http.Header{ + apiClient, err := client.New(i.ShutdownCtx, address, token, http.Header{ "User-Agent": []string{fmt.Sprintf("%s-cli/%s", version.Name, version.Version)}, }) if err != nil { @@ -321,6 +320,11 @@ func (i *Invocation) NewAPIClient() (*client.Client, error) { return apiClient, nil } +// NewAPIClient returns a new API Client configured using the invocation Profile. +func (i *Invocation) NewAPIClient() (*client.Client, error) { + return i.NewAPIClientForHost(i.Profile.GetHostname(), i.Profile.GetToken()) +} + // 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.