diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ddc39c9c..8a7a7d21 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -2,8 +2,6 @@ package cmd import ( - "os" - "github.com/spf13/cobra" "github.com/spf13/cobra/doc" @@ -106,13 +104,13 @@ $ coder completion fish > ~/.config/fish/completions/coder.fish Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - _ = cmd.Root().GenBashCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenBashCompletion(cmd.OutOrStdout()) // Best effort. case "zsh": - _ = cmd.Root().GenZshCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenZshCompletion(cmd.OutOrStdout()) // Best effort. case "fish": - _ = cmd.Root().GenFishCompletion(os.Stdout, true) // Best effort. + _ = cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) // Best effort. case "powershell": - _ = cmd.Root().GenPowerShellCompletion(os.Stdout) // Best effort. + _ = cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) // Best effort. } }, } diff --git a/internal/cmd/envs.go b/internal/cmd/envs.go index 8560748c..73d71dd8 100644 --- a/internal/cmd/envs.go +++ b/internal/cmd/envs.go @@ -8,7 +8,6 @@ import ( "io" "io/ioutil" "net/url" - "os" "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/coderutil" @@ -82,7 +81,7 @@ func lsEnvsCommand() *cobra.Command { return xerrors.Errorf("write table: %w", err) } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(envs) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(envs) if err != nil { return xerrors.Errorf("write environments as JSON: %w", err) } diff --git a/internal/cmd/images.go b/internal/cmd/images.go index b4ee6158..ff47bf61 100644 --- a/internal/cmd/images.go +++ b/internal/cmd/images.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -62,7 +61,7 @@ func lsImgsCommand(user *string) *cobra.Command { switch outputFmt { case jsonOutput: - enc := json.NewEncoder(os.Stdout) + enc := json.NewEncoder(cmd.OutOrStdout()) // pretty print the json enc.SetIndent("", "\t") diff --git a/internal/cmd/login.go b/internal/cmd/login.go index cbb0f38f..691178bf 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -4,8 +4,8 @@ import ( "bufio" "context" "fmt" + "io" "net/url" - "os" "strings" "github.com/pkg/browser" @@ -40,7 +40,7 @@ func loginCmd() *cobra.Command { // From this point, the commandline is correct. // Don't return errors as it would print the usage. - if err := login(cmd.Context(), u); err != nil { + if err := login(cmd, u); err != nil { return xerrors.Errorf("login error: %w", err) } return nil @@ -60,7 +60,7 @@ func storeConfig(envURL *url.URL, sessionToken string, urlCfg, sessionCfg config return nil } -func login(ctx context.Context, envURL *url.URL) error { +func login(cmd *cobra.Command, envURL *url.URL) error { authURL := *envURL authURL.Path = envURL.Path + "/internal-auth" q := authURL.Query() @@ -73,8 +73,8 @@ func login(ctx context.Context, envURL *url.URL) error { fmt.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String()) } - token := readLine("Paste token here: ") - if err := pingAPI(ctx, envURL, token); err != nil { + token := readLine("Paste token here: ", cmd.InOrStdin()) + if err := pingAPI(cmd.Context(), envURL, token); err != nil { return xerrors.Errorf("ping API with credentials: %w", err) } if err := storeConfig(envURL, token, config.URL, config.Session); err != nil { @@ -84,8 +84,8 @@ func login(ctx context.Context, envURL *url.URL) error { return nil } -func readLine(prompt string) string { - reader := bufio.NewReader(os.Stdin) +func readLine(prompt string, r io.Reader) string { + reader := bufio.NewReader(r) fmt.Print(prompt) text, _ := reader.ReadString('\n') return strings.TrimSuffix(text, "\n") diff --git a/internal/cmd/rebuild.go b/internal/cmd/rebuild.go index 978b3f58..f2fbbe8e 100644 --- a/internal/cmd/rebuild.go +++ b/internal/cmd/rebuild.go @@ -3,7 +3,6 @@ package cmd import ( "context" "fmt" - "os" "strings" "time" @@ -11,7 +10,6 @@ import ( "github.com/fatih/color" "github.com/manifoldco/promptui" "github.com/spf13/cobra" - "golang.org/x/crypto/ssh/terminal" "golang.org/x/xerrors" "cdr.dev/coder-cli/coder-sdk" @@ -85,7 +83,7 @@ func trailBuildLogs(ctx context.Context, client coder.Client, envID string) erro newSpinner := func() *spinner.Spinner { return spinner.New(spinner.CharSets[11], 100*time.Millisecond) } // this tells us whether to show dynamic loaders when printing output - isTerminal := terminal.IsTerminal(int(os.Stdout.Fd())) + isTerminal := showInteractiveOutput logs, err := client.FollowEnvironmentBuildLog(ctx, envID) if err != nil { diff --git a/internal/cmd/resourcemanager.go b/internal/cmd/resourcemanager.go index 775596e1..01f4875c 100644 --- a/internal/cmd/resourcemanager.go +++ b/internal/cmd/resourcemanager.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "io" - "os" "sort" "text/tabwriter" @@ -96,7 +95,7 @@ func runResourceTop(options *resourceTopOptions) func(cmd *cobra.Command, args [ return xerrors.Errorf("unknown --group %q", options.group) } - return printResourceTop(os.Stdout, groups, labeler, options.showEmptyGroups, options.sortBy) + return printResourceTop(cmd.OutOrStdout(), groups, labeler, options.showEmptyGroups, options.sortBy) } } diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go index 35df969d..d33dbbbf 100644 --- a/internal/cmd/shell.go +++ b/internal/cmd/shell.go @@ -24,6 +24,12 @@ import ( "cdr.dev/coder-cli/pkg/clog" ) +var ( + showInteractiveOutput = terminal.IsTerminal(int(os.Stdout.Fd())) + outputFd = os.Stdout.Fd() + inputFd = os.Stdin.Fd() +) + func getEnvsForCompletion(user string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := cmd.Context() @@ -146,15 +152,14 @@ func shell(cmd *cobra.Command, cmdArgs []string) error { } // TODO: Verify this is the correct behavior - isInteractive := terminal.IsTerminal(int(os.Stdout.Fd())) - if isInteractive { // checkAndRebuildEnvironment requires an interactive shell + if showInteractiveOutput { // checkAndRebuildEnvironment requires an interactive shell // Checks & Rebuilds the environment if needed. if err := checkAndRebuildEnvironment(ctx, client, env); err != nil { return err } } - if err := runCommand(ctx, client, env, command, args); err != nil { + if err := runCommand(cmd, client, env, command, args); err != nil { if exitErr, ok := err.(wsep.ExitError); ok { os.Exit(exitErr.Code) } @@ -309,26 +314,23 @@ func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process) } } -func runCommand(ctx context.Context, client coder.Client, env *coder.Environment, command string, args []string) error { - termFD := os.Stdout.Fd() - - isInteractive := terminal.IsTerminal(int(termFD)) - if isInteractive { +func runCommand(cmd *cobra.Command, client coder.Client, env *coder.Environment, command string, args []string) error { + if showInteractiveOutput { // If the client has a tty, take over it by setting the raw mode. // This allows for all input to be directly forwarded to the remote process, // otherwise, the local terminal would buffer input, interpret special keys, etc. - stdinState, err := xterminal.MakeRaw(os.Stdin.Fd()) + stdinState, err := xterminal.MakeRaw(inputFd) if err != nil { return err } defer func() { // Best effort. If this fails it will result in a broken terminal, // but there is nothing we can do about it. - _ = xterminal.Restore(os.Stdin.Fd(), stdinState) + _ = xterminal.Restore(inputFd, stdinState) }() } - ctx, cancel := context.WithCancel(ctx) + ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() conn, err := coderutil.DialEnvWsep(ctx, client, env) @@ -338,7 +340,7 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment go heartbeat(ctx, conn, 15*time.Second) var cmdEnv []string - if isInteractive { + if showInteractiveOutput { term := os.Getenv("TERM") if term == "" { term = "xterm" @@ -350,7 +352,7 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment process, err := execer.Start(ctx, wsep.Command{ Command: command, Args: args, - TTY: isInteractive, + TTY: showInteractiveOutput, Stdin: true, Env: cmdEnv, }) @@ -363,8 +365,8 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment } // Now that the remote process successfully started, if we have a tty, start the resize event watcher. - if isInteractive { - go sendResizeEvents(ctx, termFD, process) + if showInteractiveOutput { + go sendResizeEvents(ctx, outputFd, process) } go func() { @@ -373,17 +375,17 @@ func runCommand(ctx context.Context, client coder.Client, env *coder.Environment ap := activity.NewPusher(client, env.ID, sshActivityName) wr := ap.Writer(stdin) - if _, err := io.Copy(wr, os.Stdin); err != nil { + if _, err := io.Copy(wr, cmd.InOrStdin()); err != nil { cancel() } }() go func() { - if _, err := io.Copy(os.Stdout, process.Stdout()); err != nil { + if _, err := io.Copy(cmd.OutOrStdout(), process.Stdout()); err != nil { cancel() } }() go func() { - if _, err := io.Copy(os.Stderr, process.Stderr()); err != nil { + if _, err := io.Copy(cmd.ErrOrStderr(), process.Stderr()); err != nil { cancel() } }() diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index c1c09bca..fc059ac0 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -90,11 +90,15 @@ func makeRunSync(init *bool) func(cmd *cobra.Command, args []string) error { } s := sync.Sync{ - Init: *init, - Env: *env, - RemoteDir: remoteDir, - LocalDir: absLocal, - Client: client, + Init: *init, + Env: *env, + RemoteDir: remoteDir, + LocalDir: absLocal, + Client: client, + OutW: cmd.OutOrStdout(), + ErrW: cmd.ErrOrStderr(), + InputReader: cmd.InOrStdin(), + IsInteractiveOutput: showInteractiveOutput, } localVersion := rsyncVersion() diff --git a/internal/cmd/tags.go b/internal/cmd/tags.go index 13163e60..3a357f14 100644 --- a/internal/cmd/tags.go +++ b/internal/cmd/tags.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -113,7 +112,7 @@ func tagsLsCmd() *cobra.Command { return err } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(tags) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(tags) if err != nil { return err } diff --git a/internal/cmd/tokens.go b/internal/cmd/tokens.go index 66d11230..81a705c6 100644 --- a/internal/cmd/tokens.go +++ b/internal/cmd/tokens.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -56,7 +55,7 @@ func lsTokensCmd() *cobra.Command { return xerrors.Errorf("write table: %w", err) } case jsonOutput: - err := json.NewEncoder(os.Stdout).Encode(tokens) + err := json.NewEncoder(cmd.OutOrStdout()).Encode(tokens) if err != nil { return xerrors.Errorf("write tokens as JSON: %w", err) } diff --git a/internal/cmd/urls.go b/internal/cmd/urls.go index 8c3308ff..cb7f7ca1 100644 --- a/internal/cmd/urls.go +++ b/internal/cmd/urls.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "regexp" "strconv" "strings" @@ -107,7 +106,7 @@ func listDevURLsCmd(outputFmt *string) func(cmd *cobra.Command, args []string) e return xerrors.Errorf("write table: %w", err) } case jsonOutput: - if err := json.NewEncoder(os.Stdout).Encode(devURLs); err != nil { + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(devURLs); err != nil { return xerrors.Errorf("encode DevURLs as json: %w", err) } default: diff --git a/internal/cmd/users.go b/internal/cmd/users.go index a9d6725f..a844fd41 100644 --- a/internal/cmd/users.go +++ b/internal/cmd/users.go @@ -2,7 +2,6 @@ package cmd import ( "encoding/json" - "os" "github.com/spf13/cobra" "golang.org/x/xerrors" @@ -51,7 +50,7 @@ func listUsers(outputFmt *string) func(cmd *cobra.Command, args []string) error return xerrors.Errorf("write table: %w", err) } case "json": - if err := json.NewEncoder(os.Stdout).Encode(users); err != nil { + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(users); err != nil { return xerrors.Errorf("encode users as json: %w", err) } default: diff --git a/internal/sync/sync.go b/internal/sync/sync.go index e9f16be8..cc6d22ea 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -41,8 +41,12 @@ type Sync struct { // DisableMetrics disables activity metric pushing. DisableMetrics bool - Env coder.Environment - Client coder.Client + Env coder.Environment + Client coder.Client + OutW io.Writer + ErrW io.Writer + InputReader io.Reader + IsInteractiveOutput bool } // See https://lxadm.com/Rsync_exit_codes#List_of_standard_rsync_exit_codes. @@ -71,9 +75,9 @@ func (s Sync) syncPaths(delete bool, local, remote string) error { // (AB): compression sped up the initial sync of the enterprise repo by 30%, leading me to believe it's // good in general for codebases. cmd := exec.Command("rsync", args...) - cmd.Stdout = os.Stdout + cmd.Stdout = s.OutW cmd.Stderr = ioutil.Discard - cmd.Stdin = os.Stdin + cmd.Stdin = s.InputReader if err := cmd.Run(); err != nil { if exitError, ok := err.(*exec.ExitError); ok { @@ -106,8 +110,8 @@ func (s Sync) remoteCmd(ctx context.Context, prog string, args ...string) error return xerrors.Errorf("exec remote process: %w", err) } // NOTE: If the copy routine fail, it will result in `process.Wait` to unblock and report an error. - go func() { _, _ = io.Copy(os.Stdout, process.Stdout()) }() // Best effort. - go func() { _, _ = io.Copy(os.Stderr, process.Stderr()) }() // Best effort. + go func() { _, _ = io.Copy(s.OutW, process.Stdout()) }() // Best effort. + go func() { _, _ = io.Copy(s.ErrW, process.Stderr()) }() // Best effort. if err := process.Wait(); err != nil { if code, ok := err.(wsep.ExitError); ok { @@ -235,7 +239,7 @@ func (s Sync) workEventGroup(evs []timedEvent) { var wg sync.WaitGroup for _, ev := range cache.ConcurrentEvents() { - setConsoleTitle(fmtUpdateTitle(ev.Path())) + setConsoleTitle(fmtUpdateTitle(ev.Path()), s.IsInteractiveOutput) wg.Add(1) // TODO: Document why this error is discarded. See https://github.com/cdr/coder-cli/issues/122 for reference. @@ -326,7 +330,7 @@ func (s Sync) Run() error { ap := activity.NewPusher(s.Client, s.Env.ID, activityName) ap.Push(ctx) - setConsoleTitle("⏳ syncing project") + setConsoleTitle("⏳ syncing project", s.IsInteractiveOutput) if err := s.initSync(); err != nil { return err } @@ -363,7 +367,7 @@ func (s Sync) Run() error { defer dispatchEventGroup.Stop() for { const watchingFilesystemTitle = "🛰 watching filesystem" - setConsoleTitle(watchingFilesystemTitle) + setConsoleTitle(watchingFilesystemTitle, s.IsInteractiveOutput) select { case ev := <-timedEvents: diff --git a/internal/sync/title.go b/internal/sync/title.go index c9a91c8b..ae7630d8 100644 --- a/internal/sync/title.go +++ b/internal/sync/title.go @@ -2,14 +2,11 @@ package sync import ( "fmt" - "os" "path/filepath" - - "golang.org/x/crypto/ssh/terminal" ) -func setConsoleTitle(title string) { - if !terminal.IsTerminal(int(os.Stdout.Fd())) { +func setConsoleTitle(title string, isInteractiveOutput bool) { + if !isInteractiveOutput { return } fmt.Printf("\033]0;%s\007", title) diff --git a/pkg/clog/clog.go b/pkg/clog/clog.go index eafc18aa..0a523e1f 100644 --- a/pkg/clog/clog.go +++ b/pkg/clog/clog.go @@ -3,6 +3,7 @@ package clog import ( "errors" "fmt" + "io" "os" "strings" @@ -10,6 +11,13 @@ import ( "golang.org/x/xerrors" ) +var writer io.Writer = os.Stderr + +// SetOutput sets the package-level writer target for log functions. +func SetOutput(w io.Writer) { + writer = w +} + // CLIMessage provides a human-readable message for CLI errors and messages. type CLIMessage struct { Level string @@ -45,12 +53,12 @@ func Log(err error) { if !xerrors.As(err, &cliErr) { cliErr = Fatal(err.Error()) } - fmt.Fprintln(os.Stderr, cliErr.String()) + fmt.Fprintln(writer, cliErr.String()) } // LogInfo prints the given info message to stderr. func LogInfo(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "info", Color: color.FgBlue, Header: header, @@ -60,7 +68,7 @@ func LogInfo(header string, lines ...string) { // LogSuccess prints the given info message to stderr. func LogSuccess(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "success", Color: color.FgGreen, Header: header, @@ -70,7 +78,7 @@ func LogSuccess(header string, lines ...string) { // LogWarn prints the given warn message to stderr. func LogWarn(header string, lines ...string) { - fmt.Fprint(os.Stderr, CLIMessage{ + fmt.Fprint(writer, CLIMessage{ Level: "warning", Color: color.FgYellow, Header: header, diff --git a/pkg/clog/clog_test.go b/pkg/clog/clog_test.go index 8d1c88b4..4c75a3e5 100644 --- a/pkg/clog/clog_test.go +++ b/pkg/clog/clog_test.go @@ -20,7 +20,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close() @@ -39,7 +39,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close() @@ -59,7 +59,7 @@ func TestError(t *testing.T) { assert.Success(t, "create pipe", err) //! clearly not thread safe - os.Stderr = writer + SetOutput(writer) Log(mockErr) writer.Close()