Repository: cosmos/relayer
Pull request #597 (2022-03-14 19:23:51)

Refactor cmd package to pass stdio
And add first "system test" for the cmd package, using the same patterns
recently added to strangelove/lens.

This switches from plain use of fmt.Print in the cmd package, to
fmt.Fprint variants passing the command's stdin/stdout/stderr as
appropriate. The rule of thumb is that stderr is used for diagnostic
output and stdout is used for "conventional" output that one would
perhaps pipe to another process.

This also replaces the relayer.Chain type's Print method with an Sprint
method, so that callers can decide how to directly the output.


Patch:
diff --git a/cmd/chains.go b/cmd/chains.go
index 31fcd62..8e71eb0 100644
--- a/cmd/chains.go
+++ b/cmd/chains.go
@@ -61,7 +61,7 @@ $ %s ch addr ibc-0`, appName, appName)),
 			if err != nil {
 				return err
 			}
-			fmt.Println(address)
+			fmt.Fprintln(cmd.OutOrStdout(), address)
 			return nil
 		},
 	}
@@ -99,7 +99,7 @@ $ %s ch s ibc-0 --yaml`, appName, appName, appName, appName)),
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
 				pcfgw := &ProviderConfigWrapper{
@@ -110,7 +110,7 @@ $ %s ch s ibc-0 --yaml`, appName, appName, appName, appName)),
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			}
 		},
@@ -164,18 +164,18 @@ func chainsRegistryList() *cobra.Command {
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			case jsn:
 				out, err := json.Marshal(chains)
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
 				for _, chain := range chains {
-					fmt.Println(chain)
+					fmt.Fprintln(cmd.OutOrStdout(), chain)
 				}
 			}
 			return nil
@@ -203,22 +203,27 @@ $ %s ch l`, appName, appName)),
 				return err
 			}
 
