diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go index 01f52d0..ceb0b37 100644 --- a/cli/cmd/plugin/list.go +++ b/cli/cmd/plugin/list.go @@ -3,45 +3,98 @@ package plugin import ( "fmt" "os" - "path/filepath" "text/tabwriter" "github.com/spf13/cobra" "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" "github.com/open-semantic-interchange/ossie/cli/internal/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/registry" ) var listCmd = &cobra.Command{ Use: "list", - 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, + Short: "List available and installed plugins", + RunE: runPluginList, } func runPluginList(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return err + } + pluginsDir, err := ossiedir.PluginDir() if err != nil { return err } - plugins, err := plugin.Discover(pluginsDir, os.Stderr) + installed, err := plugin.Discover(pluginsDir, os.Stderr) if err != nil { return err } - if len(plugins) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no plugins installed") + // Index installed plugins by platform name for O(1) lookup. + installedByPlatform := make(map[string]*plugin.Plugin, len(installed)) + for _, p := range installed { + installedByPlatform[p.Platform.Name] = p + } + + // Identify community plugins: installed but absent from the registry. + var community []*plugin.Plugin + for _, p := range installed { + if _, ok := reg.LatestEntry(p.Platform.Name); !ok { + community = append(community, p) + } + } + + platforms := reg.Platforms() // sorted alphabetically + out := cmd.OutOrStdout() + + if len(platforms) == 0 && len(installed) == 0 { + fmt.Fprintln(out, "no plugins available") 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) + if len(platforms) > 0 { + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tINSTALLED\tLATEST") + for _, platform := range platforms { + latest, _ := reg.LatestEntry(platform) + p, isInstalled := installedByPlatform[platform] + + var status, installedVer string + if isInstalled { + installedVer = p.OSSIEPluginSpec + if installedVer == latest.Version { + status = "installed" + } else { + status = "update available" + } + } else { + installedVer = "—" + status = "not installed" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", platform, status, installedVer, latest.Version) + } + if err := w.Flush(); err != nil { + return err + } } - return w.Flush() + + if len(community) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Community plugins:") + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tINSTALLED") + for _, p := range community { + fmt.Fprintf(w, "%s\t%s\n", p.Platform.Name, p.OSSIEPluginSpec) + } + if err := w.Flush(); err != nil { + return err + } + } + + return nil } diff --git a/cli/internal/registry/plugins-registry.yaml b/cli/internal/registry/plugins-registry.yaml new file mode 100644 index 0000000..9a03d86 --- /dev/null +++ b/cli/internal/registry/plugins-registry.yaml @@ -0,0 +1,45 @@ +# OSSIE Plugin Registry +# +# Format: each top-level key is a platform name. The value is a list of +# versioned entries ordered oldest → newest; the last entry is the latest. +# +# Entry types: +# git_release — download from a GitHub release tarball +# raw_url — download from an arbitrary URL +# +# NOTE: All entries below are placeholders pending plugin packaging. + +dbt: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-dbt-v0.1.0 + checksum: sha256:placeholder + +gooddata: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-gooddata-v0.1.0 + checksum: sha256:placeholder + +polaris: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-polaris-v0.1.0 + checksum: sha256:placeholder + +salesforce: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-salesforce-v0.1.0 + checksum: sha256:placeholder + +snowflake: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-snowflake-v0.1.0 + checksum: sha256:placeholder diff --git a/cli/internal/registry/registry.go b/cli/internal/registry/registry.go new file mode 100644 index 0000000..d2817bf --- /dev/null +++ b/cli/internal/registry/registry.go @@ -0,0 +1,76 @@ +package registry + +import ( + _ "embed" // blank import required to activate //go:embed + "fmt" + "sort" + + "gopkg.in/yaml.v3" +) + +// Entry types supported by the registry. +// These constants are consumed by P2 (plugin install) when dispatching +// on how to download and extract a plugin archive. +const ( + EntryTypeGitRelease = "git_release" + EntryTypeRawURL = "raw_url" +) + +// Entry is a single versioned release of a plugin for one platform. +type Entry struct { + Version string `yaml:"version"` + Type string `yaml:"type"` // EntryTypeGitRelease or EntryTypeRawURL + URL string `yaml:"url"` + Tag string `yaml:"tag,omitempty"` // git_release only + Checksum string `yaml:"checksum"` +} + +// Registry maps a platform name to its list of versioned entries, ordered +// oldest to newest. The last entry in each slice is the latest version. +type Registry map[string][]Entry + +//go:embed plugins-registry.yaml +var registryData []byte + +// Load parses the embedded plugins-registry.yaml and returns a Registry. +// An error here indicates a build-time defect; registry_test.go validates +// the embedded data on every test run. +func Load() (Registry, error) { + var r Registry + if err := yaml.Unmarshal(registryData, &r); err != nil { + return nil, fmt.Errorf("failed to parse embedded plugin registry: %w", err) + } + return r, nil +} + +// Platforms returns all platform names in the registry in alphabetical order. +func (r Registry) Platforms() []string { + names := make([]string, 0, len(r)) + for name := range r { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// LatestEntry returns the last (newest) entry for the given platform. +// It returns false if the platform is not in the registry or has no entries. +// Registry maintainers are responsible for keeping entries ordered oldest → newest. +func (r Registry) LatestEntry(platform string) (Entry, bool) { + entries, ok := r[platform] + if !ok || len(entries) == 0 { + return Entry{}, false + } + return entries[len(entries)-1], true +} + +// FindEntry returns the entry matching the given platform and version. +// It returns false if the platform or version is not found. +func (r Registry) FindEntry(platform, version string) (Entry, bool) { + for _, entry := range r[platform] { + if entry.Version == version { + return entry, true + } + } + return Entry{}, false +} diff --git a/cli/internal/registry/registry_test.go b/cli/internal/registry/registry_test.go new file mode 100644 index 0000000..92288d8 --- /dev/null +++ b/cli/internal/registry/registry_test.go @@ -0,0 +1,115 @@ +package registry_test + +import ( + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/registry" +) + +// knownPlatforms is the authoritative list of platforms that must be present +// in the embedded registry for the CLI to have useful output. +// Declared at package level (not inside the test) because slices cannot be +// constants in Go, and multiple tests reference this list. +var knownPlatforms = []string{"dbt", "gooddata", "polaris", "salesforce", "snowflake"} + +func TestLoad_succeeds(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if reg == nil { + t.Fatal("Load() returned nil registry") + } +} + +func TestLoad_containsAllPlatforms(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + for _, platform := range knownPlatforms { + entries, ok := reg[platform] + if !ok { + t.Errorf("platform %q not found in registry", platform) + continue + } + if len(entries) == 0 { + t.Errorf("platform %q has no entries", platform) + } + } +} + +func TestRegistry_Platforms_sorted(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + got := reg.Platforms() + if len(got) == 0 { + t.Fatal("Platforms() returned empty slice") + } + for i := 1; i < len(got); i++ { + if got[i] < got[i-1] { + t.Errorf("Platforms() not sorted: %q appears after %q", got[i-1], got[i]) + } + } +} + +func TestRegistry_LatestEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.LatestEntry("dbt") + if !ok { + t.Fatal("LatestEntry(\"dbt\") returned false, want true") + } + if entry.Version == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Version") + } + if entry.Type == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Type") + } + if entry.URL == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty URL") + } + if entry.Checksum == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Checksum") + } +} + +func TestRegistry_LatestEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.LatestEntry("does-not-exist") + if ok { + t.Error("LatestEntry(\"does-not-exist\") returned true, want false") + } +} + +func TestRegistry_FindEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.FindEntry("dbt", "0.1.0") + if !ok { + t.Fatal("FindEntry(\"dbt\", \"0.1.0\") returned false, want true") + } + if entry.Version != "0.1.0" { + t.Errorf("FindEntry version: got %q, want %q", entry.Version, "0.1.0") + } +} + +func TestRegistry_FindEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.FindEntry("dbt", "99.99.99") + if ok { + t.Error("FindEntry(\"dbt\", \"99.99.99\") returned true, want false") + } +}