diff --git a/execute/execute.go b/execute/execute.go index cc5bdaea..d6e98c4b 100644 --- a/execute/execute.go +++ b/execute/execute.go @@ -7,28 +7,45 @@ import ( "fmt" "io" "os/exec" + "strings" ) // Command represents a Bash command that can be executed by asdf type Command struct { - Command string - Args []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Env map[string]string + Command string + Expression string + Args []string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Env map[string]string } -// New takes a string containing a Bash expression and a slice of string -// arguments and returns a Command struct +// New takes a string containing the path to a Bash script, and a slice of +// string arguments and returns a Command struct func New(command string, args []string) Command { return Command{Command: command, Args: args} } +// NewExpression takes a string containing a Bash expression and a slice of +// string arguments and returns a Command struct +func NewExpression(expression string, args []string) Command { + return Command{Expression: expression, Args: args} +} + // Run executes a Command with Bash and returns the error if there is one func (c Command) Run() error { - args := append([]string{"-c", c.Command}, c.Args...) - cmd := exec.Command("bash", args...) + var command string + if c.Expression != "" { + // Expressions need to be invoked inside a Bash function, so variables like + // $0 and $@ are available + command = fmt.Sprintf("fn() { %s; }; fn %s", c.Expression, formatArgString(c.Args)) + } else { + // Scripts can be invoked directly, with args provided + command = fmt.Sprintf("%s %s", c.Command, formatArgString(c.Args)) + } + + cmd := exec.Command("bash", "-c", command) cmd.Env = mapToSlice(c.Env) cmd.Stdin = c.Stdin @@ -40,6 +57,14 @@ func (c Command) Run() error { return cmd.Run() } +func formatArgString(args []string) string { + var newArgs []string + for _, str := range args { + newArgs = append(newArgs, fmt.Sprintf("\"%s\"", str)) + } + return strings.Join(newArgs, " ") +} + func mapToSlice(env map[string]string) (slice []string) { for key, value := range env { slice = append(slice, fmt.Sprintf("%s=%s", key, value)) diff --git a/execute/execute_test.go b/execute/execute_test.go index a361cd3a..7486ab5b 100644 --- a/execute/execute_test.go +++ b/execute/execute_test.go @@ -12,12 +12,21 @@ func TestNew(t *testing.T) { t.Run("Returns new command", func(t *testing.T) { cmd := New("echo", []string{"test string"}) assert.Equal(t, "echo", cmd.Command) + assert.Equal(t, "", cmd.Expression) }) } -func TestRun(t *testing.T) { +func TestNewExpression(t *testing.T) { + t.Run("Returns new command expression", func(t *testing.T) { + cmd := NewExpression("echo", []string{"test string"}) + assert.Equal(t, "echo", cmd.Expression) + assert.Equal(t, "", cmd.Command) + }) +} + +func TestRun_Command(t *testing.T) { t.Run("command is executed with bash", func(t *testing.T) { - cmd := New("echo $(type -a sh)", []string{}) + cmd := New("echo $(type -a sh);", []string{}) var stdout strings.Builder cmd.Stdout = &stdout @@ -27,8 +36,8 @@ func TestRun(t *testing.T) { assert.Equal(t, "sh is /bin/sh\n", stdout.String()) }) - t.Run("positional args are passed to command", func(t *testing.T) { - cmd := New("echo $0", []string{"test string"}) + t.Run("positional arg is passed to command", func(t *testing.T) { + cmd := New("testdata/script", []string{"test string"}) var stdout strings.Builder cmd.Stdout = &stdout @@ -38,8 +47,19 @@ func TestRun(t *testing.T) { assert.Equal(t, "test string\n", stdout.String()) }) + t.Run("positional args are passed to command", func(t *testing.T) { + cmd := New("testdata/script", []string{"test string", "another string"}) + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "test string another string\n", stdout.String()) + }) + t.Run("environment variables are passed to command", func(t *testing.T) { - cmd := New("echo $MYVAR", []string{}) + cmd := New("echo $MYVAR;", []string{}) cmd.Env = map[string]string{"MYVAR": "my var value"} var stdout strings.Builder @@ -78,3 +98,78 @@ func TestRun(t *testing.T) { assert.Equal(t, 12, err.(*exec.ExitError).ExitCode()) }) } + +func TestRun_Expression(t *testing.T) { + t.Run("expression is executed with bash", func(t *testing.T) { + cmd := NewExpression("echo $(type -a sh)", []string{}) + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "sh is /bin/sh\n", stdout.String()) + }) + + t.Run("positional arg is passed to expression", func(t *testing.T) { + cmd := NewExpression("echo $1; true", []string{"test string"}) + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "test string\n", stdout.String()) + }) + + t.Run("positional args are passed to expression", func(t *testing.T) { + cmd := NewExpression("echo $@; true", []string{"test string", "another string"}) + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "test string another string\n", stdout.String()) + }) + + t.Run("environment variables are passed to expression", func(t *testing.T) { + cmd := NewExpression("echo $MYVAR", []string{}) + cmd.Env = map[string]string{"MYVAR": "my var value"} + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "my var value\n", stdout.String()) + }) + + t.Run("captures stdout and stdin", func(t *testing.T) { + cmd := NewExpression("echo 'a test' | tee /dev/stderr", []string{}) + cmd.Env = map[string]string{"MYVAR": "my var value"} + + var stdout strings.Builder + cmd.Stdout = &stdout + var stderr strings.Builder + cmd.Stderr = &stderr + + err := cmd.Run() + + assert.Nil(t, err) + assert.Equal(t, "a test\n", stdout.String()) + assert.Equal(t, "a test\n", stderr.String()) + }) + + t.Run("returns error when non-zero exit code", func(t *testing.T) { + cmd := NewExpression("exit 12", []string{}) + + var stdout strings.Builder + cmd.Stdout = &stdout + err := cmd.Run() + + assert.NotNil(t, err) + assert.Equal(t, "", stdout.String()) + assert.Equal(t, 12, err.(*exec.ExitError).ExitCode()) + }) +} diff --git a/hook/hook.go b/hook/hook.go index 013bc83a..51750dbc 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -20,7 +20,7 @@ func Run(config config.Config, hookName string, arguments []string) error { return nil } - cmd := execute.New(hookCmd, arguments) + cmd := execute.NewExpression(hookCmd, arguments) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/hook/hook_test.go b/hook/hook_test.go index 2b00cb28..a1b17ea8 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -29,6 +29,14 @@ func TestRun(t *testing.T) { assert.Equal(t, 123, err.(*exec.ExitError).ExitCode()) }) + t.Run("passes arguments to command", func(t *testing.T) { + config, err := config.LoadConfig() + assert.Nil(t, err) + + err = Run(config, "pre_asdf_plugin_add_test3", []string{"exit 123"}) + assert.Equal(t, 123, err.(*exec.ExitError).ExitCode()) + }) + t.Run("does not return error when no such hook is defined in asdfrc", func(t *testing.T) { config, err := config.LoadConfig() assert.Nil(t, err) diff --git a/hook/testdata/asdfrc b/hook/testdata/asdfrc index 9e187698..ad71a419 100644 --- a/hook/testdata/asdfrc +++ b/hook/testdata/asdfrc @@ -4,4 +4,5 @@ # Hooks pre_asdf_plugin_add = echo Executing with args: $@ pre_asdf_plugin_add_test = echo Executing with args: $@ -pre_asdf_plugin_add_test2 = exit $0 +pre_asdf_plugin_add_test2 = exit $1 +pre_asdf_plugin_add_test3 = eval $@ diff --git a/main_test.go b/main_test.go index b5260be9..dd2726c9 100644 --- a/main_test.go +++ b/main_test.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "os/exec" + "strings" "testing" ) @@ -45,9 +47,9 @@ func TestBatsTests(t *testing.T) { // runBatsFile(t, dir, "list_command.bats") //}) - //t.Run("plugin_add_command", func(t *testing.T) { - // runBatsFile(t, dir, "plugin_add_command.bats") - //}) + t.Run("plugin_add_command", func(t *testing.T) { + runBatsFile(t, dir, "plugin_add_command.bats") + }) //t.Run("plugin_extension_command", func(t *testing.T) { // runBatsFile(t, dir, "plugin_extension_command.bats") @@ -118,32 +120,32 @@ func TestBatsTests(t *testing.T) { //}) } -// func runBatsFile(t *testing.T, dir, filename string) { -// t.Helper() +func runBatsFile(t *testing.T, dir, filename string) { + t.Helper() -// cmd := exec.Command("bats", "--verbose-run", fmt.Sprintf("test/%s", filename)) + cmd := exec.Command("bats", "--verbose-run", fmt.Sprintf("test/%s", filename)) -// // Capture stdout and stderr -// var stdout strings.Builder -// var stderr strings.Builder -// cmd.Stdout = &stdout -// cmd.Stderr = &stderr + // Capture stdout and stderr + var stdout strings.Builder + var stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr -// // Add dir to asdf test variables -// asdfTestHome := fmt.Sprintf("BASE_DIR=%s", dir) -// asdfBinPath := fmt.Sprintf("ASDF_BIN=%s", dir) -// cmd.Env = []string{asdfBinPath, asdfTestHome} + // Add dir to asdf test variables + asdfTestHome := fmt.Sprintf("BASE_DIR=%s", dir) + asdfBinPath := fmt.Sprintf("ASDF_BIN=%s", dir) + cmd.Env = []string{asdfBinPath, asdfTestHome} -// err := cmd.Run() -// if err != nil { -// // If command fails print both stderr and stdout -// fmt.Println("stdout:", stdout.String()) -// fmt.Println("stderr:", stderr.String()) -// t.Fatal("bats command failed to run test file successfully") + err := cmd.Run() + if err != nil { + // If command fails print both stderr and stdout + fmt.Println("stdout:", stdout.String()) + fmt.Println("stderr:", stderr.String()) + t.Fatal("bats command failed to run test file successfully") -// return -// } -//} + return + } +} func buildAsdf(t *testing.T, dir string) { cmd := exec.Command("go", "build", "-o", dir) diff --git a/plugins/plugins.go b/plugins/plugins.go index 42f85d49..08d2ddfd 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -4,11 +4,13 @@ package plugins import ( "errors" "fmt" + "io" "os" "path/filepath" "regexp" "asdf/config" + "asdf/execute" "asdf/git" "asdf/hook" "asdf/pluginindex" @@ -30,10 +32,22 @@ func (e PluginAlreadyExists) Error() string { return fmt.Sprintf(pluginAlreadyExistsMsg, e.plugin) } +// NoCallbackError is an error returned by RunCallback when a callback with +// particular name does not exist +type NoCallbackError struct { + callback string + plugin string +} + +func (e NoCallbackError) Error() string { + return fmt.Sprintf(hasNoCallbackMsg, e.plugin, e.callback) +} + const ( dataDirPlugins = "plugins" invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'" pluginAlreadyExistsMsg = "Plugin named %s already added" + hasNoCallbackMsg = "Plugin named %s does not have a callback named %s" ) // Plugin struct represents an asdf plugin to all asdf code. The name and dir @@ -46,6 +60,32 @@ type Plugin struct { URL string } +// New takes config and a plugin name and returns a Plugin struct. It is +// intended for functions that need to quickly initialize a plugin. +func New(config config.Config, name string) Plugin { + pluginsDir := DataDirectory(config.DataDir) + dir := filepath.Join(pluginsDir, name) + return Plugin{Dir: dir, Name: name} +} + +// RunCallback invokes a callback with the given name if it exists for the plugin +func (p Plugin) RunCallback(name string, arguments []string, environment map[string]string, stdOut io.Writer, errOut io.Writer) error { + callback := filepath.Join(p.Dir, "bin", name) + + _, err := os.Stat(callback) + if errors.Is(err, os.ErrNotExist) { + return NoCallbackError{callback: name, plugin: p.Name} + } + + cmd := execute.New(fmt.Sprintf("'%s'", callback), arguments) + cmd.Env = environment + + cmd.Stdout = stdOut + cmd.Stderr = errOut + + return cmd.Run() +} + // List takes config and flags for what to return and builds a list of plugins // representing the currently installed plugins on the system. func List(config config.Config, urls, refs bool) (plugins []Plugin, err error) { @@ -117,11 +157,7 @@ func Add(config config.Config, pluginName, pluginURL string) error { return NewPluginAlreadyExists(pluginName) } - pluginDir := PluginDirectory(config.DataDir, pluginName) - - if err != nil { - return fmt.Errorf("unable to create plugin directory: %w", err) - } + plugin := New(config, pluginName) if pluginURL == "" { // Ignore error here as the default value is fine @@ -147,18 +183,23 @@ func Add(config config.Config, pluginName, pluginURL string) error { } } - // Run pre hooks - hook.Run(config, "pre_asdf_plugin_add", []string{}) - hook.Run(config, fmt.Sprintf("pre_asdf_plugin_add_%s", pluginName), []string{}) + plugin.URL = pluginURL - err = git.NewRepo(pluginDir).Clone(pluginURL) + // Run pre hooks + hook.Run(config, "pre_asdf_plugin_add", []string{plugin.Name}) + hook.Run(config, fmt.Sprintf("pre_asdf_plugin_add_%s", plugin.Name), []string{}) + + err = git.NewRepo(plugin.Dir).Clone(plugin.URL) if err != nil { return err } + env := map[string]string{"ASDF_PLUGIN_SOURCE_URL": plugin.URL, "ASDF_PLUGIN_PATH": plugin.Dir} + plugin.RunCallback("post-plugin-add", []string{}, env, os.Stdout, os.Stderr) + // Run post hooks - hook.Run(config, "post_asdf_plugin_add", []string{}) - hook.Run(config, fmt.Sprintf("post_asdf_plugin_add_%s", pluginName), []string{}) + hook.Run(config, "post_asdf_plugin_add", []string{plugin.Name}) + hook.Run(config, fmt.Sprintf("post_asdf_plugin_add_%s", plugin.Name), []string{}) return nil } diff --git a/plugins/plugins_test.go b/plugins/plugins_test.go index 37baed42..573c37fb 100644 --- a/plugins/plugins_test.go +++ b/plugins/plugins_test.go @@ -73,6 +73,18 @@ func TestList(t *testing.T) { }) } +func TestNew(t *testing.T) { + testDataDir := t.TempDir() + conf := config.Config{DataDir: testDataDir} + + t.Run("returns Plugin struct with Dir and Name fields set correctly", func(t *testing.T) { + plugin := New(conf, "test-plugin") + + assert.Equal(t, "test-plugin", plugin.Name) + assert.Equal(t, filepath.Join(testDataDir, "plugins", "test-plugin"), plugin.Dir) + }) +} + func TestAdd(t *testing.T) { testDataDir := t.TempDir() @@ -300,7 +312,7 @@ func TestPluginDirectory(t *testing.T) { func TestValidatePluginName(t *testing.T) { t.Run("returns no error when plugin name is valid", func(t *testing.T) { err := validatePluginName(testPluginName) - refuteError(t, err) + assert.Nil(t, err) }) invalids := []string{"plugin^name", "plugin%name", "plugin name", "PLUGIN_NAME"} @@ -316,10 +328,55 @@ func TestValidatePluginName(t *testing.T) { } } -func refuteError(t *testing.T, err error) { - if err != nil { - t.Fatal("Returned unexpected error", err) - } +func TestRunCallback(t *testing.T) { + emptyEnv := map[string]string{} + + testDataDir := t.TempDir() + conf := config.Config{DataDir: testDataDir} + testRepo, err := installMockPluginRepo(testDataDir, testPluginName) + assert.Nil(t, err) + + err = Add(conf, testPluginName, testRepo) + plugin := New(conf, testPluginName) + + t.Run("returns NoCallback error when callback with name not found", func(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + + err = plugin.RunCallback("non-existant", []string{}, emptyEnv, &stdout, &stderr) + + assert.Equal(t, err.(NoCallbackError).Error(), "Plugin named lua does not have a callback named non-existant") + }) + + t.Run("passes argument to command", func(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + + err = plugin.RunCallback("debug", []string{"123"}, emptyEnv, &stdout, &stderr) + assert.Nil(t, err) + assert.Equal(t, "123\n", stdout.String()) + assert.Equal(t, "", stderr.String()) + }) + + t.Run("passes arguments to command", func(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + + err = plugin.RunCallback("debug", []string{"123", "test string"}, emptyEnv, &stdout, &stderr) + assert.Nil(t, err) + assert.Equal(t, "123 test string\n", stdout.String()) + assert.Equal(t, "", stderr.String()) + }) + + t.Run("passes env to command", func(t *testing.T) { + var stdout strings.Builder + var stderr strings.Builder + + err = plugin.RunCallback("post-plugin-update", []string{}, map[string]string{"ASDF_PLUGIN_PREV_REF": "TEST"}, &stdout, &stderr) + assert.Nil(t, err) + assert.Equal(t, "plugin updated path= old git-ref=TEST new git-ref=\n", stdout.String()) + assert.Equal(t, "", stderr.String()) + }) } func touchFile(name string) error { diff --git a/test/fixtures/dummy_plugin/bin/debug b/test/fixtures/dummy_plugin/bin/debug new file mode 100755 index 00000000..7e89bf37 --- /dev/null +++ b/test/fixtures/dummy_plugin/bin/debug @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo $@ diff --git a/test/test_helpers.bash b/test/test_helpers.bash index eceda964..cf821222 100644 --- a/test/test_helpers.bash +++ b/test/test_helpers.bash @@ -16,7 +16,7 @@ setup_asdf_dir() { mkdir "$BASE_DIR" # HOME is now defined by the Golang test code in main_test.go - HOME="$BASE_DIR/home" + HOME="$BASE_DIR" export HOME ASDF_DIR="$HOME/.asdf" mkdir -p "$ASDF_DIR/plugins"