Skip to content

Commit

Permalink
feat(golang-rewrite): implement asdf plugin extension commands
Browse files Browse the repository at this point in the history
* Enable `plugin_extension_command.bats` tests
* Add comment on `version_commands.bats` tests
* Show help output when asdf run with no arguments
* Document breaking change in asdf extension commands feature
* Create `Plugin.ExtensionCommandPath` method
* Create `asdf cmd` subcommand
* Get `plugin_extension_command.bats` tests passing
* Document breaking changes to asdf extension commands
  • Loading branch information
Your Name authored and Stratus3D committed Dec 18, 2024
1 parent 82f1943 commit ccc98ad
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 37 deletions.
64 changes: 60 additions & 4 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, "", "")
},
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
57 changes: 57 additions & 0 deletions docs/guide/upgrading-from-v0-14-to-v0-15.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 43 additions & 3 deletions internal/help/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"strings"

"asdf/internal/config"
"asdf/internal/plugins"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
24 changes: 23 additions & 1 deletion internal/help/help_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package help

import (
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -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()

Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
58 changes: 58 additions & 0 deletions internal/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit ccc98ad

Please sign in to comment.