Repository: tetratelabs/getenvoy (go)

Issue:
`getenvoy run` to start the latest version by default
`getenvoy run` requires users to set a target as an argument but target names are verbose. I find myself often having to `getenvoy list` to copy/paste the target name in development time. Would it be reasonable if `getenvoy run` was running the latest version compatible to the host platform by default? If users want to use specific a version/platform, they can keep passing an argument.

Pull request:
Removes need to specify an envoy version when running
This removes the version argument so that you can `getenvoy run`
without knowing which version you want first.

Ex.
```bash
$ getenvoy run --config-yaml "admin: {access_log_path: '/dev/stdout', address: {socket_address: {address: '127.0.0.1', port_value: 0}}}"
looking up latest version
downloading https://dl.getenvoy.io/public/raw/files/getenvoy-envoy-1.18.3.p0.g98c1c9e-1p77.gb76c773-darwin-release-x86_64.tar.xz
--snip--
```

The version of Envoy run is chosen from $ENVOY_VERSION (value),
$PWD/.envoy-version (file contents), $GETENVOY_HOME/version (file
contents).

The ENV allows people who really want to control the version to do so,
and without effecting the arg parsing of `envoy`.

When first run $GETENVOY_HOME/version is initialized the the remote
version stored as "lastestVersion" in the envoy-versions.json file.
Specifically, that's here: https://getenvoy.io/envoy-versions.json

To ensure arguments don't interfere (ex. envoy also has a '-h'), this
removes the '-h' argument from `getenvoy`, replaced with a `help`
command that is run by default and can be command-scoped.

Ex. `getenvoy` or `getenvoy help` show help, and `getenvoy help run` is
scoped as you'd expect.

Fixes #106

