Skip to content

Commit 9993a6e

Browse files
janduboisolamilekan000
authored andcommitted
limactl help and info flag should show available plugins
Signed-off-by: olalekan odukoya <[email protected]>
2 parents c8dd6b1 + 95176fa commit 9993a6e

File tree

5 files changed

+310
-55
lines changed

5 files changed

+310
-55
lines changed

cmd/limactl/main.go

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/lima-vm/lima/v2/pkg/fsutil"
2424
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
2525
"github.com/lima-vm/lima/v2/pkg/osutil"
26+
"github.com/lima-vm/lima/v2/pkg/plugin"
2627
"github.com/lima-vm/lima/v2/pkg/version"
2728
)
2829

@@ -165,6 +166,10 @@ func newApp() *cobra.Command {
165166
}
166167
rootCmd.AddGroup(&cobra.Group{ID: "basic", Title: "Basic Commands:"})
167168
rootCmd.AddGroup(&cobra.Group{ID: "advanced", Title: "Advanced Commands:"})
169+
rootCmd.AddGroup(&cobra.Group{ID: "plugin", Title: "Available Plugins (Experimental):"})
170+
171+
// Discover and add plugins as commands
172+
addPluginCommands(rootCmd)
168173
rootCmd.AddCommand(
169174
newCreateCommand(),
170175
newStartCommand(),
@@ -215,14 +220,6 @@ func handleExitError(err error) {
215220

216221
// executeWithPluginSupport handles command execution with plugin support.
217222
func executeWithPluginSupport(rootCmd *cobra.Command, args []string) error {
218-
if len(args) > 0 {
219-
cmd, _, err := rootCmd.Find(args)
220-
if err != nil || cmd == rootCmd {
221-
// Function calls os.Exit() if it found and executed the plugin
222-
runExternalPlugin(rootCmd.Context(), args[0], args[1:])
223-
}
224-
}
225-
226223
rootCmd.SetArgs(args)
227224
return rootCmd.Execute()
228225
}
@@ -232,7 +229,7 @@ func runExternalPlugin(ctx context.Context, name string, args []string) {
232229
ctx = context.Background()
233230
}
234231

235-
if err := updatePathEnv(); err != nil {
232+
if err := plugin.UpdatePathForPlugins(); err != nil {
236233
logrus.Warnf("failed to update PATH environment: %v", err)
237234
// PATH update failure shouldn't prevent plugin execution
238235
}
@@ -257,23 +254,30 @@ func runExternalPlugin(ctx context.Context, name string, args []string) {
257254
logrus.Fatalf("external command %q failed: %v", execPath, err)
258255
}
259256

260-
func updatePathEnv() error {
261-
exe, err := os.Executable()
257+
func addPluginCommands(rootCmd *cobra.Command) {
258+
plugins, err := plugin.DiscoverPlugins()
262259
if err != nil {
263-
return fmt.Errorf("failed to get executable path: %w", err)
260+
logrus.Debugf("Failed to discover plugins: %v", err)
261+
return
264262
}
265263

266-
binDir := filepath.Dir(exe)
267-
currentPath := os.Getenv("PATH")
268-
newPath := binDir + string(filepath.ListSeparator) + currentPath
269-
270-
if err := os.Setenv("PATH", newPath); err != nil {
271-
return fmt.Errorf("failed to set PATH environment: %w", err)
272-
}
264+
for _, p := range plugins {
265+
pluginName := p.Name
266+
pluginCmd := &cobra.Command{
267+
Use: pluginName,
268+
Short: p.Description,
269+
GroupID: "plugin",
270+
DisableFlagParsing: true,
271+
Run: func(cmd *cobra.Command, args []string) {
272+
runExternalPlugin(cmd.Context(), pluginName, args)
273+
},
274+
}
273275

274-
logrus.Debugf("updated PATH to prioritize %s", binDir)
276+
pluginCmd.SilenceUsage = true
277+
pluginCmd.SilenceErrors = true
275278

276-
return nil
279+
rootCmd.AddCommand(pluginCmd)
280+
}
277281
}
278282

279283
// WrapArgsError annotates cobra args error with some context, so the error message is more user-friendly.

pkg/limainfo/limainfo.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
1818
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
1919
"github.com/lima-vm/lima/v2/pkg/limayaml"
20+
"github.com/lima-vm/lima/v2/pkg/plugin"
2021
"github.com/lima-vm/lima/v2/pkg/registry"
2122
"github.com/lima-vm/lima/v2/pkg/templatestore"
2223
"github.com/lima-vm/lima/v2/pkg/usrlocalsharelima"
@@ -35,6 +36,7 @@ type LimaInfo struct {
3536
HostOS string `json:"hostOS"` // since Lima v2.0.0
3637
HostArch string `json:"hostArch"` // since Lima v2.0.0
3738
IdentityFile string `json:"identityFile"` // since Lima v2.0.0
39+
Plugins []plugin.Plugin `json:"plugins"` // since Lima v2.0.0
3840
}
3941

4042
type DriverExt struct {
@@ -108,5 +110,15 @@ func New(ctx context.Context) (*LimaInfo, error) {
108110
Location: bin,
109111
}
110112
}
113+
114+
plugins, err := plugin.DiscoverPlugins()
115+
if err != nil {
116+
logrus.WithError(err).Debug("Failed to discover plugins")
117+
// Don't fail the entire info command if plugin discovery fails
118+
info.Plugins = []plugin.Plugin{}
119+
} else {
120+
info.Plugins = plugins
121+
}
122+
111123
return info, nil
112124
}

pkg/plugin/plugin.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package plugin
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/sirupsen/logrus"
12+
13+
"github.com/lima-vm/lima/v2/pkg/usrlocalsharelima"
14+
)
15+
16+
type Plugin struct {
17+
Name string `json:"name"`
18+
Path string `json:"path"`
19+
Description string `json:"description,omitempty"`
20+
}
21+
22+
func DiscoverPlugins() ([]Plugin, error) {
23+
var plugins []Plugin
24+
seen := make(map[string]bool)
25+
26+
dirs := getPluginDirectories()
27+
28+
for _, dir := range dirs {
29+
pluginsInDir, err := scanDirectory(dir)
30+
if err != nil {
31+
logrus.Debugf("Failed to scan directory %s: %v", dir, err)
32+
continue
33+
}
34+
35+
for _, plugin := range pluginsInDir {
36+
if !seen[plugin.Name] {
37+
plugins = append(plugins, plugin)
38+
seen[plugin.Name] = true
39+
}
40+
}
41+
}
42+
43+
return plugins, nil
44+
}
45+
46+
func getPluginDirectories() []string {
47+
var dirs []string
48+
49+
selfPaths := []string{}
50+
51+
selfViaArgs0, err := usrlocalsharelima.ExecutableViaArgs0()
52+
if err != nil {
53+
logrus.WithError(err).Debug("failed to find executable")
54+
} else {
55+
selfPaths = append(selfPaths, selfViaArgs0)
56+
}
57+
58+
selfViaOS, err := os.Executable()
59+
if err != nil {
60+
logrus.WithError(err).Debug("failed to find os.Executable()")
61+
} else {
62+
selfFinalPathViaOS, err := filepath.EvalSymlinks(selfViaOS)
63+
if err != nil {
64+
logrus.WithError(err).Debug("failed to resolve symlinks")
65+
selfFinalPathViaOS = selfViaOS
66+
}
67+
68+
if len(selfPaths) == 0 || selfFinalPathViaOS != selfPaths[0] {
69+
selfPaths = append(selfPaths, selfFinalPathViaOS)
70+
}
71+
}
72+
73+
for _, self := range selfPaths {
74+
binDir := filepath.Dir(self)
75+
dirs = append(dirs, binDir)
76+
}
77+
78+
pathEnv := os.Getenv("PATH")
79+
if pathEnv != "" {
80+
pathDirs := filepath.SplitList(pathEnv)
81+
dirs = append(dirs, pathDirs...)
82+
}
83+
84+
if prefixDir, err := usrlocalsharelima.Prefix(); err == nil {
85+
libexecDir := filepath.Join(prefixDir, "libexec", "lima")
86+
if _, err := os.Stat(libexecDir); err == nil {
87+
dirs = append(dirs, libexecDir)
88+
}
89+
}
90+
91+
return dirs
92+
}
93+
94+
func scanDirectory(dir string) ([]Plugin, error) {
95+
var plugins []Plugin
96+
97+
entries, err := os.ReadDir(dir)
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
for _, entry := range entries {
103+
if entry.IsDir() {
104+
continue
105+
}
106+
107+
name := entry.Name()
108+
if !strings.HasPrefix(name, "limactl-") {
109+
continue
110+
}
111+
112+
pluginName := strings.TrimPrefix(name, "limactl-")
113+
114+
if strings.Contains(pluginName, ".") {
115+
if filepath.Ext(name) == ".exe" {
116+
pluginName = strings.TrimSuffix(pluginName, ".exe")
117+
} else {
118+
continue
119+
}
120+
}
121+
122+
fullPath := filepath.Join(dir, name)
123+
124+
if !isExecutable(fullPath) {
125+
continue
126+
}
127+
128+
plugin := Plugin{
129+
Name: pluginName,
130+
Path: fullPath,
131+
}
132+
133+
if desc := extractDescFromScript(fullPath); desc != "" {
134+
plugin.Description = desc
135+
}
136+
137+
plugins = append(plugins, plugin)
138+
}
139+
140+
return plugins, nil
141+
}
142+
143+
func isExecutable(path string) bool {
144+
info, err := os.Stat(path)
145+
if err != nil {
146+
return false
147+
}
148+
149+
mode := info.Mode()
150+
if mode&0o111 != 0 {
151+
return true
152+
}
153+
154+
if filepath.Ext(path) == ".exe" {
155+
return true
156+
}
157+
158+
return false
159+
}
160+
161+
func extractDescFromScript(path string) string {
162+
content, err := os.ReadFile(path)
163+
if err != nil {
164+
logrus.Debugf("Failed to read plugin script %s: %v", path, err)
165+
return ""
166+
}
167+
168+
lines := strings.Split(string(content), "\n")
169+
for _, line := range lines {
170+
line = strings.TrimSpace(line)
171+
172+
// Look for pattern: # <limactl-desc>Description text</limactl-desc>
173+
if strings.HasPrefix(line, "#") && strings.Contains(line, "<limactl-desc>") && strings.Contains(line, "</limactl-desc>") {
174+
start := strings.Index(line, "<limactl-desc>") + len("<limactl-desc>")
175+
end := strings.Index(line, "</limactl-desc>")
176+
177+
if start < end {
178+
desc := strings.TrimSpace(line[start:end])
179+
logrus.Debugf("Plugin %s: extracted description from script: %q", filepath.Base(path), desc)
180+
return desc
181+
}
182+
}
183+
}
184+
185+
logrus.Debugf("Plugin %s: no <limactl-desc> found in script", filepath.Base(path))
186+
187+
return ""
188+
}
189+
190+
func UpdatePathForPlugins() error {
191+
pluginDirs := getPluginDirectories()
192+
newPath := strings.Join(pluginDirs, string(filepath.ListSeparator))
193+
return os.Setenv("PATH", newPath)
194+
}

pkg/usrlocalsharelima/usrlocalsharelima.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ import (
1919
"github.com/lima-vm/lima/v2/pkg/limatype"
2020
)
2121

22-
// executableViaArgs0 returns the absolute path to the executable used to start this process.
22+
// ExecutableViaArgs0 returns the absolute path to the executable used to start this process.
2323
// It will also append the file extension on Windows, if necessary.
2424
// This function is different from os.Executable(), which will use /proc/self/exe on Linux
2525
// and therefore will resolve any symlink used to locate the executable. This function will
2626
// return the symlink instead because we want to be able to locate ../share/lima relative
2727
// to the location of the symlink, and not the actual executable. This is important when
2828
// using Homebrew.
29-
var executableViaArgs0 = sync.OnceValues(func() (string, error) {
29+
var ExecutableViaArgs0 = sync.OnceValues(func() (string, error) {
3030
if os.Args[0] == "" {
3131
return "", errors.New("os.Args[0] has not been set")
3232
}
@@ -47,7 +47,7 @@ var executableViaArgs0 = sync.OnceValues(func() (string, error) {
4747
func Dir() (string, error) {
4848
selfPaths := []string{}
4949

50-
selfViaArgs0, err := executableViaArgs0()
50+
selfViaArgs0, err := ExecutableViaArgs0()
5151
if err != nil {
5252
logrus.WithError(err).Warn("failed to find executable from os.Args[0]")
5353
} else {

0 commit comments

Comments
 (0)