+			configs := ConfigToWrapper(config).ProviderConfigs
+			if len(configs) == 0 {
+				fmt.Fprintln(cmd.ErrOrStderr(), "warning: no chains found (do you need to run 'rly chains add'?)")
+			}
+
 			switch {
 			case yml && jsn:
 				return fmt.Errorf("can't pass both --json and --yaml, must pick one")
 			case yml:
-				out, err := yaml.Marshal(ConfigToWrapper(config).ProviderConfigs)
+				out, err := yaml.Marshal(configs)
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			case jsn:
-				out, err := json.Marshal(ConfigToWrapper(config).ProviderConfigs)
+				out, err := json.Marshal(configs)
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
 				for i, c := range config.Chains {
@@ -242,7 +247,7 @@ $ %s ch l`, appName, appName)),
 							p = check
 						}
 					}
-					fmt.Printf("%2d: %-20s -> type(%s) key(%s) bal(%s) path(%s)\n", i, c.ChainID(), c.ChainProvider.Type(), key, bal, p)
+					fmt.Fprintf(cmd.OutOrStdout(), "%2d: %-20s -> type(%s) key(%s) bal(%s) path(%s)\n", i, c.ChainID(), c.ChainProvider.Type(), key, bal, p)
 				}
 				return nil
 			}
@@ -310,7 +315,7 @@ $ %s chains add-dir testnet/chains/
 $ %s ch ad testnet/chains/`, appName, appName)),
 		RunE: func(cmd *cobra.Command, args []string) (err error) {
 			var out *Config
-			if out, err = cfgFilesAddChains(args[0]); err != nil {
+			if out, err = cfgFilesAddChains(cmd, args[0]); err != nil {
 				return err
 			}
 			return overWriteConfig(out)
diff --git a/cmd/config.go b/cmd/config.go
index 8e0235d..f329bb6 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -19,9 +19,11 @@ package cmd
 import (
 	"encoding/json"
 	"fmt"
+	"io"
 	"io/ioutil"
 	"os"
 	"path"
+	"path/filepath"
 	"reflect"
 	"strings"
 	"time"
@@ -33,6 +35,7 @@ import (
 	"github.com/cosmos/relayer/relayer/provider/cosmos"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
+	"github.com/tendermint/tendermint/libs/log"
 	"gopkg.in/yaml.v3"
 )
 
@@ -101,14 +104,14 @@ $ %s cfg list`, appName, defaultHome, appName)),
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
 				out, err := yaml.Marshal(ConfigToWrapper(config))
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			}
 		},
@@ -185,7 +188,7 @@ func configAddChainsCmd() *cobra.Command {
 $ %s config add-chains configs/chains`, appName)),
 		RunE: func(cmd *cobra.Command, args []string) (err error) {
 			var out *Config
-			if out, err = cfgFilesAddChains(args[0]); err != nil {
+			if out, err = cfgFilesAddChains(cmd, args[0]); err != nil {
 				return err
 			}
 			return overWriteConfig(out)
@@ -207,7 +210,7 @@ func configAddPathsCmd() *cobra.Command {
 $ %s config add-paths configs/paths`, appName)),
 		RunE: func(cmd *cobra.Command, args []string) (err error) {
 			var out *Config
-			if out, err = cfgFilesAddPaths(args[0]); err != nil {
+			if out, err = cfgFilesAddPaths(cmd, args[0]); err != nil {
 				return err
 			}
 			return overWriteConfig(out)
@@ -217,7 +220,7 @@ $ %s config add-paths configs/paths`, appName)),
 	return cmd
 }
 
-func cfgFilesAddChains(dir string) (cfg *Config, err error) {
+func cfgFilesAddChains(cmd *cobra.Command, dir string) (cfg *Config, err error) {
 	dir = path.Clean(dir)
 	files, err := ioutil.ReadDir(dir)
 	if err != nil {
@@ -225,42 +228,42 @@ func cfgFilesAddChains(dir string) (cfg *Config, err error) {
 	}
 	cfg = config
 	for _, f := range files {
-		pth := fmt.Sprintf("%s/%s", dir, f.Name())
+		pth := filepath.Join(dir, f.Name())
 		if f.IsDir() {
-			fmt.Printf("directory at %s, skipping...\n", pth)
+			fmt.Fprintf(cmd.ErrOrStderr(), "directory at %s, skipping...\n", pth)
 			continue
 		}
 
 		byt, err := os.ReadFile(pth)
 		if err != nil {
-			fmt.Printf("failed to read file %s. Err: %v skipping...\n", pth, err)
+			fmt.Fprintf(cmd.ErrOrStderr(), "failed to read file %s. Err: %v skipping...\n", pth, err)
 			continue
 		}
 
 		var pcw ProviderConfigWrapper
 		if err = json.Unmarshal(byt, &pcw); err != nil {
-			fmt.Printf("failed to unmarshal file %s. Err: %v skipping...\n", pth, err)
+			fmt.Fprintf(cmd.ErrOrStderr(), "failed to unmarshal file %s. Err: %v skipping...\n", pth, err)
 			continue
 		}
 
 		prov, err := pcw.Value.NewProvider(homePath, debug)
 		if err != nil {
-			fmt.Printf("failed to build ChainProvider for %s. Err: %v \n", pth, err)
+			fmt.Fprintf(cmd.ErrOrStderr(), "failed to build ChainProvider for %s. Err: %v \n", pth, err)
 			continue
 		}
 
 		c := &relayer.Chain{ChainProvider: prov}
 
 		if err = cfg.AddChain(c); err != nil {
-			fmt.Printf("failed to add chain %s: %v \n", pth, err)
+			fmt.Fprintf(cmd.ErrOrStderr(), "failed to add chain %s: %v \n", pth, err)
 			continue
 		}
-		fmt.Printf("added chain %s...\n", c.ChainProvider.ChainId())
+		fmt.Fprintf(cmd.ErrOrStderr(), "added chain %s...\n", c.ChainProvider.ChainId())
 	}
 	return cfg, nil
 }
 
-func cfgFilesAddPaths(dir string) (cfg *Config, err error) {
+func cfgFilesAddPaths(cmd *cobra.Command, dir string) (cfg *Config, err error) {
 	dir = path.Clean(dir)
 	files, err := ioutil.ReadDir(dir)
 	if err != nil {
@@ -270,7 +273,7 @@ func cfgFilesAddPaths(dir string) (cfg *Config, err error) {
 	for _, f := range files {
 		pth := fmt.Sprintf("%s/%s", dir, f.Name())
 		if f.IsDir() {
-			fmt.Printf("directory at %s, skipping...\n", pth)
+			fmt.Fprintf(cmd.ErrOrStderr(), "directory at %s, skipping...\n", pth)
 			continue
 		}
 
@@ -299,7 +302,7 @@ func cfgFilesAddPaths(dir string) (cfg *Config, err error) {
 		}
 
 		pthName := strings.Split(f.Name(), ".")[0]
-		if err = config.ValidatePath(p); err != nil {
+		if err = config.ValidatePath(cmd.ErrOrStderr(), p); err != nil {
 			return nil, fmt.Errorf("failed to validate path %s: %w", pth, err)
 		}
 
@@ -307,7 +310,7 @@ func cfgFilesAddPaths(dir string) (cfg *Config, err error) {
 			return nil, fmt.Errorf("failed to add path %s: %w", pth, err)
 		}
 
-		fmt.Printf("added path %s...\n\n", pthName)
+		fmt.Fprintf(cmd.ErrOrStderr(), "added path %s...\n\n", pthName)
 	}
 
 	return cfg, nil
@@ -601,16 +604,16 @@ func initConfig(cmd *cobra.Command) error {
 			// read the config file bytes
 			file, err := os.ReadFile(viper.ConfigFileUsed())
 			if err != nil {
-				fmt.Println("Error reading file:", err)
-				os.Exit(1)
+				fmt.Fprintln(cmd.ErrOrStderr(), "Error reading file:", err)
+				return err
 			}
 
 			// unmarshall them into the wrapper struct
 			cfgWrapper := &ConfigInputWrapper{}
 			err = yaml.Unmarshal(file, cfgWrapper)
 			if err != nil {
-				fmt.Println("Error unmarshalling config:", err)
-				os.Exit(1)
+				fmt.Fprintln(cmd.ErrOrStderr(), "Error unmarshalling config:", err)
+				return err
 			}
 
 			// build the config struct
@@ -622,7 +625,7 @@ func initConfig(cmd *cobra.Command) error {
 				}
 
 				chain := &relayer.Chain{ChainProvider: prov}
-				chain.Init(nil, debug)
+				chain.Init(log.NewTMLogger(log.NewSyncWriter(cmd.ErrOrStderr())), debug)
 				chains = append(chains, chain)
 			}
 
@@ -635,8 +638,8 @@ func initConfig(cmd *cobra.Command) error {
 			// ensure config has []*relayer.Chain used for all chain operations
 			err = validateConfig(config)
 			if err != nil {
-				fmt.Println("Error parsing chain config:", err)
-				os.Exit(1)
+				fmt.Fprintln(cmd.ErrOrStderr(), "Error parsing chain config:", err)
+				return err
 			}
 		}
 	}
@@ -674,14 +677,14 @@ func overWriteConfig(cfg *Config) (err error) {
 }
 
 // ValidatePath checks that a path is valid
-func (c *Config) ValidatePath(p *relayer.Path) (err error) {
+func (c *Config) ValidatePath(stderr io.Writer, p *relayer.Path) (err error) {
 	if p.Src.Version == "" {
 		return fmt.Errorf("source must specify a version")
 	}
-	if err = c.ValidatePathEnd(p.Src); err != nil {
+	if err = c.ValidatePathEnd(stderr, p.Src); err != nil {
 		return sdkerrors.Wrapf(err, "chain %s failed path validation", p.Src.ChainID)
 	}
-	if err = c.ValidatePathEnd(p.Dst); err != nil {
+	if err = c.ValidatePathEnd(stderr, p.Dst); err != nil {
 		return sdkerrors.Wrapf(err, "chain %s failed path validation", p.Dst.ChainID)
 	}
 	if p.Src.Order != p.Dst.Order {
@@ -692,14 +695,14 @@ func (c *Config) ValidatePath(p *relayer.Path) (err error) {
 }
 
 // ValidatePathEnd validates provided pathend and returns error for invalid identifiers
-func (c *Config) ValidatePathEnd(pe *relayer.PathEnd) error {
+func (c *Config) ValidatePathEnd(stderr io.Writer, pe *relayer.PathEnd) error {
 	if err := pe.ValidateBasic(); err != nil {
 		return err
 	}
 
 	chain, err := c.Chains.Get(pe.ChainID)
 	if err != nil {
-		fmt.Printf("Chain %s is not currently configured. \n", pe.ChainID)
+		fmt.Fprintf(stderr, "Chain %s is not currently configured.\n", pe.ChainID)
 		return nil
 	}
 
diff --git a/cmd/keys.go b/cmd/keys.go
index cc6f748..3b52d96 100644
--- a/cmd/keys.go
+++ b/cmd/keys.go
@@ -19,6 +19,7 @@ package cmd
 import (
 	"encoding/json"
 	"fmt"
+	"io"
 	"log"
 	"strings"
 
@@ -81,7 +82,7 @@ $ %s k a ibc-2 testkey`, appName, appName, appName)),
 				return err
 			}
 
-			fmt.Println(string(out))
+			fmt.Fprintln(cmd.OutOrStdout(), string(out))
 			return nil
 		},
 	}
@@ -116,7 +117,7 @@ $ %s k r ibc-1 faucet-key "[mnemonic-words]"`, appName, appName)),
 				return err
 			}
 
-			fmt.Println(address)
+			fmt.Fprintln(cmd.OutOrStdout(), address)
 			return nil
 		},
 	}
@@ -148,18 +149,18 @@ $ %s k d ibc-2 testkey`, appName, appName, appName)),
 			}
 
 			if skip, _ := cmd.Flags().GetBool(flagSkip); !skip {
-				fmt.Printf("Are you sure you want to delete key(%s) from chain(%s)? (Y/n)\n", keyName, args[0])
-				if !askForConfirmation() {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Are you sure you want to delete key(%s) from chain(%s)? (Y/n)\n", keyName, args[0])
+				if !askForConfirmation(cmd.InOrStdin(), cmd.ErrOrStderr()) {
 					return nil
 				}
 			}
 
 			err = chain.ChainProvider.DeleteKey(keyName)
 			if err != nil {
-				panic(err)
+				return err
 			}
 
-			fmt.Printf("key %s deleted\n", keyName)
+			fmt.Fprintf(cmd.ErrOrStderr(), "key %s deleted\n", keyName)
 			return nil
 		},
 	}
@@ -167,10 +168,10 @@ $ %s k d ibc-2 testkey`, appName, appName, appName)),
 	return skipConfirm(cmd)
 }
 
-func askForConfirmation() bool {
+func askForConfirmation(stdin io.Reader, stderr io.Writer) bool {
 	var response string
 
-	_, err := fmt.Scanln(&response)
+	_, err := fmt.Fscanln(stdin, &response)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -181,8 +182,8 @@ func askForConfirmation() bool {
 	case "n", "no":
 		return false
 	default:
-		fmt.Println("please type (y)es or (n)o and then press enter")
-		return askForConfirmation()
+		fmt.Fprintln(stderr, "please type (y)es or (n)o and then press enter")
+		return askForConfirmation(stdin, stderr)
 	}
 }
 
diff --git a/cmd/paths.go b/cmd/paths.go
index 857390d..9d63c5b 100644
--- a/cmd/paths.go
+++ b/cmd/paths.go
@@ -7,6 +7,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path"
+	"path/filepath"
 	"strings"
 
 	"github.com/cosmos/relayer/relayer"
@@ -83,14 +84,14 @@ $ %s pth l`, appName, appName, appName)),
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			case jsn:
 				out, err := json.Marshal(config.Paths)
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
 				i := 0
@@ -100,7 +101,7 @@ $ %s pth l`, appName, appName, appName)),
 						return err
 					}
 					stat := pth.QueryPathStatus(chains[pth.Src.ChainID], chains[pth.Dst.ChainID]).Status
-					printPath(i, k, pth, checkmark(stat.Chains), checkmark(stat.Clients),
+					printPath(cmd.OutOrStdout(), i, k, pth, checkmark(stat.Chains), checkmark(stat.Clients),
 						checkmark(stat.Connection), checkmark(stat.Channel))
 					i++
 				}
@@ -111,8 +112,8 @@ $ %s pth l`, appName, appName, appName)),
 	return yamlFlag(jsonFlag(cmd))
 }
 
-func printPath(i int, k string, pth *relayer.Path, chains, clients, connection, channel string) {
-	fmt.Printf("%2d: %-20s -> chns(%s) clnts(%s) conn(%s) chan(%s) (%s:%s<>%s:%s)\n",
+func printPath(stdout io.Writer, i int, k string, pth *relayer.Path, chains, clients, connection, channel string) {
+	fmt.Fprintf(stdout, "%2d: %-20s -> chns(%s) clnts(%s) conn(%s) chan(%s) (%s:%s<>%s:%s)\n",
 		i, k, chains, clients, connection, channel, pth.Src.ChainID, pth.Src.PortID, pth.Dst.ChainID, pth.Dst.PortID)
 }
 
@@ -153,17 +154,17 @@ $ %s pth s path-name`, appName, appName, appName)),
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			case jsn:
 				out, err := json.Marshal(pathWithStatus)
 				if err != nil {
 					return err
 				}
-				fmt.Println(string(out))
+				fmt.Fprintln(cmd.OutOrStdout(), string(out))
 				return nil
 			default:
-				fmt.Println(pathWithStatus.PrintString(args[0]))
+				fmt.Fprintln(cmd.OutOrStdout(), pathWithStatus.PrintString(args[0]))
 			}
 
 			return nil
@@ -196,11 +197,11 @@ $ %s pth a ibc-0 ibc-1 demo-path`, appName, appName, appName)),
 			}
 
 			if file != "" {
-				if out, err = fileInputPathAdd(file, args[2]); err != nil {
+				if out, err = fileInputPathAdd(cmd.ErrOrStderr(), file, args[2]); err != nil {
 					return err
 				}
 			} else {
-				if out, err = userInputPathAdd(src, dst, args[2]); err != nil {
+				if out, err = userInputPathAdd(cmd.InOrStdin(), cmd.ErrOrStderr(), src, dst, args[2]); err != nil {
 					return err
 				}
 			}
@@ -289,17 +290,16 @@ $ %s pth fch`, appName, defaultHome, appName)),
 					dir := path.Clean(localPathsDir)
 					files, err := ioutil.ReadDir(dir)
 					if err != nil {
-						fmt.Printf("path info does not exist for chain: %s. Consider adding it's info to %s. Error: %v \n", srcChain.ChainID(), path.Join(PATHSURL, "interchain"), err)
+						fmt.Fprintf(cmd.ErrOrStderr(), "path info does not exist for chain: %s. Consider adding its info to %s. Error: %v\n", srcChain.ChainID(), path.Join(PATHSURL, "interchain"), err)
 						break
 					}
 					cfg := config
 
 					// For each path file, check that the dst is also a configured chain in the relayers config
 					for _, f := range files {
-						pth := fmt.Sprintf("%s/%s", dir, f.Name())
-						fmt.Println(pth)
+						pth := filepath.Join(dir, f.Name())
 						if f.IsDir() {
-							fmt.Printf("directory at %s, skipping...\n", pth)
+							fmt.Fprintf(cmd.ErrOrStderr(), "directory at %s, skipping...\n", pth)
 							continue
 						}
 
@@ -335,7 +335,7 @@ $ %s pth fch`, appName, defaultHome, appName)),
 								return fmt.Errorf("failed to add path %s: %w", pth, err)
 							}
 
-							fmt.Printf("added path %s...\n", pthName)
+							fmt.Fprintf(cmd.ErrOrStderr(), "added path %s...\n", pthName)
 						}
 					}
 
@@ -356,7 +356,7 @@ func cleanupDir(dir string) {
 	_ = os.RemoveAll(dir)
 }
 
-func fileInputPathAdd(file, name string) (cfg *Config, err error) {
+func fileInputPathAdd(stderr io.Writer, file, name string) (cfg *Config, err error) {
 	// If the user passes in a file, attempt to read the chain config from that file
 	p := &relayer.Path{}
 	if _, err := os.Stat(file); err != nil {
@@ -372,7 +372,7 @@ func fileInputPathAdd(file, name string) (cfg *Config, err error) {
 		return nil, err
 	}
 
-	if err = config.ValidatePath(p); err != nil {
+	if err = config.ValidatePath(stderr, p); err != nil {
 		return nil, err
 	}
 
@@ -383,7 +383,7 @@ func fileInputPathAdd(file, name string) (cfg *Config, err error) {
 	return config, nil
 }
 
-func userInputPathAdd(src, dst, name string) (*Config, error) {
+func userInputPathAdd(stdin io.Reader, stderr io.Writer, src, dst, name string) (*Config, error) {
 	var (
 		value string
 		err   error
@@ -399,8 +399,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		}
 	)
 
-	fmt.Printf("enter src(%s) client-id...\n", src)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter src(%s) client-id...\n", src)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -410,8 +410,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter src(%s) connection-id...\n", src)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter src(%s) connection-id...\n", src)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -421,8 +421,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter src(%s) channel-id...\n", src)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter src(%s) channel-id...\n", src)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -432,8 +432,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter src(%s) port-id...\n", src)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter src(%s) port-id...\n", src)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -443,8 +443,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter src(%s) version...\n", src)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter src(%s) version...\n", src)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -454,8 +454,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter dst(%s) client-id...\n", dst)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter dst(%s) client-id...\n", dst)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -465,8 +465,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter dst(%s) connection-id...\n", dst)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter dst(%s) connection-id...\n", dst)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -476,8 +476,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter dst(%s) channel-id...\n", dst)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter dst(%s) channel-id...\n", dst)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -487,8 +487,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter dst(%s) port-id...\n", dst)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter dst(%s) port-id...\n", dst)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -498,8 +498,8 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	fmt.Printf("enter dst(%s) version...\n", dst)
-	if value, err = readStdin(); err != nil {
+	fmt.Fprintf(stderr, "enter dst(%s) version...\n", dst)
+	if value, err = readLine(stdin); err != nil {
 		return nil, err
 	}
 
@@ -509,7 +509,7 @@ func userInputPathAdd(src, dst, name string) (*Config, error) {
 		return nil, err
 	}
 
-	if err = config.ValidatePath(path); err != nil {
+	if err = config.ValidatePath(stderr, path); err != nil {
 		return nil, err
 	}
 
diff --git a/cmd/query.go b/cmd/query.go
index 73779f0..5d40100 100644
--- a/cmd/query.go
+++ b/cmd/query.go
@@ -76,7 +76,7 @@ $ %s q ibc-denoms ibc-0`,
 			}
 
 			for _, d := range res {
-				fmt.Println(d)
+				fmt.Fprintln(cmd.OutOrStdout(), d)
 			}
 			return nil
 		},
