Repository: tetratelabs/getenvoy
Pull request #252 (2021-06-01 06:51:43)

Folds `getenvoy installed` into `getenvoy versions` -a/--all
* `getenvoy install` collides on auto-complete with `getenvoy installed` which is annoying
* `getenvoy installed` output is different than `getenvoy versions` mainly because we didn't have release date locally
* during install, we can persist the "releaseDate" field into the $ENVOY_HOME/versions/$version directory create date
* at that point `getenvoy versions` shows the installed ones and `getenvoy versions -a` shows all including remote, both knowing the release date

See #248

codefromthecrypt: @mathetake  PTAL  to make things work I had to make a second test file. I don't know another way in go because we need `package cmd_test` to access test fixtures, but also need to `package cmd` to avoid exposing internal symbols just for unit tests

Patch:
diff --git a/internal/cmd/app.go b/internal/cmd/app.go
index cb14172..749673f 100644
--- a/internal/cmd/app.go
+++ b/internal/cmd/app.go
@@ -70,7 +70,6 @@ func NewApp(o *globals.GlobalOpts) *cli.App {
 		NewRunCmd(o),
 		NewVersionsCmd(o),
 		NewInstallCmd(o),
-		NewInstalledCmd(o),
 		NewDocCmd(),
 	}
 	return app
diff --git a/internal/cmd/installed.go b/internal/cmd/installed.go
deleted file mode 100644
index 01831f3..0000000
--- a/internal/cmd/installed.go
+++ /dev/null
@@ -1,59 +0,0 @@
-// 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
-
-import (
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-
-	"github.com/urfave/cli/v2"
-
-	"github.com/tetratelabs/getenvoy/internal/globals"
-)
-
-// NewInstalledCmd returns command that lists installed Envoy versions
-func NewInstalledCmd(o *globals.GlobalOpts) *cli.Command {
-	return &cli.Command{
-		Name:  "installed",
-		Usage: "List installed Envoy versions",
-		Action: func(c *cli.Context) error {
-			files, err := os.ReadDir(filepath.Join(o.HomeDir, "versions"))
-			if os.IsNotExist(err) {
-				_, err = fmt.Fprintln(o.Out, "No envoy versions installed, yet")
-				return err
-			} else if err != nil {
-				return err
-			}
-
-			rows := []string{"VERSION"}
-			for _, f := range files {
-				if f.IsDir() {
-					rows = append(rows, f.Name())
-				}
-			}
-
-			// Sort so that new versions appear first
-			sort.Slice(rows, func(i, j int) bool {
-				return rows[i] > rows[j]
-			})
-
-			_, err = fmt.Fprintln(o.Out, strings.Join(rows, "\n"))
-			return err
-		},
-	}
-}
diff --git a/internal/cmd/versions.go b/internal/cmd/versions.go
index 547655a..e758183 100644
--- a/internal/cmd/versions.go
+++ b/internal/cmd/versions.go
@@ -15,24 +15,104 @@
 package cmd
 
 import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"time"
+
 	"github.com/urfave/cli/v2"
 
 	"github.com/tetratelabs/getenvoy/internal/envoy"
 	"github.com/tetratelabs/getenvoy/internal/globals"
+	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
 // NewVersionsCmd returns command that lists available Envoy versions for the current platform.
 func NewVersionsCmd(o *globals.GlobalOpts) *cli.Command {
 	return &cli.Command{
 		Name:  "versions",
-		Usage: "List available Envoy versions",
+		Usage: "List Envoy versions",
+		Flags: []cli.Flag{
+			&cli.BoolFlag{
+				Name:    "all",
+				Aliases: []string{"a"},
+				Usage:   "Show all versions including ones not yet installed",
+			}},
 		Action: func(c *cli.Context) error {
-			m, err := envoy.GetEnvoyVersions(o.EnvoyVersionsURL, o.UserAgent)
+			rows, err := getInstalledVersions(o.HomeDir)
 			if err != nil {
 				return err
 			}
-			envoy.PrintVersions(m, globals.CurrentPlatform, c.App.Writer)
+			if c.Bool("all") {
+				if ev, err := envoy.GetEnvoyVersions(o.EnvoyVersionsURL, o.UserAgent); err != nil {
+					return err
+				} else if err := addAvailableVersions(&rows, ev.Versions, globals.CurrentPlatform); err != nil {
+					return err
+				}
+			}
+
+			if len(rows) == 0 {
+				fmt.Fprintln(c.App.Writer, "No Envoy versions, yet") //nolint
+				return nil
+			}
+
+			// Sort so that new release dates appear first and on conflict choosing the higher version
+			sort.Slice(rows, func(i, j int) bool {
+				if rows[i].releaseDate == rows[j].releaseDate {
+					return rows[i].version > rows[j].version
+				}
+				return rows[i].releaseDate > rows[j].releaseDate
+			})
+
+			// This doesn't use tabwriter because the columns are likely to remain the same width.
+			fmt.Fprintln(c.App.Writer, "VERSION\tRELEASE_DATE") //nolint
+			for _, vr := range rows {                           //nolint:gocritic
+				fmt.Fprintf(c.App.Writer, "%s\t%s\n", vr.version, vr.releaseDate) //nolint
+			}
 			return nil
 		},
 	}
 }
