From f86667a6410dc108d168bb1fa94ead220805d18f Mon Sep 17 00:00:00 2001
From: Jessica Yuen <im.jessicayuen@gmail.com>
Date: Wed, 13 Sep 2017 13:41:34 -0700
Subject: [PATCH] Add subcommand 'env set'

'env set <name>' sets environment fields such as the name, and cluster
URI. It currently accepts the flags '--name' and '--uri'. Changing the
name of an environment will also update the directory structure in
'environments'.
---
 cmd/env.go                   |  62 +++++++++++++--
 cmd/init.go                  |   7 +-
 cmd/root.go                  |   1 +
 metadata/environment.go      | 146 +++++++++++++++++++++++++++--------
 metadata/environment_test.go |  77 ++++++++++++++----
 metadata/interface.go        |   4 +-
 metadata/manager.go          |  31 ++++----
 pkg/kubecfg/env.go           |  45 ++++++++---
 8 files changed, 291 insertions(+), 82 deletions(-)

diff --git a/cmd/env.go b/cmd/env.go
index b128a259..04b6ac65 100644
--- a/cmd/env.go
+++ b/cmd/env.go
@@ -25,14 +25,26 @@ import (
 	"github.com/ksonnet/kubecfg/pkg/kubecfg"
 )
 
+const (
+	flagEnvName = "name"
+	flagEnvURI  = "uri"
+)
+
 func init() {
 	RootCmd.AddCommand(envCmd)
 	envCmd.AddCommand(envAddCmd)
 	envCmd.AddCommand(envRmCmd)
 	envCmd.AddCommand(envListCmd)
+	envCmd.AddCommand(envSetCmd)
+
 	// TODO: We need to make this default to checking the `kubeconfig` file.
 	envAddCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0",
 		"Manually specify API version from OpenAPI schema, cluster, or Kubernetes version")
+
+	envSetCmd.PersistentFlags().String(flagEnvName, "",
+		"Specify name to rename environment to. Name must not already exist")
+	envSetCmd.PersistentFlags().String(flagEnvURI, "",
+		"Specify URI to point environment cluster to a new location")
 }
 
 var envCmd = &cobra.Command{
@@ -160,14 +172,54 @@ var envListCmd = &cobra.Command{
 			return err
 		}
 
-		formattedEnvsString, err := c.Run()
+		return c.Run(cmd.OutOrStdout())
+	},
+	Long: `List all environments within a ksonnet project. This will
+display the name and the URI of each environment within the ksonnet project.`,
+}
+
+var envSetCmd = &cobra.Command{
+	Use:   "set <env-name> [parameter-flags]",
+	Short: "Set environment fields such as the name, and cluster URI.",
+	RunE: func(cmd *cobra.Command, args []string) error {
+		flags := cmd.Flags()
+		if len(args) != 1 {
+			return fmt.Errorf("'env set' takes a single argument, that is the name of the environment")
+		}
+
+		envName := args[0]
+
+		appDir, err := os.Getwd()
 		if err != nil {
 			return err
 		}
-		fmt.Print(formattedEnvsString)
+		appRoot := metadata.AbsPath(appDir)
 
-		return nil
+		desiredEnvName, err := flags.GetString(flagEnvName)
+		if err != nil {
+			return err
+		}
+
+		desiredEnvURI, err := flags.GetString(flagEnvURI)
+		if err != nil {
+			return err
+		}
+
+		c, err := kubecfg.NewEnvSetCmd(envName, desiredEnvName, desiredEnvURI, appRoot)
+		if err != nil {
+			return err
+		}
+
+		return c.Run()
 	},
-	Long: `List all environments within a ksonnet project. This will
-display the name and the URI of each environment within the ksonnet project.`,
+	Long: `Set environment fields such as the name, and cluster URI. Changing
+the name of an environment will also update the directory structure in
+'environments'.
+`,
+	Example: `  # Updates the URI of the environment 'us-west/staging'.
+  ksonnet env set us-west/staging --uri=http://example.com
+
+  # Updates both the name and the URI of the environment 'us-west/staging'.
+  # Updating the name will update the directory structure in 'environments'
+  ksonnet env set us-west/staging --uri=http://example.com --name=us-east/staging`,
 }