@@ -111,7 +111,7 @@ $ %s q tx ibc-0 A5DF8D272F1C451CFF92BA6C41942C4D29B5CF180279439ED6AB038282F956BE
 				return err
 			}
 
-			fmt.Println(string(out))
+			fmt.Fprintln(cmd.OutOrStdout(), string(out))
 			return nil
 		},
 	}
@@ -163,7 +163,7 @@ $ %s q txs ibc-0 "message.action=transfer"`,
 				return err
 			}
 
-			fmt.Println(string(out))
+			fmt.Fprintln(cmd.OutOrStdout(), string(out))
 			return nil
 		},
 	}
@@ -265,7 +265,7 @@ $ %s query balance ibc-0 testkey`,
 				return err
 			}
 
-			fmt.Printf("address {%s} balance {%s} \n", addr, coins)
+			fmt.Fprintf(cmd.OutOrStdout(), "address {%s} balance {%s} \n", addr, coins)
 			return nil
 		},
 	}
@@ -304,7 +304,14 @@ $ %s query header ibc-0 1400`,
 				}
 			}
 
-			return chain.Print(header, false, false)
+			s, err := chain.Sprint(header, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal header: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -334,7 +341,14 @@ $ %s q node-state ibc-1`,
 				return err
 			}
 
-			return chain.Print(csRes, false, false)
+			s, err := chain.Sprint(csRes, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal consensus state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -378,7 +392,14 @@ $ %s query client ibc-0 ibczeroclient --height 1205`,
 				return err
 			}
 
-			return chain.Print(res, false, false)
+			s, err := chain.Sprint(res, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -414,14 +435,16 @@ $ %s query clients ibc-2 --offset 2 --limit 30`,
 			}
 
 			for _, client := range res {
-				err = chain.Print(&client, false, false)
+				s, err := chain.Sprint(&client, false, false)
 				if err != nil {
-					return err
+					fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal state: %v\n", err)
+					continue
 				}
+
+				fmt.Fprintln(cmd.OutOrStdout(), s)
 			}
 
 			return nil
