Repository: stripe/stripe-cli
Pull request #1016 (2023-01-06 20:45:01)

Refactor `samples.go` to be more configurable
### Reviewers
r? @vcheung-stripe 
cc @stripe/developer-products

 ### Summary
Refactors the `samples` command so that the code can be re-used/configured via plugin.

Specifically
* I renamed the `Samples` struct to `SampleManager` as identifier `samples` was referring both to the package and to an instance of `Samples` which I found confusing.
* I gave `SampleManager` a constructor helper, `NewSampleManager` with a default initialization, rather than having it initialized directly everywhere.
* I changed the `Create` and `GetSampleConfig` functions to be methods with a `SampleManager` receiver, so that they are more configurable.
* I refactored `ConfigureDotEnv` into `SampleManager.ConfigureDotEnv` (which can be given a custom implementation, now that it's on the struct), and `WriteDotEnv`.
* I introduced a `SampleLister` interface to replace `getSamples`, so that this can be customized (e.g., an implementation that returns a hardcoded list instead of fetching and caching from a specific github repository) and refactored the existing implementation into `cachedGithubSampleLister`.

### Testing
As a basic sanity check, I manually ran `stripe samples list` and `stripe samples create` and they appear still to work.
<!-- Simple summary of what the code does or what you have changed. If this is a visual change consider including a screenshot/gif. See go/screencap for tips/tools. -->



Patch:
diff --git a/pkg/cmd/samples/create.go b/pkg/cmd/samples/create.go
index 033e2d0..0d339eb 100644
--- a/pkg/cmd/samples/create.go
+++ b/pkg/cmd/samples/create.go
@@ -64,7 +64,13 @@ func (cc *CreateCmd) runCreateCmd(cmd *cobra.Command, args []string) error {
 	color := ansi.Color(os.Stdout)
 	spinner := ansi.StartNewSpinner(fmt.Sprintf("Downloading %s", selectedSample), os.Stdout)
 
-	sampleConfig, err := samples.GetSampleConfig(selectedSample, cc.forceRefresh)
+	sampleManager, err := samples.NewSampleManager(cc.cfg)
+	if err != nil {
+		ansi.StopSpinner(spinner, "", os.Stdout)
+		return err
+	}
+
+	sampleConfig, err := sampleManager.GetSampleConfig(selectedSample, cc.forceRefresh)
 	if err != nil {
 		ansi.StopSpinner(spinner, "", os.Stdout)
 		return err
@@ -83,9 +89,8 @@ func (cc *CreateCmd) runCreateCmd(cmd *cobra.Command, args []string) error {
 
 	resultChan := make(chan samples.CreationResult)
 
-	go samples.Create(
+	go sampleManager.Create(
 		cmd.Context(),
-		cc.cfg,
 		selectedSample,
 		selectedConfig,
 		destination,
diff --git a/pkg/rpcservice/sample_configs.go b/pkg/rpcservice/sample_configs.go
index e3f587d..a44c65b 100644
--- a/pkg/rpcservice/sample_configs.go
+++ b/pkg/rpcservice/sample_configs.go
@@ -3,26 +3,24 @@ package rpcservice
 import (
 	"context"
 
-	"github.com/spf13/afero"
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 
-	gitpkg "github.com/stripe/stripe-cli/pkg/git"
 	"github.com/stripe/stripe-cli/pkg/samples"
 	"github.com/stripe/stripe-cli/rpc"
 )
 
 // Make overridable for tests
 var fetchRawSampleIntegrations = func(req *rpc.SampleConfigsRequest) ([]samples.SampleConfigIntegration, error) {
-	sample := samples.Samples{
-		Fs:  afero.NewOsFs(),
-		Git: gitpkg.Operations{},
+	sampleManager, err := samples.NewSampleManager(nil)
+	if err != nil {
+		return nil, err
 	}
-	err := sample.Initialize(req.SampleName)
+	err = sampleManager.Initialize(req.SampleName)
 	if err != nil {
 		return nil, err
 	}
-	return sample.SampleConfig.Integrations, nil
+	return sampleManager.SampleConfig.Integrations, nil
 }
 
 // SampleConfigs returns a list of available configs for a given Stripe sample.
diff --git a/pkg/rpcservice/sample_create.go b/pkg/rpcservice/sample_create.go
index 88899d2..7141f21 100644
--- a/pkg/rpcservice/sample_create.go
+++ b/pkg/rpcservice/sample_create.go
@@ -10,20 +10,42 @@ import (
 	"google.golang.org/grpc/status"
 )
 
-var getSampleConfig = samples.GetSampleConfig
-var createSample = samples.Create
+// We declare these functions here insteoad of calling `sample.GetSampleConfig` or `sample.Create` directly
+// so that they can be overridden during tests
+
+type getSampleConfigFunc = func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error)
+
+var getSampleConfig = func(sample *samples.SampleManager) getSampleConfigFunc {
+	return sample.GetSampleConfig
+}
+
+type createSampleFunc = func(
+	ctx context.Context,
+	sampleName string,
+	selectedConfig *samples.SelectedConfig,
+	destination string,
+	forceRefresh bool,
+	resultChan chan<- samples.CreationResult)
+
+var createSample = func(sample *samples.SampleManager) createSampleFunc {
+	return sample.Create
+}
 
 // SampleCreate creates a sample at a given path with the selected integration, client language, and server language.
 func (srv *RPCService) SampleCreate(ctx context.Context, req *rpc.SampleCreateRequest) (*rpc.SampleCreateResponse, error) {
+	sampleManager, err := samples.NewSampleManager(srv.cfg.UserCfg)
+	if err != nil {
+		return nil, err
+	}
+
 	selectedConfig, err := getSelectedConfig(req)
 	if err != nil {
 		return nil, err
 	}
 
 	resultChan := make(chan samples.CreationResult)
-	go createSample(
+	go createSample(sampleManager)(
 		ctx,
-		srv.cfg.UserCfg,
 		req.SampleName,
 		selectedConfig,
 		req.Path,
@@ -47,8 +69,12 @@ func (srv *RPCService) SampleCreate(ctx context.Context, req *rpc.SampleCreateRe
 }
 
 func getSelectedConfig(req *rpc.SampleCreateRequest) (*samples.SelectedConfig, error) {
+	sampleManager, err := samples.NewSampleManager(nil)
+	if err != nil {
+		return nil, err
+	}
 	// Validate the selected integration exists
-	sampleConfig, err := getSampleConfig(req.SampleName, req.ForceRefresh)
+	sampleConfig, err := getSampleConfig(sampleManager)(req.SampleName, req.ForceRefresh)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/samples/create.go b/pkg/samples/create.go
index eba4ea4..4a27135 100644
--- a/pkg/samples/create.go
+++ b/pkg/samples/create.go
@@ -9,9 +9,6 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/afero"
 
-	"github.com/stripe/stripe-cli/pkg/config"
-	gitpkg "github.com/stripe/stripe-cli/pkg/git"
-
 	"gopkg.in/src-d/go-git.v4"
 )
 
@@ -50,9 +47,8 @@ type CreationResult struct {
 }
 
 // Create creates a sample at a destination with the selected integration, client language, and server language
-func Create(
+func (sampleManager *SampleManager) Create(
 	ctx context.Context,
-	config *config.Config,
 	sampleName string,
 	selectedConfig *SelectedConfig,
 	destination string,
@@ -61,20 +57,27 @@ func Create(
 ) {
 	defer close(resultChan)
 
-	sample := Samples{
-		Config: config,
-		Fs:     afero.NewOsFs(),
-		Git:    gitpkg.Operations{},
+	cacheFolder, err := sampleManager.appCacheFolder("samples-list")
+	if err != nil {
+		logger := log.Logger{
+			Out: os.Stdout,
+		}
+
+		logger.WithFields(log.Fields{
+			"prefix": "samples.create.cacheFolder",
+			"error":  err,
+		}).Debug("Could not create cacheFolder for samples")
 	}
+	sampleManager.SampleLister = newCachedGithubSampleLister(sampleManager, sampleListGithubURL, cacheFolder)
 
-	exists, _ := afero.DirExists(sample.Fs, destination)
+	exists, _ := afero.DirExists(sampleManager.Fs, destination)
 	if exists {
 		resultChan <- CreationResult{Err: fmt.Errorf("Path already exists for: %s", destination)}
 		return
 	}
 
 	if forceRefresh {
-		err := sample.DeleteCache(sampleName)
+		err := sampleManager.DeleteCache(sampleName)
 		if err != nil {
 			logger := log.Logger{
 				Out: os.Stdout,
@@ -94,7 +97,7 @@ func Create(
 	// depending on whether or not it's. Additionally, this
 	// identifies if the sample has multiple integrations and what
 	// languages it supports.
-	err := sample.Initialize(sampleName)
+	err = sampleManager.Initialize(sampleName)
 	if err != nil {
 		switch e := err.Error(); e {
 		case git.NoErrAlreadyUpToDate.Error():
@@ -114,7 +117,7 @@ func Create(
 
 	resultChan <- CreationResult{State: DidInitialize}
 
-	sample.SelectedConfig = *selectedConfig
+	sampleManager.SelectedConfig = *selectedConfig
 
 	// Setup to intercept ctrl+c
 	c := make(chan os.Signal, 1)
@@ -122,7 +125,7 @@ func Create(
 
 	go func() {
 		<-c
-		sample.Cleanup(sampleName)
+		sampleManager.Cleanup(sampleName)
 		os.Exit(1)
 	}()
 
@@ -131,7 +134,7 @@ func Create(
 	// Create the target folder to copy the sample in to. We do
 	// this here in case any of the steps above fail, minimizing
 	// the change that we create a dangling empty folder
-	targetPath, err := sample.MakeFolder(destination)
+	targetPath, err := sampleManager.MakeFolder(destination)
 	if err != nil {
 		resultChan <- CreationResult{Err: err}
 		return
@@ -139,7 +142,7 @@ func Create(
 
 	// Perform the copy of the sample given the selected options
 	// from the selections above
-	err = sample.Copy(targetPath)
+	err = sampleManager.Copy(targetPath)
 	if err != nil {
 		resultChan <- CreationResult{Err: err}
 		return
@@ -149,7 +152,7 @@ func Create(
 
 	resultChan <- CreationResult{State: WillConfigure}
 
-	err = sample.ConfigureDotEnv(ctx, targetPath)
+	err = sampleManager.WriteDotEnv(ctx, targetPath)
 	if err != nil {
 		resultChan <- CreationResult{Err: err}
 		return
@@ -157,5 +160,5 @@ func Create(
 
 	resultChan <- CreationResult{State: DidConfigure}
 
-	resultChan <- CreationResult{State: Done, Path: targetPath, PostInstall: sample.PostInstall()}
+	resultChan <- CreationResult{State: Done, Path: targetPath, PostInstall: sampleManager.PostInstall()}
 }
diff --git a/pkg/samples/list.go b/pkg/samples/list.go
index 9a5f213..59571ef 100644
--- a/pkg/samples/list.go
+++ b/pkg/samples/list.go
@@ -48,19 +48,47 @@ func Names(list map[string]*SampleData) []string {
 	return keys
 }
 
-func (s *Samples) getFromCacheOrGithub(noNetwork bool) error {
-	listPath, err := s.appCacheFolder("samples-list")
-	if err != nil {
-		return err
+// SampleLister gets the list of valid stripe samples. It is used both in
+// `stripe samples list` to show the users what they can do, and in
+// `stripe samples create <name>` in order to look up the repo url corresponding
+// to <name>
+type SampleLister interface {
+	ListSamples(mode string) (map[string]*SampleData, error)
+}
+
+type cachedGithubSampleLister struct {
+	// Used for .Fs and .Git
+	s *SampleManager
+
+	// URL like https://github.com/stripe-samples/samples-list.git
+	// expected to contain a repository with a "samples.json" file at the root
+	// with the expected format/contents
+	sampleListGithubURL string
+
+	// Place on the user's filesystem where a cached copy of the repo designated by
+	// https://github.com/stripe-samples/samples-list.git will be stored
+	cacheFolder string
+
+	// In-memory cache of the sample data contained in samples.json
+	result map[string]*SampleData
+}
+
+func newCachedGithubSampleLister(s *SampleManager, sampleListGithubURL string, cacheFolder string) SampleLister {
+	return &cachedGithubSampleLister{
+		s:                   s,
+		sampleListGithubURL: sampleListGithubURL,
+		cacheFolder:         cacheFolder,
 	}
+}
 
-	if _, err := s.Fs.Stat(listPath); os.IsNotExist(err) {
-		err = s.Git.Clone(listPath, sampleListGithubURL)
+func (l *cachedGithubSampleLister) getFromCacheOrGithub(noNetwork bool) (map[string]*SampleData, error) {
+	if _, err := l.s.Fs.Stat(l.cacheFolder); os.IsNotExist(err) {
+		err = l.s.Git.Clone(l.cacheFolder, l.sampleListGithubURL)
 		if err != nil {
-			return err
+			return nil, err
 		}
 	} else if !noNetwork {
-		err := s.Git.Pull(listPath)
+		err := l.s.Git.Pull(l.cacheFolder)
 		if err != nil {
 			if err != nil {
 				switch e := err.Error(); e {
@@ -69,38 +97,38 @@ func (s *Samples) getFromCacheOrGithub(noNetwork bool) error {
 					// error to continue as normal
 					break
 				default:
-					return err
+					return nil, err
 				}
 			}
 		}
 	}
 
-	file, err := afero.ReadFile(s.Fs, filepath.Join(listPath, "samples.json"))
+	file, err := afero.ReadFile(l.s.Fs, filepath.Join(l.cacheFolder, "samples.json"))
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	var allSamples SampleList
 
 	err = json.Unmarshal(file, &allSamples)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	s.SamplesList = make(map[string]*SampleData)
+	ret := make(map[string]*SampleData)
 	for i, sample := range allSamples.Samples {
-		s.SamplesList[sample.Name] = &allSamples.Samples[i]
+		ret[sample.Name] = &allSamples.Samples[i]
 	}
 
-	return nil
+	return ret, nil
 }
 
-func (s *Samples) getSamples(mode string) (map[string]*SampleData, error) {
-	if len(s.SamplesList) != 0 {
-		return s.SamplesList, nil
+func (l *cachedGithubSampleLister) ListSamples(mode string) (map[string]*SampleData, error) {
+	if len(l.result) != 0 {
+		return l.result, nil
 	}
 
-	// Reduce the amount of request to GitHub
+	// Reduce the number of requests to GitHub
 	var noNetwork bool
 	switch mode {
 	case "list":
@@ -111,13 +139,12 @@ func (s *Samples) getSamples(mode string) (map[string]*SampleData, error) {
 		noNetwork = false
 	}
 
-	// Get the samples from the cache or GitHub
-	err := s.getFromCacheOrGithub(noNetwork)
+	ret, err := l.getFromCacheOrGithub(noNetwork)
 	if err != nil {
 		return nil, err
 	}
-
-	return s.SamplesList, nil
+	l.result = ret
+	return ret, nil
 }
 
 // GetSamples returns a list that contains a mapping of Stripe Samples that
@@ -125,10 +152,16 @@ func (s *Samples) getSamples(mode string) (map[string]*SampleData, error) {
 // TODO: what do we want to name these for it to be easier for users to select?
 // TODO: should we group them by products for easier exploring?
 func GetSamples(mode string) (map[string]*SampleData, error) {
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs:  afero.NewOsFs(),
 		Git: gitpkg.Operations{},
 	}
 
-	return sample.getSamples(mode)
+	cacheFolder, err := sampleManager.appCacheFolder("samples-list")
+	if err != nil {
+		return nil, err
+	}
+	sampleManager.SampleLister = newCachedGithubSampleLister(&sampleManager, sampleListGithubURL, cacheFolder)
+
+	return sampleManager.SampleLister.ListSamples(mode)
 }
diff --git a/pkg/samples/os.go b/pkg/samples/os.go
index 83689d1..1f6a98d 100644
--- a/pkg/samples/os.go
+++ b/pkg/samples/os.go
@@ -10,7 +10,7 @@ import (
 )
 
 // cacheFolder is the local directory where we place local copies of samples
-func (s *Samples) cacheFolder() (string, error) {
+func (s *SampleManager) cacheFolder() (string, error) {
 	configPath := s.Config.GetConfigFolder(os.Getenv("XDG_CONFIG_HOME"))
 	cachePath := filepath.Join(configPath, "samples-cache")
 
@@ -25,7 +25,7 @@ func (s *Samples) cacheFolder() (string, error) {
 }
 
 // appCacheFolder returns the full path of the local cache with the recipe name
-func (s *Samples) appCacheFolder(app string) (string, error) {
+func (s *SampleManager) appCacheFolder(app string) (string, error) {
 	path, err := s.cacheFolder()
 	if err != nil {
 		return "", err
@@ -37,7 +37,7 @@ func (s *Samples) appCacheFolder(app string) (string, error) {
 }
 
 // MakeFolder creates the folder that'll contain the Stripe app the user is creating
-func (s *Samples) MakeFolder(name string) (string, error) {
+func (s *SampleManager) MakeFolder(name string) (string, error) {
 	appFolder, err := filepath.Abs(name)
 	if err != nil {
 		return "", err
@@ -55,7 +55,7 @@ func (s *Samples) MakeFolder(name string) (string, error) {
 }
 
 // GetFolders returns a list of all folders for a given path
-func (s *Samples) GetFolders(path string) ([]string, error) {
+func (s *SampleManager) GetFolders(path string) ([]string, error) {
 	var dir []string
 
 	files, err := afero.ReadDir(s.Fs, path)
@@ -74,7 +74,7 @@ func (s *Samples) GetFolders(path string) ([]string, error) {
 }
 
 // GetFiles returns a list of files for a given path
-func (s *Samples) GetFiles(path string) ([]string, error) {
+func (s *SampleManager) GetFiles(path string) ([]string, error) {
 	var file []string
 
 	files, err := afero.ReadDir(s.Fs, path)
@@ -92,7 +92,7 @@ func (s *Samples) GetFiles(path string) ([]string, error) {
 	return file, nil
 }
 
-func (s *Samples) delete(name string) error {
+func (s *SampleManager) delete(name string) error {
 	dir, err := os.Getwd()
 	if err != nil {
 		return err
diff --git a/pkg/samples/samples.go b/pkg/samples/samples.go
index 75aa438..26bf919 100644
--- a/pkg/samples/samples.go
+++ b/pkg/samples/samples.go
@@ -96,38 +96,67 @@ type SelectedConfig struct {
 	Server      string
 }
 
-// Samples stores the information for the selected sample in addition to the
-// selected configuration option to copy over
-type Samples struct {
-	Config *config.Config
-	Fs     afero.Fs
-	Git    g.Interface
-
-	name string
+// SampleManager supports operations related to listing, cloning, and setup of
+// Stripe samples. It contains configurable dependencies like `SampleLister`
+// and `Fs` so that the behavior can be customized in tests or plugins, and it
+// contains stateful properties like `repoPath`, `selectedConfig` that keep
+// track of the operation as it goes along.
+type SampleManager struct {
+	// Holds functionality for getting the list of stripe sample repositories.
+	SampleLister SampleLister
+	// Function to populating the .env file in a created sample
+	// (using information from the user's stripe-cli config)
+	ConfigureDotEnv func(ctx context.Context, config *config.Config) (map[string]string, error)
+	// Filesystem operations
+	Fs afero.Fs
+	// Git operations.
+	Git g.Interface
 
-	// source repository to clone from
-	repo string
+	Config *config.Config
 
-	SamplesList map[string]*SampleData
+	// Filesystem path where the sample repository is cloned
+	repoPath string
 
+	// Information (defined by the sample) about what integrations it supports
+	// and how it ought to be set up by the CLI.
 	SampleConfig SampleConfig
 
+	// Represents the user selection about which part of the sample to copy over during
+	// setup.
 	SelectedConfig SelectedConfig
 }
 
-// Initialize get the sample ready for the user to copy. It:
+// NewSampleManager creates a SampleManager with default behavior. I.e. it uses
+// go-git for git operations, it uses the standard filesystem implementation,
+// it lists samples by fetching samples.json from the standard samples-list
+// repository, and it copies .env files in the standard way
+func NewSampleManager(config *config.Config) (*SampleManager, error) {
+	sampleManager := &SampleManager{
+		Fs:              afero.NewOsFs(),
+		Git:             gitpkg.Operations{},
+		ConfigureDotEnv: ConfigureDotEnv,
+		Config:          config,
+	}
+
+	cacheFolder, err := sampleManager.appCacheFolder("samples-list")
+	if err != nil {
+		return nil, err
+	}
+	sampleManager.SampleLister = newCachedGithubSampleLister(sampleManager, sampleListGithubURL, cacheFolder)
+	return sampleManager, nil
+}
+
+// Initialize gets the sample ready for the user to copy. It:
 // 1. creates the sample cache folder if it doesn't exist
-// 2. store the path of the locale cache folder for later use
+// 2. store the path of the local cache folder for later use
 // 3. if the selected app does not exist in the local cache folder, clone it
 // 4. if the selected app does exist in the local cache folder, pull changes
 // 5. parse the sample cli config file
-func (s *Samples) Initialize(app string) error {
+func (s *SampleManager) Initialize(app string) error {
 	if app == "" {
 		return errors.New("Sample name is empty")
 	}
 
-	s.name = app
-
 	appPath, err := s.appCacheFolder(app)
 	if err != nil {
 		return err
@@ -135,9 +164,9 @@ func (s *Samples) Initialize(app string) error {
 
 	// We still set the repo path here. There are some failure cases
 	// that we can still work with (like no updates or repo already exists)
-	s.repo = appPath
+	s.repoPath = appPath
 
-	list, err := s.getSamples("create")
+	list, err := s.SampleLister.ListSamples("create")
 	if err != nil {
 		return err
 	}
@@ -198,7 +227,7 @@ func (s *Samples) Initialize(app string) error {
 //     the server top-level (example above)
 //   - If the user selects an integration, mirror the structure above for the
 //     selected integration (example above)
-func (s *Samples) Copy(target string) error {
+func (s *SampleManager) Copy(target string) error {
 	integration := s.SelectedConfig.Integration.name()
 
 	if s.SelectedConfig.Integration.hasServers() {
@@ -212,7 +241,7 @@ func (s *Samples) Copy(target string) error {
 			)
 		}
 
-		serverSource := filepath.Join(s.repo, integration, "server", s.SelectedConfig.Server)
+		serverSource := filepath.Join(s.repoPath, integration, "server", s.SelectedConfig.Server)
 		serverDestination := filepath.Join(target, "server")
 
 		err := copy.Copy(serverSource, serverDestination)
@@ -232,7 +261,7 @@ func (s *Samples) Copy(target string) error {
 			)
 		}
 
-		clientSource := filepath.Join(s.repo, integration, "client", s.SelectedConfig.Client)
+		clientSource := filepath.Join(s.repoPath, integration, "client", s.SelectedConfig.Client)
 		clientDestination := filepath.Join(target, "client")
 
 		err := copy.Copy(clientSource, clientDestination)
@@ -241,26 +270,26 @@ func (s *Samples) Copy(target string) error {
 		}
 	}
 
-	filesSource, err := s.GetFiles(filepath.Join(s.repo, integration))
+	filesSource, err := s.GetFiles(filepath.Join(s.repoPath, integration))
 	if err != nil {
 		return err
 	}
 
 	for _, file := range filesSource {
-		err = copy.Copy(filepath.Join(s.repo, integration, file), filepath.Join(target, file))
+		err = copy.Copy(filepath.Join(s.repoPath, integration, file), filepath.Join(target, file))
 		if err != nil {
 			return err
 		}
 	}
 
 	// This copies all top-level files specific to the entire sample repo
-	filesSource, err = s.GetFiles(s.repo)
+	filesSource, err = s.GetFiles(s.repoPath)
 	if err != nil {
 		return err
 	}
 
 	for _, file := range filesSource {
-		err = copy.Copy(filepath.Join(s.repo, file), filepath.Join(target, file))
+		err = copy.Copy(filepath.Join(s.repoPath, file), filepath.Join(target, file))
 		if err != nil {
 			return err
 		}
@@ -269,9 +298,42 @@ func (s *Samples) Copy(target string) error {
 	return nil
 }
 
-// ConfigureDotEnv takes the .env.example from the provided location and
+// ConfigureDotEnv returns a map of environment variables to copy into the
+// .env file of the target project, based on the user's stripe-cli profile.
+func ConfigureDotEnv(ctx context.Context, config *config.Config) (map[string]string, error) {
+	publishableKey, _ := config.Profile.GetPublishableKey(false)
+	if publishableKey == "" {
+		return nil, fmt.Errorf("we could not set the publishable key in the .env file; please set this manually or login again to set it automatically next time")
+	}
+
+	apiKey, err := config.Profile.GetAPIKey(false)
+	if err != nil {
+		return nil, err
+	}
+
+	deviceName, err := config.Profile.GetDeviceName()
+	if err != nil {
+		return nil, err
+	}
+
+	authClient := stripeauth.NewClient(apiKey, nil)
+
+	authSession, err := authClient.Authorize(ctx, deviceName, "webhooks", nil, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	return map[string]string{
+		"STRIPE_PUBLISHABLE_KEY": publishableKey,
+		"STRIPE_SECRET_KEY":      apiKey,
+		"STRIPE_WEBHOOK_SECRET":  authSession.Secret,
+		"STATIC_DIR":             "../client",
+	}, nil
+}
+
+// WriteDotEnv takes the .env.example from the provided location and
 // modifies it to automatically configure it for the users settings
-func (s *Samples) ConfigureDotEnv(ctx context.Context, sampleLocation string) error {
+func (s *SampleManager) WriteDotEnv(ctx context.Context, sampleLocation string) error {
 	if s.SelectedConfig.Integration.hasServers() {
 		if !s.SampleConfig.ConfigureDotEnv {
 			return nil
@@ -290,35 +352,15 @@ func (s *Samples) ConfigureDotEnv(ctx context.Context, sampleLocation string) er
 			return err
 		}
 
-		publishableKey, _ := s.Config.Profile.GetPublishableKey(false)
-		if publishableKey == "" {
-			return fmt.Errorf("we could not set the publishable key in the .env file; please set this manually or login again to set it automatically next time")
-		}
-
-		apiKey, err := s.Config.Profile.GetAPIKey(false)
+		envVars, err := s.ConfigureDotEnv(ctx, s.Config)
 		if err != nil {
 			return err
 		}
-
-		deviceName, err := s.Config.Profile.GetDeviceName()
-		if err != nil {
-			return err
-		}
-
-		authClient := stripeauth.NewClient(apiKey, nil)
-
-		authSession, err := authClient.Authorize(ctx, deviceName, "webhooks", nil, nil)
-		if err != nil {
-			return err
+		for k, v := range envVars {
+			dotenv[k] = v
 		}
 
-		dotenv["STRIPE_PUBLISHABLE_KEY"] = publishableKey
-		dotenv["STRIPE_SECRET_KEY"] = apiKey
-		dotenv["STRIPE_WEBHOOK_SECRET"] = authSession.Secret
-		dotenv["STATIC_DIR"] = "../client"
-
 		envFile := filepath.Join(sampleLocation, "server", ".env")
-
 		err = godotenv.Write(dotenv, envFile)
 		if err != nil {
 			return err
@@ -329,13 +371,13 @@ func (s *Samples) ConfigureDotEnv(ctx context.Context, sampleLocation string) er
 }
 
 // PostInstall returns any installation for post installation instructions
-func (s *Samples) PostInstall() string {
+func (s *SampleManager) PostInstall() string {
 	message := s.SampleConfig.PostInstall["message"]
 	return message
 }
 
 // Cleanup performs cleanup for the recently created sample
-func (s *Samples) Cleanup(name string) error {
+func (s *SampleManager) Cleanup(name string) error {
 	fmt.Println("Cleaning up...")
 
 	return s.delete(name)
@@ -343,7 +385,7 @@ func (s *Samples) Cleanup(name string) error {
 
 // DeleteCache forces the local sample cache to refresh in case something
 // goes awry during the initial clone or to clean out stale samples
-func (s *Samples) DeleteCache(sample string) error {
+func (s *SampleManager) DeleteCache(sample string) error {
 	appPath, err := s.appCacheFolder(sample)
 	if err != nil {
 		return err
@@ -368,14 +410,9 @@ func contains(s []string, e string) bool {
 }
 
 // GetSampleConfig returns the available config for this sample
-func GetSampleConfig(sampleName string, forceRefresh bool) (*SampleConfig, error) {
-	sample := Samples{
-		Fs:  afero.NewOsFs(),
-		Git: gitpkg.Operations{},
-	}
-
+func (s *SampleManager) GetSampleConfig(sampleName string, forceRefresh bool) (*SampleConfig, error) {
 	if forceRefresh {
-		err := sample.DeleteCache(sampleName)
+		err := s.DeleteCache(sampleName)
 		if err != nil {
 			logger := log.Logger{
 				Out: os.Stdout,
@@ -388,7 +425,7 @@ func GetSampleConfig(sampleName string, forceRefresh bool) (*SampleConfig, error
 		}
 	}
 
-	samplesList, err := sample.getSamples("create")
+	samplesList, err := s.SampleLister.ListSamples("create")
 	if err != nil {
 		return nil, err
 	}
@@ -398,10 +435,10 @@ To see supported samples, run 'stripe samples list'`, sampleName)
 		return nil, fmt.Errorf(errorMessage)
 	}
 
-	err = sample.Initialize(sampleName)
+	err = s.Initialize(sampleName)
 	if err != nil {
 		return nil, err
 	}
 
-	return &sample.SampleConfig, nil
+	return &s.SampleConfig, nil
 }
diff --git a/rpc/commands_grpc.pb.go b/rpc/commands_grpc.pb.go
index aaae07a..0fa661e 100644
--- a/rpc/commands_grpc.pb.go
+++ b/rpc/commands_grpc.pb.go
@@ -8,6 +8,7 @@ package rpc
 
 import (
 	context "context"
+
 	grpc "google.golang.org/grpc"
 	codes "google.golang.org/grpc/codes"
 	status "google.golang.org/grpc/status"


Test patch:
diff --git a/pkg/rpcservice/sample_create_test.go b/pkg/rpcservice/sample_create_test.go
index 12208d3..f165293 100644
--- a/pkg/rpcservice/sample_create_test.go
+++ b/pkg/rpcservice/sample_create_test.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/stretchr/testify/assert"
 
-	"github.com/stripe/stripe-cli/pkg/config"
 	"github.com/stripe/stripe-cli/pkg/samples"
 	"github.com/stripe/stripe-cli/rpc"
 
@@ -16,31 +15,34 @@ import (
 )
 
 func TestSampleCreateSucceeds(t *testing.T) {
-	getSampleConfig = func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
-		return &samples.SampleConfig{
-			Integrations: []samples.SampleConfigIntegration{
-				{
-					Name:    "foo",
-					Clients: []string{"foo-client-1", "foo-client-2"},
-					Servers: []string{"foo-server-1", "foo-server-2"},
+	getSampleConfig = func(sample *samples.SampleManager) getSampleConfigFunc {
+		return func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
+			return &samples.SampleConfig{
+				Integrations: []samples.SampleConfigIntegration{
+					{
+						Name:    "foo",
+						Clients: []string{"foo-client-1", "foo-client-2"},
+						Servers: []string{"foo-server-1", "foo-server-2"},
+					},
 				},
-			},
-		}, nil
+			}, nil
+		}
 	}
 
-	createSample = func(
-		ctx context.Context,
-		config *config.Config,
-		sampleName string,
-		selectedConfig *samples.SelectedConfig,
-		destination string,
-		forceRefresh bool,
-		resultChan chan<- samples.CreationResult) {
-		defer close(resultChan)
-		resultChan <- samples.CreationResult{
-			State:       samples.Done,
-			Path:        "my path",
-			PostInstall: "my post install message",
+	createSample = func(sample *samples.SampleManager) createSampleFunc {
+		return func(
+			ctx context.Context,
+			sampleName string,
+			selectedConfig *samples.SelectedConfig,
+			destination string,
+			forceRefresh bool,
+			resultChan chan<- samples.CreationResult) {
+			defer close(resultChan)
+			resultChan <- samples.CreationResult{
+				State:       samples.Done,
+				Path:        "my path",
+				PostInstall: "my post install message",
+			}
 		}
 	}
 
@@ -72,22 +74,25 @@ func TestSampleCreateSucceeds(t *testing.T) {
 }
 
 func TestSampleCreateFailsWhenGetSampleConfigFails(t *testing.T) {
-	getSampleConfig = func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
-		return nil, errors.New("getSampleConfig failed")
+	getSampleConfig = func(_ *samples.SampleManager) getSampleConfigFunc {
+		return func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
+			return nil, errors.New("getSampleConfig failed")
+		}
 	}
 
-	createSample = func(
-		ctx context.Context,
-		config *config.Config,
-		sampleName string,
-		selectedConfig *samples.SelectedConfig,
-		destination string, forceRefresh bool,
-		resultChan chan<- samples.CreationResult) {
-		defer close(resultChan)
-		resultChan <- samples.CreationResult{
-			State:       samples.Done,
-			Path:        "my path",
-			PostInstall: "my post install message",
+	createSample = func(_ *samples.SampleManager) createSampleFunc {
+		return func(
+			ctx context.Context,
+			sampleName string,
+			selectedConfig *samples.SelectedConfig,
+			destination string, forceRefresh bool,
+			resultChan chan<- samples.CreationResult) {
+			defer close(resultChan)
+			resultChan <- samples.CreationResult{
+				State:       samples.Done,
+				Path:        "my path",
+				PostInstall: "my post install message",
+			}
 		}
 	}
 
@@ -113,30 +118,33 @@ func TestSampleCreateFailsWhenGetSampleConfigFails(t *testing.T) {
 }
 
 func TestSampleCreateFailsWhenIntegrationDoesntExist(t *testing.T) {
-	getSampleConfig = func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
-		return &samples.SampleConfig{
-			Integrations: []samples.SampleConfigIntegration{
-				{
-					Name:    "foo",
-					Clients: []string{"foo-client-1", "foo-client-2"},
-					Servers: []string{"foo-server-1", "foo-server-2"},
+	getSampleConfig = func(_ *samples.SampleManager) getSampleConfigFunc {
+		return func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
+			return &samples.SampleConfig{
+				Integrations: []samples.SampleConfigIntegration{
+					{
+						Name:    "foo",
+						Clients: []string{"foo-client-1", "foo-client-2"},
+						Servers: []string{"foo-server-1", "foo-server-2"},
+					},
 				},
-			},
-		}, nil
+			}, nil
+		}
 	}
 
-	createSample = func(
-		ctx context.Context,
-		config *config.Config,
-		sampleName string,
-		selectedConfig *samples.SelectedConfig,
-		destination string, forceRefresh bool,
-		resultChan chan<- samples.CreationResult) {
-		defer close(resultChan)
-		resultChan <- samples.CreationResult{
-			State:       samples.Done,
-			Path:        "my path",
-			PostInstall: "my post install message",
+	createSample = func(_ *samples.SampleManager) createSampleFunc {
+		return func(
+			ctx context.Context,
+			sampleName string,
+			selectedConfig *samples.SelectedConfig,
+			destination string, forceRefresh bool,
+			resultChan chan<- samples.CreationResult) {
+			defer close(resultChan)
+			resultChan <- samples.CreationResult{
+				State:       samples.Done,
+				Path:        "my path",
+				PostInstall: "my post install message",
+			}
 		}
 	}
 
@@ -162,29 +170,32 @@ func TestSampleCreateFailsWhenIntegrationDoesntExist(t *testing.T) {
 }
 
 func TestSampleCreateFailsWhenCreateSampleFails(t *testing.T) {
-	getSampleConfig = func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
-		return &samples.SampleConfig{
-			Integrations: []samples.SampleConfigIntegration{
-				{
-					Name:    "foo",
-					Clients: []string{"foo-client-1", "foo-client-2"},
-					Servers: []string{"foo-server-1", "foo-server-2"},
+	getSampleConfig = func(_ *samples.SampleManager) getSampleConfigFunc {
+		return func(sampleName string, forceRefresh bool) (*samples.SampleConfig, error) {
+			return &samples.SampleConfig{
+				Integrations: []samples.SampleConfigIntegration{
+					{
+						Name:    "foo",
+						Clients: []string{"foo-client-1", "foo-client-2"},
+						Servers: []string{"foo-server-1", "foo-server-2"},
+					},
 				},
-			},
-		}, nil
+			}, nil
+		}
 	}
 
-	createSample = func(
-		ctx context.Context,
-		config *config.Config,
-		sampleName string,
-		selectedConfig *samples.SelectedConfig,
-		destination string,
-		forceRefresh bool,
-		resultChan chan<- samples.CreationResult) {
-		defer close(resultChan)
-		resultChan <- samples.CreationResult{
-			Err: errors.New("createSample failed"),
+	createSample = func(_ *samples.SampleManager) createSampleFunc {
+		return func(
+			ctx context.Context,
+			sampleName string,
+			selectedConfig *samples.SelectedConfig,
+			destination string,
+			forceRefresh bool,
+			resultChan chan<- samples.CreationResult) {
+			defer close(resultChan)
+			resultChan <- samples.CreationResult{
+				Err: errors.New("createSample failed"),
+			}
 		}
 	}
 
diff --git a/pkg/samples/os_test.go b/pkg/samples/os_test.go
index a032300..a359888 100644
--- a/pkg/samples/os_test.go
+++ b/pkg/samples/os_test.go
@@ -31,13 +31,13 @@ func TestCacheFolder(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	viper.SetFs(fs)
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
 
 	expectedPath := filepath.Join(home(), ".config", "stripe", "samples-cache")
 
-	path, _ := sample.cacheFolder()
+	path, _ := sampleManager.cacheFolder()
 	pathExists, err := afero.Exists(fs, path)
 
 	assert.Equal(t, expectedPath, path)
@@ -49,13 +49,13 @@ func TestAppCacheFolder(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	viper.SetFs(fs)
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
 
 	expectedPath := filepath.Join(home(), ".config", "stripe", "samples-cache", "bender")
 
-	path, err := sample.appCacheFolder("bender")
+	path, err := sampleManager.appCacheFolder("bender")
 
 	assert.Equal(t, expectedPath, path)
 	assert.Nil(t, err)
@@ -65,14 +65,14 @@ func TestMakeFolder(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	viper.SetFs(fs)
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
 
 	wd, _ := os.Getwd()
 	expectedPath := filepath.Join(wd, "bender")
 
-	path, err := sample.MakeFolder("bender")
+	path, err := sampleManager.MakeFolder("bender")
 	exists, _ := afero.Exists(fs, path)
 
 	assert.Equal(t, expectedPath, path)
@@ -80,7 +80,7 @@ func TestMakeFolder(t *testing.T) {
 	assert.Nil(t, err)
 
 	absolutePath := filepath.Join(wd, "absolute/path/indeed")
-	path, err = sample.MakeFolder(absolutePath)
+	path, err = sampleManager.MakeFolder(absolutePath)
 	exists, _ = afero.Exists(fs, path)
 	assert.Equal(t, absolutePath, path)
 	assert.True(t, exists)
@@ -91,7 +91,7 @@ func TestMakeFolderExists(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	viper.SetFs(fs)
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
 
@@ -99,7 +99,7 @@ func TestMakeFolderExists(t *testing.T) {
 	preExistingPath := filepath.Join(wd, "bender")
 	fs.MkdirAll(preExistingPath, os.ModePerm)
 
-	path, err := sample.MakeFolder("bender")
+	path, err := sampleManager.MakeFolder("bender")
 
 	assert.Equal(t, "", path)
 	assert.EqualError(t, err, fmt.Sprintf("Path already exists, aborting: %s", preExistingPath))
@@ -113,10 +113,10 @@ func TestGetFolders(t *testing.T) {
 	fs.Mkdir("leela", os.ModePerm)
 	fs.Create("zoidberg")
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
-	folders, err := sample.GetFolders("/")
+	folders, err := sampleManager.GetFolders("/")
 
 	assert.Nil(t, err)
 	assert.ElementsMatch(t, []string{"bender", "fry", "leela"}, folders)
@@ -130,10 +130,10 @@ func TestGetFiles(t *testing.T) {
 	fs.Create("leela")
 	fs.Mkdir("zoidberg", os.ModePerm)
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 	}
-	files, err := sample.GetFiles("/")
+	files, err := sampleManager.GetFiles("/")
 
 	assert.Nil(t, err)
 	assert.ElementsMatch(t, []string{"bender", "fry", "leela"}, files)
diff --git a/pkg/samples/samples_test.go b/pkg/samples/samples_test.go
index 44b33e4..ca8a2cd 100644
--- a/pkg/samples/samples_test.go
+++ b/pkg/samples/samples_test.go
@@ -51,49 +51,57 @@ func makeRecipe(fs afero.Fs, path string, integrations []string, languages []str
 	}
 }
 
+type testSampleLister struct {
+	data map[string]*SampleData
+}
+
+func (l testSampleLister) ListSamples(mode string) (map[string]*SampleData, error) {
+	return l.data, nil
+}
 func TestInitialize(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	name := "accept-a-payment"
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 		Git: &mockGit{
 			fs: fs,
 		},
-		SamplesList: map[string]*SampleData{
-			"accept-a-payment": {
-				Name:        "accept-a-payment",
-				Description: "Learn how to accept a payment",
-				URL:         "https://github.com/stripe-samples/accept-a-payment",
-			},
+		SampleLister: testSampleLister{
+			data: map[string]*SampleData{
+				"accept-a-payment": {
+					Name:        "accept-a-payment",
+					Description: "Learn how to accept a payment",
+					URL:         "https://github.com/stripe-samples/accept-a-payment",
+				}},
 		},
 	}
 
-	err := sample.Initialize(name)
+	err := sampleManager.Initialize(name)
 	assert.Nil(t, err)
-	assert.ElementsMatch(t, sample.SampleConfig.IntegrationNames(), []string{"webhooks", "no-webhooks"})
-	assert.ElementsMatch(t, sample.SampleConfig.integrationServers("webhooks"), []string{"node", "python", "ruby"})
+	assert.ElementsMatch(t, sampleManager.SampleConfig.IntegrationNames(), []string{"webhooks", "no-webhooks"})
+	assert.ElementsMatch(t, sampleManager.SampleConfig.integrationServers("webhooks"), []string{"node", "python", "ruby"})
 }
 
 func TestInitializeFailsWithEmptyName(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	name := ""
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 		Git: &mockGit{
 			fs: fs,
 		},
-		SamplesList: map[string]*SampleData{
+		SampleLister: testSampleLister{map[string]*SampleData{
 			"accept-a-payment": {
 				Name:        "accept-a-payment",
 				Description: "Learn how to accept a payment",
 				URL:         "https://github.com/stripe-samples/accept-a-payment",
 			},
-		},
+		}},
 	}
 
-	err := sample.Initialize(name)
+	err := sampleManager.Initialize(name)
 	assert.Equal(t, errors.New("Sample name is empty"), err)
 }
 
@@ -101,20 +109,20 @@ func TestInitializeFailsWithNonexistentSample(t *testing.T) {
 	fs := afero.NewMemMapFs()
 	name := "foo"
 
-	sample := Samples{
+	sampleManager := SampleManager{
 		Fs: fs,
 		Git: &mockGit{
 			fs: fs,
 		},
-		SamplesList: map[string]*SampleData{
+		SampleLister: testSampleLister{map[string]*SampleData{
 			"accept-a-payment": {
 				Name:        "accept-a-payment",
 				Description: "Learn how to accept a payment",
 				URL:         "https://github.com/stripe-samples/accept-a-payment",
 			},
-		},
+		}},
 	}
 
-	err := sample.Initialize(name)
+	err := sampleManager.Initialize(name)
 	assert.Equal(t, errors.New("Sample foo does not exist"), err)
 }