diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 20b53237e6b..39b93421a72 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -4,11 +4,9 @@ package main import ( - "context" "errors" "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -23,6 +21,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/fsutil" "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" "github.com/lima-vm/lima/v2/pkg/osutil" + "github.com/lima-vm/lima/v2/pkg/plugin" "github.com/lima-vm/lima/v2/pkg/version" ) @@ -49,13 +48,12 @@ func main() { } } rootCmd := newApp() - if err := executeWithPluginSupport(rootCmd, os.Args[1:]); err != nil { - server.StopAllExternalDrivers() - handleExitError(err) + err := executeWithPluginSupport(rootCmd, os.Args[1:]) + server.StopAllExternalDrivers() + osutil.HandleExitError(err) + if err != nil { logrus.Fatal(err) } - - server.StopAllExternalDrivers() } func newApp() *cobra.Command { @@ -120,12 +118,6 @@ func newApp() *cobra.Command { return fmt.Errorf("unsupported log-format: %q", logFormat) } - debug, _ := cmd.Flags().GetBool("debug") - if debug { - logrus.SetLevel(logrus.DebugLevel) - debugutil.Debug = true - } - if osutil.IsBeingRosettaTranslated() && cmd.Parent().Name() != "completion" && cmd.Name() != "generate-doc" && cmd.Name() != "validate" { // running under rosetta would provide inappropriate runtime.GOARCH info, see: https://github.com/lima-vm/lima/issues/543 // allow commands that are used for packaging to run under rosetta to allow cross-architecture builds @@ -165,6 +157,8 @@ func newApp() *cobra.Command { } rootCmd.AddGroup(&cobra.Group{ID: "basic", Title: "Basic Commands:"}) rootCmd.AddGroup(&cobra.Group{ID: "advanced", Title: "Advanced Commands:"}) + rootCmd.AddGroup(&cobra.Group{ID: "plugin", Title: "Available Plugins (Experimental):"}) + rootCmd.AddCommand( newCreateCommand(), newStartCommand(), @@ -201,79 +195,45 @@ func newApp() *cobra.Command { return rootCmd } -func handleExitError(err error) { - if err == nil { - return - } - - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - os.Exit(exitErr.ExitCode()) //nolint:revive // it's intentional to call os.Exit in this function - return - } -} - -// executeWithPluginSupport handles command execution with plugin support. func executeWithPluginSupport(rootCmd *cobra.Command, args []string) error { - if len(args) > 0 { - cmd, _, err := rootCmd.Find(args) - if err != nil || cmd == rootCmd { - // Function calls os.Exit() if it found and executed the plugin - runExternalPlugin(rootCmd.Context(), args[0], args[1:]) + rootCmd.SetArgs(args) + + if err := rootCmd.ParseFlags(args); err == nil { + if debug, _ := rootCmd.Flags().GetBool("debug"); debug { + logrus.SetLevel(logrus.DebugLevel) + debugutil.Debug = true } } - rootCmd.SetArgs(args) + addPluginCommands(rootCmd) + return rootCmd.Execute() } -func runExternalPlugin(ctx context.Context, name string, args []string) { - if ctx == nil { - ctx = context.Background() - } - - if err := updatePathEnv(); err != nil { - logrus.Warnf("failed to update PATH environment: %v", err) - // PATH update failure shouldn't prevent plugin execution - } - - externalCmd := "limactl-" + name - execPath, err := exec.LookPath(externalCmd) +func addPluginCommands(rootCmd *cobra.Command) { + plugins, err := plugin.DiscoverPlugins() if err != nil { + logrus.Warnf("Failed to discover plugins: %v", err) return } - cmd := exec.CommandContext(ctx, execPath, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = os.Environ() - - err = cmd.Run() - handleExitError(err) - if err == nil { - os.Exit(0) //nolint:revive // it's intentional to call os.Exit in this function - } - logrus.Fatalf("external command %q failed: %v", execPath, err) -} - -func updatePathEnv() error { - exe, err := os.Executable() - if err != nil { - return fmt.Errorf("failed to get executable path: %w", err) - } + for _, p := range plugins { + pluginName := p.Name + pluginCmd := &cobra.Command{ + Use: pluginName, + Short: p.Description, + GroupID: "plugin", + DisableFlagParsing: true, + Run: func(cmd *cobra.Command, args []string) { + plugin.RunExternalPlugin(cmd.Context(), pluginName, args) + }, + } - binDir := filepath.Dir(exe) - currentPath := os.Getenv("PATH") - newPath := binDir + string(filepath.ListSeparator) + currentPath + pluginCmd.SilenceUsage = true + pluginCmd.SilenceErrors = true - if err := os.Setenv("PATH", newPath); err != nil { - return fmt.Errorf("failed to set PATH environment: %w", err) + rootCmd.AddCommand(pluginCmd) } - - logrus.Debugf("updated PATH to prioritize %s", binDir) - - return nil } // WrapArgsError annotates cobra args error with some context, so the error message is more user-friendly. diff --git a/pkg/limainfo/limainfo.go b/pkg/limainfo/limainfo.go index b1f27e445b2..93235c846a2 100644 --- a/pkg/limainfo/limainfo.go +++ b/pkg/limainfo/limainfo.go @@ -17,6 +17,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" "github.com/lima-vm/lima/v2/pkg/limatype/filenames" "github.com/lima-vm/lima/v2/pkg/limayaml" + "github.com/lima-vm/lima/v2/pkg/plugin" "github.com/lima-vm/lima/v2/pkg/registry" "github.com/lima-vm/lima/v2/pkg/templatestore" "github.com/lima-vm/lima/v2/pkg/usrlocalsharelima" @@ -35,6 +36,7 @@ type LimaInfo struct { HostOS string `json:"hostOS"` // since Lima v2.0.0 HostArch string `json:"hostArch"` // since Lima v2.0.0 IdentityFile string `json:"identityFile"` // since Lima v2.0.0 + Plugins []plugin.Plugin `json:"plugins"` // since Lima v2.0.0 } type DriverExt struct { @@ -108,5 +110,15 @@ func New(ctx context.Context) (*LimaInfo, error) { Location: bin, } } + + plugins, err := plugin.DiscoverPlugins() + if err != nil { + logrus.WithError(err).Warn("Failed to discover plugins") + // Don't fail the entire info command if plugin discovery fails. + info.Plugins = []plugin.Plugin{} + } else { + info.Plugins = plugins + } + return info, nil } diff --git a/pkg/osutil/exit.go b/pkg/osutil/exit.go new file mode 100644 index 00000000000..ee14ecc20d2 --- /dev/null +++ b/pkg/osutil/exit.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package osutil + +import ( + "errors" + "os" + "os/exec" +) + +func HandleExitError(err error) { + if err == nil { + return + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitCode()) //nolint:revive // it's intentional to call os.Exit in this function + return + } +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 00000000000..81849db12b9 --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "cmp" + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/osutil" + "github.com/lima-vm/lima/v2/pkg/usrlocalsharelima" +) + +const defaultPathExt = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL" + +type Plugin struct { + Name string `json:"name"` + Path string `json:"path"` + Description string `json:"description,omitempty"` +} + +func DiscoverPlugins() ([]Plugin, error) { + var plugins []Plugin + seen := make(map[string]bool) + + for _, dir := range getPluginDirectories() { + for _, plugin := range scanDirectory(dir) { + if !seen[plugin.Name] { + plugins = append(plugins, plugin) + seen[plugin.Name] = true + } + } + } + + slices.SortFunc(plugins, + func(i, j Plugin) int { + return cmp.Compare(i.Name, j.Name) + }) + + return plugins, nil +} + +func getPluginDirectories() []string { + dirs := usrlocalsharelima.SelfDirs() + + pathEnv := os.Getenv("PATH") + if pathEnv != "" { + pathDirs := filepath.SplitList(pathEnv) + dirs = append(dirs, pathDirs...) + } + + if libexecDir, err := usrlocalsharelima.LibexecLima(); err == nil { + if _, err := os.Stat(libexecDir); err == nil { + dirs = append(dirs, libexecDir) + } + } + + return dirs +} + +// isWindowsExecutableExt checks if the given extension is a valid Windows executable extension +// according to PATHEXT environment variable. +func isWindowsExecutableExt(ext string) bool { + if runtime.GOOS != "windows" { + return false + } + + pathExt := os.Getenv("PATHEXT") + if pathExt == "" { + pathExt = defaultPathExt + } + + extensions := strings.Split(strings.ToUpper(pathExt), ";") + extUpper := strings.ToUpper(ext) + + for _, validExt := range extensions { + if validExt == extUpper { + return true + } + } + return false +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + + if !info.Mode().IsRegular() { + return false + } + + if runtime.GOOS != "windows" { + return info.Mode()&0o111 != 0 + } + + ext := strings.ToLower(filepath.Ext(path)) + pathExt := os.Getenv("PATHEXT") + if pathExt == "" { + pathExt = defaultPathExt + } + + for _, e := range strings.Split(strings.ToLower(pathExt), ";") { + if e == ext { + return true + } + } + return false +} + +func scanDirectory(dir string) []Plugin { + var plugins []Plugin + + entries, err := os.ReadDir(dir) + if err != nil { + logrus.Debugf("Plugin discovery: failed to scan directory %s: %v", dir, err) + + return plugins + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if !strings.HasPrefix(name, "limactl-") { + continue + } + + pluginName := strings.TrimPrefix(name, "limactl-") + + if runtime.GOOS == "windows" { + ext := filepath.Ext(pluginName) + if isWindowsExecutableExt(ext) { + pluginName = strings.TrimSuffix(pluginName, ext) + } + } + + fullPath := filepath.Join(dir, name) + + if !isExecutable(fullPath) { + continue + } + + plugin := Plugin{ + Name: pluginName, + Path: fullPath, + } + + if desc := extractDescFromScript(fullPath); desc != "" { + plugin.Description = desc + } + + plugins = append(plugins, plugin) + } + + return plugins +} + +func RunExternalPlugin(ctx context.Context, name string, args []string) { + if ctx == nil { + ctx = context.Background() + } + + if err := UpdatePathForPlugins(); err != nil { + logrus.Warnf("failed to update PATH environment: %v", err) + // PATH update failure shouldn't prevent plugin execution + } + + plugins, err := DiscoverPlugins() + if err != nil { + logrus.Warnf("failed to discover plugins: %v", err) + return + } + + var execPath string + for _, plugin := range plugins { + if plugin.Name == name { + execPath = plugin.Path + break + } + } + + if execPath == "" { + return + } + + cmd := exec.CommandContext(ctx, execPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + err = cmd.Run() + osutil.HandleExitError(err) + if err == nil { + os.Exit(0) //nolint:revive // it's intentional to call os.Exit in this function + } + logrus.Fatalf("external command %q failed: %v", execPath, err) +} + +var descRegex = regexp.MustCompile(`(.*?)`) + +func extractDescFromScript(path string) string { + content, err := os.ReadFile(path) + if err != nil { + logrus.Debugf("Failed to read plugin script %s: %v", path, err) + return "" + } + + if !strings.HasPrefix(string(content), "#!") { + logrus.Debugf("Plugin %s: not a script file, skipping description extraction", path) + return "" + } + + matches := descRegex.FindStringSubmatch(string(content)) + if len(matches) < 2 { + logrus.Debugf("Plugin %s: no found in script", filepath.Base(path)) + return "" + } + + desc := strings.Trim(matches[1], " ") + logrus.Debugf("Plugin %s: extracted description: %q", filepath.Base(path), desc) + return desc +} + +func UpdatePathForPlugins() error { + pluginDirs := getPluginDirectories() + newPath := strings.Join(pluginDirs, string(filepath.ListSeparator)) + return os.Setenv("PATH", newPath) +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index e9369203602..8f85ef8768f 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -102,11 +102,10 @@ func registerExternalDriver(name, path string) { } func discoverDrivers() error { - prefix, err := usrlocalsharelima.Prefix() + stdDriverDir, err := usrlocalsharelima.LibexecLima() if err != nil { return err } - stdDriverDir := filepath.Join(prefix, "libexec", "lima") logrus.Debugf("Discovering external drivers in %s", stdDriverDir) if _, err := os.Stat(stdDriverDir); err == nil { diff --git a/pkg/usrlocalsharelima/usrlocalsharelima.go b/pkg/usrlocalsharelima/usrlocalsharelima.go index cdd8ed93bca..d6daaa31ab0 100644 --- a/pkg/usrlocalsharelima/usrlocalsharelima.go +++ b/pkg/usrlocalsharelima/usrlocalsharelima.go @@ -19,14 +19,14 @@ import ( "github.com/lima-vm/lima/v2/pkg/limatype" ) -// executableViaArgs0 returns the absolute path to the executable used to start this process. +// ExecutableViaArgs0 returns the absolute path to the executable used to start this process. // It will also append the file extension on Windows, if necessary. // This function is different from os.Executable(), which will use /proc/self/exe on Linux // and therefore will resolve any symlink used to locate the executable. This function will // return the symlink instead because we want to be able to locate ../share/lima relative // to the location of the symlink, and not the actual executable. This is important when // using Homebrew. -var executableViaArgs0 = sync.OnceValues(func() (string, error) { +var ExecutableViaArgs0 = sync.OnceValues(func() (string, error) { if os.Args[0] == "" { return "", errors.New("os.Args[0] has not been set") } @@ -41,17 +41,17 @@ var executableViaArgs0 = sync.OnceValues(func() (string, error) { return executable, nil }) -// Dir returns the location of the /lima/share directory, relative to the location -// of the current executable. It checks for multiple possible filesystem layouts and returns -// the first candidate that contains the native guest agent binary. -var Dir = sync.OnceValues(func() (string, error) { - selfPaths := []string{} +// SelfDirs returns a list of directory paths where the current executable might be located. +// It checks both os.Args[0] and os.Executable() methods and returns directories containing +// the executable, resolving symlinks as needed. +func SelfDirs() []string { + var selfPaths []string - selfViaArgs0, err := executableViaArgs0() + selfViaArgs0, err := ExecutableViaArgs0() if err != nil { logrus.WithError(err).Warn("failed to find executable from os.Args[0]") } else { - selfPaths = append(selfPaths, selfViaArgs0) + selfPaths = append(selfPaths, filepath.Dir(selfViaArgs0)) } selfViaOS, err := os.Executable() @@ -64,11 +64,21 @@ var Dir = sync.OnceValues(func() (string, error) { selfFinalPathViaOS = selfViaOS // fallback to the original path } - if len(selfPaths) == 0 || selfFinalPathViaOS != selfPaths[0] { - selfPaths = append(selfPaths, selfFinalPathViaOS) + selfDir := filepath.Dir(selfFinalPathViaOS) + if len(selfPaths) == 0 || selfDir != selfPaths[0] { + selfPaths = append(selfPaths, selfDir) } } + return selfPaths +} + +// Dir returns the location of the /lima/share directory, relative to the location +// of the current executable. It checks for multiple possible filesystem layouts and returns +// the first candidate that contains the native guest agent binary. +func Dir() (string, error) { + selfDirs := SelfDirs() + ostype := limatype.NewOS("linux") arch := limatype.NewArch(runtime.GOARCH) if arch == "" { @@ -76,9 +86,8 @@ var Dir = sync.OnceValues(func() (string, error) { } gaCandidates := []string{} - for _, self := range selfPaths { - // self: /usr/local/bin/limactl - selfDir := filepath.Dir(self) + for _, selfDir := range selfDirs { + // selfDir: /usr/local/bin selfDirDir := filepath.Dir(selfDir) gaCandidates = append(gaCandidates, // candidate 0: @@ -118,8 +127,8 @@ var Dir = sync.OnceValues(func() (string, error) { } return "", fmt.Errorf("failed to find \"lima-guestagent.%s-%s\" binary for %v, attempted %v", - ostype, arch, selfPaths, gaCandidates) -}) + ostype, arch, selfDirs, gaCandidates) +} // GuestAgentBinary returns the absolute path of the guest agent binary, possibly with ".gz" suffix. func GuestAgentBinary(ostype limatype.OS, arch limatype.Arch) (string, error) { @@ -174,3 +183,12 @@ func Prefix() (string, error) { } return filepath.Dir(filepath.Dir(dir)), nil } + +// LibexecLima returns the /libexec/lima directory. +func LibexecLima() (string, error) { + prefix, err := Prefix() + if err != nil { + return "", err + } + return filepath.Join(prefix, "libexec", "lima"), nil +} diff --git a/website/content/en/docs/config/plugin/cli.md b/website/content/en/docs/config/plugin/cli.md index 78be0ef0fc6..b2376aea63e 100644 --- a/website/content/en/docs/config/plugin/cli.md +++ b/website/content/en/docs/config/plugin/cli.md @@ -1,12 +1,71 @@ --- -title: CLI plugins +title: CLI plugins (Experimental) weight: 2 --- | ⚡ Requirement | Lima >= 2.0 | |----------------|-------------| -Lima supports a plugin-like command aliasing system similar to `git`, `kubectl`, and `docker`. When you run a `limactl` command that doesn't exist, Lima will automatically look for an external program named `limactl-` in your system's PATH. +Lima supports a plugin-like command aliasing system similar to `git`, `kubectl`, and `docker`. When you run a `limactl` command that doesn't exist, Lima will automatically look for an external program named `limactl-` in your system's PATH and additional directories. + +## Plugin Discovery + +Lima discovers plugins by scanning for executables named `limactl-` in the following locations: + +1. **Directory containing the `limactl` binary** (including symlink support) +2. **All directories in your `$PATH` environment variable** +3. **`/libexec/lima`** - For plugins installed by package managers or distribution packages + +Plugin discovery respects symlinks, ensuring that even if `limactl` is installed via Homebrew and points to a symlink, all plugins are correctly discovered. + +## Plugin Information + +Available plugins are automatically displayed in: + +- **`limactl --help`** - Shows all discovered plugins with descriptions in an "Available Plugins (Experimental)" section +```bash +Available Plugins (Experimental): + ps Sample limactl-ps alias that shows running instances + sh +``` + + +- **`limactl info`** - Includes plugin information in the JSON output +```json +{ + "plugins": [ + { + "name": "ps", + "path": "/opt/homebrew/bin/limactl-ps" + }, + { + "name": "sh", + "path": "/opt/homebrew/bin/limactl-sh" + } + ] +} + +``` + +### Plugin Descriptions + +Lima extracts plugin descriptions from script comments using the `` format. Include a description comment in your plugin script: + +```bash +#!/bin/sh +# Docker wrapper that connects to Docker daemon running in Lima instance +set -eu + +# Rest of your script... +``` + +**Format Requirements:** +- Only files beginning with a shebang (`#!`) are treated as scripts, and their `` lines will be extracted as the plugin description i.e Must contain exactly `Description text` +- The description text should be concise and descriptive + +**Limitations:** +- Binary executables cannot have descriptions extracted and will appear in the help output without a description +- If no `` comment is found in a script, the plugin will appear in the help output without a description ## Creating Custom Aliases @@ -45,16 +104,25 @@ After creating this alias: ```bash limactl sh default # Equivalent to: limactl shell default limactl sh myinstance bash # Equivalent to: limactl shell myinstance bash -``` - ## How It Works 1. When you run `limactl `, Lima first tries to find a built-in command -2. If no built-in command is found, Lima searches for `limactl-` in your PATH 3. If found, Lima executes the external program and passes all remaining arguments to it 4. If not found, Lima shows the standard "unknown command" error This system allows you to: - Create personal shortcuts and aliases - Extend Lima's functionality without modifying the core application -- Share custom commands with your team by distributing scripts \ No newline at end of file +- Share custom commands with your team by distributing scripts +- Package plugins with Lima distributions in the `libexec/lima` directory + +## Package Installation + +Distribution packages and package managers can install plugins in `/libexec/lima/` where `` is typically `/usr/local` or `/opt/homebrew`. This allows plugins to be: +- Managed by the package manager +- Isolated from user's `$PATH` +- Automatically discovered by Lima + +## Experimental Status + +**Experimental Feature**: The CLI plugin system is currently experimental and may change in future versions. Breaking changes to the plugin API or discovery mechanism may occur without notice.