-			//return chain.Print(res, false, false)
 		},
 	}
 
@@ -494,14 +517,16 @@ $ %s q conns ibc-1`,
 			}
 
 			for _, connection := range res {
-				err = chain.Print(connection, false, false)
+				s, err := chain.Sprint(connection, false, false)
 				if err != nil {
-					return err
+					fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal connection: %v\n", err)
+					continue
 				}
+
+				fmt.Fprintln(cmd.OutOrStdout(), s)
 			}
 
 			return nil
-			//return chain.Print(res, false, false)
 		},
 	}
 
@@ -546,8 +571,14 @@ $ %s query client-connections ibc-0 ibczeroclient --height 1205`,
 				return err
 			}
 
-			return chain.Print(res, false, false)
-			//return chain.CLIContext(height).PrintProto(res)
+			s, err := chain.Sprint(res, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal client connection state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -585,8 +616,14 @@ $ %s q conn ibc-1 ibconeconn`,
 				return err
 			}
 
-			return chain.Print(res, false, false)
-			//return chain.CLIContext(height).PrintProto(res)
+			s, err := chain.Sprint(res, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal connection state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -625,14 +662,16 @@ $ %s query connection-channels ibc-2 ibcconnection2 --offset 2 --limit 30`,
 			}
 
 			for _, channel := range chans {
-				err = chain.Print(channel, false, false)
+				s, err := chain.Sprint(channel, false, false)
 				if err != nil {
-					return err
+					fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal channel: %v\n", err)
+					continue
 				}
+
+				fmt.Fprintln(cmd.OutOrStdout(), s)
 			}
 
 			return nil
