diff --git a/.travis.yml b/.travis.yml index ce46501..9995365 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ go: - 1.11.x before_install: - - DEP_RELEASE_TAG='v0.4.1' curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + - DEP_RELEASE_TAG='v0.5.0' curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh install: - dep ensure -v -vendor-only diff --git a/README.md b/README.md index 8b11e7e..9bfb060 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ Terminer is an cross-platform installer for terminal presets. Install Fish or ZS - [Usage](#usage) - [Quick start](#quick-start) - [Recipe](#recipe) -- [Recipes](#recipes) - [Available commands](#available-commands) - [`install`](#install) - [`rollback`](#rollback) @@ -50,21 +49,23 @@ The most basic commands are `install` and `rollback`. ### Quick start -To install a recipe, run: +To install a recipe from official repository, run: ```bash -terminer install [file path or URL] +terminer install [recipe name] ``` -To rollback a recipe, run: +To rollback a recipe from official repository, run: ```bash -terminer rollback [file path or URL] +terminer rollback [recipe name] ``` +To see all official recipes, navigate to the [`recipes`](./recipes) directory. + ### Recipe -Recipe is a YAML file with shell commands put in a proper order. Recipe consists of stages, which contain steps. Every step is a different shell command. +Recipe is a YAML or JSON file with shell commands put in a proper order. Recipe consists of stages, which contain steps. Every step is a different shell command. This is an example recipe, which just prints messages for all steps in all stages - not only during install, but also for rollback operation: @@ -109,50 +110,60 @@ stages: run: echo "Rollback of Step 1 of Stage 2" ``` -## Recipes - -To see all official recipes, navigate to the [`recipes`](./recipes) directory. - ## Available commands The following section describes all available commands in Terminer CLI. ### `install` -Install command installs a recipe from a local or remote file. Provide a relative or absolute path to a YAML file with recipe or an URL to download it. +Install command installs a recipe from the official recipe repository. You can use additional flags to install a recipe from a local or remote file. **Usage** ```bash -terminer install [file path or URL] +terminer install [recipe name] ``` +**Flags** + + -f, --filepath string Recipe file path + -h, --help help for install + -u, --url string Recipe URL + **Examples** ``` -terminer install ./recipe.yaml` -terminer install /Users/$USER/recipe.yaml` -terminer install https://example.com/recipe.yaml` +terminer install zsh-starter +terminer install -f ./recipe.yaml +terminer install --file /Users/sample-user/recipe.yml +terminer install -u https://example.com/recipe.yaml +terminer install --url http://foo.bar/recipe.yml ``` ### `rollback` -Rollback command rollbacks a recipe from a local or remote file. -Provide a relative or absolute path to a YAML file with recipe -or an URL to download it. +Rollback command uninstalls a recipe from the official recipe repository. You can use additional flags to rollback a recipe from a local or remote file. **Usage** ```bash -terminer rollback [file path or URL] +terminer rollback [recipe name] ``` +**Flags** + + -f, --filepath string Recipe file path + -h, --help help for install + -u, --url string Recipe URL + **Examples** ```bash -terminer rollback ./recipe.yaml -terminer rollback /Users/sample-user/recipe.yaml -terminer rollback https://example.com/recipe.yaml +terminer rollback zsh-starter +terminer rollback -f ./recipe.yaml +terminer rollback --file /Users/sample-user/recipe.yml +terminer rollback -u https://example.com/recipe.yaml +terminer rollback --url http://foo.bar/recipe.yml ``` ### `version` diff --git a/cmd/install.go b/cmd/install.go index 1da88bc..cc6c9ab 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,40 +1,30 @@ package cmd import ( - "github.com/pkosiec/terminer/internal/printer" + "github.com/pkosiec/terminer/internal/recipecmd" "github.com/spf13/cobra" ) // installCmd represents the install command var installCmd = &cobra.Command{ - Use: "install [file path or URL]", - Short: "Installs a recipe from given path or URL", - Long: `Install command installs a recipe from a local or remote file. -Provide a relative or absolute path to a YAML file with recipe -or an URL to download it. + Use: "install [recipe name]", + Short: "Installs a recipe from official repository, given path or URL", + Long: `Install command installs a recipe from the official recipe repository. +You can use additional flags to install a recipe from a local or remote file. Examples: - terminer install ./recipe.yaml - terminer install /Users/sample-user/recipe.yaml - terminer install https://example.com/recipe.yaml + terminer install zsh-starter + terminer install -f ./recipe.yaml + terminer install --file /Users/sample-user/recipe.yml + terminer install -u https://example.com/recipe.yaml + terminer install --url http://foo.bar/recipe.yml `, - Args: validateInstallRollbackArgs, - RunE: runInstall, + Args: recipecmd.ValidateArgs, + RunE: recipecmd.Run(recipecmd.Install), + DisableFlagsInUseLine: true, } func init() { + recipecmd.SupportFlags(installCmd) rootCmd.AddCommand(installCmd) } - -func runInstall(cmd *cobra.Command, args []string) error { - p := printer.New() - i, err := setupInstaller(args[0], p) - if err != nil { - return err - } - - err = i.Install() - p.Result(err) - - return nil -} diff --git a/cmd/install_test.go b/cmd/install_test.go deleted file mode 100644 index 9023c2b..0000000 --- a/cmd/install_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "io/ioutil" - "runtime" - "strings" - "testing" -) - -const ValidRecipePath = "./testdata/valid-recipe.yaml" -const InvalidRecipePath = "./testdata/invalid-recipe.yaml" -const EmptyRecipePath = "./testdata/empty-recipe.yaml" -const FailingRecipePath = "./testdata/failing-recipe.yaml" -const RecipeOSPlaceholder = "{CURRENT_OS_PLACEHOLDER}" - -func TestRunInstall(t *testing.T) { - t.Run("Success", func(t *testing.T) { - replaceOSLineInRecipe(t, ValidRecipePath, RecipeOSPlaceholder, runtime.GOOS) - - err := runInstall(nil, []string{ValidRecipePath}) - - replaceOSLineInRecipe(t, ValidRecipePath, runtime.GOOS, RecipeOSPlaceholder) - - assert.NoError(t, err) - }) - - t.Run("Invalid Path", func(t *testing.T) { - err := runInstall(nil, []string{"./testdata/file.yaml"}) - - assert.Error(t, err) - }) - - t.Run("Invalid URL", func(t *testing.T) { - err := runInstall(nil, []string{"https://example.com/foo/bar"}) - - assert.Error(t, err) - }) - - t.Run("Invalid Recipe", func(t *testing.T) { - err := runInstall(nil, []string{InvalidRecipePath}) - - assert.Error(t, err) - }) - - t.Run("Failing Recipe", func(t *testing.T) { - err := runInstall(nil, []string{FailingRecipePath}) - - assert.Error(t, err) - }) - - t.Run("Empty Recipe", func(t *testing.T) { - err := runInstall(nil, []string{EmptyRecipePath}) - - assert.Error(t, err) - }) -} - -func replaceOSLineInRecipe(t *testing.T, path, from, to string) { - input, err := ioutil.ReadFile(path) - require.NoError(t, err) - - output := strings.Replace(string(input), fmt.Sprintf("os: %s", from), fmt.Sprintf("os: %s", to), 1) - err = ioutil.WriteFile(path, []byte(output), 0644) - if err != nil { - t.Fatal(err) - } -} diff --git a/cmd/rollback.go b/cmd/rollback.go index 7b606f3..19877f0 100644 --- a/cmd/rollback.go +++ b/cmd/rollback.go @@ -1,40 +1,31 @@ package cmd import ( - "github.com/pkosiec/terminer/internal/printer" + "github.com/pkosiec/terminer/internal/recipecmd" "github.com/spf13/cobra" ) // rollbackCmd represents the rollback command var rollbackCmd = &cobra.Command{ - Use: "rollback [file path or URL]", - Short: "Rollbacks a recipe from given path or URL", - Long: `Rollback command rollbacks a recipe from a local or remote file. -Provide a relative or absolute path to a YAML file with recipe -or an URL to download it. + Use: "rollback [recipe name]", + Short: "Rollbacks a recipe from official repository, given path or URL", + Long: `Rollback command uninstalls a recipe from the official recipe repository. +You can use additional flags to rollback a recipe from a local or remote file. Examples: - terminer rollback ./recipe.yaml - terminer rollback /Users/sample-user/recipe.yaml - terminer rollback https://example.com/recipe.yaml + terminer rollback zsh-starter + terminer rollback -f ./recipe.yaml + terminer rollback --file /Users/sample-user/recipe.yml + terminer rollback -u https://example.com/recipe.yaml + terminer rollback --url http://foo.bar/recipe.yml `, - Args: validateInstallRollbackArgs, - RunE: runRollback, + Args: recipecmd.ValidateArgs, + RunE: recipecmd.Run(recipecmd.Rollback), + DisableFlagsInUseLine: true, } func init() { + recipecmd.SupportFlags(rollbackCmd) rootCmd.AddCommand(rollbackCmd) } -func runRollback(cmd *cobra.Command, args []string) error { - p := printer.New() - i, err := setupInstaller(args[0], p) - if err != nil { - return err - } - - err = i.Rollback() - p.Result(err) - - return nil -} diff --git a/cmd/rollback_test.go b/cmd/rollback_test.go deleted file mode 100644 index 7a1297e..0000000 --- a/cmd/rollback_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package cmd - -import ( - "github.com/stretchr/testify/assert" - "runtime" - "testing" -) - -func TestRunRollback(t *testing.T) { - t.Run("Success", func(t *testing.T) { - replaceOSLineInRecipe(t, ValidRecipePath, RecipeOSPlaceholder, runtime.GOOS) - - err := runRollback(nil, []string{ValidRecipePath}) - - replaceOSLineInRecipe(t, ValidRecipePath, runtime.GOOS, RecipeOSPlaceholder) - - assert.NoError(t, err) - }) - - t.Run("Invalid Path", func(t *testing.T) { - err := runRollback(nil, []string{"./testdata/file.yaml"}) - - assert.Error(t, err) - }) - - t.Run("Invalid URL", func(t *testing.T) { - err := runRollback(nil, []string{"https://example.com/foo/bar"}) - - assert.Error(t, err) - }) - - t.Run("Invalid Recipe", func(t *testing.T) { - err := runRollback(nil, []string{InvalidRecipePath}) - - assert.Error(t, err) - }) - - t.Run("Failing Recipe", func(t *testing.T) { - err := runRollback(nil, []string{FailingRecipePath}) - - assert.Error(t, err) - }) - - t.Run("Empty Recipe", func(t *testing.T) { - err := runRollback(nil, []string{EmptyRecipePath}) - - assert.Error(t, err) - }) -} diff --git a/cmd/root.go b/cmd/root.go index 2709e1c..3b74e65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,14 +2,8 @@ package cmd import ( "fmt" - "github.com/pkg/errors" - "github.com/pkosiec/terminer/internal/printer" - "github.com/pkosiec/terminer/pkg/installer" - "github.com/pkosiec/terminer/pkg/path" - "github.com/pkosiec/terminer/pkg/recipe" - "os" - "github.com/spf13/cobra" + "os" ) // rootCmd represents the base command when called without any subcommands @@ -17,7 +11,7 @@ var rootCmd = &cobra.Command{ Use: "terminer", Short: "Upgrade your terminal experience", Long: `Terminer is an cross-platform installer for terminal presets. -Install Fish or ZSH shell packed with useful plugins and +For example, install Fish or Zsh shell packed with useful plugins and sleek prompts. Use one of starter recipes or make yours. `, } @@ -30,33 +24,3 @@ func Execute() { os.Exit(1) } } - -func validateInstallRollbackArgs(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return errors.New("Requires one argument") - } - - return nil -} - -func setupInstaller(filePath string, p printer.Printer) (*installer.Installer, error) { - var r *recipe.Recipe - var err error - - if path.IsURL(filePath) { - r, err = recipe.FromURL(filePath) - } else { - r, err = recipe.FromPath(filePath) - } - - if err != nil { - return nil, err - } - - i, err := installer.New(r, p) - if err != nil { - return nil, err - } - - return i, nil -} diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 698e1b8..0000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package cmd - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestValidateInstallRollbackArgs(t *testing.T) { - t.Run("Success", func(t *testing.T) { - err := validateInstallRollbackArgs(nil, []string{"path/path"}) - - assert.NoError(t, err) - }) - - t.Run("No Arguments", func(t *testing.T) { - err := validateInstallRollbackArgs(nil, []string{}) - - assert.Error(t, err) - }) -} diff --git a/cmd/version.go b/cmd/version.go index db954f0..efff3dc 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -3,7 +3,6 @@ package cmd import ( "github.com/pkosiec/terminer/internal/metadata" "github.com/pkosiec/terminer/internal/printer" - "github.com/spf13/cobra" ) @@ -11,13 +10,13 @@ import ( var versionCmd = &cobra.Command{ Use: "version", Short: "Prints the application version", - Run: runVersion, + Run: PrintVersion, } func init() { rootCmd.AddCommand(versionCmd) } -func runVersion(_ *cobra.Command, _ []string) { +func PrintVersion(_ *cobra.Command, _ []string) { printer.New().AppInfo(metadata.AppName, metadata.Version, metadata.URL) -} +} \ No newline at end of file diff --git a/cmd/version_test.go b/cmd/version_test.go index 93e9fe9..4a39a8d 100644 --- a/cmd/version_test.go +++ b/cmd/version_test.go @@ -1,12 +1,14 @@ -package cmd +package cmd_test import ( + "github.com/pkosiec/terminer/cmd" "github.com/stretchr/testify/assert" "testing" ) -func TestRunVersion(t *testing.T) { +func TestPrintVersion(t *testing.T) { assert.NotPanics(t, func() { - runVersion(nil, nil) + cmd.PrintVersion(nil, nil) }) } + diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 192a38a..c22d29e 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -3,3 +3,17 @@ package metadata const AppName string = "Terminer" const Version string = "unreleased" const URL string = "https://github.com/pkosiec/terminer" + +type RepositoryDetails struct { + Owner string + Name string + BranchName string + RecipeDirectory string +} + +var Repository = RepositoryDetails{ + Owner: "pkosiec", + Name: "terminer", + BranchName: "master", + RecipeDirectory: "recipes", +} diff --git a/internal/recipecmd/flag.go b/internal/recipecmd/flag.go new file mode 100644 index 0000000..66696fa --- /dev/null +++ b/internal/recipecmd/flag.go @@ -0,0 +1,11 @@ +package recipecmd + +import "github.com/spf13/cobra" + +var URL string +var FilePath string + +func SupportFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&URL, "url", "u", "", "Recipe URL") + cmd.Flags().StringVarP(&FilePath, "filepath", "f", "", "Recipe file path") +} diff --git a/internal/recipecmd/run.go b/internal/recipecmd/run.go new file mode 100644 index 0000000..84c58b9 --- /dev/null +++ b/internal/recipecmd/run.go @@ -0,0 +1,64 @@ +package recipecmd + +import ( + "github.com/pkosiec/terminer/internal/printer" + "github.com/pkosiec/terminer/pkg/installer" + "github.com/pkosiec/terminer/pkg/recipe" + "github.com/spf13/cobra" + "net/http" +) + +type RunType string + +var ( + Install RunType = "Install" + Rollback RunType = "Rollback" +) + +func Run(runType RunType) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + p := printer.New() + i, err := loadRecipeAndSetupInstaller(args, URL, FilePath, p) + if err != nil { + return err + } + + err = func() error { + switch runType { + case Install: + return i.Install() + case Rollback: + return i.Rollback() + } + + return i.Install() + }() + p.Result(err) + + return nil + } +} + +func loadRecipeAndSetupInstaller(recipeNames []string, URL, filePath string, p printer.Printer) (*installer.Installer, error) { + var r *recipe.Recipe + var err error + + if len(recipeNames) > 0 && recipeNames[0] != "" { + r, err = recipe.FromRepository(recipeNames[0], http.DefaultClient) + } else if URL != "" { + r, _, err = recipe.FromURL(URL, http.DefaultClient) + } else if filePath != "" { + r, err = recipe.FromPath(filePath) + } + + if err != nil { + return nil, err + } + + i, err := installer.New(r, p) + if err != nil { + return nil, err + } + + return i, nil +} diff --git a/internal/recipecmd/run_test.go b/internal/recipecmd/run_test.go new file mode 100644 index 0000000..ad10834 --- /dev/null +++ b/internal/recipecmd/run_test.go @@ -0,0 +1,199 @@ +package recipecmd_test + +import ( + "bytes" + "github.com/pkosiec/terminer/internal/recipecmd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +const ValidRecipePath = "./testdata/valid-recipe.yaml" +const InvalidRecipePath = "./testdata/invalid-recipe.yaml" +const EmptyRecipePath = "./testdata/empty-recipe.yaml" +const FailingRecipePath = "./testdata/failing-recipe.yaml" + +func TestRun(t *testing.T) { + filePathBak := recipecmd.FilePath + urlBak := recipecmd.URL + + t.Run("Install", func(t *testing.T) { + installFn := recipecmd.Run(recipecmd.Install) + + t.Run("Valid recipe from path", func(t *testing.T) { + recipecmd.FilePath = ValidRecipePath + recipecmd.URL = "" + err := installFn(nil, []string{}) + + assert.NoError(t, err) + }) + + t.Run("Valid recipe from URL", func(t *testing.T) { + server := setupRemoteRecipeServer(t, "./testdata/valid-recipe.yaml") + defer server.Close() + + recipecmd.FilePath = "" + recipecmd.URL = server.URL + err := installFn(nil, []string{}) + + require.NoError(t, err) + }) + + t.Run("Invalid Recipe from path", func(t *testing.T) { + recipecmd.FilePath = InvalidRecipePath + recipecmd.URL = "" + err := installFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Invalid path", func(t *testing.T) { + path := "./testdata/file.yaml" + + recipecmd.FilePath = path + recipecmd.URL = "" + err := installFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Invalid URL", func(t *testing.T) { + url := "https://example.com/foo/bar" + + recipecmd.FilePath = "" + recipecmd.URL = url + err := installFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Failing Recipe", func(t *testing.T) { + path := FailingRecipePath + + recipecmd.FilePath = path + recipecmd.URL = "" + + err := installFn(nil, []string{}) + + // should not exit with error. It should print error instead + // TODO: Test printing error + assert.NoError(t, err) + }) + + t.Run("Empty Recipe", func(t *testing.T) { + path := EmptyRecipePath + + recipecmd.FilePath = path + recipecmd.URL = "" + err := installFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Too many parameters", func(t *testing.T) { + recipecmd.FilePath = "" + recipecmd.URL = "" + err := installFn(nil, []string{"test", "test2"}) + + assert.Error(t, err) + }) + }) + + t.Run("Rollback", func(t *testing.T) { + rollbackFn := recipecmd.Run(recipecmd.Rollback) + + t.Run("Valid recipe", func(t *testing.T) { + recipecmd.FilePath = ValidRecipePath + recipecmd.URL = "" + err := rollbackFn(nil, []string{}) + + assert.NoError(t, err) + }) + + t.Run("Invalid Recipe", func(t *testing.T) { + recipecmd.FilePath = InvalidRecipePath + recipecmd.URL = "" + err := rollbackFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Invalid Path", func(t *testing.T) { + path := "./testdata/file.yaml" + + recipecmd.FilePath = path + recipecmd.URL = "" + err := rollbackFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Invalid URL", func(t *testing.T) { + url := "https://example.com/foo/bar" + + recipecmd.FilePath = "" + recipecmd.URL = url + err := rollbackFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Failing Recipe", func(t *testing.T) { + path := FailingRecipePath + + recipecmd.FilePath = path + recipecmd.URL = "" + err := rollbackFn(nil, []string{}) + + // should not exit with error. It should print error instead + // TODO: Test printing error + assert.NoError(t, err) + }) + + t.Run("Empty Recipe", func(t *testing.T) { + path := EmptyRecipePath + + recipecmd.FilePath = path + recipecmd.URL = "" + err := rollbackFn(nil, []string{}) + + assert.Error(t, err) + }) + + t.Run("Too many parameters", func(t *testing.T) { + recipecmd.FilePath = "" + recipecmd.URL = "" + err := rollbackFn(nil, []string{"test", "test2"}) + + assert.Error(t, err) + }) + }) + + recipecmd.FilePath = filePathBak + recipecmd.URL = urlBak +} + +func setupRemoteRecipeServer(t *testing.T, recipePath string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + yamlFile, err := ioutil.ReadFile(recipePath) + require.NoError(t, err) + _, err = w.Write(yamlFile) + require.NoError(t, err) + })) + + return server +} + +func output(fn func()) string { + var b bytes.Buffer + log.SetOutput(&b) + fn() + log.SetOutput(os.Stderr) + + return b.String() +} diff --git a/cmd/testdata/empty-recipe.yaml b/internal/recipecmd/testdata/empty-recipe.yaml similarity index 86% rename from cmd/testdata/empty-recipe.yaml rename to internal/recipecmd/testdata/empty-recipe.yaml index 6032874..177d26e 100644 --- a/cmd/testdata/empty-recipe.yaml +++ b/internal/recipecmd/testdata/empty-recipe.yaml @@ -1,4 +1,4 @@ -os: {CURRENT_OS_PLACEHOLDER} +os: any metadata: name: Recipe diff --git a/cmd/testdata/failing-recipe.yaml b/internal/recipecmd/testdata/failing-recipe.yaml similarity index 87% rename from cmd/testdata/failing-recipe.yaml rename to internal/recipecmd/testdata/failing-recipe.yaml index d38e960..60ddbfe 100644 --- a/cmd/testdata/failing-recipe.yaml +++ b/internal/recipecmd/testdata/failing-recipe.yaml @@ -1,4 +1,4 @@ -os: {CURRENT_OS_PLACEHOLDER} +os: any metadata: name: Recipe @@ -16,6 +16,8 @@ stages: execute: run: - exit 1 + - echo "Test" rollback: run: - exit 1 + - echo "Test" diff --git a/cmd/testdata/invalid-recipe.yaml b/internal/recipecmd/testdata/invalid-recipe.yaml similarity index 100% rename from cmd/testdata/invalid-recipe.yaml rename to internal/recipecmd/testdata/invalid-recipe.yaml diff --git a/cmd/testdata/valid-recipe.yaml b/internal/recipecmd/testdata/valid-recipe.yaml similarity index 94% rename from cmd/testdata/valid-recipe.yaml rename to internal/recipecmd/testdata/valid-recipe.yaml index f791c75..e56f81e 100644 --- a/cmd/testdata/valid-recipe.yaml +++ b/internal/recipecmd/testdata/valid-recipe.yaml @@ -1,4 +1,4 @@ -os: {CURRENT_OS_PLACEHOLDER} +os: any metadata: name: Recipe @@ -24,6 +24,8 @@ stages: url: https://step2.stage1.example.com execute: run: + - ">&2 echo 'Write to error pipe'" + - echo '' - echo "Step 2 of Stage 1" rollback: run: diff --git a/internal/recipecmd/validate.go b/internal/recipecmd/validate.go new file mode 100644 index 0000000..abb843f --- /dev/null +++ b/internal/recipecmd/validate.go @@ -0,0 +1,16 @@ +package recipecmd + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func ValidateArgs(_ *cobra.Command, args []string) error { + if (len(args) == 0 || len(args) > 1 ) && URL == "" && FilePath == "" { + return errors.New(`This command requires single recipe name from the official repository. +You can also use additional flags to load recipe from disk or URL. +`) + } + + return nil +} diff --git a/internal/recipecmd/validate_test.go b/internal/recipecmd/validate_test.go new file mode 100644 index 0000000..af0c498 --- /dev/null +++ b/internal/recipecmd/validate_test.go @@ -0,0 +1,57 @@ +package recipecmd_test + +import ( + "fmt" + "github.com/pkosiec/terminer/internal/recipecmd" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestValidateArgs(t *testing.T) { + filePathBak := recipecmd.FilePath + urlBak := recipecmd.URL + + testCases := []struct{ + FilePath string + URL string + args []string + expectedErr bool + }{ + { + FilePath: "./test.md", + expectedErr: false, + }, + { + URL: "https://example.com", + expectedErr: false, + }, + { + args: []string{"test-recipe"}, + expectedErr: false, + }, + { + args: []string{"test-recipe", "test-recipe2"}, + expectedErr: true, + }, + { + expectedErr: true, + }, + } + + for tN, tC := range testCases { + t.Run(fmt.Sprintf("Test Case %d", tN), func(t *testing.T) { + recipecmd.URL = tC.URL + recipecmd.FilePath = tC.FilePath + err := recipecmd.ValidateArgs(nil, tC.args) + + if tC.expectedErr { + assert.Error(t, err) + } else { + assert.Nil(t, err) + } + }) + } + + recipecmd.FilePath = filePathBak + recipecmd.URL = urlBak +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index eb3aab1..3c9f4a2 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -82,7 +82,6 @@ func (installer *Installer) Rollback() error { installer.printer.Step(stepIndex, stepsLen, step.Metadata) - err := installer.sh.Exec(step.Rollback, false) if err != nil { hasErrorOccurred = true diff --git a/pkg/path/path.go b/pkg/path/path.go index dd9ccf2..ac6e4be 100644 --- a/pkg/path/path.go +++ b/pkg/path/path.go @@ -1,6 +1,8 @@ package path -import "strings" +import ( + "strings" +) // IsURL checks if given string is an URL func IsURL(path string) bool { diff --git a/pkg/recipe/automock/http_client.go b/pkg/recipe/automock/http_client.go new file mode 100644 index 0000000..da090e8 --- /dev/null +++ b/pkg/recipe/automock/http_client.go @@ -0,0 +1,33 @@ +// Code generated by mockery v1.0.0 +package automock + +import http "net/http" +import mock "github.com/stretchr/testify/mock" + +// HTTPClient is an autogenerated mock type for the HTTPClient type +type HTTPClient struct { + mock.Mock +} + +// Get provides a mock function with given fields: url +func (_m *HTTPClient) Get(url string) (*http.Response, error) { + ret := _m.Called(url) + + var r0 *http.Response + if rf, ok := ret.Get(0).(func(string) *http.Response); ok { + r0 = rf(url) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Response) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(url) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index 7552f96..d267d61 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -1,7 +1,10 @@ package recipe import ( + "encoding/json" "fmt" + "github.com/pkosiec/terminer/internal/metadata" + "github.com/pkosiec/terminer/pkg/path" "github.com/pkosiec/terminer/pkg/shell" "io/ioutil" "net/http" @@ -13,31 +16,34 @@ import ( "gopkg.in/yaml.v2" ) +// AnyOS is OS string that matches any operating system +const AnyOS = "any" + // UnitMetadata stores metadata for a generic Recipe unit, such as Recipe, Stage or Step type UnitMetadata struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - URL string `yaml:"url"` + Name string `yaml:"name",json:"name"` + Description string `yaml:"description",json:"description"` + URL string `yaml:"url",json:"url"` } // Recipe stores needed steps to install a gjven piece of functionality type Recipe struct { - OS string `yaml:"os"` - Metadata UnitMetadata - Stages []Stage + OS string `yaml:"os",json:"os"` + Metadata UnitMetadata `yaml:"metadata",json:"metadata"` + Stages []Stage `yaml:"stages",json:"stages"` } // Stage represents a logical part of recipe that consists of steps type Stage struct { - Metadata UnitMetadata - Steps []Step + Metadata UnitMetadata `yaml:"metadata",json:"metadata"` + Steps []Step `yaml:"steps",json:"steps"` } // Step contains data about a single shell command, which can be installed or reverted type Step struct { - Metadata UnitMetadata - Execute shell.Command - Rollback shell.Command + Metadata UnitMetadata `yaml:"metadata",json:"metadata"` + Execute shell.Command `yaml:"execute",json:"execute"` + Rollback shell.Command `yaml:"rollback",json:"rollback"` } // FromPath creates a Recipe from given file @@ -60,33 +66,75 @@ func FromPath(path string) (*Recipe, error) { return recipe, nil } +// HTTPClient is an interface that is used for HTTP requests +//go:generate mockery -name=HTTPClient -output=automock -outpkg=automock -case=underscore +type HTTPClient interface { + Get(url string) (resp *http.Response, err error) +} + // FromPath downloads a file from given URL and uses it to create a Recipe -func FromURL(url string) (*Recipe, error) { - res, err := http.Get(url) +func FromURL(url string, httpClient HTTPClient) (*Recipe, int, error) { + if !path.IsURL(url) { + return nil, 0, fmt.Errorf("Incorrect recipe URL") + } + + res, err := httpClient.Get(url) if err != nil { - return nil, errors.Wrapf(err, "while requesting recipe from URL %s", url) + return nil, 0, errors.Wrapf(err, "while requesting recipe from URL %s", url) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Invalid status code while downloading file from URL %s: %d. Expected: %d", url, res.StatusCode, http.StatusOK) + return nil, res.StatusCode, fmt.Errorf("Invalid status code while downloading file from URL %s: %d. Expected: %d", url, res.StatusCode, http.StatusOK) } bytes, err := ioutil.ReadAll(res.Body) if err != nil { - return nil, errors.Wrapf(err, "while reading response body while downloading file from URL %s", url) + return nil, res.StatusCode, errors.Wrapf(err, "while reading response body while downloading file from URL %s", url) } if len(bytes) == 0 { - return nil, fmt.Errorf("Empty body while downloading file from URL %s", url) + return nil, res.StatusCode, fmt.Errorf("Empty body while downloading file from URL %s", url) } recipe, err := unmarshalRecipe(bytes) if err != nil { - return nil, errors.Wrapf(err, "while loading recipe from URL %s", url) + return nil, res.StatusCode, errors.Wrapf(err, "while loading recipe from URL %s", url) } - return recipe, nil + return recipe, res.StatusCode, nil +} + +// FromRepository downloads a recipe from official recipes repository +func FromRepository(recipeName string, httpClient HTTPClient) (*Recipe, error) { + url := fmt.Sprintf( + "https://raw.githubusercontent.com/%s/%s/%s/%s/%s/%s.yaml", + metadata.Repository.Owner, + metadata.Repository.Name, + metadata.Repository.BranchName, + metadata.Repository.RecipeDirectory, + recipeName, + runtime.GOOS, + ) + + recipeListURL := fmt.Sprintf("https://github.com/%s/%s/tree/%s/%s", + metadata.Repository.Owner, + metadata.Repository.Name, + metadata.Repository.BranchName, + metadata.Repository.RecipeDirectory, + ) + + var statusCode int + r, statusCode, err := FromURL(url, httpClient) + if err != nil { + if statusCode == http.StatusNotFound { + return nil, fmt.Errorf("Cannot find recipe `%s` on official repository.\nSee the official list of the recipes on %s\n", recipeName, recipeListURL) + } + + return nil, errors.Wrapf(err, "Error while finding recipe `%s` on official repository", recipeName) + } + + return r, nil } // Validate checks if the recipe is valid to run on current OS and whether all stages and steps are not empty @@ -108,8 +156,8 @@ func validateExtension(path string) error { ext := filepath.Ext(path) lowercaseExt := strings.ToLower(ext) - if lowercaseExt != ".yaml" && lowercaseExt != ".yml" { - return fmt.Errorf("Invalid file extension `%s`. Expected: yaml or yml", ext) + if lowercaseExt != ".yaml" && lowercaseExt != ".yml" && lowercaseExt != ".json" { + return fmt.Errorf("Invalid file extension `%s`. Expected: yaml, yml or json", ext) } return nil @@ -117,13 +165,19 @@ func validateExtension(path string) error { func unmarshalRecipe(bytes []byte) (*Recipe, error) { var recipe *Recipe + + if json.Valid(bytes) { + err := json.Unmarshal(bytes, &recipe) + return recipe, err + } + err := yaml.Unmarshal(bytes, &recipe) return recipe, err } func (r *Recipe) validateOS() error { os := runtime.GOOS - if r.OS != os { + if r.OS != os && r.OS != AnyOS { return fmt.Errorf("Invalid operating system. Required: %s. Actual: %s", r.OS, os) } diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index ce9c21b..5ff8ad4 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -1,7 +1,12 @@ package recipe_test import ( + "bytes" + "encoding/json" + "fmt" + "github.com/pkg/errors" "github.com/pkosiec/terminer/pkg/recipe" + "github.com/pkosiec/terminer/pkg/recipe/automock" "github.com/pkosiec/terminer/pkg/shell" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,7 +18,7 @@ import ( ) func TestFromPath(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Success YAML", func(t *testing.T) { expected := fixRecipe("testos") r, err := recipe.FromPath("./testdata/valid-recipe.yaml") @@ -22,6 +27,15 @@ func TestFromPath(t *testing.T) { assert.Equal(t, expected, r) }) + t.Run("Success JSON", func(t *testing.T) { + expected := fixRecipe("testos") + + r, err := recipe.FromPath("./testdata/valid-recipe.json") + + require.NoError(t, err) + assert.Equal(t, expected, r) + }) + t.Run("Invalid Path", func(t *testing.T) { _, err := recipe.FromPath("./testdata/no-file-exist.yaml") @@ -44,30 +58,101 @@ func TestFromPath(t *testing.T) { }) } +func TestFromRepository(t *testing.T) { + t.Run("Success", func(t *testing.T) { + expected := &recipe.Recipe{ + OS: "test", + Metadata: recipe.UnitMetadata{ + Name: "Foo", + URL: "foo.bar", + Description: "Lorem ipsum", + }, + Stages: []recipe.Stage{ + { + Metadata: recipe.UnitMetadata{ + Name: "test", + }, + }, + }, + } + + b, err := json.Marshal(expected) + require.NoError(t, err) + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader(b)), + } + + httpCli := automock.HTTPClient{} + httpCli.On("Get", fmt.Sprintf("https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/foo/%s.yaml", runtime.GOOS)).Return(resp, nil) + + r, err := recipe.FromRepository("foo", &httpCli) + + require.NoError(t, err) + assert.Equal(t, expected, r) + }) + + t.Run("Not found", func(t *testing.T) { + resp := http.Response{ + StatusCode: http.StatusNotFound, + Body: ioutil.NopCloser(nil), + } + + httpCli := automock.HTTPClient{} + httpCli.On("Get", fmt.Sprintf("https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/foo/%s.yaml", runtime.GOOS)).Return(&resp, nil) + + _, err := recipe.FromRepository("foo", &httpCli) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Cannot find") + }) + + t.Run("Error", func(t *testing.T) { + testErr := errors.New("Test error") + httpCli := automock.HTTPClient{} + httpCli.On("Get", fmt.Sprintf("https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/foo/%s.yaml", runtime.GOOS)).Return(nil, testErr) + + _, err := recipe.FromRepository("foo", &httpCli) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Test error") + }) +} + func TestFromURL(t *testing.T) { t.Run("Success", func(t *testing.T) { expected := fixRecipe("testos") server := setupRemoteRecipeServer(t, "./testdata/valid-recipe.yaml", false) defer server.Close() - r, err := recipe.FromURL(server.URL) + r, _, err := recipe.FromURL(server.URL, http.DefaultClient) require.NoError(t, err) assert.Equal(t, expected, r) }) t.Run("Not existing path", func(t *testing.T) { - _, err := recipe.FromURL("http://foo-bar.not-existing.url") + _, _, err := recipe.FromURL("http://foo-bar.not-existing.url", http.DefaultClient) require.Error(t, err) assert.Contains(t, err.Error(), "while requesting") }) + t.Run("Invalid URL", func(t *testing.T) { + _, _, err := recipe.FromURL("foo-bar.whatever", nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Incorrect recipe URL") + }) + t.Run("Server Error", func(t *testing.T) { server := setupRemoteRecipeServer(t, "", true) defer server.Close() - _, err := recipe.FromURL(server.URL) + _, statusCode, err := recipe.FromURL(server.URL, http.DefaultClient) + + assert.Equal(t, http.StatusInternalServerError, statusCode) require.Error(t, err) assert.Contains(t, err.Error(), "Invalid status code") @@ -79,7 +164,7 @@ func TestFromURL(t *testing.T) { })) defer server.Close() - _, err := recipe.FromURL(server.URL) + _, _, err := recipe.FromURL(server.URL, http.DefaultClient) require.Error(t, err) assert.Contains(t, err.Error(), "while reading response body") @@ -91,7 +176,7 @@ func TestFromURL(t *testing.T) { })) defer server.Close() - _, err := recipe.FromURL(server.URL) + _, _, err := recipe.FromURL(server.URL, http.DefaultClient) require.Error(t, err) assert.Contains(t, err.Error(), "Empty body") @@ -101,7 +186,7 @@ func TestFromURL(t *testing.T) { server := setupRemoteRecipeServer(t, "./testdata/invalid-recipe.yaml", false) defer server.Close() - _, err := recipe.FromURL(server.URL) + _, _, err := recipe.FromURL(server.URL, http.DefaultClient) require.Error(t, err) assert.Contains(t, err.Error(), "while loading recipe from URL") @@ -117,6 +202,14 @@ func TestValidate(t *testing.T) { assert.NoError(t, err) }) + t.Run("All OSes", func(t *testing.T) { + r := fixRecipe("any") + + err := r.Validate() + + assert.NoError(t, err) + }) + t.Run("Invalid OS", func(t *testing.T) { r := fixRecipe("notexistingos") diff --git a/pkg/recipe/testdata/valid-recipe.json b/pkg/recipe/testdata/valid-recipe.json new file mode 100644 index 0000000..b21161e --- /dev/null +++ b/pkg/recipe/testdata/valid-recipe.json @@ -0,0 +1,92 @@ +{ + "os": "testos", + "metadata": { + "name": "Recipe", + "description": "Recipe Description" + }, + "stages": [ + { + "metadata": { + "name": "Stage 1", + "description": "Stage 1 description", + "url": "https://stage1.example.com" + }, + "steps": [ + { + "metadata": { + "name": "Step 1", + "url": "https://step1.stage1.example.com" + }, + "execute": { + "run": [ + "echo \"Step 1 of Stage 1\"" + ] + }, + "rollback": { + "run": [ + "echo \"Rollback of Step 1 of Stage 1\"" + ] + } + }, + { + "metadata": { + "name": "Step 2", + "url": "https://step2.stage1.example.com" + }, + "execute": { + "run": [ + "echo \"Step 2 of Stage 1\"" + ] + }, + "rollback": { + "run": [ + "echo \"Rollback of Step 2 of Stage 1\"" + ] + } + } + ] + }, + { + "metadata": { + "name": "Stage 2", + "description": "Stage 2 description", + "url": "https://stage2.example.com" + }, + "steps": [ + { + "metadata": { + "name": "Step 1", + "url": "https://step1.stage2.example.com" + }, + "execute": { + "run": [ + "echo \"Step 1 of Stage 2\"" + ], + "shell": "sh" + }, + "rollback": { + "run": [ + "echo \"Rollback of Step 1 of Stage 2\"" + ] + } + }, + { + "metadata": { + "name": "Step 2", + "url": "https://step2.stage2.example.com" + }, + "execute": { + "run": [ + "echo \"Step 2 of Stage 2\"" + ] + }, + "rollback": { + "run": [ + "echo \"Rollback of Step 2 of Stage 2\"" + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 19cb57b..0942de5 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" "io" "os/exec" + "strings" ) // PrintFn prints command output @@ -13,9 +14,9 @@ type PrintFn func(string) // Command represents command to execute in given shell type Command struct { - Run []string - Shell string - Root bool + Run []string `yaml:"run",json:"run"` + Shell string `yaml:"shell",json:"shell"` + Root bool `yaml:"root",json:"root"` } // Shell gives an ability to run shell commands @@ -44,8 +45,15 @@ func (s *shell) Exec(command Command, stopOnError bool) error { command.Shell = DefaultShell } + var errMessages []string + for _, singleCmd := range command.Run { - s.printCmd(singleCmd) + var prefix string + if command.Root { + prefix = "$ " + } + + s.printCmd(fmt.Sprintf("%s%s", prefix, singleCmd)) var cmd *exec.Cmd if command.Root { @@ -55,11 +63,20 @@ func (s *shell) Exec(command Command, stopOnError bool) error { } err := s.runCmd(cmd) - if err != nil && stopOnError { - return errors.Wrapf(err, "while executing %s", singleCmd) + if err != nil { + wrappedErr := errors.Wrapf(err, "while executing %s", singleCmd) + if stopOnError { + return wrappedErr + } + + errMessages = append(errMessages, wrappedErr.Error()) } } + if len(errMessages) > 0 { + return errors.New(strings.Join(errMessages, ",\n")) + } + return nil } @@ -100,7 +117,7 @@ func (s *shell) preparePipeScan(pipe io.ReadCloser, printer PrintFn) { }() } -// TODO: How to test it? +// TODO: Test it func (s *shell) rootCommand(shell, cmd string) *exec.Cmd { if !s.isCommandAvailable("sudo") { return exec.Command("su", "-s", shell, "-c", cmd) diff --git a/pkg/shell/shell_test.go b/pkg/shell/shell_test.go index 4a328d2..23e3a93 100644 --- a/pkg/shell/shell_test.go +++ b/pkg/shell/shell_test.go @@ -199,7 +199,8 @@ func TestShell_Exec(t *testing.T) { }, Root: false, }, false) - require.NoError(t, err) + require.Error(t, err) + assert.Contains(t, err.Error(), "while executing exit 1") }) } diff --git a/recipes/fish-starter/README.md b/recipes/fish-starter/README.md index 2d7f6ef..d60a7a5 100644 --- a/recipes/fish-starter/README.md +++ b/recipes/fish-starter/README.md @@ -2,38 +2,24 @@ Fish shell starter pack. Installs fish shell along with `fisher` package manager, some useful `fisher` packages and `pure` prompt. +**Compatibility:** macOS, Linux + ## Usage > :construction: I am aware that the current installation process is cumbersome. The user experience will be heavily improved in following releases. ### Installation -To install this recipe, run one of the following commands, applicable for your operating system: - -**macOS** - -```bash -terminer install https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/fish-starter/darwin.yaml -``` - -**Linux** +To install this recipe, run: ```bash -terminer install https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/fish-starter/linux.yaml +terminer install zsh-starter ``` ### Rollback -To uninstall this recipe, run one of the following commands, applicable for your operating system: - -**macOS** - -```bash -terminer rollback https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/fish-starter/darwin.yaml -``` - -**Linux** +To uninstall this recipe, run the following command: ```bash -terminer rollback https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/fish-starter/linux.yaml +terminer rollback zsh-starter ``` diff --git a/recipes/zsh-starter/README.md b/recipes/zsh-starter/README.md index 7cdcf2c..5ca0e51 100644 --- a/recipes/zsh-starter/README.md +++ b/recipes/zsh-starter/README.md @@ -2,38 +2,24 @@ Zsh shell starter packs. Installs Zsh shell along with `oh-my-zsh` framework, some useful packages and `pure` prompt. +**Compatibility:** macOS, Linux + ## Usage > :construction: I am aware that the current installation process is cumbersome. The user experience will be heavily improved in following releases. ### Installation -To install this recipe, run one of the following commands, applicable for your operating system: - -**macOS** - -```bash -terminer install https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/zsh-starter/darwin.yaml -``` - -**Linux** +To install this recipe, run: ```bash -terminer install https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/zsh-starter/linux.yaml +terminer install zsh-starter ``` ### Rollback -To uninstall this recipe, run one of the following commands, applicable for your operating system: - -**macOS** - -```bash -terminer rollback https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/zsh-starter/darwin.yaml -``` - -**Linux** +To uninstall this recipe, run the following command: ```bash -terminer rollback https://raw.githubusercontent.com/pkosiec/terminer/master/recipes/zsh-starter/linux.yaml +terminer rollback zsh-starter ```