Patch:
diff --git a/internal/cmd/app.go b/internal/cmd/app.go
index ba0942f..cb14172 100644
--- a/internal/cmd/app.go
+++ b/internal/cmd/app.go
@@ -18,7 +18,6 @@ import (
 	"net/url"
 	"os/user"
 	"path/filepath"
-	"regexp"
 
 	"github.com/urfave/cli/v2"
 
@@ -34,7 +33,6 @@ func NewApp(o *globals.GlobalOpts) *cli.App {
 	app := cli.NewApp()
 	app.Name = "getenvoy"
 	app.HelpName = "getenvoy"
-	app.HideHelpCommand = true
 	app.Usage = `Install and run Envoy`
 	app.Version = version.GetEnvoy
 	app.Flags = []cli.Flag{
@@ -66,7 +64,9 @@ func NewApp(o *globals.GlobalOpts) *cli.App {
 		return setEnvoyVersionsURL(o, envoyVersionsURL)
 	}
 
+	app.HideHelp = true
 	app.Commands = []*cli.Command{
+		helpCommand,
 		NewRunCmd(o),
 		NewVersionsCmd(o),
 		NewInstallCmd(o),
@@ -76,6 +76,20 @@ func NewApp(o *globals.GlobalOpts) *cli.App {
 	return app
 }
 
+// helpCommand allows us to hide the global flags which cleans up help and markdown
+var helpCommand = &cli.Command{
+	Name:      "help",
+	Usage:     "Shows how to use a [command]",
+	ArgsUsage: "[command]",
+	Action: func(c *cli.Context) error {
+		args := c.Args()
+		if args.Present() {
+			return cli.ShowCommandHelp(c, args.First())
+		}
+		return cli.ShowAppHelp(c)
+	},
+}
+
 func setEnvoyVersionsURL(o *globals.GlobalOpts, versionsURL string) error {
 	if o.EnvoyVersionsURL != "" { // overridden for tests
 		return nil
@@ -111,14 +125,3 @@ func setHomeDir(o *globals.GlobalOpts, homeDir string) error {
 	}
 	return nil
 }
-
-func validateVersionArg(c *cli.Context) error {
-	if c.NArg() == 0 {
-		return NewValidationError("missing <version> argument")
-	}
-	v := c.Args().First()
-	if matched, _ := regexp.MatchString("^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$", v); !matched {
-		return NewValidationError("invalid <version> argument: %q should look like %q", v, version.LastKnownEnvoy)
-	}
-	return nil
-}
diff --git a/internal/cmd/install.go b/internal/cmd/install.go
index af0e4fd..df9c0b8 100644
--- a/internal/cmd/install.go
+++ b/internal/cmd/install.go
@@ -28,10 +28,10 @@ import (
 func NewInstallCmd(o *globals.GlobalOpts) *cli.Command {
 	return &cli.Command{
 		Name:      "install",
-		Usage:     "Download and install a <version> of Envoy",
-		ArgsUsage: "<version>",
-		Description: fmt.Sprintf(`The '<version>' is from the "versions" command.
-The Envoy <version> will be installed into $GETENVOY_HOME/versions/<version>
+		Usage:     "Download and install a [version] of Envoy",
+		ArgsUsage: "[version]",
+		Description: fmt.Sprintf(`The '[version]' is from the "versions" command.
+The Envoy [version] will be installed into $GETENVOY_HOME/versions/[version]
 
 Example:
 $ getenvoy install %s`, version.LastKnownEnvoy),
@@ -42,3 +42,14 @@ $ getenvoy install %s`, version.LastKnownEnvoy),
 		},
 	}
 }
+
+func validateVersionArg(c *cli.Context) error {
+	if c.NArg() == 0 {
+		return NewValidationError("missing [version] argument")
+	}
+	v := c.Args().First()
+	if matched := globals.EnvoyVersionPattern.MatchString(v); !matched {
+		return NewValidationError("invalid [version] argument: %q should look like %q", v, version.LastKnownEnvoy)
+	}
+	return nil
+}
diff --git a/internal/cmd/run.go b/internal/cmd/run.go
index b272f5f..c5f1060 100644
--- a/internal/cmd/run.go
+++ b/internal/cmd/run.go
@@ -26,25 +26,44 @@ import (
 	"github.com/tetratelabs/getenvoy/internal/envoy"
 	"github.com/tetratelabs/getenvoy/internal/envoy/debug"
 	"github.com/tetratelabs/getenvoy/internal/globals"
-	latestversion "github.com/tetratelabs/getenvoy/internal/version"
 )
 
 // NewRunCmd create a command responsible for starting an Envoy process
 func NewRunCmd(o *globals.GlobalOpts) *cli.Command {
+	var envoyVersion string
 	cmd := &cli.Command{
-		Name:      "run",
-		Usage:     "Run Envoy as <version> with <args> as arguments, collecting process state on termination",
-		ArgsUsage: "<version> <args>",
-		Description: fmt.Sprintf(`The '<version>' is from the "versions" command and installed if necessary.
-The '<args>' are interpreted by Envoy.
-The Envoy working directory is archived as $GETENVOY_HOME/runs/$epochtime.tar.gz upon termination.
+		Name:            "run",
+		Usage:           "Run Envoy with the given [arguments...], collecting process state on termination",
+		ArgsUsage:       "[arguments...]",
+		SkipFlagParsing: true,
+		Description: fmt.Sprintf(`The version of Envoy run is chosen from %s
+The '[arguments...]' are interpreted by Envoy.
+
+Envoy uses $GETENVOY_HOME/runs/$epochtime as the working directory.
+Upon termination, this is archived as $GETENVOY_HOME/runs/$epochtime.tar.gz.
 
 Example:
-$ getenvoy run %s --config-path ./bootstrap.yaml`, latestversion.LastKnownEnvoy),
-		Before: validateVersionArg,
+$ getenvoy run -c ./bootstrap.yaml`, envoy.VersionUsageList()),
+		Before: func(context *cli.Context) error {
+			if err := os.MkdirAll(o.HomeDir, 0750); err != nil {
+				return NewValidationError(err.Error())
+			}
+
+			if o.EnvoyVersion == "" { // not overridden for tests
+				if err := setHomeEnvoyVersion(o); err != nil {
+					return err
+				}
+				v, _, err := envoy.CurrentVersion(o.HomeDir)
+				if err != nil {
+					return NewValidationError(err.Error())
+				}
+				o.EnvoyVersion = v
+			}
+			envoyVersion = o.EnvoyVersion
+			return nil
+		},
 		Action: func(c *cli.Context) error {
-			args := c.Args().Slice()
-			if err := initializeRunOpts(o, globals.CurrentPlatform, args[0]); err != nil {
+			if err := initializeRunOpts(o, globals.CurrentPlatform, envoyVersion); err != nil {
 				return err
 			}
 			r := envoy.NewRuntime(&o.RunOpts)
@@ -56,7 +75,7 @@ $ getenvoy run %s --config-path ./bootstrap.yaml`, latestversion.LastKnownEnvoy)
 				fmt.Fprintln(r.Out, "failed to enable debug option:", err) //nolint
 			}
 
-			return r.Run(c.Context, args[1:])
+			return r.Run(c.Context, c.Args().Slice())
 		},
 	}
 	return cmd
@@ -87,3 +106,22 @@ func initializeRunOpts(o *globals.GlobalOpts, platform, version string) error {
 	}
 	return nil
 }
+
+// setHomeEnvoyVersion makes sure the $GETENVOY_HOME/version exists.
+func setHomeEnvoyVersion(o *globals.GlobalOpts) error {
+	v, homeVersionFile, err := envoy.GetHomeVersion(o.HomeDir)
+	if err != nil {
+		return NewValidationError(err.Error())
+	} else if v != "" { // home version is already valid
+		return nil
+	}
+
+	// First time install: look up the latest version, which may be newer than version.LastKnownEnvoy!
+	fmt.Fprintln(o.Out, "looking up latest version") //nolint
+	m, err := envoy.GetEnvoyVersions(o.EnvoyVersionsURL, o.UserAgent)
+	if err != nil {
+		return NewValidationError(`couldn't read latest version from %s: %s`, o.EnvoyVersionsURL, err)
+	}
+	// Persist it for the next invocation
+	return os.WriteFile(homeVersionFile, []byte(m.LatestVersion), 0600)
+}
diff --git a/internal/envoy/version.go b/internal/envoy/version.go
new file mode 100644
index 0000000..8473bb3
--- /dev/null
+++ b/internal/envoy/version.go
@@ -0,0 +1,97 @@
+// Copyright 2021 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package envoy
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/tetratelabs/getenvoy/internal/globals"
+	"github.com/tetratelabs/getenvoy/internal/version"
+)
+
+var (
+	currentVersionVar            = "$ENVOY_VERSION"
+	currentVersionWorkingDirFile = filepath.Join("$PWD", ".envoy-version")
+	currentVersionHomeDirFile    = filepath.Join("$GETENVOY_HOME", "version")
+)
+
+// GetHomeVersion returns the default version in the "homeDir" and path to to it (homeVersionFile). When "v" is empty,
+// homeVersionFile is not yet initialized.
+func GetHomeVersion(homeDir string) (v, homeVersionFile string, err error) {
+	v, homeVersionFile, err = getHomeVersion(homeDir)
+	if err == nil && v == "" { // no home version, yet
+		return
+	}
+	err = verifyVersion(v, currentVersionHomeDirFile, err)
+	return
+}
+
+// CurrentVersion returns the first version in priority of VersionUsageList and its source or an error. The "source"
+// and error messages returned include unexpanded variables to clarify the intended context.
+func CurrentVersion(homeDir string) (v, source string, err error) {
+	v, source, err = getCurrentVersion(homeDir)
+	err = verifyVersion(v, source, err)
+	return
+}
+
+func verifyVersion(v, source string, err error) error {
+	if err != nil {
+		return fmt.Errorf("couldn't read version from %s: %w", source, err)
+	}
+	if matched := globals.EnvoyVersionPattern.MatchString(v); !matched {
+		return fmt.Errorf("invalid version in %q: %q should look like %q", source, v, version.LastKnownEnvoy)
+	}
+	return nil
+}
+
+func getCurrentVersion(homeDir string) (v, source string, err error) {
+	// Priority 1: $ENVOY_VERSION
+	if ev, ok := os.LookupEnv("ENVOY_VERSION"); ok {
+		return ev, currentVersionVar, nil
+	}
+
+	// Priority 2: $PWD/.envoy-version
+	data, err := os.ReadFile(".envoy-version")
+	if err == nil {
+		return string(data), currentVersionWorkingDirFile, nil
+	} else if !os.IsNotExist(err) {
+		return "", currentVersionWorkingDirFile, err
+	}
+
+	// Priority 3: $GETENVOY_HOME/version
+	source = currentVersionHomeDirFile
+	v, _, err = getHomeVersion(homeDir)
+	return
+}
+
+func getHomeVersion(homeDir string) (v, homeVersionFile string, err error) {
+	homeVersionFile = filepath.Join(homeDir, "version")
+	var data []byte
+	if data, err = os.ReadFile(homeVersionFile); err == nil {
+		v = string(data)
+	} else if os.IsNotExist(err) {
+		err = nil // ok on file-not-found
+	}
+	return
+}
+
+// VersionUsageList is the priority order of Envoy version sources.
+// This includes unresolved variables as it is both used statically for markdown generation, and also at runtime.
+func VersionUsageList() string {
+	return strings.Join([]string{currentVersionVar, currentVersionWorkingDirFile, currentVersionHomeDirFile}, ", ")
+}
diff --git a/internal/globals/globals.go b/internal/globals/globals.go
index 5b00fd6..6ac5b34 100644
--- a/internal/globals/globals.go
+++ b/internal/globals/globals.go
@@ -17,6 +17,7 @@ package globals
 import (
 	"fmt"
 	"io"
+	"regexp"
 	"runtime"
 
 	"github.com/tetratelabs/getenvoy/internal/version"
@@ -46,7 +47,10 @@ type GlobalOpts struct {
 	RunOpts
 	// EnvoyVersionsURL is the path to the envoy-versions.json. Defaults to DefaultEnvoyVersionsURL
 	EnvoyVersionsURL string
-	// HomeDir most importantly contains Envoy versions installed from EnvoyVersionsURL. Defaults to DefaultHomeDir
+	// EnvoyVersion is the default version of Envoy to run. Defaults to the contents of "$HomeDir/versions/version".
+	// When that file is missing, it is generated from ".latestVersion" from the EnvoyVersionsURL.
+	EnvoyVersion string
+	// HomeDir is an absolute path which most importantly contains "versions" installed from EnvoyVersionsURL. Defaults to DefaultHomeDir
 	HomeDir string
 	// UserAgent is the "User-Agent" header added to all HTTP requests. Defaults to DefaultUserAgent
 	UserAgent string
@@ -62,6 +66,8 @@ const (
 )
 
 var (
+	// EnvoyVersionPattern is used to validate versions and is the same pattern as envoy-versions-schema.json.
+	EnvoyVersionPattern = regexp.MustCompile(`^[1-9][0-9]*\.[0-9]+\.[0-9]+$`)
 	// CurrentPlatform is the platform of the current process. This is used as a key in EnvoyVersion.Tarballs.
 	CurrentPlatform = runtime.GOOS + "/" + runtime.GOARCH
 	// DefaultUserAgent is the default value for GlobalOpts.UserAgent.
diff --git a/main.go b/main.go
index 3b6eb19..c90dd56 100644
--- a/main.go
+++ b/main.go
@@ -57,5 +57,5 @@ func run(stdout, stderr io.Writer, args []string) int {
 }
 
 func logUsageError(name string, stderr io.Writer) {
-	fmt.Fprintln(stderr, "show usage with:", name, "-h") //nolint
+	fmt.Fprintln(stderr, "show usage with:", name, "help") //nolint
 }


Test patch:
diff --git a/e2e/getenvoy_run_test.go b/e2e/getenvoy_run_test.go
index 7d32c61..20bc77c 100644
--- a/e2e/getenvoy_run_test.go
+++ b/e2e/getenvoy_run_test.go
@@ -26,7 +26,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/tetratelabs/getenvoy/internal/tar"
-	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
 const terminateTimeout = 2 * time.Minute
@@ -39,8 +38,7 @@ func TestGetEnvoyRun(t *testing.T) {
 
 	c := getEnvoy(`run`)
 	// Below is the minimal config needed to run envoy
-	// TODO allow implicit version #106
-	c.args(version.LastKnownEnvoy, `--config-yaml`, `admin: {access_log_path: '/dev/stdout', address: {socket_address: {address: '127.0.0.1', port_value: 0}}}`)
+	c.args("--config-yaml", "admin: {access_log_path: '/dev/stdout', address: {socket_address: {address: '127.0.0.1', port_value: 0}}}")
 
 	stdout, stderr, terminate := c.start(t, terminateTimeout)
 
diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go
index ce57d62..afe6ef9 100644
--- a/internal/cmd/app_test.go
+++ b/internal/cmd/app_test.go
@@ -77,7 +77,7 @@ func TestGetEnvoyHomeDir(t *testing.T) {
 			name: "GETENVOY_HOME env",
 			args: []string{"getenvoy"},
 			setup: func() func() {
-				return requireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
+				return morerequire.RequireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
 			},
 			expected: "/from/GETENVOY_HOME/env",
 		},
@@ -90,7 +90,7 @@ func TestGetEnvoyHomeDir(t *testing.T) {
 			name: "prioritizes --home-dir arg over GETENVOY_HOME env",
 			args: []string{"getenvoy", "--home-dir", "/from/home-dir/arg"},
 			setup: func() func() {
-				return requireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
+				return morerequire.RequireSetenv(t, "GETENVOY_HOME", "/from/GETENVOY_HOME/env")
 			},
 			expected: "/from/home-dir/arg",
 		},
@@ -133,7 +133,7 @@ func TestEnvoyVersionsURL(t *testing.T) {
 			name: "ENVOY_VERSIONS_URL env",
 			args: []string{"getenvoy"},
 			setup: func() func() {
-				return requireSetenv(t, "ENVOY_VERSIONS_URL", "http://ENVOY_VERSIONS_URL/env")
+				return morerequire.RequireSetenv(t, "ENVOY_VERSIONS_URL", "http://ENVOY_VERSIONS_URL/env")
 			},
 			expected: "http://ENVOY_VERSIONS_URL/env",
 		},
@@ -146,7 +146,7 @@ func TestEnvoyVersionsURL(t *testing.T) {
 			name: "prioritizes --envoy-versions-url arg over ENVOY_VERSIONS_URL env",
 			args: []string{"getenvoy", "--envoy-versions-url", "http://versions/arg"},
 			setup: func() func() {
-				return requireSetenv(t, "ENVOY_VERSIONS_URL", "http://ENVOY_VERSIONS_URL/env")
+				return morerequire.RequireSetenv(t, "ENVOY_VERSIONS_URL", "http://ENVOY_VERSIONS_URL/env")
 			},
 			expected: "http://versions/arg",
 		},
@@ -204,17 +204,6 @@ func TestGetEnvoyUserAgent(t *testing.T) {
 	}
 }
 
-// requireSetenv will os.Setenv the given key and value. The function returned reverts to the original.
-func requireSetenv(t *testing.T, key, value string) func() {
-	previous := os.Getenv(key)
-	err := os.Setenv(key, value)
-	require.NoError(t, err, `error setting env variable %s=%s`, key, value)
-	return func() {
-		err := os.Setenv(key, previous)
-		require.NoError(t, err, `error reverting env variable %s=%s`, key, previous)
-	}
-}
-
 // newApp initializes a command with buffers for stdout and stderr.
 func newApp(o *globals.GlobalOpts) (c *cli.App, stdout, stderr *bytes.Buffer) {
 	stdout = new(bytes.Buffer)
@@ -244,6 +233,7 @@ func runTestCommand(t *testing.T, o *globals.GlobalOpts, args []string) error {
 // The tear-down functions reverts side-effects such as temp directories and a fake Envoy versions server.
 func setupTest(t *testing.T) (*globals.GlobalOpts, func()) {
 	result := globals.GlobalOpts{}
+	result.EnvoyVersion = version.LastKnownEnvoy
 	result.Out = io.Discard // ignore logging by default
 	var tearDown []func()
 
diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go
index 62c2be2..96a8cea 100644
--- a/internal/cmd/help_test.go
+++ b/internal/cmd/help_test.go
@@ -32,9 +32,9 @@ func TestGetEnvoyHelp(t *testing.T) {
 		command := command
 		t.Run(command, func(t *testing.T) {
 			c, stdout, _ := newApp(&globals.GlobalOpts{})
-			args := []string{"getenvoy", "-h"}
+			args := []string{"getenvoy"}
 			if command != "" {
-				args = []string{"getenvoy", command, "-h"}
+				args = []string{"getenvoy", "help", command}
 			}
 			require.NoError(t, c.Run(args))
 
diff --git a/internal/cmd/install_test.go b/internal/cmd/install_test.go
new file mode 100644
index 0000000..71db1ad
--- /dev/null
+++ b/internal/cmd/install_test.go
@@ -0,0 +1,56 @@
+// Copyright 2021 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cmd_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/tetratelabs/getenvoy/internal/version"
+)
+
+func TestGetEnvoyInstall_VersionValidates(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	tests := []struct{ name, version, expectedErr string }{
+		{
+			name:        "version empty",
+			expectedErr: fmt.Sprintf(`invalid [version] argument: "" should look like "%s"`, version.LastKnownEnvoy),
+		},
+		{
+			name:        "version invalid",
+			version:     "a.b.c",
+			expectedErr: fmt.Sprintf(`invalid [version] argument: "a.b.c" should look like "%s"`, version.LastKnownEnvoy),
+		},
+	}
+
+	for _, test := range tests {
+		test := test // pin! see https://github.com/kyoh86/scopelint for why
+
+		t.Run(test.name, func(t *testing.T) {
+			c, stdout, stderr := newApp(o)
+			err := c.Run([]string{"getenvoy", "install", test.version})
+
+			// Verify the command failed with the expected error
+			require.EqualError(t, err, test.expectedErr)
+			// GetEnvoy handles logging of errors, so we expect nothing in stdout or stderr
+			require.Empty(t, stdout)
+			require.Empty(t, stderr)
+		})
+	}
+}
diff --git a/internal/cmd/run_test.go b/internal/cmd/run_test.go
index feccdf5..a494cb7 100644
--- a/internal/cmd/run_test.go
+++ b/internal/cmd/run_test.go
@@ -15,52 +15,19 @@
 package cmd_test
 
 import (
+	"bytes"
 	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/tetratelabs/getenvoy/internal/globals"
+	"github.com/tetratelabs/getenvoy/internal/test/morerequire"
 	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
-func TestGetEnvoyRunValidateFlag(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
-
-	tests := []struct {
-		name        string
-		args        []string
-		expectedErr string
-	}{
-		{
-			name:        "arg[0] missing",
-			args:        []string{"getenvoy", "run"},
-			expectedErr: `missing <version> argument`,
-		},
-		{
-			name:        "arg[0] with invalid version",
-			args:        []string{"getenvoy", "run", "unknown"},
-			expectedErr: fmt.Sprintf(`invalid <version> argument: "unknown" should look like "%s"`, version.LastKnownEnvoy),
-		},
-	}
-
-	for _, test := range tests {
-		test := test // pin! see https://github.com/kyoh86/scopelint for why
-
-		t.Run(test.name, func(t *testing.T) {
-			// Run "getenvoy run"
-			c, stdout, stderr := newApp(o)
-			err := c.Run(test.args)
-
-			// Verify the command failed with the expected error
-			require.EqualError(t, err, test.expectedErr)
-			// GetEnvoy handles logging of errors, so we expect nothing in stdout or stderr
-			require.Empty(t, stdout)
-			require.Empty(t, stderr)
-		})
-	}
-}
-
 func TestGetEnvoyRun(t *testing.T) {
 	tests := []struct {
 		name              string
@@ -69,15 +36,11 @@ func TestGetEnvoyRun(t *testing.T) {
 	}{
 		{
 			name: "no envoy args",
-			args: []string{"getenvoy", "run", version.LastKnownEnvoy},
-		},
-		{
-			name: "empty envoy args",
-			args: []string{"getenvoy", "run", version.LastKnownEnvoy},
+			args: []string{"getenvoy", "run"},
 		},
 		{
 			name:              "envoy args",
-			args:              []string{"getenvoy", "run", version.LastKnownEnvoy, "-c", "envoy.yaml"},
+			args:              []string{"getenvoy", "run", "-c", "envoy.yaml"},
 			expectedEnvoyArgs: ` -c envoy.yaml`,
 		},
 	}
@@ -89,7 +52,6 @@ func TestGetEnvoyRun(t *testing.T) {
 			o, cleanup := setupTest(t)
 			defer cleanup()
 
-			// Run "getenvoy run 1.18.3 -c envoy.yaml"
 			c, stdout, stderr := newApp(o)
 			err := c.Run(test.args)
 
@@ -109,19 +71,90 @@ envoy args:%[3]s --admin-address-path admin-address.txt`, o.WorkingDir, o.EnvoyP
 	}
 }
 
-func TestGetEnvoyRunFailWithUnknownVersion(t *testing.T) {
+func TestGetEnvoyRun_ReadsHomeVersionFile(t *testing.T) {
 	o, cleanup := setupTest(t)
+	o.EnvoyVersion = "" // pretend this is an initial setup
+	o.Out = new(bytes.Buffer)
 	defer cleanup()
 
-	o.EnvoyPath = "" // force lookup of version flag
-	c, stdout, stderr := newApp(o)
+	require.NoError(t, os.WriteFile(filepath.Join(o.HomeDir, "version"), []byte(version.LastKnownEnvoy), 0600))
+
+	c, _, _ := newApp(o)
+	require.NoError(t, c.Run([]string{"getenvoy", "run"}))
 
-	// Run "getenvoy run unknown"
-	err := c.Run([]string{"getenvoy", "run", "unknown"})
+	// No implicit lookup
+	require.NotContains(t, o.Out.(*bytes.Buffer).String(), "looking up latest version\n")
+	require.Equal(t, version.LastKnownEnvoy, o.EnvoyVersion)
+}
+
+func TestGetEnvoyRun_CreatesHomeVersionFile(t *testing.T) {
+	o, cleanup := setupTest(t)
+	o.EnvoyVersion = "" // pretend this is an initial setup
+	o.Out = new(bytes.Buffer)
+	defer cleanup()
+
+	// make sure first run where the home doesn't exist yet, works!
+	require.NoError(t, os.RemoveAll(o.HomeDir))
+
+	c, _, _ := newApp(o)
+	require.NoError(t, c.Run([]string{"getenvoy", "run"}))
+
+	// We logged the implicit lookup
+	require.Contains(t, o.Out.(*bytes.Buffer).String(), "looking up latest version\n")
+	require.FileExists(t, filepath.Join(o.HomeDir, "version"))
+	require.Equal(t, version.LastKnownEnvoy, o.EnvoyVersion)
+}
+
+func TestGetEnvoyRun_ValidatesHomeVersion(t *testing.T) {
+	o, cleanup := setupTest(t)
+	o.Out = new(bytes.Buffer)
+	defer cleanup()
+
+	o.EnvoyVersion = ""
+	require.NoError(t, os.WriteFile(filepath.Join(o.HomeDir, "version"), []byte("a.a.a"), 0600))
+
+	c, _, _ := newApp(o)
+	err := c.Run([]string{"getenvoy", "run"})
+
+	// Verify the command failed with the expected error
+	require.EqualError(t, err, fmt.Sprintf(`invalid version in "$GETENVOY_HOME/version": "a.a.a" should look like "%s"`, version.LastKnownEnvoy))
+}
+
+// TestGetEnvoyRun_ValidatesWorkingVersion duplicates logic in version_test.go to ensure a non-home version validates.
+func TestGetEnvoyRun_ValidatesWorkingVersion(t *testing.T) {
+	o, cleanup := setupTest(t)
+	o.Out = new(bytes.Buffer)
+	defer cleanup()
+
+	o.EnvoyVersion = ""
+	wd, err := os.Getwd()
+	require.NoError(t, err)
+	defer os.Chdir(wd) //nolint
+
+	tempDir, removeTempDir := morerequire.RequireNewTempDir(t)
+	defer removeTempDir()
+	require.NoError(t, os.Chdir(tempDir))
+	require.NoError(t, os.WriteFile(".envoy-version", []byte("b.b.b"), 0600))
+
+	c, _, _ := newApp(o)
+	err = c.Run([]string{"getenvoy", "run"})
+
+	// Verify the command failed with the expected error
+	require.EqualError(t, err, fmt.Sprintf(`invalid version in "$PWD/.envoy-version": "b.b.b" should look like "%s"`, version.LastKnownEnvoy))
+}
+
+func TestGetEnvoyRun_ErrsWhenVersionsServerDown(t *testing.T) {
+	tempDir, deleteTempDir := morerequire.RequireNewTempDir(t)
+	defer deleteTempDir()
+
+	o := &globals.GlobalOpts{
+		EnvoyVersionsURL: "https://127.0.0.1:9999",
+		HomeDir:          tempDir,
+		Out:              new(bytes.Buffer),
+	}
+	c, _, _ := newApp(o)
+	err := c.Run([]string{"getenvoy", "run"})
 
-	// Verify the command failed with the expected error.
-	require.EqualError(t, err, fmt.Sprintf(`invalid <version> argument: "unknown" should look like "%s"`, version.LastKnownEnvoy))
-	// GetEnvoy handles logging of errors, so we expect nothing in stdout or stderr
-	require.Empty(t, stdout)
-	require.Empty(t, stderr)
+	require.Contains(t, o.Out.(*bytes.Buffer).String(), "looking up latest version\n")
+	require.Contains(t, err.Error(), fmt.Sprintf(`couldn't read latest version from %s`, o.EnvoyVersionsURL))
 }
diff --git a/internal/cmd/testdata/getenvoy.md b/internal/cmd/testdata/getenvoy.md
index a10ff69..9915592 100644
--- a/internal/cmd/testdata/getenvoy.md
+++ b/internal/cmd/testdata/getenvoy.md
@@ -8,7 +8,6 @@ getenvoy
 
 ```
 [--envoy-versions-url]=[value]
-[--help|-h]
 [--home-dir]=[value]
 [--version|-v]
 ```
@@ -23,8 +22,6 @@ getenvoy [GLOBAL OPTIONS] command [COMMAND OPTIONS] [ARGUMENTS...]
 
 **--envoy-versions-url**="": URL of Envoy versions JSON
 
-**--help, -h**: show help
-
 **--home-dir**="": GetEnvoy home directory (location of installed versions and run archives)
 
 **--version, -v**: print the version
@@ -32,9 +29,13 @@ getenvoy [GLOBAL OPTIONS] command [COMMAND OPTIONS] [ARGUMENTS...]
 
 # COMMANDS
 
+## help
+
+Shows how to use a [command]
+
 ## run
 
-Run Envoy as <version> with <args> as arguments, collecting process state on termination
+Run Envoy with the given [arguments...], collecting process state on termination
 
 ## versions
 
@@ -42,7 +43,7 @@ List available Envoy versions
 
 ## install
 
-Download and install a <version> of Envoy
+Download and install a [version] of Envoy
 
 ## installed
 
diff --git a/internal/cmd/testdata/getenvoy_help.txt b/internal/cmd/testdata/getenvoy_help.txt
index 37aa7a4..0fab38b 100644
--- a/internal/cmd/testdata/getenvoy_help.txt
+++ b/internal/cmd/testdata/getenvoy_help.txt
@@ -8,13 +8,13 @@ VERSION:
    dev
 
 COMMANDS:
-   run        Run Envoy as <version> with <args> as arguments, collecting process state on termination
+   help       Shows how to use a [command]
+   run        Run Envoy with the given [arguments...], collecting process state on termination
    versions   List available Envoy versions
-   install    Download and install a <version> of Envoy
+   install    Download and install a [version] of Envoy
    installed  List installed Envoy versions
 
 GLOBAL OPTIONS:
    --home-dir value            GetEnvoy home directory (location of installed versions and run archives) (default: ${HOME}/.getenvoy) [$GETENVOY_HOME]
    --envoy-versions-url value  URL of Envoy versions JSON (default: https://getenvoy.io/envoy-versions.json) [$ENVOY_VERSIONS_URL]
-   --help, -h                  show help (default: false)
    --version, -v               print the version (default: false)
diff --git a/internal/cmd/testdata/getenvoy_install_help.txt b/internal/cmd/testdata/getenvoy_install_help.txt
index bc15801..a068178 100644
--- a/internal/cmd/testdata/getenvoy_install_help.txt
+++ b/internal/cmd/testdata/getenvoy_install_help.txt
@@ -1,16 +1,12 @@
 NAME:
-   getenvoy install - Download and install a <version> of Envoy
+   getenvoy install - Download and install a [version] of Envoy
 
 USAGE:
-   getenvoy install [command options] <version>
+   getenvoy install [version]
 
 DESCRIPTION:
-   The '<version>' is from the "versions" command.
-   The Envoy <version> will be installed into $GETENVOY_HOME/versions/<version>
+   The '[version]' is from the "versions" command.
+   The Envoy [version] will be installed into $GETENVOY_HOME/versions/[version]
    
    Example:
-   $ getenvoy install {ENVOY_VERSION}
-
-OPTIONS:
-   --help, -h  show help (default: false)
-   
+   $ getenvoy install 1.18.3
diff --git a/internal/cmd/testdata/getenvoy_installed_help.txt b/internal/cmd/testdata/getenvoy_installed_help.txt
index 4bbe2c3..5f9196c 100644
--- a/internal/cmd/testdata/getenvoy_installed_help.txt
+++ b/internal/cmd/testdata/getenvoy_installed_help.txt
@@ -2,8 +2,4 @@ NAME:
    getenvoy installed - List installed Envoy versions
 
 USAGE:
-   getenvoy installed [command options] [arguments...]
-
-OPTIONS:
-   --help, -h  show help (default: false)
-   
+   getenvoy installed [arguments...]
diff --git a/internal/cmd/testdata/getenvoy_run_help.txt b/internal/cmd/testdata/getenvoy_run_help.txt
index 1540b50..caefb8d 100644
--- a/internal/cmd/testdata/getenvoy_run_help.txt
+++ b/internal/cmd/testdata/getenvoy_run_help.txt
@@ -1,17 +1,15 @@
 NAME:
-   getenvoy run - Run Envoy as <version> with <args> as arguments, collecting process state on termination
+   getenvoy run - Run Envoy with the given [arguments...], collecting process state on termination
 
 USAGE:
-   getenvoy run [command options] <version> <args>
+   getenvoy run [arguments...]
 
 DESCRIPTION:
-   The '<version>' is from the "versions" command and installed if necessary.
-   The '<args>' are interpreted by Envoy.
-   The Envoy working directory is archived as $GETENVOY_HOME/runs/$epochtime.tar.gz upon termination.
+   The version of Envoy run is chosen from $ENVOY_VERSION, $PWD/.envoy-version, $GETENVOY_HOME/version
+   The '[arguments...]' are interpreted by Envoy.
    
-   Example:
-   $ getenvoy run {ENVOY_VERSION} --config-path ./bootstrap.yaml
-
-OPTIONS:
-   --help, -h  show help (default: false)
+   Envoy uses $GETENVOY_HOME/runs/$epochtime as the working directory.
+   Upon termination, this is archived as $GETENVOY_HOME/runs/$epochtime.tar.gz.
    
+   Example:
+   $ getenvoy run -c ./bootstrap.yaml
diff --git a/internal/cmd/testdata/getenvoy_versions_help.txt b/internal/cmd/testdata/getenvoy_versions_help.txt
index 1e84cd1..4d607a5 100644
--- a/internal/cmd/testdata/getenvoy_versions_help.txt
+++ b/internal/cmd/testdata/getenvoy_versions_help.txt
@@ -2,8 +2,4 @@ NAME:
    getenvoy versions - List available Envoy versions
 
 USAGE:
-   getenvoy versions [command options] [arguments...]
-
-OPTIONS:
-   --help, -h  show help (default: false)
-   
+   getenvoy versions [arguments...]
diff --git a/internal/envoy/version_test.go b/internal/envoy/version_test.go
new file mode 100644
index 0000000..b68f564
--- /dev/null
+++ b/internal/envoy/version_test.go
@@ -0,0 +1,156 @@
+// Copyright 2021 Tetrate
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package envoy
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/tetratelabs/getenvoy/internal/test/morerequire"
+	"github.com/tetratelabs/getenvoy/internal/version"
+)
+
+func TestVersionUsageList(t *testing.T) {
+	require.Equal(t, "$ENVOY_VERSION, $PWD/.envoy-version, $GETENVOY_HOME/version", VersionUsageList())
+}
+
+func TestGetHomeVersion(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	removeHomeDir() // delete the home directory
+
+	t.Run("doesn't error on no home dir", func(t *testing.T) {
+		v, homeVersionFile, err := GetHomeVersion(homeDir)
+		require.Empty(t, v)
+		require.Equal(t, filepath.Join(homeDir, "version"), homeVersionFile)
+		require.NoError(t, err)
+	})
+
+	homeDir, removeHomeDir = morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+
+	t.Run("doesn't error on no home version file", func(t *testing.T) {
+		v, homeVersionFile, err := GetHomeVersion(homeDir)
+		require.Empty(t, v)
+		require.Equal(t, filepath.Join(homeDir, "version"), homeVersionFile)
+		require.NoError(t, err)
+	})
+
+	homeVersionFile := filepath.Join(homeDir, "version")
+	require.NoError(t, os.WriteFile(homeVersionFile, []byte(""), 0600))
+
+	// This could be considered unexpected, but it is less to code to overwrite on this edge-case
+	t.Run("doesn't error on no empty home version file", func(t *testing.T) {
+		v, _, err := GetHomeVersion(homeDir)
+		require.Empty(t, v)
+		require.NoError(t, err)
+	})
+
+	require.NoError(t, os.WriteFile(homeVersionFile, []byte(version.LastKnownEnvoy), 0600))
+	t.Run("reads valid home version file", func(t *testing.T) {
+		v, _, err := GetHomeVersion(homeDir)
+		require.Equal(t, version.LastKnownEnvoy, v)
+		require.NoError(t, err)
+	})
+
+	require.NoError(t, os.WriteFile(homeVersionFile, []byte("a.a.a"), 0600))
+	t.Run("errors on invalid home version file", func(t *testing.T) {
+		_, _, err := GetHomeVersion(homeDir)
+		require.EqualError(t, err, fmt.Sprintf(`invalid version in "$GETENVOY_HOME/version": "a.a.a" should look like "%s"`, version.LastKnownEnvoy))
+	})
+}
+
+func TestCurrentVersion(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+	require.NoError(t, os.WriteFile(filepath.Join(homeDir, "version"), []byte("1.1.1"), 0600))
+
+	t.Run("defaults to home version", func(t *testing.T) {
+		v, source, err := CurrentVersion(homeDir)
+		require.Equal(t, "1.1.1", v)
+		require.Equal(t, "$GETENVOY_HOME/version", source)
+		require.NoError(t, err)
+	})
+
+	wd, err := os.Getwd()
+	require.NoError(t, err)
+	defer os.Chdir(wd) //nolint
+
+	tempDir, removeTempDir := morerequire.RequireNewTempDir(t)
+	defer removeTempDir()
+	require.NoError(t, os.Chdir(tempDir))
+	require.NoError(t, os.WriteFile(".envoy-version", []byte("2.2.2"), 0600))
+
+	t.Run("prefers $PWD/.envoy-version over home version", func(t *testing.T) {
+		v, source, err := CurrentVersion(homeDir)
+		require.Equal(t, "2.2.2", v)
+		require.Equal(t, "$PWD/.envoy-version", source)
+		require.NoError(t, err)
+	})
+
+	revert := morerequire.RequireSetenv(t, "ENVOY_VERSION", "3.3.3")
+	defer revert()
+
+	t.Run("prefers $ENVOY_VERSION over $PWD/.envoy-version", func(t *testing.T) {
+		v, source, err := CurrentVersion(homeDir)
+		require.Equal(t, "3.3.3", v)
+		require.Equal(t, "$ENVOY_VERSION", source)
+		require.NoError(t, err)
+	})
+}
+
+func TestCurrentVersion_Validates(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+	require.NoError(t, os.WriteFile(filepath.Join(homeDir, "version"), []byte("a.a.a"), 0600))
+
+	t.Run("validates home version", func(t *testing.T) {
+		_, _, err := CurrentVersion(homeDir)
+		require.EqualError(t, err, fmt.Sprintf(`invalid version in "$GETENVOY_HOME/version": "a.a.a" should look like "%s"`, version.LastKnownEnvoy))
+	})
+
+	wd, err := os.Getwd()
+	require.NoError(t, err)
+	defer os.Chdir(wd) //nolint
+
+	tempDir, removeTempDir := morerequire.RequireNewTempDir(t)
+	defer removeTempDir()
+	require.NoError(t, os.Chdir(tempDir))
+	require.NoError(t, os.WriteFile(".envoy-version", []byte("b.b.b"), 0600))
+
+	t.Run("validates $PWD/.envoy-version", func(t *testing.T) {
+		_, _, err := CurrentVersion(homeDir)
+		require.EqualError(t, err, fmt.Sprintf(`invalid version in "$PWD/.envoy-version": "b.b.b" should look like "%s"`, version.LastKnownEnvoy))
+	})
+
+	require.NoError(t, os.Remove(".envoy-version"))
+	require.NoError(t, os.Mkdir(".envoy-version", 0700))
+
+	t.Run("shows error reading $PWD/.envoy-version", func(t *testing.T) {
+		_, _, err := CurrentVersion(homeDir)
+		require.Contains(t, err.Error(), "couldn't read version from $PWD/.envoy-version")
+	})
+
+	revert := morerequire.RequireSetenv(t, "ENVOY_VERSION", "c.c.c")
+	defer revert()
+
+	t.Run("validates $ENVOY_VERSION", func(t *testing.T) {
+		_, _, err := CurrentVersion(homeDir)
+		require.EqualError(t, err, fmt.Sprintf(`invalid version in "$ENVOY_VERSION": "c.c.c" should look like "%s"`, version.LastKnownEnvoy))
+	})
+}
diff --git a/internal/test/morerequire/morerequire.go b/internal/test/morerequire/morerequire.go
index ed74405..54df12f 100644
--- a/internal/test/morerequire/morerequire.go
+++ b/internal/test/morerequire/morerequire.go
@@ -49,3 +49,18 @@ var (
 func RequireCaptureScript(t *testing.T, path string) {
 	require.NoError(t, os.WriteFile(path, captureScript, 0700)) //nolint:gosec
 }
+
+// RequireSetenv will os.Setenv the given key and value. The function returned reverts to the original.
+func RequireSetenv(t *testing.T, key, value string) func() {
+	previous := os.Getenv(key)
+	err := os.Setenv(key, value)
+	require.NoError(t, err, `error setting env variable %s=%s`, key, value)
+	return func() {
+		if previous != "" {
+			err = os.Setenv(key, previous)
+		} else {
+			err = os.Unsetenv(key)
+		}
+		require.NoError(t, err, `error reverting env variable %s=%s`, key, previous)
+	}
+}
diff --git a/main_test.go b/main_test.go
index e7aae76..65bab6d 100644
--- a/main_test.go
+++ b/main_test.go
@@ -47,7 +47,7 @@ func TestRunErrors(t *testing.T) {
 			args:           []string{"getenvoy", "--d"},
 			expectedStatus: 1,
 			expectedStderr: `flag provided but not defined: -d
-show usage with: getenvoy -h
+show usage with: getenvoy help
 `,
 		},
 		{
@@ -55,7 +55,7 @@ show usage with: getenvoy -h
 			args:           []string{"getenvoy", "--envoy-versions-url", ".", "list"},
 			expectedStatus: 1,
 			expectedStderr: `"." is not a valid Envoy versions URL
-show usage with: getenvoy -h
+show usage with: getenvoy help
 `,
 		},
 		{
@@ -63,7 +63,7 @@ show usage with: getenvoy -h
 			args:           []string{"getenvoy", "fly"},
 			expectedStatus: 1,
 			expectedStderr: `unknown command "fly"
-show usage with: getenvoy -h
+show usage with: getenvoy help
 `,
 		},
 		{