-			//return chain.Print(chans, false, false)
 		},
 	}
 
@@ -677,8 +716,14 @@ $ %s query channel ibc-2 ibctwochannel transfer --height 1205`,
 				return err
 			}
 
-			return chain.Print(res, false, false)
-			//return chain.CLIContext(height).PrintProto(res)
+			s, err := chain.Sprint(res, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal channel state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -713,10 +758,13 @@ $ %s query channels ibc-2 --offset 2 --limit 30`,
 			}
 
 			for _, channel := range res {
-				err = chain.Print(channel, false, false)
+				s, err := chain.Sprint(channel, false, false)
 				if err != nil {
-					return err
+					fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal channel: %v\n", err)
+					continue
 				}
+
+				fmt.Fprintln(cmd.OutOrStdout(), s)
 			}
 
 			return nil
@@ -757,7 +805,14 @@ $ %s q packet-commit ibc-1 ibconechannel transfer 31`,
 				return err
 			}
 
-			return chain.Print(res, false, false)
+			s, err := chain.Sprint(res, false, false)
+			if err != nil {
+				fmt.Fprintf(cmd.ErrOrStderr(), "Failed to marshal packet-commit state: %v\n", err)
+				return err
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), s)
+			return nil
 		},
 	}
 
@@ -806,7 +861,7 @@ $ %s query unrelayed-pkts demo-path`,
 				return err
 			}
 
