diff --git a/cli/Makefile b/cli/Makefile index 9ecbcb77..d1f7d66c 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,11 +1,14 @@ BINARY_NAME := ossie BUILD_DIR := dist -.PHONY: build test lint release-dry-run clean +.PHONY: build install test lint release-dry-run clean build: go build -o $(BUILD_DIR)/$(BINARY_NAME) . +install: + go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) . + test: go test ./... diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go index f69e8bbe..202450d8 100644 --- a/cli/cmd/plugin/list.go +++ b/cli/cmd/plugin/list.go @@ -2,17 +2,46 @@ package plugin import ( "fmt" + "os" + "path/filepath" + "text/tabwriter" "github.com/spf13/cobra" + + "github.com/apache/ossie/cli/internal/ossiedir" + "github.com/apache/ossie/cli/internal/plugin" ) var listCmd = &cobra.Command{ Use: "list", - Short: "List installed and available plugins", - RunE: runPluginList, + Short: "List installed plugins", + // TODO(P1): cross-reference against the embedded plugin registry to show + // latest available versions and update indicators once registry embedding + // (F4) is implemented. + RunE: runPluginList, } func runPluginList(cmd *cobra.Command, args []string) error { - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") - return nil + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return err + } + + plugins, err := plugin.Discover(pluginsDir, os.Stderr) + if err != nil { + return err + } + + if len(plugins) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no plugins installed") + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tPLATFORM\tSPEC") + for _, p := range plugins { + name := filepath.Base(p.Path) + fmt.Fprintf(w, "%s\t%s\t%s\n", name, p.Platform.Name, p.OSSIEPluginSpec) + } + return w.Flush() } diff --git a/cli/go.mod b/cli/go.mod index c57048e1..5aa2cb43 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,7 +2,10 @@ module github.com/apache/ossie/cli go 1.26.2 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index a6ee3e0f..47edb24d 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -7,4 +7,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go new file mode 100644 index 00000000..7263e323 --- /dev/null +++ b/cli/internal/plugin/discover.go @@ -0,0 +1,76 @@ +package plugin + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const pluginYAML = "plugin.yaml" + +// Discover scans pluginsDir for subdirectories that contain a plugin.yaml, +// parses and validates each one, and returns all valid plugins. +// +// Malformed or invalid plugin directories are skipped with a warning written +// to stderr in the format: +// +// warning: skipping plugin at : +// +// A non-existent pluginsDir is treated as empty and returns (nil, nil). +// A hard error is only returned if pluginsDir itself cannot be read. +func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) { + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err) + } + + var plugins []*Plugin + for _, entry := range entries { + // TODO: handle symlinked plugin directories + // (entry.Type()&os.ModeSymlink != 0 requires os.Stat to resolve) + if !entry.IsDir() { + continue + } + pluginPath := filepath.Join(pluginsDir, entry.Name()) + p, err := loadPlugin(pluginPath) + if err != nil { + fmt.Fprintf(stderr, "warning: skipping plugin at %s: %s\n", pluginPath, err) + continue + } + plugins = append(plugins, p) + } + return plugins, nil +} + +// loadPlugin reads, parses, and validates the plugin.yaml inside dir. +func loadPlugin(dir string) (*Plugin, error) { + yamlPath := filepath.Join(dir, pluginYAML) + data, err := os.ReadFile(yamlPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("no plugin.yaml found") + } + return nil, fmt.Errorf("could not read plugin.yaml: %w", err) + } + + // yaml.Unmarshal is lenient by default: unknown fields are silently ignored. + // This is intentional — future spec versions may add fields that older CLI + // versions should tolerate rather than reject. + var raw rawPlugin + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + + if err := raw.validate(); err != nil { + return nil, err + } + + return raw.toPlugin(dir), nil +} diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go new file mode 100644 index 00000000..cecd6ed0 --- /dev/null +++ b/cli/internal/plugin/discover_test.go @@ -0,0 +1,302 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/apache/ossie/cli/internal/plugin" +) + +// validPluginYAML is the canonical plugin.yaml fixture using ossie_* keys. +const validPluginYAML = ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt + vendor: dbt Labs +convert: + to_ossie: + invoke: ["ossie-plugin-dbt", "to-ossie"] + accepts: [".yaml", ".json"] + from_ossie: + invoke: ["ossie-plugin-dbt", "from-ossie"] +` + +// writePlugin creates a plugin directory under root with the given plugin.yaml content. +func writePlugin(t *testing.T, root, name, content string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("could not create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644); err != nil { + t.Fatalf("could not write plugin.yaml: %v", err) + } + return dir +} + +func TestDiscover_emptyDir(t *testing.T) { + dir := t.TempDir() + var stderr strings.Builder + + plugins, err := plugin.Discover(dir, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected no plugins, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings, got: %q", stderr.String()) + } +} + +func TestDiscover_nonExistentDir(t *testing.T) { + var stderr strings.Builder + + plugins, err := plugin.Discover(filepath.Join(t.TempDir(), "nonexistent"), &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if plugins != nil { + t.Errorf("expected nil slice, got %v", plugins) + } +} + +func TestDiscover_validPlugin(t *testing.T) { + root := t.TempDir() + pluginDir := writePlugin(t, root, "dbt", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0] + if p.Path != pluginDir { + t.Errorf("Path: got %q, want %q", p.Path, pluginDir) + } + if p.Platform.Name != "dbt" { + t.Errorf("Platform.Name: got %q, want %q", p.Platform.Name, "dbt") + } + if p.Platform.Vendor != "dbt Labs" { + t.Errorf("Platform.Vendor: got %q, want %q", p.Platform.Vendor, "dbt Labs") + } + if p.OSSIEPluginSpec != "0.1.0" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", p.OSSIEPluginSpec, "0.1.0") + } + if p.OSSIESpecVersion != ">=0.2.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") + } + wantInvoke := []string{"ossie-plugin-dbt", "to-ossie"} + if !slices.Equal(p.Convert.ToOssie.Invoke, wantInvoke) { + t.Errorf("ToOssie.Invoke: got %v, want %v", p.Convert.ToOssie.Invoke, wantInvoke) + } + wantAccepts := []string{".yaml", ".json"} + if !slices.Equal(p.Convert.ToOssie.Accepts, wantAccepts) { + t.Errorf("ToOssie.Accepts: got %v, want %v", p.Convert.ToOssie.Accepts, wantAccepts) + } + wantFromInvoke := []string{"ossie-plugin-dbt", "from-ossie"} + if !slices.Equal(p.Convert.FromOssie.Invoke, wantFromInvoke) { + t.Errorf("FromOssie.Invoke: got %v, want %v", p.Convert.FromOssie.Invoke, wantFromInvoke) + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_malformedYAML(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "bad", "this: is: not: valid: yaml: ][") + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "bad")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingRequiredField(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "noplat", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + vendor: dbt Labs +convert: + to_ossie: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "noplat")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingPluginYAML(t *testing.T) { + root := t.TempDir() + // Create a directory with no plugin.yaml inside it. + if err := os.MkdirAll(filepath.Join(root, "empty-plugin"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "empty-plugin")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_multipleMixed(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + writePlugin(t, root, "malformed", "bad: yaml: ][") + if err := os.MkdirAll(filepath.Join(root, "no-yaml"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + warnings := stderr.String() + if !strings.Contains(warnings, filepath.Join(root, "malformed")) { + t.Errorf("expected warning for malformed, got: %q", warnings) + } + if !strings.Contains(warnings, filepath.Join(root, "no-yaml")) { + t.Errorf("expected warning for no-yaml, got: %q", warnings) + } +} + +func TestDiscover_unknownFieldsIgnored(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "extra-fields", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +future_field: "some value" +platform: + name: test + future_platform_field: "ignored" +convert: + to_ossie: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d: future spec fields should be silently ignored", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_strayFileIgnored(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + // Place a plain file alongside the plugin directory. + if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("not a plugin"), 0644); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings for stray file, got: %q", stderr.String()) + } +} + +func TestDiscover_setupFieldPopulated(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "with-setup", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt +setup: bin/setup +convert: + to_ossie: + invoke: ["bin/convert", "to-ossie"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert", "from-ossie"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "bin/setup" { + t.Errorf("Setup: got %q, want %q", plugins[0].Setup, "bin/setup") + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_setupFieldAbsent(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "no-setup", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "" { + t.Errorf("Setup: got %q, want empty string", plugins[0].Setup) + } +} diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go new file mode 100644 index 00000000..aa83cd8b --- /dev/null +++ b/cli/internal/plugin/plugin.go @@ -0,0 +1,99 @@ +package plugin + +import "errors" + +// Plugin is the parsed and validated representation of a single installed plugin. +// Path is the absolute path to the plugin's installation directory on disk. +type Plugin struct { + Path string // absolute path to plugin dir on disk + OSSIEPluginSpec string + OSSIESpecVersion string + Platform Platform + Setup string // relative path to setup script; empty = no setup + Convert ConvertConfig +} + +// Platform identifies the semantic platform this plugin handles. +type Platform struct { + Name string // required; matched against --from/--to values + Vendor string // optional; human-readable +} + +// ConvertConfig holds both conversion directions. +type ConvertConfig struct { + ToOssie Direction + FromOssie Direction +} + +// Direction describes one conversion direction. +type Direction struct { + Invoke []string // command + args; first element is the executable + Accepts []string // file extensions e.g. [".yaml"]; populated on ToOssie only +} + +// rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. +type rawPlugin struct { + OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` + OSSIESpecVersion string `yaml:"ossie_spec_version"` + + Platform struct { + Name string `yaml:"name"` + Vendor string `yaml:"vendor"` + } `yaml:"platform"` + + Setup string `yaml:"setup"` + + Convert struct { + ToOssie struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_ossie"` + FromOssie struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_ossie"` + } `yaml:"convert"` +} + +// validate checks that all required fields are present. +// It performs presence checks only — not format validation. +func (r *rawPlugin) validate() error { + switch { + case r.OSSIEPluginSpec == "": + return errors.New("missing required field: ossie_plugin_spec") + case r.OSSIESpecVersion == "": + return errors.New("missing required field: ossie_spec_version") + case r.Platform.Name == "": + return errors.New("missing required field: platform.name") + case len(r.Convert.ToOssie.Invoke) == 0: + return errors.New("missing required field: convert.to_ossie.invoke") + case len(r.Convert.ToOssie.Accepts) == 0: + return errors.New("missing required field: convert.to_ossie.accepts") + case len(r.Convert.FromOssie.Invoke) == 0: + return errors.New("missing required field: convert.from_ossie.invoke") + } + return nil +} + +// toPlugin maps a validated rawPlugin to the exported Plugin type. +// path is the absolute directory path of the plugin's installation. +func (r *rawPlugin) toPlugin(path string) *Plugin { + return &Plugin{ + Path: path, + OSSIEPluginSpec: r.OSSIEPluginSpec, + OSSIESpecVersion: r.OSSIESpecVersion, + Platform: Platform{ + Name: r.Platform.Name, + Vendor: r.Platform.Vendor, + }, + Setup: r.Setup, + Convert: ConvertConfig{ + ToOssie: Direction{ + Invoke: r.Convert.ToOssie.Invoke, + Accepts: r.Convert.ToOssie.Accepts, + }, + FromOssie: Direction{ + Invoke: r.Convert.FromOssie.Invoke, + }, + }, + } +}