diff --git a/app/electron/main.ts b/app/electron/main.ts index bab5cd2024..4b1e0a1ac5 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -60,6 +60,22 @@ const startUrl = ( .replace(/\\/g, '/'); const args = yargs(hideBin(process.argv)) + .command( + 'list-plugins', + 'List all static and user-added plugins.', + () => {}, + () => { + try { + const backendPath = path.join(process.resourcesPath, 'headlamp-server'); + const stdout = execSync(`${backendPath} list-plugins`); + process.stdout.write(stdout); + process.exit(0); + } catch (error) { + console.error(`Error listing plugins: ${error}`); + process.exit(1); + } + } + ) .options({ headless: { describe: 'Open Headlamp in the default web browser instead of its app window', diff --git a/backend/cmd/server.go b/backend/cmd/server.go index 2959990c40..736e10a365 100644 --- a/backend/cmd/server.go +++ b/backend/cmd/server.go @@ -8,9 +8,24 @@ import ( "github.com/headlamp-k8s/headlamp/backend/pkg/config" "github.com/headlamp-k8s/headlamp/backend/pkg/kubeconfig" "github.com/headlamp-k8s/headlamp/backend/pkg/logger" + "github.com/headlamp-k8s/headlamp/backend/pkg/plugins" ) func main() { + if len(os.Args) == 2 && os.Args[1] == "list-plugins" { + conf, err := config.Parse(os.Args[2:]) + if err != nil { + logger.Log(logger.LevelError, nil, err, "fetching config:%v") + os.Exit(1) + } + + if err := plugins.ListPlugins(conf.StaticDir, conf.PluginsDir); err != nil { + logger.Log(logger.LevelError, nil, err, "listing plugins") + } + + return + } + conf, err := config.Parse(os.Args) if err != nil { logger.Log(logger.LevelError, nil, err, "fetching config:%v") diff --git a/backend/pkg/plugins/plugins.go b/backend/pkg/plugins/plugins.go index 431052af21..ae2bd4e5a7 100644 --- a/backend/pkg/plugins/plugins.go +++ b/backend/pkg/plugins/plugins.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "encoding/json" "fmt" "io/fs" "net/http" @@ -77,8 +78,8 @@ func periodicallyWatchSubfolders(watcher *fsnotify.Watcher, path string, interva } } -// GeneratePluginPaths takes the staticPluginDir and pluginDir and returns a list of plugin paths. -func GeneratePluginPaths(staticPluginDir string, pluginDir string) ([]string, error) { +// generateSeparatePluginPaths takes the staticPluginDir and pluginDir and returns separate lists of plugin paths. +func generateSeparatePluginPaths(staticPluginDir, pluginDir string) ([]string, []string, error) { var pluginListURLStatic []string if staticPluginDir != "" { @@ -86,11 +87,21 @@ func GeneratePluginPaths(staticPluginDir string, pluginDir string) ([]string, er pluginListURLStatic, err = pluginBasePathListForDir(staticPluginDir, "static-plugins") if err != nil { - return nil, err + return nil, nil, err } } pluginListURL, err := pluginBasePathListForDir(pluginDir, "plugins") + if err != nil { + return nil, nil, err + } + + return pluginListURLStatic, pluginListURL, nil +} + +// GeneratePluginPaths generates a concatenated list of plugin paths from the staticPluginDir and pluginDir. +func GeneratePluginPaths(staticPluginDir, pluginDir string) ([]string, error) { + pluginListURLStatic, pluginListURL, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) if err != nil { return nil, err } @@ -103,6 +114,59 @@ func GeneratePluginPaths(staticPluginDir string, pluginDir string) ([]string, er return pluginListURL, nil } +// ListPlugins lists the plugins in the static and user-added plugin directories. +func ListPlugins(staticPluginDir, pluginDir string) error { + staticPlugins, userPlugins, err := generateSeparatePluginPaths(staticPluginDir, pluginDir) + if err != nil { + logger.Log(logger.LevelError, nil, err, "listing plugins") + return fmt.Errorf("listing plugins: %w", err) + } + + getPluginName := func(pluginDir string) string { + packageJSONPath := filepath.Join(pluginDir, "package.json") + + content, err := os.ReadFile(packageJSONPath) + if err != nil { + // If there's an error reading package.json, just return the folder name as fallback. + return filepath.Base(pluginDir) + } + + var packageData struct { + Name string `json:"name"` + } + + // Parse the JSON and extract the name. If it fails, return the folder name. + if err := json.Unmarshal(content, &packageData); err != nil || packageData.Name == "" { + return strings.TrimPrefix(filepath.Base(pluginDir), "plugins/") + } + + return packageData.Name + } + + if len(staticPlugins) > 0 { + fmt.Printf("Static Plugins (%s):\n", staticPluginDir) + + for _, plugin := range staticPlugins { + fmt.Println(" -", getPluginName(plugin)) + } + } else { + fmt.Println("No static plugins found.") + } + + if len(userPlugins) > 0 { + fmt.Printf("\nUser-added Plugins (%s):\n", pluginDir) + + for _, plugin := range userPlugins { + pluginName := getPluginName(filepath.Join(pluginDir, plugin)) + fmt.Println(" -", pluginName) + } + } else { + fmt.Printf("No user-added plugins found.") + } + + return nil +} + // pluginBasePathListForDir returns a list of valid plugin paths for the given directory. func pluginBasePathListForDir(pluginDir string, baseURL string) ([]string, error) { files, err := os.ReadDir(pluginDir) diff --git a/backend/pkg/plugins/plugins_test.go b/backend/pkg/plugins/plugins_test.go index e01aca81ee..69034325cd 100644 --- a/backend/pkg/plugins/plugins_test.go +++ b/backend/pkg/plugins/plugins_test.go @@ -2,6 +2,7 @@ package plugins_test import ( "context" + "io" "net/http/httptest" "os" "path" @@ -178,6 +179,107 @@ func TestGeneratePluginPaths(t *testing.T) { //nolint:funlen require.NoError(t, err) } +// Helper function for capturing output. +func captureOutput(f func()) (string, error) { + r, w, err := os.Pipe() + if err != nil { + return "", err + } + + originalStdout := os.Stdout + os.Stdout = w + + f() + + err = w.Close() + if err != nil { + return "", err + } + + os.Stdout = originalStdout + + outputBytes, err := io.ReadAll(r) + if err != nil { + return "", err + } + + return string(outputBytes), nil +} + +// Helper function for creating a plugin. +func createPlugin(t *testing.T, baseDir string, pluginName string) string { + pluginDir := path.Join(baseDir, pluginName) + err := os.Mkdir(pluginDir, 0o755) + require.NoError(t, err) + + // create main.js + mainJsPath := path.Join(pluginDir, "main.js") + _, err = os.Create(mainJsPath) + require.NoError(t, err) + + // create package.json + packageJSONPath := path.Join(pluginDir, "package.json") + _, err = os.Create(packageJSONPath) + require.NoError(t, err) + + return pluginDir +} + +func TestListPlugins(t *testing.T) { + // Create a temporary directory if it doesn't exist + _, err := os.Stat("/tmp/") + if os.IsNotExist(err) { + err = os.Mkdir("/tmp/", 0o755) + require.NoError(t, err) + } + + // create a static plugin directory in /tmp + staticPluginDir := path.Join("/tmp", uuid.NewString()) + err = os.Mkdir(staticPluginDir, 0o755) + require.NoError(t, err) + + createPlugin(t, staticPluginDir, "static-plugin-1") + + // create a user plugin directory in /tmp + pluginDir := path.Join("/tmp", uuid.NewString()) + err = os.Mkdir(pluginDir, 0o755) + require.NoError(t, err) + + plugin1Dir := createPlugin(t, pluginDir, "user-plugin-1") + + // capture the output of the ListPlugins function + output, err := captureOutput(func() { + err := plugins.ListPlugins(staticPluginDir, pluginDir) + require.NoError(t, err) + }) + require.NoError(t, err) + + require.Contains(t, output, "Static Plugins") + require.Contains(t, output, "static-plugin-1") + require.Contains(t, output, "User-added Plugins") + require.Contains(t, output, "user-plugin-1") + + // test missing package.json + os.Remove(path.Join(plugin1Dir, "package.json")) + + output, err = captureOutput(func() { + err := plugins.ListPlugins(staticPluginDir, pluginDir) + require.NoError(t, err) + }) + require.NoError(t, err) + require.Contains(t, output, "user-plugin-1") // should use folder name + + // test invalid package.json + err = os.WriteFile(path.Join(plugin1Dir, "package.json"), []byte("invalid json"), 0o600) + require.NoError(t, err) + output, err = captureOutput(func() { + err := plugins.ListPlugins(staticPluginDir, pluginDir) + require.NoError(t, err) + }) + require.NoError(t, err) + require.Contains(t, output, "user-plugin-1") // should use folder name +} + func TestHandlePluginEvents(t *testing.T) { //nolint:funlen // Create a temporary directory if it doesn't exist _, err := os.Stat("/tmp/")