-			fmt.Println(string(out))
+			fmt.Fprintln(cmd.OutOrStdout(), string(out))
 			return nil
 		},
 	}
@@ -855,7 +910,7 @@ $ %s query unrelayed-acks demo-path`,
 				return err
 			}
 
-			fmt.Println(string(out))
+			fmt.Fprintln(cmd.OutOrStdout(), string(out))
 			return nil
 		},
 	}
diff --git a/cmd/root.go b/cmd/root.go
index bfccbd8..4e3ea84 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -18,6 +18,7 @@ package cmd
 
 import (
 	"bufio"
+	"io"
 	"os"
 	"strings"
 
@@ -108,8 +109,8 @@ func Execute() {
 	}
 }
 
-// readLineFromBuf reads one line from stdin.
-func readStdin() (string, error) {
-	str, err := bufio.NewReader(os.Stdin).ReadString('\n')
+// readLine reads one line from the given reader.
+func readLine(in io.Reader) (string, error) {
+	str, err := bufio.NewReader(in).ReadString('\n')
 	return strings.TrimSpace(str), err
 }
diff --git a/cmd/start.go b/cmd/start.go
index a194267..456b27e 100644
--- a/cmd/start.go
+++ b/cmd/start.go
@@ -18,6 +18,7 @@ package cmd
 
 import (
 	"fmt"
+	"io"
 	"math"
 	"os"
 	"os/signal"
@@ -99,7 +100,7 @@ $ %s start demo-path2 --max-tx-size 10`, appName, appName)),
 				c[src].Log(fmt.Sprintf("update clients error. Err: %v", err))
 			}
 