+
+type versionReleaseDate struct {
+	// version ex "1.15.5"
+	version string
+	// releaseDate ex "2021-05-11"
+	releaseDate string
+}
+
+func getInstalledVersions(homeDir string) ([]versionReleaseDate, error) {
+	var rows []versionReleaseDate
+	files, err := os.ReadDir(filepath.Join(homeDir, "versions"))
+	if os.IsNotExist(err) {
+		return rows, nil
+	} else if err != nil {
+		return nil, err
+	}
+
+	for _, f := range files {
+		if i, err := f.Info(); f.IsDir() && err == nil {
+			rows = append(rows, versionReleaseDate{f.Name(), i.ModTime().Format("2006-01-02")})
+		}
+	}
+	return rows, nil
+}
+
+// addAvailableVersions adds remote Envoy versions valid for this platform to "rows", if they don't already exist
+func addAvailableVersions(rows *[]versionReleaseDate, remote map[string]version.EnvoyVersion, p string) error {
+	existingVersions := make(map[string]bool)
+	for _, v := range *rows { //nolint:gocritic
+		existingVersions[v.version] = true
+	}
+
+	for k, v := range remote {
+		if _, ok := v.Tarballs[p]; ok && !existingVersions[k] {
+			if _, err := time.Parse("2006-01-02", v.ReleaseDate); err != nil {
+				return fmt.Errorf("invalid releaseDate of version %q for platform %q: %w", k, p, err)
+			}
+			*rows = append(*rows, versionReleaseDate{k, v.ReleaseDate})
+		}
+	}
+	return nil
+}
diff --git a/internal/envoy/install.go b/internal/envoy/install.go
index ade5247..4392136 100644
--- a/internal/envoy/install.go
+++ b/internal/envoy/install.go
@@ -20,6 +20,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/schollz/progressbar/v3"
 
