Merge pull request #90 from asdf-vm/tb/plugin-extension-commands

feat(golang-rewrite): implement asdf plugin extension commands
This commit is contained in:
Trevor Brown 2024-12-09 20:41:04 -05:00 committed by Trevor Brown
commit a27ae46831
8 changed files with 350 additions and 37 deletions

View File

@ -55,6 +55,14 @@ func Execute(version string) {
Usage: "The multiple runtime version manager",
UsageText: usageText,
Commands: []*cli.Command{
{
Name: "cmd",
Action: func(cCtx *cli.Context) error {
args := cCtx.Args().Slice()
return extensionCommand(logger, args)
},
},
{
Name: "current",
Action: func(cCtx *cli.Context) error {
@ -247,9 +255,7 @@ func Execute(version string) {
},
},
Action: func(_ *cli.Context) error {
// TODO: flesh this out
log.Print("Late but latest -- Rajinikanth")
return nil
return helpCommand(logger, version, "", "")
},
}
@ -479,6 +485,50 @@ func execCommand(logger *log.Logger, command string, args []string) error {
return exec.Exec(executable, args, execute.MapToSlice(env))
}
func extensionCommand(logger *log.Logger, args []string) error {
if len(args) < 1 {
err := errors.New("no plugin name specified")
logger.Printf("%s", err.Error())
return err
}
conf, err := config.LoadConfig()
if err != nil {
logger.Printf("error loading config: %s", err)
return err
}
pluginName := args[0]
plugin := plugins.New(conf, pluginName)
err = runExtensionCommand(plugin, args[1:], execenv.SliceToMap(os.Environ()))
logger.Printf("error running extension command: %s", err.Error())
return err
}
func runExtensionCommand(plugin plugins.Plugin, args []string, environment map[string]string) (err error) {
path := ""
if len(args) > 0 {
path, err = plugin.ExtensionCommandPath(args[0])
if err != nil {
path, err = plugin.ExtensionCommandPath("")
if err != nil {
return err
}
} else {
args = args[1:]
}
} else {
path, err = plugin.ExtensionCommandPath("")
if err != nil {
return err
}
}
return exec.Exec(path, args, execute.MapToSlice(environment))
}
func getExecutable(logger *log.Logger, conf config.Config, command string) (executable string, plugin plugins.Plugin, version string, err error) {
currentDir, err := os.Getwd()
if err != nil {
@ -726,10 +776,16 @@ func helpCommand(logger *log.Logger, asdfVersion, tool, version string) error {
return err
}
err = help.Print(asdfVersion)
allPlugins, err := plugins.List(conf, false, false)
if err != nil {
os.Exit(1)
}
err = help.Print(asdfVersion, allPlugins)
if err != nil {
os.Exit(1)
}
return err
}

View File

@ -49,6 +49,63 @@ not an executable. The new rewrite removes all shell code from asdf, and it is
now a binary rather than a shell function, so setting environment variables
directly in the shell is no longer possible.
### Plugin extension commands must now be prefixed with `cmd`
Previously plugin extension commands could be run like this:
```
asdf nodejs nodebuild --version
```
Now they must be prefixed with `cmd` to avoid causing confusion with built-in
commands:
```
asdf cmd nodejs nodebuild --version
```
### Extension commands have been redesigned
There are a number of breaking changes for plugin extension commands:
* They must be runnable by `exec` syscall. If your extension commands are shell
scripts in order to be run with `exec` they must start with a proper shebang
line.
* They can now be binaries or scripts in any language. It no
longer makes sense to require a `.bash` extension as it is misleading.
* They must have executable permission set.
* They are no longer sourced by asdf as Bash scripts when they lack executable
permission.
Additionally, only the first argument after plugin name is used to determine
the extension command to run. This means effectively there is the default
`command` extension command that asdf defaults to when no command matching the
first argument after plugin name is found. For example:
```
foo/
lib/commands/
command
command-bar
command-bat-man
```
Previously these scripts would work like this:
```
$ asdf cmd foo # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command`
$ asdf cmd foo bar # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command-bar`
$ asdf cmd foo bat man # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command-bat-man`
```
Now:
```
$ asdf cmd foo # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command`
$ asdf cmd foo bar # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command-bar`
$ asdf cmd foo bat man # same as running `$ASDF_DATA_DIR/plugins/foo/lib/commands/command bat man`
```
### Executables Shims Resolve to Must Runnable by `syscall.Exec`
The most obvious example of this breaking change are scripts that lack a proper

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"strings"
"asdf/internal/config"
"asdf/internal/plugins"
@ -19,8 +20,8 @@ var helpText string
const quote = "\"Late but latest\"\n-- Rajinikanth"
// Print help output to STDOUT
func Print(asdfVersion string) error {
return Write(asdfVersion, os.Stdout)
func Print(asdfVersion string, plugins []plugins.Plugin) error {
return Write(asdfVersion, plugins, os.Stdout)
}
// PrintTool write tool help output to STDOUT
@ -34,7 +35,7 @@ func PrintToolVersion(conf config.Config, toolName, toolVersion string) error {
}
// Write help output to an io.Writer
func Write(asdfVersion string, writer io.Writer) error {
func Write(asdfVersion string, allPlugins []plugins.Plugin, writer io.Writer) error {
_, err := writer.Write([]byte(fmt.Sprintf("version: %s\n\n", asdfVersion)))
if err != nil {
return err
@ -50,6 +51,22 @@ func Write(asdfVersion string, writer io.Writer) error {
return err
}
extensionCommandHelp, err := pluginExtensionCommands(allPlugins)
if err != nil {
fmt.Printf("err %#+v\n", err)
return err
}
_, err = writer.Write([]byte(extensionCommandHelp))
if err != nil {
return err
}
_, err = writer.Write([]byte("\n"))
if err != nil {
return err
}
_, err = writer.Write([]byte(quote))
if err != nil {
return err
@ -118,3 +135,26 @@ func writePluginHelp(conf config.Config, toolName, toolVersion string, writer io
return nil
}
func pluginExtensionCommands(plugins []plugins.Plugin) (string, error) {
var output strings.Builder
for _, plugin := range plugins {
commands, err := plugin.GetExtensionCommands()
if err != nil {
return output.String(), err
}
if len(commands) > 0 {
output.WriteString(fmt.Sprintf("PLUGIN %s\n", plugin.Name))
for _, command := range commands {
if command == "" {
// must be default command
output.WriteString(fmt.Sprintf(" asdf %s\n", plugin.Name))
} else {
output.WriteString(fmt.Sprintf(" asdf %s %s\n", plugin.Name, command))
}
}
}
}
return output.String(), nil
}

View File

@ -1,6 +1,7 @@
package help
import (
"fmt"
"os"
"path/filepath"
"strings"
@ -20,12 +21,19 @@ const (
func TestWrite(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
err := os.MkdirAll(filepath.Join(testDataDir, "plugins"), 0o777)
assert.Nil(t, err)
// install dummy plugin
_, err = repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
assert.Nil(t, err)
plugin := plugins.New(conf, testPluginName)
writeExtensionCommand(t, plugin, "", "")
var stdout strings.Builder
err = Write(version, &stdout)
err = Write(version, []plugins.Plugin{plugin}, &stdout)
assert.Nil(t, err)
output := stdout.String()
@ -35,6 +43,7 @@ func TestWrite(t *testing.T) {
assert.Contains(t, output, "MANAGE TOOLS\n")
assert.Contains(t, output, "UTILS\n")
assert.Contains(t, output, "RESOURCES\n")
assert.Contains(t, output, "PLUGIN lua\n")
}
func TestWriteToolHelp(t *testing.T) {
@ -131,3 +140,16 @@ func installPlugin(t *testing.T, conf config.Config, fixture, pluginName string)
return plugins.New(conf, pluginName)
}
func writeExtensionCommand(t *testing.T, plugin plugins.Plugin, name, contents string) error {
t.Helper()
assert.Nil(t, os.MkdirAll(filepath.Join(plugin.Dir, "lib", "commands"), 0o777))
filename := "command"
if name != "" {
filename = fmt.Sprintf("command-%s", name)
}
path := filepath.Join(plugin.Dir, "lib", "commands", filename)
err := os.WriteFile(path, []byte(contents), 0o777)
return err
}

View File

@ -56,12 +56,24 @@ func (e NoCallbackError) Error() string {
return fmt.Sprintf(hasNoCallbackMsg, e.plugin, e.callback)
}
// NoCommandError is an error returned by ExtensionCommandPath when an extension
// command with the given name does not exist
type NoCommandError struct {
command string
plugin string
}
func (e NoCommandError) Error() string {
return fmt.Sprintf(hasNoCommandMsg, e.plugin, e.command)
}
const (
dataDirPlugins = "plugins"
invalidPluginNameMsg = "%s is invalid. Name may only contain lowercase letters, numbers, '_', and '-'"
pluginAlreadyExistsMsg = "Plugin named %s already added"
pluginMissingMsg = "Plugin named %s not installed"
hasNoCallbackMsg = "Plugin named %s does not have a callback named %s"
hasNoCommandMsg = "Plugin named %s does not have a extension command named %s"
)
// Plugin struct represents an asdf plugin to all asdf code. The name and dir
@ -179,6 +191,52 @@ func (p Plugin) CallbackPath(name string) (string, error) {
return path, nil
}
// GetExtensionCommands returns a slice of strings representing all available
// extension commands for the plugin.
func (p Plugin) GetExtensionCommands() ([]string, error) {
commands := []string{}
files, err := os.ReadDir(filepath.Join(p.Dir, "lib/commands"))
if _, ok := err.(*fs.PathError); ok {
return commands, nil
}
if err != nil {
return commands, err
}
for _, file := range files {
if !file.IsDir() {
name := file.Name()
if name == "command" {
commands = append(commands, "")
} else {
if strings.HasPrefix(name, "command-") {
commands = append(commands, strings.TrimPrefix(name, "command-"))
}
}
}
}
return commands, nil
}
// ExtensionCommandPath returns the path to the plugin's extension command
// script matching the name if it exists.
func (p Plugin) ExtensionCommandPath(name string) (string, error) {
commandName := "command"
if name != "" {
commandName = fmt.Sprintf("command-%s", name)
}
path := filepath.Join(p.Dir, "lib", "commands", commandName)
_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return "", NoCommandError{command: name, plugin: p.Name}
}
return path, nil
}
// 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) {

View File

@ -1,6 +1,7 @@
package plugins
import (
"fmt"
"os"
"path/filepath"
"strings"
@ -454,6 +455,78 @@ func TestCallbackPath(t *testing.T) {
})
}
func TestGetExtensionCommands(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
_, err := repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
assert.Nil(t, err)
plugin := New(conf, testPluginName)
t.Run("returns empty slice when no extension commands defined", func(t *testing.T) {
commands, err := plugin.GetExtensionCommands()
assert.Nil(t, err)
assert.Empty(t, commands)
})
t.Run("returns slice of with default extension command if it is present", func(t *testing.T) {
assert.Nil(t, writeExtensionCommand(t, plugin, "", "#!/usr/bin/env bash\necho $1"))
commands, err := plugin.GetExtensionCommands()
assert.Nil(t, err)
assert.Equal(t, commands, []string{""})
})
t.Run("returns slice of all extension commands when they are present", func(t *testing.T) {
assert.Nil(t, writeExtensionCommand(t, plugin, "", "#!/usr/bin/env bash\necho $1"))
assert.Nil(t, writeExtensionCommand(t, plugin, "foobar", "#!/usr/bin/env bash\necho $1"))
commands, err := plugin.GetExtensionCommands()
assert.Nil(t, err)
assert.Equal(t, commands, []string{"", "foobar"})
})
}
func TestExtensionCommandPath(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}
_, err := repotest.InstallPlugin("dummy_plugin", testDataDir, testPluginName)
assert.Nil(t, err)
plugin := New(conf, testPluginName)
t.Run("returns NoCallback error when callback with name not found", func(t *testing.T) {
path, err := plugin.ExtensionCommandPath("non-existent")
assert.Equal(t, err.(NoCommandError).Error(), "Plugin named lua does not have a extension command named non-existent")
assert.Equal(t, path, "")
})
t.Run("returns default extension command script when no name", func(t *testing.T) {
assert.Nil(t, writeExtensionCommand(t, plugin, "", "#!/usr/bin/env bash\necho $1"))
path, err := plugin.ExtensionCommandPath("")
assert.Nil(t, err)
assert.Equal(t, filepath.Base(path), "command")
})
t.Run("passes arguments to command", func(t *testing.T) {
assert.Nil(t, writeExtensionCommand(t, plugin, "debug", "#!/usr/bin/env bash\necho $@"))
path, err := plugin.ExtensionCommandPath("debug")
assert.Nil(t, err)
assert.Equal(t, filepath.Base(path), "command-debug")
})
}
func writeExtensionCommand(t *testing.T, plugin Plugin, name, contents string) error {
t.Helper()
assert.Nil(t, os.MkdirAll(filepath.Join(plugin.Dir, "lib", "commands"), 0o777))
filename := "command"
if name != "" {
filename = fmt.Sprintf("command-%s", name)
}
path := filepath.Join(plugin.Dir, "lib", "commands", filename)
err := os.WriteFile(path, []byte(contents), 0o777)
return err
}
func TestLegacyFilenames(t *testing.T) {
testDataDir := t.TempDir()
conf := config.Config{DataDir: testDataDir}

View File

@ -47,9 +47,9 @@ func TestBatsTests(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")
//})
t.Run("plugin_extension_command", func(t *testing.T) {
runBatsFile(t, dir, "plugin_extension_command.bats")
})
t.Run("plugin_list_all_command", func(t *testing.T) {
runBatsFile(t, dir, "plugin_list_all_command.bats")
@ -91,6 +91,10 @@ func TestBatsTests(t *testing.T) {
runBatsFile(t, dir, "uninstall_command.bats")
})
// Version commands like `asdf global` and `asdf local` aren't going to be
// available, however it would be nice to still support environment variable
// versions, e.g. ASDF_RUBY_VERSION=2.0.0. Some of these tests could be
// enabled and implemented.
//t.Run("version_commands", func(t *testing.T) {
// runBatsFile(t, dir, "version_commands.bats")
//})

View File

@ -18,15 +18,15 @@ teardown() {
@test "asdf help shows plugin extension commands" {
local plugin_path listed_cmds
plugin_path="$(get_plugin_path dummy)"
touch "$plugin_path/lib/commands/command.bash"
touch "$plugin_path/lib/commands/command-foo.bash"
touch "$plugin_path/lib/commands/command-foo-bar.bash"
touch "$plugin_path/lib/commands/command"
touch "$plugin_path/lib/commands/command-foo"
touch "$plugin_path/lib/commands/command-foo-bar"
run asdf help
[ "$status" -eq 0 ]
echo "$output" | grep "PLUGIN dummy" # should present plugin section
listed_cmds=$(echo "$output" | grep -c "asdf dummy")
[ "$listed_cmds" -eq 3 ]
echo "$output" | grep "asdf dummy foo bar" # should present commands without hyphens
echo "$output" | grep "asdf dummy foo-bar"
}
@test "asdf help shows extension commands for plugin with hyphens in the name" {
@ -37,9 +37,9 @@ teardown() {
plugin_path="$(get_plugin_path $plugin_name)"
mkdir -p "$plugin_path/lib/commands"
touch "$plugin_path/lib/commands/command.bash"
touch "$plugin_path/lib/commands/command-foo.bash"
touch "$plugin_path/lib/commands/command-foo-bar.bash"
touch "$plugin_path/lib/commands/command"
touch "$plugin_path/lib/commands/command-foo"
touch "$plugin_path/lib/commands/command-foo-bar"
run asdf help
[ "$status" -eq 0 ]
@ -47,52 +47,55 @@ teardown() {
listed_cmds=$(grep -c "asdf $plugin_name" <<<"${output}")
[[ $listed_cmds -eq 3 ]]
[[ "$output" == *"asdf $plugin_name foo"* ]]
[[ "$output" == *"asdf $plugin_name foo bar"* ]]
[[ "$output" == *"asdf $plugin_name foo-bar"* ]]
}
@test "asdf can execute plugin bin commands" {
plugin_path="$(get_plugin_path dummy)"
# this plugin defines a new `asdf dummy foo` command
cat <<'EOF' >"$plugin_path/lib/commands/command-foo.bash"
cat <<'EOF' >"$plugin_path/lib/commands/command-foo"
#!/usr/bin/env bash
echo this is an executable $*
EOF
chmod +x "$plugin_path/lib/commands/command-foo.bash"
chmod +x "$plugin_path/lib/commands/command-foo"
expected="this is an executable bar"
run asdf dummy foo bar
run asdf cmd dummy foo bar
[ "$status" -eq 0 ]
[ "$output" = "$expected" ]
}
@test "asdf can source plugin bin scripts" {
plugin_path="$(get_plugin_path dummy)"
# No longer supported. If you want to do this you'll need to manual source the
# file containing the functions you want via relative path.
#@test "asdf can source plugin bin scripts" {
# plugin_path="$(get_plugin_path dummy)"
# this plugin defines a new `asdf dummy foo` command
echo 'echo sourced script has asdf utils $(get_plugin_path dummy) $*' >"$plugin_path/lib/commands/command-foo.bash"
# # this plugin defines a new `asdf dummy foo` command
# echo '#!/usr/bin/env bash
# echo sourced script has asdf utils $(get_plugin_path dummy) $*' >"$plugin_path/lib/commands/command-foo"
expected="sourced script has asdf utils $plugin_path bar"
# expected="sourced script has asdf utils $plugin_path bar"
run asdf dummy foo bar
[ "$status" -eq 0 ]
[ "$output" = "$expected" ]
}
# run asdf cmd dummy foo bar
# [ "$status" -eq 0 ]
# [ "$output" = "$expected" ]
#}
@test "asdf can execute plugin default command without arguments" {
plugin_path="$(get_plugin_path dummy)"
# this plugin defines a new `asdf dummy` command
cat <<'EOF' >"$plugin_path/lib/commands/command.bash"
cat <<'EOF' >"$plugin_path/lib/commands/command"
#!/usr/bin/env bash
echo hello
EOF
chmod +x "$plugin_path/lib/commands/command.bash"
chmod +x "$plugin_path/lib/commands/command"
expected="hello"
run asdf dummy
run asdf cmd dummy
[ "$status" -eq 0 ]
[ "$output" = "$expected" ]
}
@ -101,15 +104,15 @@ EOF
plugin_path="$(get_plugin_path dummy)"
# this plugin defines a new `asdf dummy` command
cat <<'EOF' >"$plugin_path/lib/commands/command.bash"
cat <<'EOF' >"$plugin_path/lib/commands/command"
#!/usr/bin/env bash
echo hello $*
EOF
chmod +x "$plugin_path/lib/commands/command.bash"
chmod +x "$plugin_path/lib/commands/command"
expected="hello world"
run asdf dummy world
run asdf cmd dummy world
[ "$status" -eq 0 ]
[ "$output" = "$expected" ]
}