-			trapSignal(done)
+			trapSignal(cmd.ErrOrStderr(), done)
 			return nil
 		},
 	}
@@ -107,14 +108,14 @@ $ %s start demo-path2 --max-tx-size 10`, appName, appName)),
 }
 
 // trap signal waits for a SIGINT or SIGTERM and then sends down the done channel
-func trapSignal(done func()) {
+func trapSignal(stderr io.Writer, done func()) {
 	sigCh := make(chan os.Signal, 1)
 
 	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
 
 	// wait for a signal
 	sig := <-sigCh
-	fmt.Println("Signal Received", sig.String())
+	fmt.Fprintln(stderr, "Signal Received", sig.String())
 	close(sigCh)
 
 	// call the cleanup func
diff --git a/cmd/tx.go b/cmd/tx.go
index 7e79378..ffb2bd6 100644
--- a/cmd/tx.go
+++ b/cmd/tx.go
@@ -535,7 +535,7 @@ $ %s tx link-then-start demo-path --timeout 5s`, appName, appName)),
 			lCmd := linkCmd()
 
 			for err := lCmd.RunE(cmd, args); err != nil; err = lCmd.RunE(cmd, args) {
-				fmt.Printf("retrying link: %s\n", err)
+				fmt.Fprintf(cmd.ErrOrStderr(), "retrying link: %s\n", err)
 				time.Sleep(1 * time.Second)
 			}
 