diff --git a/cmd/init.go b/cmd/init.go
index 384080ad..250ca7d6 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -25,14 +25,11 @@ import (
 	"github.com/spf13/cobra"
 )
 
-const (
-	flagAPISpec = "api-spec"
-)
-
 func init() {
 	RootCmd.AddCommand(initCmd)
 	// TODO: We need to make this default to checking the `kubeconfig` file.
-	initCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0", "Manually specify API version from OpenAPI schema, cluster, or Kubernetes version")
+	initCmd.PersistentFlags().String(flagAPISpec, "version:v1.7.0",
+		"Manually specify API version from OpenAPI schema, cluster, or Kubernetes version")
 }
 
 var initCmd = &cobra.Command{
diff --git a/cmd/root.go b/cmd/root.go
index 7b8c2036..46e8d74d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -51,6 +51,7 @@ const (
 	flagTlaVarFile = "tla-str-file"
 	flagResolver   = "resolve-images"
 	flagResolvFail = "resolve-images-error"
+	flagAPISpec    = "api-spec"
 
 	// For use in the commands (e.g., diff, apply, delete) that require either an
 	// environment or the -f flag.
diff --git a/metadata/environment.go b/metadata/environment.go
index 78ef4493..24f184e4 100644
--- a/metadata/environment.go
+++ b/metadata/environment.go
@@ -17,9 +17,10 @@ package metadata
 
 import (
 	"encoding/json"
-	"errors"
+	"fmt"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strings"
 
 	"github.com/spf13/afero"
@@ -37,17 +38,28 @@ const (
 	specFilename          = "spec.json"
 )
 
+// Environment represents all fields of a ksonnet environment
 type Environment struct {
 	Path string
 	Name string
 	URI  string
 }
 
+// EnvironmentSpec represents the contents in spec.json.
 type EnvironmentSpec struct {
 	URI string `json:"uri"`
 }
 
-func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensionsLibData, k8sLibData []byte) error {
+func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec) error {
+	extensionsLibData, k8sLibData, specData, err := m.generateKsonnetLibData(spec)
+	if err != nil {
+		return err
+	}
+
+	return m.createEnvironment(name, uri, extensionsLibData, k8sLibData, specData)
+}
+
+func (m *manager) createEnvironment(name, uri string, extensionsLibData, k8sLibData, specData []byte) error {
 	exists, err := m.environmentExists(name)
 	if err != nil {
 		return err
@@ -56,14 +68,13 @@ func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensio
 		return fmt.Errorf("Environment '%s' already exists", name)
 	}
 
-	envPath := appendToAbsPath(m.environmentsDir, name)
-	err = m.appFS.MkdirAll(string(envPath), os.ModePerm)
-	if err != nil {
-		return err
+	// ensure environment name does not contain punctuation
+	if !isValidName(name) {
+		return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", name)
 	}
 
-	// Get cluster specification data, possibly from the network.
-	specData, err := spec.data()
+	envPath := appendToAbsPath(m.environmentsPath, name)
+	err = m.appFS.MkdirAll(string(envPath), os.ModePerm)
 	if err != nil {
 		return err
 	}
@@ -98,23 +109,15 @@ func (m *manager) CreateEnvironment(name, uri string, spec ClusterSpec, extensio
 }
 
 func (m *manager) DeleteEnvironment(name string) error {
-	envPath := string(appendToAbsPath(m.environmentsDir, name))
+	envPath := string(appendToAbsPath(m.environmentsPath, name))
 
-	envs, err := m.GetEnvironments()
+	// Check whether this environment exists
+	envExists, err := m.environmentExists(name)
 	if err != nil {
 		return err
 	}
-
-	// Check whether this environment exists
-	envExists := false
-	for _, env := range envs {
-		if env.Path == envPath {
-			envExists = true
-			break
-		}
-	}
 	if !envExists {
-		return errors.New("Environment \"" + name + "\" does not exist.")
+		return fmt.Errorf("Environment '%s' does not exist", name)
 	}
 
 	// Remove the directory and all files within the environment path.
@@ -124,11 +127,10 @@ func (m *manager) DeleteEnvironment(name string) error {
 	}
 
 	// Need to ensure empty parent directories are also removed.
-	dirs := strings.Split(name, "/")
 	parentDir := name
-	for i := len(dirs) - 2; i >= 0; i-- {
-		parentDir = strings.TrimSuffix(parentDir, "/"+dirs[i+1])
-		parentPath := string(appendToAbsPath(m.environmentsDir, parentDir))
+	for parentDir != "." {
+		parentDir = filepath.Dir(parentDir)
+		parentPath := string(appendToAbsPath(m.environmentsPath, parentDir))
 
 		isEmpty, err := afero.IsEmpty(m.appFS, parentPath)
 		if err != nil {
@@ -148,7 +150,7 @@ func (m *manager) DeleteEnvironment(name string) error {
 func (m *manager) GetEnvironments() ([]Environment, error) {
 	envs := []Environment{}
 
-	err := afero.Walk(m.appFS, string(m.environmentsDir), func(path string, f os.FileInfo, err error) error {
+	err := afero.Walk(m.appFS, string(m.environmentsPath), func(path string, f os.FileInfo, err error) error {
 		isDir, err := afero.IsDir(m.appFS, path)
 		if err != nil {
 			return err
@@ -162,7 +164,7 @@ func (m *manager) GetEnvironments() ([]Environment, error) {
 				return err
 			}
 			if specFileExists {
-				envName := filepath.Clean(strings.TrimPrefix(path, string(m.environmentsDir)+"/"))
+				envName := filepath.Clean(strings.TrimPrefix(path, string(m.environmentsPath)+"/"))
 				specFile, err := afero.ReadFile(m.appFS, specPath)
 				if err != nil {
 					return err
@@ -187,30 +189,112 @@ func (m *manager) GetEnvironments() ([]Environment, error) {
 	return envs, nil
 }
 
-func (m *manager) GenerateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, error) {
+func (m *manager) SetEnvironment(name string, desired Environment) error {
+	// Check whether this environment exists
+	envExists, err := m.environmentExists(name)
+	if err != nil {
+		return err
+	}
+	if !envExists {
+		return fmt.Errorf("Environment '%s' does not exist", name)
+	}
+
+	if name == desired.Name {
+		return nil
+	}
+
+	// ensure new environment name does not contain punctuation
+	if !isValidName(desired.Name) {
+		return fmt.Errorf("Environment '%s' is not valid; must not contain punctuation or trailing slashes", desired.Name)
+	}
+
+	// If the name has changed, the directory location needs to be moved to
+	// reflect the change.
+	if len(desired.Name) != 0 {
+		// Ensure not overwriting another environment
+		desiredExists, err := m.environmentExists(desired.Name)
+		if err != nil {
+			return err
+		}
+		if desiredExists {
+			return fmt.Errorf("Can not update '%s' to '%s', it already exists", name, desired.Name)
+		}
+
+		// Move the directory
+		pathOld := string(appendToAbsPath(m.environmentsPath, name))
+		pathNew := string(appendToAbsPath(m.environmentsPath, desired.Name))
+		err = m.appFS.Rename(pathOld, pathNew)
+		if err != nil {
+			return err
+		}
+
+		name = desired.Name
+	}
+
+	// Update fields in spec.json
+	if len(desired.URI) != 0 {
+		newSpec, err := generateSpecData(desired.URI)
+		if err != nil {
+			return err
+		}
+
+		envPath := appendToAbsPath(m.environmentsPath, name)
+		specPath := appendToAbsPath(envPath, specFilename)
+		return afero.WriteFile(m.appFS, string(specPath), newSpec, os.ModePerm)
+	}
+
+	return nil
+}
+
+func (m *manager) generateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, []byte, error) {
 	// Get cluster specification data, possibly from the network.
 	text, err := spec.data()
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, nil, err
 	}
 
-	ksonnetLibDir := appendToAbsPath(m.environmentsDir, defaultEnvName)
+	ksonnetLibDir := appendToAbsPath(m.environmentsPath, defaultEnvName)
 
 	// Deserialize the API object.
 	s := kubespec.APISpec{}
 	err = json.Unmarshal(text, &s)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, nil, err
 	}
 
 	s.Text = text
 	s.FilePath = filepath.Dir(string(ksonnetLibDir))
 
 	// Emit Jsonnet code.
-	return ksonnet.Emit(&s, nil, nil)
+	extensionsLibData, k8sLibData, err := ksonnet.Emit(&s, nil, nil)
+	return extensionsLibData, k8sLibData, text, err
 }
 
 func generateSpecData(uri string) ([]byte, error) {
 	// Format the spec json and return; preface keys with 2 space idents.
 	return json.MarshalIndent(EnvironmentSpec{URI: uri}, "", "  ")
 }
+
+func (m *manager) environmentExists(name string) (bool, error) {
+	envs, err := m.GetEnvironments()
+	if err != nil {
+		return false, err
+	}
+
+	envExists := false
+	for _, env := range envs {
+		if env.Name == name {
+			envExists = true
+			break
+		}
+	}
+
+	return envExists, nil
+}
+
+// regex matcher to ensure environment name does not contain punctuation
+func isValidName(envName string) bool {
+	hasPunctuation := regexp.MustCompile(`[,;.':!()?"{}\[\]*&%@$]+`).MatchString
+	hasTrailingSlashes := regexp.MustCompile(`/+$`).MatchString
+	return !hasPunctuation(envName) && !hasTrailingSlashes(envName)
+}
diff --git a/metadata/environment_test.go b/metadata/environment_test.go
index 532c2c8e..3d9c9fd3 100644
--- a/metadata/environment_test.go
+++ b/metadata/environment_test.go
@@ -16,9 +16,10 @@
 package metadata
 
 import (
+	"encoding/json"
 	"fmt"
 	"os"
-	"strings"	
+	"strings"
 	"testing"
 
 	"github.com/spf13/afero"
@@ -28,9 +29,9 @@ const (
 	mockSpecJSON    = "spec.json"
 	mockSpecJSONURI = "localhost:8080"
 
-	mockEnvName     = "us-west/test"
-	mockEnvName2    = "us-west/prod"
-	mockEnvName3	= "us-east/test"
+	mockEnvName  = "us-west/test"
+	mockEnvName2 = "us-west/prod"
+	mockEnvName3 = "us-east/test"
 )
 
 func mockEnvironments(t *testing.T, appName string) *manager {
@@ -45,14 +46,11 @@ func mockEnvironments(t *testing.T, appName string) *manager {
 		t.Fatalf("Failed to init cluster spec: %v", err)
 	}
 
-	envDir := appendToAbsPath(appPath, environmentsDir)
-	testDirExists(t, string(envDir))
-
 	envNames := []string{defaultEnvName, mockEnvName, mockEnvName2, mockEnvName3}
 	for _, env := range envNames {
-		envPath := appendToAbsPath(envDir, env)
+		envPath := appendToAbsPath(m.environmentsPath, env)
 
-		specPath := appendToAbsPath(envPath, mockSpecJSON)		
+		specPath := appendToAbsPath(envPath, mockSpecJSON)
 		specData, err := generateSpecData(mockSpecJSONURI)
 		if err != nil {
 			t.Fatalf("Expected to marshal:\n%s\n, but failed", mockSpecJSONURI)
@@ -74,7 +72,7 @@ func testDirExists(t *testing.T, path string) {
 		t.Fatalf("Expected directory at '%s' to exist, but failed:\n%v", path, err)
 	} else if !exists {
 		t.Fatalf("Expected directory at '%s' to exist, but it does not", path)
-	}	
+	}
 }
 
 func testDirNotExists(t *testing.T, path string) {
@@ -91,9 +89,9 @@ func TestDeleteEnvironment(t *testing.T) {
 	m := mockEnvironments(t, appName)
 
 	// Test that both directory and empty parent directory is deleted.
-	expectedPath := appendToAbsPath(m.environmentsDir, mockEnvName3)
+	expectedPath := appendToAbsPath(m.environmentsPath, mockEnvName3)
 	parentDir := strings.Split(mockEnvName3, "/")[0]
-	expectedParentPath := appendToAbsPath(m.environmentsDir, parentDir)
+	expectedParentPath := appendToAbsPath(m.environmentsPath, parentDir)
 	err := m.DeleteEnvironment(mockEnvName3)
 	if err != nil {
 		t.Fatalf("Expected %s to be deleted but got err:\n  %s", mockEnvName3, err)
@@ -102,9 +100,9 @@ func TestDeleteEnvironment(t *testing.T) {
 	testDirNotExists(t, string(expectedParentPath))
 
 	// Test that only leaf directory is deleted if parent directory is shared
-	expectedPath = appendToAbsPath(m.environmentsDir, mockEnvName2)
+	expectedPath = appendToAbsPath(m.environmentsPath, mockEnvName2)
 	parentDir = strings.Split(mockEnvName2, "/")[0]
-	expectedParentPath = appendToAbsPath(m.environmentsDir, parentDir)
+	expectedParentPath = appendToAbsPath(m.environmentsPath, parentDir)
 	err = m.DeleteEnvironment(mockEnvName2)
 	if err != nil {
 		t.Fatalf("Expected %s to be deleted but got err:\n  %s", mockEnvName3, err)
@@ -129,3 +127,54 @@ func TestGetEnvironments(t *testing.T) {
 		t.Fatalf("Expected env URI to be %s, got %s", mockSpecJSONURI, envs[0].URI)
 	}
 }
+
+func TestSetEnvironment(t *testing.T) {
+	appName := "test-set-envs"
+	m := mockEnvironments(t, appName)
+
+	setName := "new-env"
+	setURI := "http://example.com"
+	set := Environment{Name: setName, URI: setURI}
+
+	// Test updating an environment that doesn't exist
+	err := m.SetEnvironment("notexists", set)
+	if err == nil {
+		t.Fatal("Expected error when setting an environment that does not exist")
+	}
+
+	// Test updating an environment to an environment that already exists
+	err = m.SetEnvironment(mockEnvName, Environment{Name: mockEnvName2})
+	if err == nil {
+		t.Fatalf("Expected error when setting \"%s\" to \"%s\", because env already exists", mockEnvName, mockEnvName2)
+	}
+
+	// Test changing the name and URI of a an existing environment.
+	// Ensure new env directory is created, and old directory no longer exists.
+	// Also ensure URI is set in spec.json
+	err = m.SetEnvironment(mockEnvName, set)
+	if err != nil {
+		t.Fatalf("Could not set \"%s\", got:\n  %s", mockEnvName, err)
+	}
+
+	envPath := appendToAbsPath(AbsPath(appName), environmentsDir)
+	expectedPathExists := appendToAbsPath(envPath, set.Name)
+	expectedPathNotExists := appendToAbsPath(envPath, mockEnvName)
+
+	testDirExists(t, string(expectedPathExists))
+	testDirNotExists(t, string(expectedPathNotExists))
+
+	expectedSpecPath := appendToAbsPath(expectedPathExists, specFilename)
+	specData, err := afero.ReadFile(testFS, string(expectedSpecPath))
+	if err != nil {
+		t.Fatalf("Failed to read spec file:\n  %s", err)
+	}
+
+	var envSpec EnvironmentSpec
+	err = json.Unmarshal(specData, &envSpec)
+	if err != nil {
+		t.Fatalf("Failed to read spec file:\n  %s", err)
+	}
+	if envSpec.URI != set.URI {
+		t.Fatalf("Expected set URI to be \"%s\", got:\n  %s", set.URI, envSpec.URI)
+	}
+}
diff --git a/metadata/interface.go b/metadata/interface.go
index d450bd03..6a928670 100644
--- a/metadata/interface.go
+++ b/metadata/interface.go
@@ -36,10 +36,10 @@ type Manager interface {
 	Root() AbsPath
 	ComponentPaths() (AbsPaths, error)
 	LibPaths(envName string) (libPath, envLibPath AbsPath)
-	GenerateKsonnetLibData(spec ClusterSpec) ([]byte, []byte, error)
-	CreateEnvironment(name, uri string, spec ClusterSpec, extensionsLibData, k8sLibData []byte) error
+	CreateEnvironment(name, uri string, spec ClusterSpec) error
 	DeleteEnvironment(name string) error
 	GetEnvironments() ([]Environment, error)
+	SetEnvironment(name string, desired Environment) error
 	//
 	// TODO: Fill in methods as we need them.
 	//
diff --git a/metadata/manager.go b/metadata/manager.go
index 987a37f8..9ba21b80 100644
--- a/metadata/manager.go
+++ b/metadata/manager.go
@@ -40,12 +40,12 @@ const (
 type manager struct {
 	appFS afero.Fs
 
-	rootPath        AbsPath
-	ksonnetPath     AbsPath
-	libPath         AbsPath
-	componentsPath  AbsPath
-	environmentsDir AbsPath
-	vendorDir       AbsPath
+	rootPath         AbsPath
+	ksonnetPath      AbsPath
+	libPath          AbsPath
+	componentsPath   AbsPath
+	environmentsPath AbsPath
+	vendorDir        AbsPath
 }
 
 func findManager(abs AbsPath, appFS afero.Fs) (*manager, error) {
@@ -80,7 +80,7 @@ func initManager(rootPath AbsPath, spec ClusterSpec, appFS afero.Fs) (*manager,
 	// either (e.g., GET'ing the spec from a live cluster returns 404) does not
 	// result in a partially-initialized directory structure.
 	//
-	extensionsLibData, k8sLibData, err := m.GenerateKsonnetLibData(spec)
+	extensionsLibData, k8sLibData, specData, err := m.generateKsonnetLibData(spec)
 	if err != nil {
 		return nil, err
 	}
@@ -92,7 +92,7 @@ func initManager(rootPath AbsPath, spec ClusterSpec, appFS afero.Fs) (*manager,
 
 	// Initialize environment, and cache specification data.
 	// TODO the URI for the default environment needs to be generated from KUBECONFIG
-	if err := m.CreateEnvironment(defaultEnvName, "", spec, extensionsLibData, k8sLibData); err != nil {
+	if err := m.createEnvironment(defaultEnvName, "", extensionsLibData, k8sLibData, specData); err != nil {
 		return nil, err
 	}
 
@@ -103,12 +103,12 @@ func newManager(rootPath AbsPath, appFS afero.Fs) *manager {
 	return &manager{
 		appFS: appFS,
 
-		rootPath:        rootPath,
-		ksonnetPath:     appendToAbsPath(rootPath, ksonnetDir),
-		libPath:         appendToAbsPath(rootPath, libDir),
-		componentsPath:  appendToAbsPath(rootPath, componentsDir),
-		environmentsDir: appendToAbsPath(rootPath, environmentsDir),
-		vendorDir:       appendToAbsPath(rootPath, vendorDir),
+		rootPath:         rootPath,
+		ksonnetPath:      appendToAbsPath(rootPath, ksonnetDir),
+		libPath:          appendToAbsPath(rootPath, libDir),
+		componentsPath:   appendToAbsPath(rootPath, componentsDir),
+		environmentsPath: appendToAbsPath(rootPath, environmentsDir),
+		vendorDir:        appendToAbsPath(rootPath, vendorDir),
 	}
 }
 
@@ -136,7 +136,7 @@ func (m *manager) ComponentPaths() (AbsPaths, error) {
 }
 
 func (m *manager) LibPaths(envName string) (libPath, envLibPath AbsPath) {
-	return m.libPath, appendToAbsPath(m.environmentsDir, envName)
+	return m.libPath, appendToAbsPath(m.environmentsPath, envName)
 }
 
 func (m *manager) createAppDirTree() error {
@@ -152,6 +152,7 @@ func (m *manager) createAppDirTree() error {
 		m.ksonnetPath,
 		m.libPath,
 		m.componentsPath,
+		m.environmentsPath,
 		m.vendorDir,
 	}
 
diff --git a/pkg/kubecfg/env.go b/pkg/kubecfg/env.go
index 47b4ac2f..d43592c7 100644
--- a/pkg/kubecfg/env.go
+++ b/pkg/kubecfg/env.go
@@ -16,6 +16,8 @@
 package kubecfg
 
 import (
+	"fmt"
+	"io"
 	"sort"
 	"strings"
 
@@ -45,12 +47,7 @@ func (c *EnvAddCmd) Run() error {
 		return err
 	}
 
-	extensionsLibData, k8sLibData, err := manager.GenerateKsonnetLibData(c.spec)
-	if err != nil {
-		return err
-	}
-
-	return manager.CreateEnvironment(c.name, c.uri, c.spec, extensionsLibData, k8sLibData)
+	return manager.CreateEnvironment(c.name, c.uri, c.spec)
 }
 
 // ==================================================================
@@ -84,15 +81,15 @@ func NewEnvListCmd(rootPath metadata.AbsPath) (*EnvListCmd, error) {
 	return &EnvListCmd{rootPath: rootPath}, nil
 }
 
-func (c *EnvListCmd) Run() (string, error) {
+func (c *EnvListCmd) Run(out io.Writer) error {
 	manager, err := metadata.Find(c.rootPath)
 	if err != nil {
-		return "", err
+		return err
 	}
 
 	envs, err := manager.GetEnvironments()
 	if err != nil {
-		return "", err
+		return err
 	}
 
 	// Sort environments by ascending alphabetical name
@@ -119,5 +116,33 @@ func (c *EnvListCmd) Run() (string, error) {
 		lines = append(lines, env.Name+nameSpacing+env.URI+"\n")
 	}
 
-	return strings.Join(lines, ""), nil
+	formattedEnvsList := strings.Join(lines, "")
+
+	_, err = fmt.Fprint(out, formattedEnvsList)
+	return err
+}
+
+// ==================================================================
+
+type EnvSetCmd struct {
+	name string
+
+	desiredName string
+	desiredURI  string
+
+	rootPath metadata.AbsPath
+}
+
+func NewEnvSetCmd(name, desiredName, desiredURI string, rootPath metadata.AbsPath) (*EnvSetCmd, error) {
+	return &EnvSetCmd{name: name, desiredName: desiredName, desiredURI: desiredURI, rootPath: rootPath}, nil
+}
+
+func (c *EnvSetCmd) Run() error {
+	manager, err := metadata.Find(c.rootPath)
+	if err != nil {
+		return err
+	}
+
+	desired := metadata.Environment{Name: c.desiredName, URI: c.desiredURI}
+	return manager.SetEnvironment(c.name, desired)
 }
-- 
GitLab