From f40d78188b8e3ff877696836cf7b4e7aa72a30ad Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:11:37 -0500 Subject: [PATCH 01/10] feat(cli): add plugin discovery infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream segments (convert, plugin list, plugin install) all need a way to find and parse installed plugins. This adds the shared foundation: a typed Plugin struct and a Discover() function that scans ~/.ossie/plugins/ for valid plugin.yaml files. Design decisions: - internal/plugin accepts pluginsDir as a parameter rather than calling ossiedir.PluginDir() internally — keeps the package testable without env var manipulation - plugin.yaml supports both ossie_* (preferred) and osi_* (deprecated fallback) key families to avoid breaking plugins written before the OSI → OSSIE rename; ossie_* wins when both are present - Malformed plugin dirs emit a warning to an io.Writer and are skipped; only an unreadable plugins directory is a hard error, matching the behavior specified in the architecture doc - Non-directory entries in the plugins dir are silently skipped (no warning); non-existent dir returns nil, nil Caveats: - Symlinked plugin directories are not yet handled — os.ReadDir reports symlinks with ModeSymlink, not IsDir(); a TODO comment marks the location for a future follow-up - cmd/plugin/list.go wiring is deferred to P1, which also requires the embedded registry before the full table output can be rendered --- cli/go.mod | 5 +- cli/go.sum | 3 + cli/internal/plugin/discover.go | 72 +++++++ cli/internal/plugin/discover_test.go | 299 +++++++++++++++++++++++++++ cli/internal/plugin/plugin.go | 123 +++++++++++ 5 files changed, 501 insertions(+), 1 deletion(-) create mode 100644 cli/internal/plugin/discover.go create mode 100644 cli/internal/plugin/discover_test.go create mode 100644 cli/internal/plugin/plugin.go 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..700dec10 --- /dev/null +++ b/cli/internal/plugin/discover.go @@ -0,0 +1,72 @@ +package plugin + +import ( + "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 os.IsNotExist(err) { + 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 os.IsNotExist(err) { + return nil, fmt.Errorf("no plugin.yaml found") + } + return nil, fmt.Errorf("could not read plugin.yaml: %w", err) + } + + 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..b2155ee7 --- /dev/null +++ b/cli/internal/plugin/discover_test.go @@ -0,0 +1,299 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "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_osi: + invoke: ["ossie-plugin-dbt", "to-osi"] + accepts: [".yaml", ".json"] + from_osi: + invoke: ["ossie-plugin-dbt", "from-osi"] +` + +// 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("/this/path/does/not/exist", &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-osi"} + if !equalStringSlice(p.Convert.ToOSI.Invoke, wantInvoke) { + t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) + } + wantAccepts := []string{".yaml", ".json"} + if !equalStringSlice(p.Convert.ToOSI.Accepts, wantAccepts) { + t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) + } + wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"} + if !equalStringSlice(p.Convert.FromOSI.Invoke, wantFromInvoke) { + t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.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_osi: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_osi: + 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_ossieKeyPreferred(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "both-keys", ` +ossie_plugin_spec: "ossie-value" +osi_plugin_spec: "osi-value" +ossie_spec_version: ">=1.0.0" +osi_spec_version: ">=0.1.0" +platform: + name: test +convert: + to_osi: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_osi: + 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", len(plugins)) + } + if plugins[0].OSSIEPluginSpec != "ossie-value" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "ossie-value") + } + if plugins[0].OSSIESpecVersion != ">=1.0.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=1.0.0") + } +} + +func TestDiscover_osiKeyFallback(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "osi-only", ` +osi_plugin_spec: "0.2.0" +osi_spec_version: ">=0.1.0" +platform: + name: legacy +convert: + to_osi: + invoke: ["bin/convert"] + accepts: [".json"] + from_osi: + 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", len(plugins)) + } + if plugins[0].OSSIEPluginSpec != "0.2.0" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "0.2.0") + } + if plugins[0].OSSIESpecVersion != ">=0.1.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=0.1.0") + } +} + +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()) + } +} + +// equalStringSlice returns true if a and b contain the same elements in the same order. +func equalStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go new file mode 100644 index 00000000..00fa5a15 --- /dev/null +++ b/cli/internal/plugin/plugin.go @@ -0,0 +1,123 @@ +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 { + ToOSI Direction + FromOSI 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 ToOSI only +} + +// rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. +// +// ossie_* keys are the preferred (current brand) names. osi_* keys are +// deprecated aliases kept for backward compatibility with plugins written +// before the OSI → OSSIE rename. When both are present, ossie_* wins. +// TODO: remove osi_* fallback support in a future major version. +type rawPlugin struct { + OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` + OSSIESpecVersion string `yaml:"ossie_spec_version"` + OSIPluginSpec string `yaml:"osi_plugin_spec"` // deprecated + OSISpecVersion string `yaml:"osi_spec_version"` // deprecated + + Platform struct { + Name string `yaml:"name"` + Vendor string `yaml:"vendor"` + } `yaml:"platform"` + + Setup string `yaml:"setup"` + + Convert struct { + ToOSI struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_osi"` + FromOSI struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_osi"` + } `yaml:"convert"` +} + +// validate checks that all required fields are present. +// It performs presence checks only — not format validation. +func (r *rawPlugin) validate() error { + pluginSpec := r.OSSIEPluginSpec + if pluginSpec == "" { + pluginSpec = r.OSIPluginSpec + } + specVersion := r.OSSIESpecVersion + if specVersion == "" { + specVersion = r.OSISpecVersion + } + + switch { + case pluginSpec == "": + return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") + case specVersion == "": + return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") + case r.Platform.Name == "": + return errors.New("missing required field: platform.name") + case len(r.Convert.ToOSI.Invoke) == 0: + return errors.New("missing required field: convert.to_osi.invoke") + case len(r.Convert.ToOSI.Accepts) == 0: + return errors.New("missing required field: convert.to_osi.accepts") + case len(r.Convert.FromOSI.Invoke) == 0: + return errors.New("missing required field: convert.from_osi.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 { + pluginSpec := r.OSSIEPluginSpec + if pluginSpec == "" { + pluginSpec = r.OSIPluginSpec + } + specVersion := r.OSSIESpecVersion + if specVersion == "" { + specVersion = r.OSISpecVersion + } + return &Plugin{ + Path: path, + OSSIEPluginSpec: pluginSpec, + OSSIESpecVersion: specVersion, + Platform: Platform{ + Name: r.Platform.Name, + Vendor: r.Platform.Vendor, + }, + Setup: r.Setup, + Convert: ConvertConfig{ + ToOSI: Direction{ + Invoke: r.Convert.ToOSI.Invoke, + Accepts: r.Convert.ToOSI.Accepts, + }, + FromOSI: Direction{ + Invoke: r.Convert.FromOSI.Invoke, + }, + }, + } +} From dd431b07dee10711704b7fc94d38007cb64db7a3 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:20:24 -0500 Subject: [PATCH 02/10] test(cli): replace equalStringSlice helper with slices.Equal go.mod declares go 1.22 so slices.Equal (stdlib since 1.21) is available. Removes the hand-rolled utility in favour of the standard library equivalent. --- cli/internal/plugin/discover_test.go | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index b2155ee7..f229c7c6 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -3,6 +3,7 @@ package plugin_test import ( "os" "path/filepath" + "slices" "strings" "testing" @@ -94,15 +95,15 @@ func TestDiscover_validPlugin(t *testing.T) { t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") } wantInvoke := []string{"ossie-plugin-dbt", "to-osi"} - if !equalStringSlice(p.Convert.ToOSI.Invoke, wantInvoke) { + if !slices.Equal(p.Convert.ToOSI.Invoke, wantInvoke) { t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) } wantAccepts := []string{".yaml", ".json"} - if !equalStringSlice(p.Convert.ToOSI.Accepts, wantAccepts) { + if !slices.Equal(p.Convert.ToOSI.Accepts, wantAccepts) { t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) } wantFromInvoke := []string{"ossie-plugin-dbt", "from-osi"} - if !equalStringSlice(p.Convert.FromOSI.Invoke, wantFromInvoke) { + if !slices.Equal(p.Convert.FromOSI.Invoke, wantFromInvoke) { t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke) } if stderr.Len() != 0 { @@ -285,15 +286,3 @@ func TestDiscover_strayFileIgnored(t *testing.T) { } } -// equalStringSlice returns true if a and b contain the same elements in the same order. -func equalStringSlice(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} From ac47fea9df424557fa58e79226958cad88fee0b5 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:25:51 -0500 Subject: [PATCH 03/10] fix(cli): use errors.Is for ErrNotExist checks in discover os.IsNotExist does not handle wrapped errors correctly. Replace both occurrences with errors.Is(err, os.ErrNotExist), the modern idiom since Go 1.13. --- cli/internal/plugin/discover.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index 700dec10..d49bf3d0 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -1,6 +1,7 @@ package plugin import ( + "errors" "fmt" "io" "os" @@ -24,7 +25,7 @@ const pluginYAML = "plugin.yaml" func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) { entries, err := os.ReadDir(pluginsDir) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil, nil } return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err) @@ -53,7 +54,7 @@ func loadPlugin(dir string) (*Plugin, error) { yamlPath := filepath.Join(dir, pluginYAML) data, err := os.ReadFile(yamlPath) if err != nil { - if os.IsNotExist(err) { + 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) From fe9ab12833a99ea22f8be6ea9af3bdcc02a78969 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:28:43 -0500 Subject: [PATCH 04/10] test(cli): use t.TempDir for nonexistent dir test fixture Replace hardcoded /this/path/does/not/exist with a path derived from t.TempDir(). The parent is guaranteed to exist by the test harness but the child subdir does not, making the fixture deterministically nonexistent without relying on filesystem assumptions. --- cli/internal/plugin/discover_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index f229c7c6..554518d6 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -57,7 +57,7 @@ func TestDiscover_emptyDir(t *testing.T) { func TestDiscover_nonExistentDir(t *testing.T) { var stderr strings.Builder - plugins, err := plugin.Discover("/this/path/does/not/exist", &stderr) + plugins, err := plugin.Discover(filepath.Join(t.TempDir(), "nonexistent"), &stderr) if err != nil { t.Fatalf("unexpected error: %v", err) } From b4acb5929cf492a01981b82ef51b4198b0a4d166 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:30:10 -0500 Subject: [PATCH 05/10] test(cli): add test coverage for plugin setup field Adds two cases: setup path is correctly parsed when present, and setup is empty string when the field is absent from plugin.yaml. The setup path is consumed by the install segment (P2) when running the plugin's setup script. --- cli/internal/plugin/discover_test.go | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 554518d6..8484208f 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -286,3 +286,51 @@ func TestDiscover_strayFileIgnored(t *testing.T) { } } +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_osi: + invoke: ["bin/convert", "to-osi"] + accepts: [".yaml"] + from_osi: + invoke: ["bin/convert", "from-osi"] +`) + 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) + } +} From d5bb43945d6d521ff7302ff1372ac2cdf0fbc1c1 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Mon, 15 Jun 2026 10:33:15 -0500 Subject: [PATCH 06/10] docs(cli): document intentional lenient YAML unmarshaling in loadPlugin Unknown fields in plugin.yaml are silently ignored by yaml.Unmarshal. Adding a comment to make clear this is a deliberate forward- compatibility choice, not an oversight. --- cli/internal/plugin/discover.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index d49bf3d0..7263e323 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -60,6 +60,9 @@ func loadPlugin(dir string) (*Plugin, error) { 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) From 74b81aa933f9e63efaa9ed0125445ca5dbbdd661 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:20:35 -0500 Subject: [PATCH 07/10] =?UTF-8?q?refactor(plugin):=20rename=20osi=20?= =?UTF-8?q?=E2=86=92=20ossie=20in=20ConvertConfig,=20yaml=20fields,=20and?= =?UTF-8?q?=20invoke=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConvertConfig.ToOSI/FromOSI and the plugin.yaml fields to_osi/from_osi were left using the old brand name. This sweeps them to ossie consistently with the rest of the CLI rename. to_ossie/from_ossie are now the canonical yaml keys. to_osi/from_osi are kept as deprecated fallbacks in rawPlugin, using the same ossie-wins merge pattern already established for ossie_plugin_spec/osi_plugin_spec. The invoke-arg convention (to-ossie, from-ossie) is updated in all canonical test fixtures; the two backward-compat tests (TestDiscover_ossieKeyPreferred, TestDiscover_osiKeyFallback) intentionally retain to_osi/from_osi since they exercise the deprecated-key path. --- cli/internal/plugin/discover_test.go | 36 +++++++-------- cli/internal/plugin/plugin.go | 66 +++++++++++++++++++++------- 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 8484208f..82c4bb04 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -18,11 +18,11 @@ platform: name: dbt vendor: dbt Labs convert: - to_osi: - invoke: ["ossie-plugin-dbt", "to-osi"] + to_ossie: + invoke: ["ossie-plugin-dbt", "to-ossie"] accepts: [".yaml", ".json"] - from_osi: - invoke: ["ossie-plugin-dbt", "from-osi"] + from_ossie: + invoke: ["ossie-plugin-dbt", "from-ossie"] ` // writePlugin creates a plugin directory under root with the given plugin.yaml content. @@ -94,17 +94,17 @@ func TestDiscover_validPlugin(t *testing.T) { if p.OSSIESpecVersion != ">=0.2.0" { t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") } - wantInvoke := []string{"ossie-plugin-dbt", "to-osi"} - if !slices.Equal(p.Convert.ToOSI.Invoke, wantInvoke) { - t.Errorf("ToOSI.Invoke: got %v, want %v", p.Convert.ToOSI.Invoke, wantInvoke) + 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.ToOSI.Accepts, wantAccepts) { - t.Errorf("ToOSI.Accepts: got %v, want %v", p.Convert.ToOSI.Accepts, wantAccepts) + 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-osi"} - if !slices.Equal(p.Convert.FromOSI.Invoke, wantFromInvoke) { - t.Errorf("FromOSI.Invoke: got %v, want %v", p.Convert.FromOSI.Invoke, wantFromInvoke) + 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()) @@ -136,10 +136,10 @@ ossie_spec_version: ">=0.2.0" platform: vendor: dbt Labs convert: - to_osi: + to_ossie: invoke: ["bin/convert"] accepts: [".yaml"] - from_osi: + from_ossie: invoke: ["bin/convert"] `) var stderr strings.Builder @@ -295,11 +295,11 @@ platform: name: dbt setup: bin/setup convert: - to_osi: - invoke: ["bin/convert", "to-osi"] + to_ossie: + invoke: ["bin/convert", "to-ossie"] accepts: [".yaml"] - from_osi: - invoke: ["bin/convert", "from-osi"] + from_ossie: + invoke: ["bin/convert", "from-ossie"] `) var stderr strings.Builder diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go index 00fa5a15..f370bf79 100644 --- a/cli/internal/plugin/plugin.go +++ b/cli/internal/plugin/plugin.go @@ -21,14 +21,14 @@ type Platform struct { // ConvertConfig holds both conversion directions. type ConvertConfig struct { - ToOSI Direction - FromOSI Direction + 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 ToOSI only + Accepts []string // file extensions e.g. [".yaml"]; populated on ToOssie only } // rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. @@ -51,11 +51,18 @@ type rawPlugin struct { Setup string `yaml:"setup"` Convert struct { - ToOSI struct { + ToOssie struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_ossie"` + ToOSI struct { // deprecated Invoke []string `yaml:"invoke"` Accepts []string `yaml:"accepts"` } `yaml:"to_osi"` - FromOSI struct { + FromOssie struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_ossie"` + FromOSI struct { // deprecated Invoke []string `yaml:"invoke"` } `yaml:"from_osi"` } `yaml:"convert"` @@ -73,6 +80,19 @@ func (r *rawPlugin) validate() error { specVersion = r.OSISpecVersion } + toInvoke := r.Convert.ToOssie.Invoke + if len(toInvoke) == 0 { + toInvoke = r.Convert.ToOSI.Invoke + } + toAccepts := r.Convert.ToOssie.Accepts + if len(toAccepts) == 0 { + toAccepts = r.Convert.ToOSI.Accepts + } + fromInvoke := r.Convert.FromOssie.Invoke + if len(fromInvoke) == 0 { + fromInvoke = r.Convert.FromOSI.Invoke + } + switch { case pluginSpec == "": return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") @@ -80,12 +100,12 @@ func (r *rawPlugin) validate() error { return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") case r.Platform.Name == "": return errors.New("missing required field: platform.name") - case len(r.Convert.ToOSI.Invoke) == 0: - return errors.New("missing required field: convert.to_osi.invoke") - case len(r.Convert.ToOSI.Accepts) == 0: - return errors.New("missing required field: convert.to_osi.accepts") - case len(r.Convert.FromOSI.Invoke) == 0: - return errors.New("missing required field: convert.from_osi.invoke") + case len(toInvoke) == 0: + return errors.New("missing required field: convert.to_ossie.invoke (or convert.to_osi.invoke)") + case len(toAccepts) == 0: + return errors.New("missing required field: convert.to_ossie.accepts (or convert.to_osi.accepts)") + case len(fromInvoke) == 0: + return errors.New("missing required field: convert.from_ossie.invoke (or convert.from_osi.invoke)") } return nil } @@ -101,6 +121,20 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { if specVersion == "" { specVersion = r.OSISpecVersion } + + toInvoke := r.Convert.ToOssie.Invoke + if len(toInvoke) == 0 { + toInvoke = r.Convert.ToOSI.Invoke + } + toAccepts := r.Convert.ToOssie.Accepts + if len(toAccepts) == 0 { + toAccepts = r.Convert.ToOSI.Accepts + } + fromInvoke := r.Convert.FromOssie.Invoke + if len(fromInvoke) == 0 { + fromInvoke = r.Convert.FromOSI.Invoke + } + return &Plugin{ Path: path, OSSIEPluginSpec: pluginSpec, @@ -111,12 +145,12 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { }, Setup: r.Setup, Convert: ConvertConfig{ - ToOSI: Direction{ - Invoke: r.Convert.ToOSI.Invoke, - Accepts: r.Convert.ToOSI.Accepts, + ToOssie: Direction{ + Invoke: toInvoke, + Accepts: toAccepts, }, - FromOSI: Direction{ - Invoke: r.Convert.FromOSI.Invoke, + FromOssie: Direction{ + Invoke: fromInvoke, }, }, } From 2df62497cc1550ae6e82fdff669728e3245436b1 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:27:41 -0500 Subject: [PATCH 08/10] refactor(plugin): drop osi_* backward compat from rawPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preemptive osi_* fallback fields (osi_plugin_spec, osi_spec_version, to_osi, from_osi) were added in anticipation of backward compatibility, but no plugins exist yet using that format — the rename is happening before any external consumers. Carrying dead compatibility code with no real users adds noise and keeps osi in the codebase. Drops all deprecated fields and merge logic from rawPlugin, simplifying validate() and toPlugin() back to direct field access. TestDiscover_ossieKeyPreferred and TestDiscover_osiKeyFallback (which existed solely to exercise the now-removed paths) are replaced with TestDiscover_unknownFieldsIgnored, which covers the lenient-YAML behavior (future spec fields are silently tolerated) that discover.go explicitly documents. --- cli/internal/plugin/discover_test.go | 56 ++++-------------- cli/internal/plugin/plugin.go | 88 +++++----------------------- 2 files changed, 26 insertions(+), 118 deletions(-) diff --git a/cli/internal/plugin/discover_test.go b/cli/internal/plugin/discover_test.go index 82c4bb04..cecd6ed0 100644 --- a/cli/internal/plugin/discover_test.go +++ b/cli/internal/plugin/discover_test.go @@ -201,51 +201,20 @@ func TestDiscover_multipleMixed(t *testing.T) { } } -func TestDiscover_ossieKeyPreferred(t *testing.T) { +func TestDiscover_unknownFieldsIgnored(t *testing.T) { root := t.TempDir() - writePlugin(t, root, "both-keys", ` -ossie_plugin_spec: "ossie-value" -osi_plugin_spec: "osi-value" -ossie_spec_version: ">=1.0.0" -osi_spec_version: ">=0.1.0" + 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_osi: + to_ossie: invoke: ["bin/convert"] accepts: [".yaml"] - from_osi: - 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", len(plugins)) - } - if plugins[0].OSSIEPluginSpec != "ossie-value" { - t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "ossie-value") - } - if plugins[0].OSSIESpecVersion != ">=1.0.0" { - t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=1.0.0") - } -} - -func TestDiscover_osiKeyFallback(t *testing.T) { - root := t.TempDir() - writePlugin(t, root, "osi-only", ` -osi_plugin_spec: "0.2.0" -osi_spec_version: ">=0.1.0" -platform: - name: legacy -convert: - to_osi: - invoke: ["bin/convert"] - accepts: [".json"] - from_osi: + from_ossie: invoke: ["bin/convert"] `) var stderr strings.Builder @@ -255,13 +224,10 @@ convert: t.Fatalf("unexpected error: %v", err) } if len(plugins) != 1 { - t.Fatalf("expected 1 plugin, got %d", len(plugins)) + t.Fatalf("expected 1 plugin, got %d: future spec fields should be silently ignored", len(plugins)) } - if plugins[0].OSSIEPluginSpec != "0.2.0" { - t.Errorf("OSSIEPluginSpec: got %q, want %q", plugins[0].OSSIEPluginSpec, "0.2.0") - } - if plugins[0].OSSIESpecVersion != ">=0.1.0" { - t.Errorf("OSSIESpecVersion: got %q, want %q", plugins[0].OSSIESpecVersion, ">=0.1.0") + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) } } diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go index f370bf79..aa83cd8b 100644 --- a/cli/internal/plugin/plugin.go +++ b/cli/internal/plugin/plugin.go @@ -32,16 +32,9 @@ type Direction struct { } // rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. -// -// ossie_* keys are the preferred (current brand) names. osi_* keys are -// deprecated aliases kept for backward compatibility with plugins written -// before the OSI → OSSIE rename. When both are present, ossie_* wins. -// TODO: remove osi_* fallback support in a future major version. type rawPlugin struct { OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` OSSIESpecVersion string `yaml:"ossie_spec_version"` - OSIPluginSpec string `yaml:"osi_plugin_spec"` // deprecated - OSISpecVersion string `yaml:"osi_spec_version"` // deprecated Platform struct { Name string `yaml:"name"` @@ -55,57 +48,28 @@ type rawPlugin struct { Invoke []string `yaml:"invoke"` Accepts []string `yaml:"accepts"` } `yaml:"to_ossie"` - ToOSI struct { // deprecated - Invoke []string `yaml:"invoke"` - Accepts []string `yaml:"accepts"` - } `yaml:"to_osi"` FromOssie struct { Invoke []string `yaml:"invoke"` } `yaml:"from_ossie"` - FromOSI struct { // deprecated - Invoke []string `yaml:"invoke"` - } `yaml:"from_osi"` } `yaml:"convert"` } // validate checks that all required fields are present. // It performs presence checks only — not format validation. func (r *rawPlugin) validate() error { - pluginSpec := r.OSSIEPluginSpec - if pluginSpec == "" { - pluginSpec = r.OSIPluginSpec - } - specVersion := r.OSSIESpecVersion - if specVersion == "" { - specVersion = r.OSISpecVersion - } - - toInvoke := r.Convert.ToOssie.Invoke - if len(toInvoke) == 0 { - toInvoke = r.Convert.ToOSI.Invoke - } - toAccepts := r.Convert.ToOssie.Accepts - if len(toAccepts) == 0 { - toAccepts = r.Convert.ToOSI.Accepts - } - fromInvoke := r.Convert.FromOssie.Invoke - if len(fromInvoke) == 0 { - fromInvoke = r.Convert.FromOSI.Invoke - } - switch { - case pluginSpec == "": - return errors.New("missing required field: ossie_plugin_spec (or osi_plugin_spec)") - case specVersion == "": - return errors.New("missing required field: ossie_spec_version (or osi_spec_version)") + 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(toInvoke) == 0: - return errors.New("missing required field: convert.to_ossie.invoke (or convert.to_osi.invoke)") - case len(toAccepts) == 0: - return errors.New("missing required field: convert.to_ossie.accepts (or convert.to_osi.accepts)") - case len(fromInvoke) == 0: - return errors.New("missing required field: convert.from_ossie.invoke (or convert.from_osi.invoke)") + 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 } @@ -113,32 +77,10 @@ func (r *rawPlugin) validate() error { // 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 { - pluginSpec := r.OSSIEPluginSpec - if pluginSpec == "" { - pluginSpec = r.OSIPluginSpec - } - specVersion := r.OSSIESpecVersion - if specVersion == "" { - specVersion = r.OSISpecVersion - } - - toInvoke := r.Convert.ToOssie.Invoke - if len(toInvoke) == 0 { - toInvoke = r.Convert.ToOSI.Invoke - } - toAccepts := r.Convert.ToOssie.Accepts - if len(toAccepts) == 0 { - toAccepts = r.Convert.ToOSI.Accepts - } - fromInvoke := r.Convert.FromOssie.Invoke - if len(fromInvoke) == 0 { - fromInvoke = r.Convert.FromOSI.Invoke - } - return &Plugin{ Path: path, - OSSIEPluginSpec: pluginSpec, - OSSIESpecVersion: specVersion, + OSSIEPluginSpec: r.OSSIEPluginSpec, + OSSIESpecVersion: r.OSSIESpecVersion, Platform: Platform{ Name: r.Platform.Name, Vendor: r.Platform.Vendor, @@ -146,11 +88,11 @@ func (r *rawPlugin) toPlugin(path string) *Plugin { Setup: r.Setup, Convert: ConvertConfig{ ToOssie: Direction{ - Invoke: toInvoke, - Accepts: toAccepts, + Invoke: r.Convert.ToOssie.Invoke, + Accepts: r.Convert.ToOssie.Accepts, }, FromOssie: Direction{ - Invoke: fromInvoke, + Invoke: r.Convert.FromOssie.Invoke, }, }, } From 4053da40c758bfafd96bba8256dad3d1618bf379 Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:43:03 -0500 Subject: [PATCH 09/10] feat(plugin): implement basic ossie plugin list command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires plugin list to the discovery infrastructure added in F2. Reads from the resolved plugin directory, prints a NAME/PLATFORM/SPEC table via tabwriter, and falls back to a "no plugins installed" message when the directory is empty or missing. This is a minimal first pass — the full P1 spec calls for cross-referencing against the embedded plugin registry (installed vs. available, latest version, update indicator), which depends on registry embedding (F4) not yet implemented. --- cli/cmd/plugin/list.go | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) 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() } From f2873b5296df4301abdd33eb764c1c5c4092594b Mon Sep 17 00:00:00 2001 From: Quigley Malcolm Date: Tue, 23 Jun 2026 16:53:02 -0500 Subject: [PATCH 10/10] chore(cli): add make install target for local development go install . names the binary after the module path's last segment (cli) rather than the intended ossie. make install uses go build -o directly into $GOPATH/bin with the correct binary name. --- cli/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 ./...