diff --git a/cmd/version.go b/cmd/version.go
index ec8adf4..788cf0f 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -56,7 +56,7 @@ $ %s v`,
 				bz, err = yaml.Marshal(&verInfo)
 			}
 
-			fmt.Println(string(bz))
+			fmt.Fprintln(cmd.OutOrStdout(), string(bz))
 			return err
 		},
 	}
diff --git a/relayer/chain.go b/relayer/chain.go
index 8fbe654..6ea469b 100644
--- a/relayer/chain.go
+++ b/relayer/chain.go
@@ -4,7 +4,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/url"
-	"os"
 	"strings"
 	"time"
 
@@ -161,20 +160,18 @@ func ValidateChannelParams(src, dst *Chain) error {
 // Init initializes the pieces of a chain that aren't set when it parses a config
 // NOTE: All validation of the chain should happen here.
 func (c *Chain) Init(logger log.Logger, debug bool) {
+	if logger == nil {
+		// If no logger is provided, that is a programmer error.
+		panic(fmt.Errorf("chain incorrectly initialized without logger"))
+	}
+
 	c.logger = logger
 	c.debug = debug
-	if c.logger == nil {
-		c.logger = defaultChainLogger()
-	}
 
 	// TODO logging/encoding needs refactored
 	c.Encoding = MakeCodec(ModuleBasics)
 }
 
-func defaultChainLogger() log.Logger {
-	return log.NewTMLogger(log.NewSyncWriter(os.Stdout))
-}
-
 func (c *Chain) ChainID() string {
 	return c.ChainProvider.ChainId()
 }
@@ -228,32 +225,24 @@ func (c *Chain) String() string {
 	return string(out)
 }
 
-// Print fmt.Printlns the json or yaml representation of whatever is passed in
+// Sprint returns the json or yaml representation of whatever is passed in.
 // CONTRACT: The cmd calling this function needs to have the "json" and "indent" flags set
 // TODO: better "text" printing here would be a nice to have
 // TODO: fix indenting all over the code base
-func (c *Chain) Print(toPrint proto.Message, text, indent bool) error {
-	var (
-		out []byte
-		err error
-	)
-
+func (c *Chain) Sprint(toPrint proto.Message, text, indent bool) (string, error) {
 	switch {
 	case indent && text:
-		return fmt.Errorf("must pass either indent or text, not both")
+		return "", fmt.Errorf("must pass either indent or text, not both")
 	case text:
 		// TODO: This isn't really a good option,
-		out = []byte(fmt.Sprintf("%v", toPrint))
+		return fmt.Sprintf("%v", toPrint), nil
 	default:
-		out, err = c.Encoding.Marshaler.MarshalJSON(toPrint)
-	}
-
-	if err != nil {
-		return err
+		out, err := c.Encoding.Marshaler.MarshalJSON(toPrint)
+		if err != nil {
+			return "", err
+		}
+		return string(out), nil
 	}
-
-	fmt.Println(string(out))
-	return nil
 }
 
 //// SendAndPrint sends a transaction and prints according to the passed args
diff --git a/relayer/log-chain.go b/relayer/log-chain.go
index 390aa67..2ace78f 100644
--- a/relayer/log-chain.go
+++ b/relayer/log-chain.go
@@ -20,7 +20,13 @@ func (c *Chain) LogFailedTx(res *provider.RelayerTxResponse, err error, msgs []p
 	if c.debug {
 		c.Log(fmt.Sprintf("- [%s] -> failed sending transaction:", c.ChainID()))
 		for _, msg := range msgs {
-			_ = c.Print(cosmos.CosmosMsg(msg), false, false)
+			s, err := c.Sprint(cosmos.CosmosMsg(msg), false, false)
+			if err != nil {
+				c.Log(fmt.Sprintf("Error formatting message: %v", err))
+			}
+			if s != "" {
+				c.Log("Failed value: " + s)
+			}
 		}
 	}
 


Test patch:
diff --git a/cmd/chains_test.go b/cmd/chains_test.go
new file mode 100644
index 0000000..6e554d3
--- /dev/null
+++ b/cmd/chains_test.go
@@ -0,0 +1,19 @@
+package cmd_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestChainsList_Empty(t *testing.T) {
+	sys := NewSystem(t)
+
+	_ = sys.MustRun(t, "config", "init")
+
+	res := sys.MustRun(t, "chains", "list")
+
+	// Before adding any chains, attempting to list the chains gives a helpful message on stderr.
+	require.Empty(t, res.Stdout.String())
+	require.Contains(t, res.Stderr.String(), "no chains found")
+}
diff --git a/cmd/system_test.go b/cmd/system_test.go
new file mode 100644
index 0000000..962a5b6
--- /dev/null
+++ b/cmd/system_test.go
@@ -0,0 +1,87 @@
+package cmd_test
+
+import (
+	"bytes"
+	"io"
+	"testing"
+
+	"github.com/cosmos/relayer/cmd"
+)
+
+// System is a system under test.
+type System struct {
+	// Temporary directory to be injected as --home argument.
+	HomeDir string
+}
+
+// NewSystem creates a new system with a home dir associated with a temp dir belonging to t.
+//
+// The returned System does not store a reference to t;
+// some of its methods expect a *testing.T as an argument.
+// This allows creating one instance of System to be shared with subtests.
+func NewSystem(t *testing.T) *System {
+	t.Helper()
+
+	homeDir := t.TempDir()
+
+	return &System{
+		HomeDir: homeDir,
+	}
+}
+
+// RunResult is the stdout and stderr resulting from a call to (*System).Run,
+// and any error that was returned.
+type RunResult struct {
+	Stdout, Stderr bytes.Buffer
+
+	Err error
+}
+
+// Run calls s.RunWithInput with an empty stdin.
+func (s *System) Run(args ...string) RunResult {
+	return s.RunWithInput(bytes.NewReader(nil), args...)
+}
+
+// RunWithInput executes the root command with the given args,
+// providing in as the command's standard input,
+// and returns a RunResult that has its Stdout and Stderr populated.
+func (s *System) RunWithInput(in io.Reader, args ...string) RunResult {
+	rootCmd := cmd.NewRootCmd()
+	rootCmd.SetIn(in)
+	// cmd.Execute also sets SilenceUsage,
+	// so match that here for more correct assertions.
+	rootCmd.SilenceUsage = true
+
+	var res RunResult
+	rootCmd.SetOutput(&res.Stdout)
+	rootCmd.SetErr(&res.Stderr)
+
+	// Prepend the system's home directory to any provided args.
+	args = append([]string{"--home", s.HomeDir}, args...)
+	rootCmd.SetArgs(args)
+
+	res.Err = rootCmd.Execute()
+	return res
+}
+
+// MustRun calls Run, but also calls t.Fatal if RunResult.Err is not nil.
+func (s *System) MustRun(t *testing.T, args ...string) RunResult {
+	t.Helper()
+
+	return s.MustRunWithInput(t, bytes.NewReader(nil), args...)
+}
+
+// MustRunWithInput calls RunWithInput, but also calls t.Fatal if RunResult.Err is not nil.
+func (s *System) MustRunWithInput(t *testing.T, in io.Reader, args ...string) RunResult {
+	t.Helper()
+
+	res := s.RunWithInput(in, args...)
+	if res.Err != nil {
+		t.Logf("Error executing %v: %v", args, res.Err)
+		t.Logf("Stdout: %q", res.Stdout.String())
+		t.Logf("Stderr: %q", res.Stderr.String())
+		t.FailNow()
+	}
+
+	return res
+}
diff --git a/test/test_setup.go b/test/test_setup.go
index 2660221..6e86e85 100644
--- a/test/test_setup.go
+++ b/test/test_setup.go
@@ -22,6 +22,7 @@ import (
 	sdked25519 "github.com/cosmos/cosmos-sdk/crypto/keys/ed25519"
 	sdkcryptotypes "github.com/cosmos/cosmos-sdk/crypto/types"
 	tmed25519 "github.com/tendermint/tendermint/crypto/ed25519"
+	"github.com/tendermint/tendermint/libs/log"
 	"github.com/tendermint/tendermint/privval"
 )
 
@@ -133,7 +134,8 @@ func spinUpTestContainer(rchan chan<- *dockertest.Resource, pool *dockertest.Poo
 	}
 
 	// initialize the chain
-	c.Init(nil, debug)
+	// TODO: is there a better logger we can use for tests?
+	c.Init(log.NewTMLogger(log.NewSyncWriter(os.Stderr)), debug)
 
 	// create the test key
 	if err := c.CreateTestKey(); err != nil {