@@ -37,25 +38,32 @@ func InstallIfNeeded(o *globals.GlobalOpts, p, v string) (string, error) {
 	_, err := os.Stat(envoyPath)
 	switch {
 	case os.IsNotExist(err):
-		if err = os.MkdirAll(installPath, 0750); err != nil {
-			return "", fmt.Errorf("unable to create directory %q: %w", installPath, err)
-		}
-
-		var ev version.EnvoyVersions
+		var ev version.EnvoyVersions // Get version metadata for what we will install
 		ev, err = GetEnvoyVersions(o.EnvoyVersionsURL, o.UserAgent)
 		if err != nil {
 			return "", err
 		}
 
-		tarballURL := ev.Versions[v].Tarballs[p]
+		tarballURL := ev.Versions[v].Tarballs[p] // Ensure there is a version for this platform
 		if tarballURL == "" {
 			return "", fmt.Errorf("couldn't find version %q for platform %q", v, p)
 		}
 
+		var mtime time.Time // Create a directory for the version, preserving the release date as its mtime
+		if mtime, err = time.Parse("2006-01-02", ev.Versions[v].ReleaseDate); err != nil {
+			return "", fmt.Errorf("couldn't find releaseDate of version %q for platform %q: %w", v, p, err)
+		}
+		if err = os.MkdirAll(installPath, 0750); err != nil {
+			return "", fmt.Errorf("unable to create directory %q: %w", installPath, err)
+		}
+
 		fmt.Fprintln(o.Out, "downloading", tarballURL) //nolint
 		if err = untarEnvoy(installPath, tarballURL, o.UserAgent, o.Out); err != nil {
 			return "", err
 		}
+		if err = os.Chtimes(installPath, mtime, mtime); err != nil { // overwrite the mtime to preserve it in the list
+			return "", fmt.Errorf("unable to set date of directory %q: %w", installPath, err)
+		}
 	case err == nil:
 		fmt.Fprintln(o.Out, v, "is already downloaded") //nolint
 	default:
diff --git a/internal/envoy/versions.go b/internal/envoy/versions.go
index c4f3079..747c2cf 100644
--- a/internal/envoy/versions.go
+++ b/internal/envoy/versions.go
@@ -19,7 +19,6 @@ import (
 	"fmt"
 	"io"
 	"net/http"
-	"sort"
 
 	"github.com/tetratelabs/getenvoy/internal/version"
 )
@@ -47,30 +46,3 @@ func GetEnvoyVersions(envoyVersionsURL, userAgent string) (version.EnvoyVersions
 	}
 	return result, nil
 }
-
-// PrintVersions retrieves the Envoy versions from the passed location and writes it to the passed writer
-func PrintVersions(vs version.EnvoyVersions, p string, w io.Writer) {
-	// Build a list of Envoy versions with release date for this platform
-	type versionReleaseDate struct{ version, releaseDate string }
-
-	var rows []versionReleaseDate
-	for v, t := range vs.Versions {
-		if _, ok := t.Tarballs[p]; ok {
-			rows = append(rows, versionReleaseDate{v, t.ReleaseDate})
-		}
-	}
-
-	// Sort so that new release dates appear first and on conflict choosing the higher version
-	sort.Slice(rows, func(i, j int) bool {
-		if rows[i].releaseDate == rows[j].releaseDate {
-			return rows[i].version > rows[j].version
-		}
-		return rows[i].releaseDate > rows[j].releaseDate
-	})
-
-	// This doesn't use tabwriter because the columns are likely to remain the same width for the foreseeable future.
-	fmt.Fprintln(w, "VERSION\tRELEASE_DATE") //nolint
-	for _, vr := range rows {                //nolint:gocritic
-		fmt.Fprintf(w, "%s\t%s\n", vr.version, vr.releaseDate) //nolint
-	}
-}


Test patch:
diff --git a/e2e/getenvoy_installed_test.go b/e2e/getenvoy_installed_test.go
deleted file mode 100644
index 3f84515..0000000
--- a/e2e/getenvoy_installed_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// 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 e2e
-
-import (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-
-	"github.com/tetratelabs/getenvoy/internal/test/morerequire"
-)
-
-func TestGetEnvoyInstalled_NothingYet(t *testing.T) {
-	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
-	defer removeHomeDir()
-
-	stdout, stderr, err := getEnvoy("--home-dir", homeDir, "installed").exec()
-
-	require.NoError(t, err)
-	require.Equal(t, `No envoy versions installed, yet
-`, stdout)
-	require.Empty(t, stderr)
-}
-
-// TestGetEnvoyInstalled verifies output is sorted
-func TestGetEnvoyInstalled(t *testing.T) {
-	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
-	defer removeHomeDir()
-
-	require.NoError(t, os.MkdirAll(filepath.Join(homeDir, "versions", "1.16.1"), 0700))
-	require.NoError(t, os.MkdirAll(filepath.Join(homeDir, "versions", "1.17.2"), 0700))
-
-	stdout, stderr, err := getEnvoy("--home-dir", homeDir, "installed").exec()
-
-	require.NoError(t, err)
-	require.Equal(t, `VERSION
-1.17.2
-1.16.1
-`, stdout)
-	require.Empty(t, stderr)
-}
diff --git a/e2e/getenvoy_test.go b/e2e/getenvoy_test.go
index 92aeddb..fdd0aca 100644
--- a/e2e/getenvoy_test.go
+++ b/e2e/getenvoy_test.go
@@ -15,12 +15,9 @@
 package e2e
 
 import (
-	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/require"
-
-	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
 func TestGetEnvoyVersion(t *testing.T) {
@@ -33,17 +30,6 @@ func TestGetEnvoyVersion(t *testing.T) {
 	require.NoError(t, err)
 }
 
-func TestGetEnvoyVersions(t *testing.T) {
-	t.Parallel()
-
-	stdout, stderr, err := getEnvoy("versions").exec()
-
-	require.Regexp(t, "^VERSION\tRELEASE_DATE\n", stdout)
-	require.Regexp(t, fmt.Sprintf("%s\t202[1-9]-[01][0-9]-[0-3][0-9]\n", version.LastKnownEnvoy), stdout)
-	require.Empty(t, stderr)
-	require.NoError(t, err)
-}
-
 func TestGetEnvoyString(t *testing.T) {
 	t.Parallel()
 
diff --git a/e2e/getenvoy_versions_test.go b/e2e/getenvoy_versions_test.go
new file mode 100644
index 0000000..e1287e7
--- /dev/null
+++ b/e2e/getenvoy_versions_test.go
@@ -0,0 +1,82 @@
+// 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 e2e
+
+import (
+	"bufio"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/tetratelabs/getenvoy/internal/test/morerequire"
+	"github.com/tetratelabs/getenvoy/internal/version"
+)
+
+func TestGetEnvoyVersions_NothingYet(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+
+	stdout, stderr, err := getEnvoy("--home-dir", homeDir, "versions").exec()
+
+	require.NoError(t, err)
+	require.Equal(t, `No Envoy versions, yet
+`, stdout)
+	require.Empty(t, stderr)
+}
+
+func TestGetEnvoyVersions(t *testing.T) {
+	t.Parallel()
+
+	stdout, stderr, err := getEnvoy("versions").exec()
+
+	require.Regexp(t, "^VERSION\tRELEASE_DATE\n", stdout)
+	require.Regexp(t, fmt.Sprintf("%s\t202[1-9]-[01][0-9]-[0-3][0-9]\n", version.LastKnownEnvoy), stdout)
+	require.Empty(t, stderr)
+	require.NoError(t, err)
+}
+
+func TestGetEnvoyVersions_All(t *testing.T) {
+	t.Parallel()
+
+	stdout, stderr, err := getEnvoy("versions", "-a").exec()
+
+	require.Regexp(t, "^VERSION\tRELEASE_DATE\n", stdout)
+	require.Regexp(t, fmt.Sprintf("%s\t202[1-9]-[01][0-9]-[0-3][0-9]\n", version.LastKnownEnvoy), stdout)
+	require.Empty(t, stderr)
+	require.NoError(t, err)
+}
+
+func TestGetEnvoyVersions_AllIncludesInstalled(t *testing.T) {
+	t.Parallel()
+
+	// Cheap test that one includes the other. It doesn't actually parse the output, but the above tests prove the
+	// latest version is in each deviation.
+	allVersions, _, err := getEnvoy("versions", "-a").exec()
+	require.NoError(t, err)
+	installedVersions, _, err := getEnvoy("versions").exec()
+	require.NoError(t, err)
+
+	require.Greater(t, countLines(allVersions), countLines(installedVersions), "expected more versions available than installed")
+}
+
+func countLines(stdout string) (count int) {
+	s := bufio.NewScanner(strings.NewReader(stdout))
+	for s.Scan() {
+		count++
+	}
+	return
+}
diff --git a/internal/cmd/app_test.go b/internal/cmd/app_test.go
index afe6ef9..315a317 100644
--- a/internal/cmd/app_test.go
+++ b/internal/cmd/app_test.go
@@ -55,7 +55,7 @@ func TestGetEnvoyValidateArgs(t *testing.T) {
 	}
 }
 
-func TestGetEnvoyHomeDir(t *testing.T) {
+func TestHomeDir(t *testing.T) {
 	type testCase struct {
 		name string
 		args []string
diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go
index 96a8cea..4216499 100644
--- a/internal/cmd/help_test.go
+++ b/internal/cmd/help_test.go
@@ -28,7 +28,7 @@ import (
 )
 
 func TestGetEnvoyHelp(t *testing.T) {
-	for _, command := range []string{"", "install", "installed", "versions", "run"} {
+	for _, command := range []string{"", "install", "versions", "run"} {
 		command := command
 		t.Run(command, func(t *testing.T) {
 			c, stdout, _ := newApp(&globals.GlobalOpts{})
diff --git a/internal/cmd/install_test.go b/internal/cmd/install_test.go
index 71db1ad..a39dddd 100644
--- a/internal/cmd/install_test.go
+++ b/internal/cmd/install_test.go
@@ -16,10 +16,13 @@ package cmd_test
 
 import (
 	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/require"
 
+	"github.com/tetratelabs/getenvoy/internal/test"
 	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
@@ -39,18 +42,35 @@ func TestGetEnvoyInstall_VersionValidates(t *testing.T) {
 		},
 	}
 
-	for _, test := range tests {
-		test := test // pin! see https://github.com/kyoh86/scopelint for why
+	for _, tc := range tests {
+		tc := tc // pin! see https://github.com/kyoh86/scopelint for why
 
-		t.Run(test.name, func(t *testing.T) {
+		t.Run(tc.name, func(t *testing.T) {
 			c, stdout, stderr := newApp(o)
-			err := c.Run([]string{"getenvoy", "install", test.version})
+			err := c.Run([]string{"getenvoy", "install", tc.version})
 
 			// Verify the command failed with the expected error
-			require.EqualError(t, err, test.expectedErr)
+			require.EqualError(t, err, tc.expectedErr)
 			// GetEnvoy handles logging of errors, so we expect nothing in stdout or stderr
 			require.Empty(t, stdout)
 			require.Empty(t, stderr)
 		})
 	}
 }
+
+func TestGetEnvoyInstall_PreservesReleaseDate(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	c, _, _ := newApp(o)
+	require.NoError(t, c.Run([]string{"getenvoy", "install", o.EnvoyVersion}))
+
+	// The directory was created
+	versionDir := filepath.Join(o.HomeDir, "versions", o.EnvoyVersion)
+	require.DirExists(t, versionDir)
+
+	// The directory timestamp matches the fake release date, not the current time
+	f, err := os.Stat(versionDir)
+	require.NoError(t, err)
+	require.Equal(t, f.ModTime().Format("2006-01-02"), test.FakeReleaseDate)
+}
diff --git a/internal/cmd/installed_test.go b/internal/cmd/installed_test.go
deleted file mode 100644
index f15aeb1..0000000
--- a/internal/cmd/installed_test.go
+++ /dev/null
@@ -1,91 +0,0 @@
-// 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 (
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-)
-
-func TestGetEnvoyInstalled_NothingYet(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
-
-	c, stdout, stderr := newApp(o)
-	o.Out = stdout
-	err := c.Run([]string{"getenvoy", "installed"})
-
-	require.NoError(t, err)
-	require.Equal(t, "No envoy versions installed, yet\n", stdout.String())
-	require.Empty(t, stderr)
-}
-
-// TestGetEnvoyInstalled verifies output is sorted
-func TestGetEnvoyInstalled_Sorted(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
-
-	require.NoError(t, os.MkdirAll(filepath.Join(o.HomeDir, "versions", "1.16.1"), 0700))
-	require.NoError(t, os.MkdirAll(filepath.Join(o.HomeDir, "versions", "1.17.2"), 0700))
-
-	c, stdout, stderr := newApp(o)
-	o.Out = stdout
-	err := c.Run([]string{"getenvoy", "installed"})
-
-	require.NoError(t, err)
-	require.Equal(t, `VERSION
-1.17.2
-1.16.1
-`, stdout.String())
-	require.Empty(t, stderr)
-}
-
-func TestGetEnvoyInstalled_DirIsFile(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
-
-	require.NoError(t, os.WriteFile(filepath.Join(o.HomeDir, "versions"), []byte{}, 0700))
-
-	c, stdout, stderr := newApp(o)
-	o.Out = stdout
-	err := c.Run([]string{"getenvoy", "installed"})
-
-	require.Error(t, err)
-	require.Empty(t, stdout)
-	require.Empty(t, stderr)
-}
-
-func TestGetEnvoyInstalled_SkipsFiles(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
-
-	require.NoError(t, os.MkdirAll(filepath.Join(o.HomeDir, "versions", "1.16.1"), 0700))
-	require.NoError(t, os.WriteFile(filepath.Join(o.HomeDir, "versions", "1.17.1"), []byte{}, 0700))
-	require.NoError(t, os.MkdirAll(filepath.Join(o.HomeDir, "versions", "1.17.2"), 0700))
-
-	c, stdout, stderr := newApp(o)
-	o.Out = stdout
-	err := c.Run([]string{"getenvoy", "installed"})
-
-	require.NoError(t, err)
-	require.Equal(t, `VERSION
-1.17.2
-1.16.1
-`, stdout.String())
-	require.Empty(t, stderr)
-}
diff --git a/internal/cmd/testdata/getenvoy.md b/internal/cmd/testdata/getenvoy.md
index 9915592..d23b687 100644
--- a/internal/cmd/testdata/getenvoy.md
+++ b/internal/cmd/testdata/getenvoy.md
@@ -39,13 +39,11 @@ Run Envoy with the given [arguments...], collecting process state on termination
 
 ## versions
 
-List available Envoy versions
+List Envoy versions
+
+**--all, -a**: Show all versions including ones not yet installed
 
 ## install
 
 Download and install a [version] of Envoy
 
-## installed
-
-List installed Envoy versions
-
diff --git a/internal/cmd/testdata/getenvoy_help.txt b/internal/cmd/testdata/getenvoy_help.txt
index 0fab38b..6d76288 100644
--- a/internal/cmd/testdata/getenvoy_help.txt
+++ b/internal/cmd/testdata/getenvoy_help.txt
@@ -8,11 +8,10 @@ VERSION:
    dev
 
 COMMANDS:
-   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
-   installed  List installed Envoy versions
+   help      Shows how to use a [command]
+   run       Run Envoy with the given [arguments...], collecting process state on termination
+   versions  List Envoy versions
+   install   Download and install a [version] of Envoy
 
 GLOBAL OPTIONS:
    --home-dir value            GetEnvoy home directory (location of installed versions and run archives) (default: ${HOME}/.getenvoy) [$GETENVOY_HOME]
diff --git a/internal/cmd/testdata/getenvoy_installed_help.txt b/internal/cmd/testdata/getenvoy_installed_help.txt
deleted file mode 100644
index 5f9196c..0000000
--- a/internal/cmd/testdata/getenvoy_installed_help.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-NAME:
-   getenvoy installed - List installed Envoy versions
-
-USAGE:
-   getenvoy installed [arguments...]
diff --git a/internal/cmd/testdata/getenvoy_versions_help.txt b/internal/cmd/testdata/getenvoy_versions_help.txt
index 4d607a5..518b6d7 100644
--- a/internal/cmd/testdata/getenvoy_versions_help.txt
+++ b/internal/cmd/testdata/getenvoy_versions_help.txt
@@ -1,5 +1,9 @@
 NAME:
-   getenvoy versions - List available Envoy versions
+   getenvoy versions - List Envoy versions
 
 USAGE:
-   getenvoy versions [arguments...]
+   getenvoy versions [command options] [arguments...]
+
+OPTIONS:
+   --all, -a  Show all versions including ones not yet installed (default: false)
+   
diff --git a/internal/cmd/versions_cmd_test.go b/internal/cmd/versions_cmd_test.go
new file mode 100644
index 0000000..237c0ae
--- /dev/null
+++ b/internal/cmd/versions_cmd_test.go
@@ -0,0 +1,106 @@
+// 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"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/tetratelabs/getenvoy/internal/test/morerequire"
+	"github.com/tetratelabs/getenvoy/internal/version"
+)
+
+func TestGetEnvoyVersions_NothingYet(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	c, stdout, stderr := newApp(o)
+	o.Out = stdout
+	err := c.Run([]string{"getenvoy", "versions"})
+
+	require.NoError(t, err)
+	require.Equal(t, "No Envoy versions, yet\n", stdout.String())
+	require.Empty(t, stderr)
+}
+
+func TestGetEnvoyVersions_Sorted(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	oneOneTwo := filepath.Join(o.HomeDir, "versions", "1.1.2")
+	require.NoError(t, os.MkdirAll(oneOneTwo, 0700))
+	morerequire.RequireSetMtime(t, oneOneTwo, "2020-12-31")
+
+	oneTwoOne := filepath.Join(o.HomeDir, "versions", "1.2.1")
+	require.NoError(t, os.MkdirAll(oneTwoOne, 0700))
+	morerequire.RequireSetMtime(t, oneTwoOne, "2020-12-30")
+
+	oneTwoTwo := filepath.Join(o.HomeDir, "versions", "1.2.2")
+	require.NoError(t, os.MkdirAll(oneTwoTwo, 0700))
+	morerequire.RequireSetMtime(t, oneTwoTwo, "2020-12-31")
+
+	c, stdout, stderr := newApp(o)
+	o.Out = stdout
+	err := c.Run([]string{"getenvoy", "versions"})
+
+	require.NoError(t, err)
+	require.Equal(t, `VERSION	RELEASE_DATE
+1.2.2	2020-12-31
+1.1.2	2020-12-31
+1.2.1	2020-12-30
+`, stdout.String())
+	require.Empty(t, stderr)
+}
+
+func TestGetEnvoyVersions_All_IncludesRemote(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	// Run "getenvoy versions"
+	c, stdout, stderr := newApp(o)
+	o.Out = stdout
+	err := c.Run([]string{"getenvoy", "versions", "-a"})
+
+	require.NoError(t, err)
+	require.Equal(t, fmt.Sprintf(`VERSION	RELEASE_DATE
+%s	2020-12-31
+`, version.LastKnownEnvoy), stdout.String())
+	require.Empty(t, stderr)
+}
+
+func TestGetEnvoyVersions_All_Mixed(t *testing.T) {
+	o, cleanup := setupTest(t)
+	defer cleanup()
+
+	oneOneTwo := filepath.Join(o.HomeDir, "versions", "1.1.2")
+	require.NoError(t, os.MkdirAll(oneOneTwo, 0700))
+	morerequire.RequireSetMtime(t, oneOneTwo, "2020-12-31")
+
+	// Run "getenvoy versions"
+	c, stdout, stderr := newApp(o)
+	o.Out = stdout
+	err := c.Run([]string{"getenvoy", "versions", "-a"})
+
+	require.NoError(t, err)
+	require.Equal(t, fmt.Sprintf(`VERSION	RELEASE_DATE
+%s	2020-12-31
+1.1.2	2020-12-31
+`, version.LastKnownEnvoy), stdout.String())
+	require.Empty(t, stderr)
+}
diff --git a/internal/cmd/versions_test.go b/internal/cmd/versions_test.go
index 70f6114..35993ce 100644
--- a/internal/cmd/versions_test.go
+++ b/internal/cmd/versions_test.go
@@ -12,30 +12,196 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package cmd_test
+package cmd
 
 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 TestGetEnvoyVersions(t *testing.T) {
-	o, cleanup := setupTest(t)
-	defer cleanup()
+func TestGetInstalledVersions_ErrorsWhenFileIsInVersionsDir(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
 
-	// Run "getenvoy versions"
-	c, stdout, stderr := newApp(o)
-	o.Out = stdout
-	err := c.Run([]string{"getenvoy", "versions"})
+	versionsDir := filepath.Join(homeDir, "versions")
+	require.NoError(t, os.WriteFile(versionsDir, []byte{}, 0700))
 
-	// Verify the command invoked, passing the correct default commandline
+	_, err := getInstalledVersions(homeDir)
+	require.Error(t, err)
+}
+
+func TestGetInstalledVersions_MissingOrEmptyVersionsDir(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+
+	rows, err := getInstalledVersions(homeDir)
+	require.NoError(t, err) // ensures we don't error just because nothing is installed yet.
+	require.Empty(t, rows)
+
+	// Now, create the versions directory but don't add one
+	versionsDir := filepath.Join(homeDir, "versions")
+	require.NoError(t, os.Mkdir(versionsDir, 0700))
+
+	rows, err = getInstalledVersions(homeDir)
+	require.NoError(t, err)
+	require.Empty(t, rows)
+}
+
+func TestGetInstalledVersions_ReleaseDateFromMtime(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+
+	oneOneTwo := filepath.Join(homeDir, "versions", "1.1.2")
+	require.NoError(t, os.MkdirAll(oneOneTwo, 0700))
+	morerequire.RequireSetMtime(t, oneOneTwo, "2020-12-31")
+
+	rows, err := getInstalledVersions(homeDir)
 	require.NoError(t, err)
-	require.Equal(t, fmt.Sprintf(`VERSION	RELEASE_DATE
-%s	2020-12-31
-`, version.LastKnownEnvoy), stdout.String())
-	require.Empty(t, stderr)
+	require.EqualValues(t, rows, []versionReleaseDate{{"1.1.2", "2020-12-31"}})
+}
+
+func TestGetInstalledVersions_SkipsFileInVersionsDir(t *testing.T) {
+	homeDir, removeHomeDir := morerequire.RequireNewTempDir(t)
+	defer removeHomeDir()
+
+	// make the versions directory
+	versionsDir := filepath.Join(homeDir, "versions")
+	require.NoError(t, os.Mkdir(versionsDir, 0700))
+
+	// create a file which looks like a version
+	oneOneTwo := filepath.Join(versionsDir, "1.1.2")
+	require.NoError(t, os.WriteFile(oneOneTwo, []byte{}, 0700))
+	morerequire.RequireSetMtime(t, oneOneTwo, "2020-12-31")
+
+	// ensure there are no versions in the output
+	rows, err := getInstalledVersions(homeDir)
+	require.NoError(t, err)
+	require.Empty(t, rows)
+}
+
+func TestAddAvailableVersions(t *testing.T) {
+	goodVersions := map[string]version.EnvoyVersion{
+		"1.14.7": {
+			ReleaseDate: "2021-04-15",
+			Tarballs: map[string]string{
+				"darwin/amd64": "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-darwin-x86_64.tar.gz",
+				"linux/amd64":  "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-linux-x86_64.tar.gz",
+			},
+		},
+		"1.17.3": {
+			ReleaseDate: "2021-05-11",
+			Tarballs: map[string]string{
+				"linux/amd64": "https://getenvoy.io/versions/1.17.3/envoy-1.17.3-linux-x86_64.tar.gz",
+			},
+		},
+		"1.18.3": {
+			ReleaseDate: "2021-05-11",
+			Tarballs: map[string]string{
+				"darwin/amd64": "https://getenvoy.io/versions/1.18.3/envoy-1.18.3-darwin-x86_64.tar.gz",
+				"linux/amd64":  "https://getenvoy.io/versions/1.18.3/envoy-1.18.3-linux-x86_64.tar.gz",
+			},
+		},
+	}
+
+	tests := []struct {
+		name     string
+		existing []versionReleaseDate
+		update   map[string]version.EnvoyVersion
+		platform string
+		expected []versionReleaseDate
+	}{
+		{
+			name:     "darwin",
+			existing: []versionReleaseDate{},
+			update:   goodVersions,
+			platform: "darwin/amd64",
+			expected: []versionReleaseDate{{"1.14.7", "2021-04-15"}, {"1.18.3", "2021-05-11"}},
+		},
+		{
+			name:     "linux",
+			platform: "linux/amd64",
+			existing: []versionReleaseDate{},
+			update:   goodVersions,
+			expected: []versionReleaseDate{{"1.14.7", "2021-04-15"}, {"1.17.3", "2021-05-11"}, {"1.18.3", "2021-05-11"}},
+		},
+		{
+			name:     "already exists",
+			existing: []versionReleaseDate{{"1.14.7", "2020-01-01"}},
+			update:   goodVersions,
+			platform: "darwin/amd64",
+			expected: []versionReleaseDate{{"1.14.7", "2020-01-01"}, {"1.18.3", "2021-05-11"}},
+		},
+		{
+			name:     "unsupported OS",
+			existing: []versionReleaseDate{},
+			update:   goodVersions,
+			platform: "windows/amd64",
+			expected: []versionReleaseDate{},
+		},
+		{
+			name:     "unsupported Arch",
+			existing: []versionReleaseDate{},
+			update:   goodVersions,
+			platform: "linux/arm64",
+			expected: []versionReleaseDate{},
+		},
+		{
+			name:     "empty version list",
+			existing: []versionReleaseDate{},
+			platform: "darwin/amd64",
+			expected: []versionReleaseDate{},
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			require.NoError(t, addAvailableVersions(&tc.existing, tc.update, tc.platform))
+			require.ElementsMatch(t, tc.expected, tc.existing)
+		})
+	}
+}
+
+func TestAddAvailableVersions_Validates(t *testing.T) {
+	tests := []struct {
+		name   string
+		update map[string]version.EnvoyVersion
+	}{
+		{
+			name: "invalid releaseDate",
+			update: map[string]version.EnvoyVersion{
+				"1.14.7": {
+					ReleaseDate: "ice cream",
+					Tarballs: map[string]string{
+						"darwin/amd64": "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-darwin-x86_64.tar.gz",
+					},
+				},
+			},
+		},
+		{
+			name: "missing releaseDate",
+			update: map[string]version.EnvoyVersion{
+				"1.14.7": {
+					Tarballs: map[string]string{
+						"darwin/amd64": "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-darwin-x86_64.tar.gz",
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tc := tt
+		t.Run(tc.name, func(t *testing.T) {
+			err := addAvailableVersions(&[]versionReleaseDate{}, tc.update, "darwin/amd64")
+			require.Error(t, err)
+			require.Contains(t, err.Error(), `invalid releaseDate of version "1.14.7" for platform "darwin/amd64":`)
+		})
+	}
 }
diff --git a/internal/envoy/versions_test.go b/internal/envoy/versions_test.go
deleted file mode 100644
index c8c1245..0000000
--- a/internal/envoy/versions_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-// 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 (
-	"bytes"
-	"testing"
-
-	"github.com/stretchr/testify/require"
-
-	"github.com/tetratelabs/getenvoy/internal/version"
-)
-
-func TestPrintVersions(t *testing.T) {
-	tests := []struct {
-		name, platform string
-		versions       version.EnvoyVersions
-		expected       string
-	}{
-		{
-			name:     "darwin",
-			platform: "darwin/amd64",
-			versions: goodVersions,
-			expected: `VERSION	RELEASE_DATE
-1.18.3	2021-05-11
-1.14.7	2021-04-15
-`,
-		},
-		{
-			name:     "linux",
-			platform: "linux/amd64",
-			versions: goodVersions,
-			expected: `VERSION	RELEASE_DATE
-1.18.3	2021-05-11
-1.17.3	2021-05-11
-1.14.7	2021-04-15
-`,
-		},
-		{
-			name:     "unsupported OS",
-			platform: "windows/amd64",
-			versions: goodVersions,
-			expected: `VERSION	RELEASE_DATE
-`,
-		},
-		{
-			name:     "unsupported Arch",
-			platform: "linux/arm64",
-			versions: goodVersions,
-			expected: `VERSION	RELEASE_DATE
-`,
-		},
-		{
-			name:     "empty version list",
-			platform: "darwin/amd64",
-			expected: `VERSION	RELEASE_DATE
-`,
-		},
-	}
-
-	for _, tt := range tests {
-		tc := tt
-		t.Run(tc.name, func(t *testing.T) {
-			out := new(bytes.Buffer)
-			PrintVersions(tc.versions, tc.platform, out)
-			require.Equal(t, tc.expected, out.String())
-		})
-	}
-}
-
-var goodVersions = version.EnvoyVersions{
-	LatestVersion: "1.18.3",
-	Versions: map[string]version.EnvoyVersion{
-		"1.14.7": {
-			ReleaseDate: "2021-04-15",
-			Tarballs: map[string]string{
-				"darwin/amd64": "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-darwin-x86_64.tar.gz",
-				"linux/amd64":  "https://getenvoy.io/versions/1.14.7/envoy-1.14.7-linux-x86_64.tar.gz",
-			},
-		},
-		"1.17.3": {
-			ReleaseDate: "2021-05-11",
-			Tarballs: map[string]string{
-				"linux/amd64": "https://getenvoy.io/versions/1.17.3/envoy-1.17.3-linux-x86_64.tar.gz",
-			},
-		},
-		"1.18.3": {
-			ReleaseDate: "2021-05-11",
-			Tarballs: map[string]string{
-				"darwin/amd64": "https://getenvoy.io/versions/1.18.3/envoy-1.18.3-darwin-x86_64.tar.gz",
-				"linux/amd64":  "https://getenvoy.io/versions/1.18.3/envoy-1.18.3-linux-x86_64.tar.gz",
-			},
-		},
-	},
-}
diff --git a/internal/test/morerequire/morerequire.go b/internal/test/morerequire/morerequire.go
index 54df12f..a79ce70 100644
--- a/internal/test/morerequire/morerequire.go
+++ b/internal/test/morerequire/morerequire.go
@@ -23,6 +23,7 @@ import (
 	"os"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/require"
 )
@@ -38,6 +39,13 @@ func RequireNewTempDir(t *testing.T) (string, func()) {
 	}
 }
 
+// RequireSetMtime sets the mtime of the dir given a string formatted date. Ex "2006-01-02"
+func RequireSetMtime(t *testing.T, dir, date string) {
+	td, err := time.Parse("2006-01-02", date)
+	require.NoError(t, err)
+	require.NoError(t, os.Chtimes(dir, td, td))
+}
+
 var (
 	// captureScript is a test script used for capturing arguments and signals. If it receives an argument "exit=N",
 	// where N is a code number, the script exits with that status. Otherwise it sleeps until a signal interrupts it.
diff --git a/internal/test/server.go b/internal/test/server.go
index bf20caa..0b5dd25 100644
--- a/internal/test/server.go
+++ b/internal/test/server.go
@@ -34,10 +34,14 @@ import (
 	"github.com/tetratelabs/getenvoy/internal/version"
 )
 
-// Even though currently binaries are compressed with "xz" more than "gz", using "gz" in tests allows us to re-use
-// tar.TarGz instead of complicating internal utilities or adding dependencies only for tests.
-const archiveFormat = ".tar.gz"
-const versionsPath = "/versions/"
+const (
+	// FakeReleaseDate helps us make sure main code doesn't accidentally write the system time instead of the expected.
+	FakeReleaseDate = "2020-12-31"
+	// Even though currently binaries are compressed with "xz" more than "gz", using "gz" in tests allows us to re-use
+	// tar.TarGz instead of complicating internal utilities or adding dependencies only for tests.
+	archiveFormat = ".tar.gz"
+	versionsPath  = "/versions/"
+)
 
 // RequireEnvoyVersionsTestServer serves "/envoy-versions.json", containing download links a fake Envoy archive.
 func RequireEnvoyVersionsTestServer(t *testing.T, v string) *httptest.Server {
@@ -46,7 +50,7 @@ func RequireEnvoyVersionsTestServer(t *testing.T, v string) *httptest.Server {
 	s.versions = version.EnvoyVersions{
 		LatestVersion: v,
 		Versions: map[string]version.EnvoyVersion{ // hard-code date so that tests don't drift
-			v: {ReleaseDate: "2020-12-31", Tarballs: map[string]string{
+			v: {ReleaseDate: FakeReleaseDate, Tarballs: map[string]string{
 				"linux/" + runtime.GOARCH:  TarballURL(h.URL, "linux", runtime.GOARCH, v),
 				"darwin/" + runtime.GOARCH: TarballURL(h.URL, "darwin", runtime.GOARCH, v),
 			}}},
diff --git a/main_test.go b/main_test.go
index 65bab6d..e3cf184 100644
--- a/main_test.go
+++ b/main_test.go
@@ -68,7 +68,7 @@ show usage with: getenvoy help
 		},
 		{
 			name:           "execution error",
-			args:           []string{"getenvoy", "--envoy-versions-url", server.URL, "versions"},
+			args:           []string{"getenvoy", "--envoy-versions-url", server.URL, "versions", "-a"},
 			expectedStatus: 1,
 			expectedStderr: `error: error unmarshalling Envoy versions: unexpected end of JSON input
 `,