From bc2f310e733e658de57cddcc675709a9fb2dda84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sun, 19 Jul 2026 17:10:53 +0100 Subject: [PATCH] feat(plugins): add plugin marketplace with curated registry and updates (#1798) * feat(plugins): add plugin marketplace with curated registry and updates Discover and install plugins from inside kandev instead of only by URL/upload. - Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge (dedup, install_state, degraded-source handling), and the /api/plugins/ marketplace[/sources] API. Install reuses the existing install-by-URL pipeline. - Frontend: Settings > Plugins gains a Browse tab (search/category/star sort), source management, one-click install, and an Update button on the Installed tab when the catalog has a newer version. Plugins may ship an icon (manifest `icon` -> catalog icon_url) with a letter-tile fallback. - Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages, daily star refresh). Ships empty at launch; the empty catalog is valid. - Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): address marketplace PR review feedback - CodeQL: stub fetch routers keyed off a path segment, not a hostname substring. - Nullable stars end to end: a failed star lookup stays unknown (not 0) and sorts last, instead of decoding null -> 0 and corrupting ranking. - Fetch marketplace sources concurrently and dedupe identical in-flight downloads (singleflight), so a slow source can't serialize the Browse tab. - Register /marketplace routes before the /:id wildcard (gin tree ordering). - Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error. - Browse: filter category client-side so the category dropdown never collapses. - marketplaceInstall swallows after toasting (no unhandled promise rejection). - Source badge keys off source_id, not a name substring. - build-index: read block-sequence manifest categories, not just inline. - Registry index workflow validates on pull_request; Pages deploy gated to push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): guard catalog repo_url against javascript: href injection repo_url comes from an untrusted index.json, so a malicious/compromised source could set a javascript: URL that runs in the operator's session when the Repo link is clicked (rel/target don't block it). Only render the anchor for http(s) schemes. Adds regression tests for the scheme guard and null-star rendering. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): require https for remote sources; test oversized download - Reject remote plain-http marketplace sources (MITM catalog-poisoning risk); loopback http stays allowed for local dev/e2e fixtures. - Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized so the maxIndexBytes memory bound has coverage. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(plugins): detach source download from singleflight leader's context Two concurrent Browse opens after a cache Refresh share one singleflight download; if the leader's request context is canceled (it navigates away), the waiters must not inherit that cancellation. Use context.WithoutCancel so values are preserved but the leader can't cancel everyone; the client's 20s timeout still bounds the download. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/plugin-registry-index.yml | 91 +++++ .github/workflows/plugin-registry-stars.yml | 26 ++ apps/backend/internal/plugins/handlers.go | 4 + .../internal/plugins/manifest/manifest.go | 6 + apps/backend/internal/plugins/marketplace.go | 33 ++ .../internal/plugins/marketplace/catalog.go | 343 ++++++++++++++++++ .../plugins/marketplace/catalog_test.go | 292 +++++++++++++++ .../plugins/marketplace/sourcestore.go | 246 +++++++++++++ .../plugins/marketplace/sourcestore_test.go | 141 +++++++ .../internal/plugins/marketplace/types.go | 128 +++++++ .../internal/plugins/marketplace_handlers.go | 160 ++++++++ .../plugins/marketplace_handlers_test.go | 138 +++++++ apps/backend/internal/plugins/provider.go | 33 ++ apps/backend/internal/plugins/service.go | 5 + .../settings/plugins/marketplace-browser.tsx | 235 ++++++++++++ .../plugins/marketplace-entry-row.test.tsx | 55 +++ .../plugins/marketplace-entry-row.tsx | 153 ++++++++ .../plugins/marketplace-sources-dialog.tsx | 178 +++++++++ .../settings/plugins/plugin-row.test.tsx | 82 +++++ .../settings/plugins/plugin-row.tsx | 129 +++++-- .../settings/plugins/plugins-settings.tsx | 179 ++++++--- .../settings/plugins/use-plugin-actions.ts | 16 + .../domains/plugins/use-marketplace.test.ts | 70 ++++ .../hooks/domains/plugins/use-marketplace.ts | 58 +++ .../plugins/use-plugin-updates.test.ts | 52 +++ .../domains/plugins/use-plugin-updates.ts | 44 +++ .../lib/api/domains/marketplace-api.test.ts | 167 +++++++++ apps/web/lib/api/domains/marketplace-api.ts | 96 +++++ apps/web/lib/types/plugins.ts | 58 +++ docs/specs/INDEX.md | 1 + docs/specs/plugins/marketplace.md | 341 +++++++++++++++++ docs/specs/plugins/spec.md | 8 +- plugin-registry/.gitignore | 2 + plugin-registry/README.md | 86 +++++ plugin-registry/build-index.mjs | 336 +++++++++++++++++ plugin-registry/build-index.test.mjs | 157 ++++++++ plugin-registry/plugins.yaml | 32 ++ plugin-registry/schema.json | 44 +++ 38 files changed, 4130 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/plugin-registry-index.yml create mode 100644 .github/workflows/plugin-registry-stars.yml create mode 100644 apps/backend/internal/plugins/marketplace.go create mode 100644 apps/backend/internal/plugins/marketplace/catalog.go create mode 100644 apps/backend/internal/plugins/marketplace/catalog_test.go create mode 100644 apps/backend/internal/plugins/marketplace/sourcestore.go create mode 100644 apps/backend/internal/plugins/marketplace/sourcestore_test.go create mode 100644 apps/backend/internal/plugins/marketplace/types.go create mode 100644 apps/backend/internal/plugins/marketplace_handlers.go create mode 100644 apps/backend/internal/plugins/marketplace_handlers_test.go create mode 100644 apps/web/components/settings/plugins/marketplace-browser.tsx create mode 100644 apps/web/components/settings/plugins/marketplace-entry-row.test.tsx create mode 100644 apps/web/components/settings/plugins/marketplace-entry-row.tsx create mode 100644 apps/web/components/settings/plugins/marketplace-sources-dialog.tsx create mode 100644 apps/web/components/settings/plugins/plugin-row.test.tsx create mode 100644 apps/web/hooks/domains/plugins/use-marketplace.test.ts create mode 100644 apps/web/hooks/domains/plugins/use-marketplace.ts create mode 100644 apps/web/hooks/domains/plugins/use-plugin-updates.test.ts create mode 100644 apps/web/hooks/domains/plugins/use-plugin-updates.ts create mode 100644 apps/web/lib/api/domains/marketplace-api.test.ts create mode 100644 apps/web/lib/api/domains/marketplace-api.ts create mode 100644 docs/specs/plugins/marketplace.md create mode 100644 plugin-registry/.gitignore create mode 100644 plugin-registry/README.md create mode 100644 plugin-registry/build-index.mjs create mode 100644 plugin-registry/build-index.test.mjs create mode 100644 plugin-registry/plugins.yaml create mode 100644 plugin-registry/schema.json diff --git a/.github/workflows/plugin-registry-index.yml b/.github/workflows/plugin-registry-index.yml new file mode 100644 index 0000000000..91015a976f --- /dev/null +++ b/.github/workflows/plugin-registry-index.yml @@ -0,0 +1,91 @@ +name: Plugin Registry Index + +# Builds the marketplace catalog (plugin-registry/index.json) from the curated +# plugins.yaml pointer list and publishes it to GitHub Pages. This is the single +# source of the built catalog: its daily schedule also serves as the star-count +# refresh (see plugin-registry-stars.yml for a manual "refresh now" trigger). + +on: + # Rebuild whenever the curated list or the build tooling changes. + push: + branches: [main] + paths: + - "plugin-registry/**" + - ".github/workflows/plugin-registry-index.yml" + # Validate registry edits before merge (build + test run; the Pages deploy + # steps below are gated to non-PR events, so a PR never deploys). + pull_request: + paths: + - "plugin-registry/**" + - ".github/workflows/plugin-registry-index.yml" + # Manual rebuild. + workflow_dispatch: + # Allow a sibling workflow (star refresh) to invoke this build. + workflow_call: + # Daily rebuild — keeps star counts and latest versions fresh without commits. + schedule: + - cron: "0 6 * * *" + +# Least-privilege for a Pages deploy: read source, write Pages, mint the OIDC +# token deploy-pages needs. +permissions: + contents: read + pages: write + id-token: write + +# Only one concurrent Pages deployment; let an in-progress deploy finish. +concurrency: + group: plugin-registry-pages + cancel-in-progress: false + +jobs: + build-and-deploy: + name: Build index.json and deploy to Pages + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "22" + + - name: Test build script + run: node --test plugin-registry/build-index.test.mjs + + - name: Build index.json + # Zero-dependency Node script (stdlib + global fetch); no install step. + # GITHUB_TOKEN authenticates the GitHub API calls. It reads the public + # plugin repos fine here; at larger scale, reading arbitrary external + # repos' stars reliably may want a PAT with `public_repo` scope set as + # this secret instead. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node plugin-registry/build-index.mjs + + - name: Stage Pages site + # Publish index.json under /plugins/index.json on the Pages host. + run: | + mkdir -p _site/plugins + cp plugin-registry/index.json _site/plugins/index.json + + - name: Configure Pages + if: github.event_name != 'pull_request' + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + + - name: Upload Pages artifact + if: github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: _site + + - name: Deploy to GitHub Pages + id: deploy + if: github.event_name != 'pull_request' + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/plugin-registry-stars.yml b/.github/workflows/plugin-registry-stars.yml new file mode 100644 index 0000000000..2148504cb7 --- /dev/null +++ b/.github/workflows/plugin-registry-stars.yml @@ -0,0 +1,26 @@ +name: Plugin Registry Star Refresh + +# Thin, manual "refresh stars now" trigger. The catalog is rebuilt daily by +# plugin-registry-index.yml (that schedule already refreshes GitHub star counts +# and latest versions), so this workflow adds NO build logic of its own — it +# simply re-runs that single source of truth via workflow_call. Use it to force +# an out-of-band refresh (for example right after a plugin repo goes viral) +# without waiting for the daily run. +# +# Kept DRY on purpose: the actual index build lives only in +# plugin-registry-index.yml. + +on: + workflow_dispatch: + +# Inherit the same least-privilege permissions the called build needs for its +# Pages deploy. +permissions: + contents: read + pages: write + id-token: write + +jobs: + refresh: + name: Rebuild catalog to refresh stars + uses: ./.github/workflows/plugin-registry-index.yml diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 69612fba8b..91c3dbe198 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -44,6 +44,10 @@ func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.L api := router.Group("/api/plugins") api.POST("/install", ctrl.install) api.POST("/sync", ctrl.sync) + // Register the static /marketplace routes before the /:id wildcard, matching + // the /install and /sync ordering — some gin/httprouter tree versions reject + // a static sibling added after an existing wildcard for the same method. + ctrl.registerMarketplaceRoutes(api) api.GET("", ctrl.list) api.GET("/:id", ctrl.get) api.GET("/:id/config", ctrl.getConfig) diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 4b6464ac74..a53da07d09 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -16,6 +16,12 @@ type Manifest struct { Author string `yaml:"author" json:"author"` Categories []string `yaml:"categories" json:"categories"` + // Icon is an optional package-relative path (e.g. "icon.svg") to an + // image the plugin ships for display in the marketplace and plugin + // lists. The registry index-build resolves it to an absolute icon_url; + // for an installed plugin it is served from the extracted package. + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` + BaseURL string `yaml:"base_url" json:"base_url"` Endpoints Endpoints `yaml:"endpoints" json:"endpoints"` diff --git a/apps/backend/internal/plugins/marketplace.go b/apps/backend/internal/plugins/marketplace.go new file mode 100644 index 0000000000..27d157053c --- /dev/null +++ b/apps/backend/internal/plugins/marketplace.go @@ -0,0 +1,33 @@ +package plugins + +import "github.com/kandev/kandev/internal/plugins/marketplace" + +// SetMarketplace attaches the plugin-discovery catalog service. Called by +// Provide after the source store is built; nil-safe callers must check +// Marketplace() before use (a Service constructed via NewService in tests has +// no marketplace unless one is set). +func (s *Service) SetMarketplace(m *marketplace.Service) { + s.mu.Lock() + s.marketplace = m + s.mu.Unlock() +} + +// Marketplace returns the attached catalog service, or nil when the +// marketplace subsystem is unavailable (e.g. its source store failed to +// initialize). Handlers guard on nil and return 503. +func (s *Service) Marketplace() *marketplace.Service { + s.mu.Lock() + defer s.mu.Unlock() + return s.marketplace +} + +// InstalledForMarketplace maps the current registry into the minimal +// id+version facts the catalog needs to compute each entry's install state. +func (s *Service) InstalledForMarketplace() []marketplace.InstalledPlugin { + records := s.List() + out := make([]marketplace.InstalledPlugin, 0, len(records)) + for _, rec := range records { + out = append(out, marketplace.InstalledPlugin{ID: rec.ID, Version: rec.Version}) + } + return out +} diff --git a/apps/backend/internal/plugins/marketplace/catalog.go b/apps/backend/internal/plugins/marketplace/catalog.go new file mode 100644 index 0000000000..cd96e54eea --- /dev/null +++ b/apps/backend/internal/plugins/marketplace/catalog.go @@ -0,0 +1,343 @@ +package marketplace + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/plugins/manifest" +) + +// maxIndexBytes caps the index.json body read from any source, bounding +// worst-case memory from a hostile or misconfigured source. +const maxIndexBytes = 5 << 20 // 5 MiB + +// defaultCacheTTL is how long a fetched index document is reused before the +// source is re-fetched. Refresh() (and adding/removing a source) invalidates +// the cache immediately. +const defaultCacheTTL = 5 * time.Minute + +// Service fetches, caches, and merges catalog documents across the configured +// marketplace sources. +type Service struct { + store *SourceStore + client *http.Client + log *logger.Logger + + ttl time.Duration + now func() time.Time + + mu sync.Mutex + cache map[string]cacheEntry + // sf collapses concurrent downloads of the same source URL into one HTTP + // request (Browse opened in two tabs while the cache is cold no longer + // races two identical fetches). + sf singleflight.Group +} + +type cacheEntry struct { + doc *IndexDocument + at time.Time +} + +// NewService builds a marketplace Service over the given source store. +func NewService(store *SourceStore, log *logger.Logger) *Service { + return &Service{ + store: store, + client: &http.Client{Timeout: 20 * time.Second}, + log: log, + ttl: defaultCacheTTL, + now: time.Now, + cache: map[string]cacheEntry{}, + } +} + +// Sources returns every configured source (built-in first). +func (s *Service) Sources() ([]SourceRecord, error) { return s.store.List() } + +// AddSource registers a new operator source and clears the cache. +func (s *Service) AddSource(name, url string) (*SourceRecord, error) { + rec, err := s.store.Add(name, url) + if err == nil { + s.Refresh() + } + return rec, err +} + +// UpdateSource renames or enables/disables a source and clears the cache. +func (s *Service) UpdateSource(id string, name *string, enabled *bool) (*SourceRecord, error) { + rec, err := s.store.Update(id, name, enabled) + if err == nil { + s.Refresh() + } + return rec, err +} + +// DeleteSource removes a non-builtin source and clears the cache. +func (s *Service) DeleteSource(id string) error { + err := s.store.Delete(id) + if err == nil { + s.Refresh() + } + return err +} + +// Refresh drops every cached index document so the next Catalog call re-fetches. +func (s *Service) Refresh() { + s.mu.Lock() + s.cache = map[string]cacheEntry{} + s.mu.Unlock() +} + +// Catalog fetches and merges every enabled source into a single deduped +// catalog, annotating each entry with its install state against `installed`. +// A source that fails to fetch/parse is reported degraded (Healthy=false) and +// contributes no entries; the healthy sources still return. When the same id +// appears in more than one source, the first configured source (built-in +// first) wins and later duplicates are dropped. +func (s *Service) Catalog(ctx context.Context, installed []InstalledPlugin) (*CatalogResult, error) { + sources, err := s.store.List() + if err != nil { + return nil, err + } + fetched := s.fetchAll(ctx, sources) + + // Merge sequentially in source order so first-source-wins dedup is stable, + // even though the fetches above ran concurrently. + installedByID := indexInstalled(installed) + result := &CatalogResult{Plugins: []CatalogEntry{}, Sources: []SourceStatus{}} + seen := map[string]bool{} + for i, src := range sources { + status := statusFor(src) + if !src.Enabled { + result.Sources = append(result.Sources, status) + continue + } + if fetched[i].err != nil { + status.Healthy = false + status.Error = fetched[i].err.Error() + result.Sources = append(result.Sources, status) + continue + } + result.Plugins = append(result.Plugins, mergeEntries(fetched[i].doc, src, installedByID, seen)...) + result.Sources = append(result.Sources, status) + } + return result, nil +} + +type fetchOutcome struct { + doc *IndexDocument + err error +} + +// fetchAll fetches every enabled source concurrently, so one slow/unreachable +// source can't serialize (up to 20s each) in front of the others — the Browse +// tab's latency is bounded by the slowest single source, not their sum. +func (s *Service) fetchAll(ctx context.Context, sources []SourceRecord) []fetchOutcome { + out := make([]fetchOutcome, len(sources)) + var wg sync.WaitGroup + for i, src := range sources { + if !src.Enabled { + continue + } + wg.Add(1) + go func(i int, url string) { + defer wg.Done() + doc, err := s.fetch(ctx, url) + out[i] = fetchOutcome{doc: doc, err: err} + }(i, src.URL) + } + wg.Wait() + return out +} + +// mergeEntries appends this source's not-yet-seen entries as annotated catalog +// entries, marking their ids seen so later sources can't shadow them. +func mergeEntries(doc *IndexDocument, src SourceRecord, installed map[string]string, seen map[string]bool) []CatalogEntry { + out := make([]CatalogEntry, 0, len(doc.Plugins)) + for _, e := range doc.Plugins { + if e.ID == "" || seen[e.ID] { + continue + } + seen[e.ID] = true + out = append(out, annotate(e, src, installed)) + } + return out +} + +func statusFor(src SourceRecord) SourceStatus { + return SourceStatus{ + ID: src.ID, + Name: src.Name, + URL: src.URL, + Enabled: src.Enabled, + Builtin: src.Builtin, + Healthy: true, + } +} + +func indexInstalled(installed []InstalledPlugin) map[string]string { + m := make(map[string]string, len(installed)) + for _, p := range installed { + m[p.ID] = p.Version + } + return m +} + +// annotate derives a catalog entry's install state from what is installed. +func annotate(e IndexEntry, src SourceRecord, installed map[string]string) CatalogEntry { + ce := CatalogEntry{IndexEntry: e, SourceID: src.ID, SourceName: src.Name, InstallState: StateAvailable} + if v, ok := installed[e.ID]; ok { + ce.InstalledVersion = v + if manifest.CompareVersions(v, e.Version) < 0 { + ce.InstallState = StateUpdateAvailable + } else { + ce.InstallState = StateInstalled + } + } + return ce +} + +// fetch returns a source's index document from cache when fresh, otherwise +// downloads and caches it. +func (s *Service) fetch(ctx context.Context, url string) (*IndexDocument, error) { + if doc, ok := s.cached(url); ok { + return doc, nil + } + // singleflight collapses concurrent misses on the same URL into one + // download; latecomers get the leader's result. + v, err, _ := s.sf.Do(url, func() (any, error) { + if doc, ok := s.cached(url); ok { // another caller may have filled it + return doc, nil + } + // Detach from the leader's cancellation: the singleflight result is + // shared across all waiters, so if the first caller navigates away its + // canceled ctx must not fail everyone else's still-live request. Values + // are preserved; the client's 20s timeout still bounds the download. + doc, derr := s.download(context.WithoutCancel(ctx), url) + if derr != nil { + return nil, derr + } + s.mu.Lock() + s.cache[url] = cacheEntry{doc: doc, at: s.now()} + s.mu.Unlock() + return doc, nil + }) + if err != nil { + return nil, err + } + return v.(*IndexDocument), nil +} + +// cached returns a still-fresh cached document for url, if any. +func (s *Service) cached(url string) (*IndexDocument, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if ce, ok := s.cache[url]; ok && s.now().Sub(ce.at) < s.ttl { + return ce.doc, true + } + return nil, false +} + +func (s *Service) download(ctx context.Context, url string) (*IndexDocument, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("source returned status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxIndexBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxIndexBytes { + return nil, fmt.Errorf("index exceeds %d bytes", maxIndexBytes) + } + var doc IndexDocument + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("parse index: %w", err) + } + return &doc, nil +} + +// ApplyQuery filters and sorts a merged catalog per the request. Filtering: +// Text matches id/name/description (case-insensitive substring); Category +// matches any of an entry's categories. Sort: "name" (asc), "recent" +// (updated_at desc), or "stars" (desc, the default). +func ApplyQuery(entries []CatalogEntry, q Query) []CatalogEntry { + out := make([]CatalogEntry, 0, len(entries)) + text := strings.ToLower(strings.TrimSpace(q.Text)) + category := strings.ToLower(strings.TrimSpace(q.Category)) + for _, e := range entries { + if matchesText(e, text) && matchesCategory(e, category) { + out = append(out, e) + } + } + sortEntries(out, q.Sort) + return out +} + +func matchesText(e CatalogEntry, text string) bool { + if text == "" { + return true + } + hay := strings.ToLower(e.ID + " " + e.Name + " " + e.Description) + return strings.Contains(hay, text) +} + +func matchesCategory(e CatalogEntry, category string) bool { + if category == "" { + return true + } + for _, c := range e.Categories { + if strings.ToLower(c) == category { + return true + } + } + return false +} + +func sortEntries(entries []CatalogEntry, mode string) { + switch mode { + case "name": + sort.SliceStable(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + }) + case "recent": + sort.SliceStable(entries, func(i, j int) bool { + return entries[i].UpdatedAt > entries[j].UpdatedAt + }) + default: // "stars" + sort.SliceStable(entries, func(i, j int) bool { + return starsBefore(entries[i].Stars, entries[j].Stars) + }) + } +} + +// starsBefore orders by star count descending, with unknown (nil) stars sorted +// last so a repo whose star lookup failed never outranks a real one. +func starsBefore(a, b *int) bool { + if a == nil { + return false + } + if b == nil { + return true + } + return *a > *b +} diff --git a/apps/backend/internal/plugins/marketplace/catalog_test.go b/apps/backend/internal/plugins/marketplace/catalog_test.go new file mode 100644 index 0000000000..8fed4adceb --- /dev/null +++ b/apps/backend/internal/plugins/marketplace/catalog_test.go @@ -0,0 +1,292 @@ +package marketplace + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// indexJSON is a minimal valid index.json body with the given plugins block. +func indexJSON(pluginsBlock string) string { + return `{"schema_version":1,"generated_at":"2026-07-18T00:00:00Z",` + + `"source":{"name":"Test","url":""},"plugins":[` + pluginsBlock + `]}` +} + +func entryJSON(id, version string, stars int) string { + return `{"id":"` + id + `","name":"` + id + `","version":"` + version + + `","stars":` + itoa(stars) + `,"package_url":"https://ex/` + id + `.tar.gz"}` +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} + +func ip(n int) *int { return &n } + +// TestStarSortPlacesUnknownLast proves a nil (unknown) star count sorts after +// real ones instead of ranking as zero. +func TestStarSortPlacesUnknownLast(t *testing.T) { + entries := []CatalogEntry{ + {IndexEntry: IndexEntry{ID: "unknown", Stars: nil}}, + {IndexEntry: IndexEntry{ID: "low", Stars: ip(1)}}, + {IndexEntry: IndexEntry{ID: "high", Stars: ip(99)}}, + } + sorted := ApplyQuery(entries, Query{}) + if sorted[0].ID != "high" || sorted[1].ID != "low" || sorted[2].ID != "unknown" { + t.Fatalf("unknown stars must sort last, got %s,%s,%s", sorted[0].ID, sorted[1].ID, sorted[2].ID) + } +} + +func newTestService(t *testing.T) *Service { + t.Helper() + store := newTestSourceStore(t) + return NewService(store, nil) +} + +func serve(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestCatalogMergesSourcesAndDedupesFirstWins(t *testing.T) { + official := serve(t, indexJSON(entryJSON("alpha", "1.0.0", 10)+","+entryJSON("beta", "2.0.0", 5))) + acme := serve(t, indexJSON(entryJSON("beta", "9.9.9", 999)+","+entryJSON("gamma", "1.0.0", 3))) + + s := newTestService(t) + if err := s.store.EnsureBuiltin("Official", official.URL); err != nil { + t.Fatal(err) + } + if _, err := s.store.Add("Acme", acme.URL); err != nil { + t.Fatal(err) + } + + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if len(result.Plugins) != 3 { + t.Fatalf("want 3 merged plugins, got %d", len(result.Plugins)) + } + beta := findEntry(result.Plugins, "beta") + if beta == nil || beta.Version != "2.0.0" || beta.SourceName != "Official" { + t.Fatalf("first-source-wins failed for beta: %+v", beta) + } + if len(result.Sources) != 2 { + t.Fatalf("want 2 source statuses, got %d", len(result.Sources)) + } +} + +func TestCatalogInstallStateAnnotation(t *testing.T) { + official := serve(t, indexJSON( + entryJSON("installed", "1.0.0", 1)+","+ + entryJSON("outdated", "2.0.0", 1)+","+ + entryJSON("fresh", "1.0.0", 1))) + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", official.URL) + + installed := []InstalledPlugin{ + {ID: "installed", Version: "1.0.0"}, + {ID: "outdated", Version: "1.0.0"}, + } + result, err := s.Catalog(context.Background(), installed) + if err != nil { + t.Fatalf("catalog: %v", err) + } + assertState(t, result.Plugins, "installed", StateInstalled) + assertState(t, result.Plugins, "outdated", StateUpdateAvailable) + assertState(t, result.Plugins, "fresh", StateAvailable) +} + +func TestCatalogDegradedSourceDoesNotAbortMerge(t *testing.T) { + good := serve(t, indexJSON(entryJSON("alpha", "1.0.0", 1))) + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", good.URL) + // A source pointing nowhere reachable. + if _, err := s.store.Add("Broken", "http://127.0.0.1:1/index.json"); err != nil { + t.Fatal(err) + } + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if len(result.Plugins) != 1 { + t.Fatalf("healthy source should still return its entries, got %d", len(result.Plugins)) + } + broken := findSource(result.Sources, "Broken") + if broken == nil || broken.Healthy || broken.Error == "" { + t.Fatalf("broken source should be degraded with an error, got %+v", broken) + } +} + +func TestCatalogSkipsDisabledSource(t *testing.T) { + official := serve(t, indexJSON(entryJSON("alpha", "1.0.0", 1))) + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", official.URL) + rec, _ := s.store.Add("Acme", serve(t, indexJSON(entryJSON("beta", "1.0.0", 1))).URL) + disabled := false + if _, err := s.UpdateSource(rec.ID, nil, &disabled); err != nil { + t.Fatal(err) + } + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if findEntry(result.Plugins, "beta") != nil { + t.Fatalf("disabled source must not contribute entries") + } +} + +func TestCatalogCachesWithinTTLAndRefreshInvalidates(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(indexJSON(entryJSON("alpha", "1.0.0", 1)))) + })) + t.Cleanup(srv.Close) + + s := newTestService(t) + base := time.Unix(0, 0) + s.now = func() time.Time { return base } + _ = s.store.EnsureBuiltin("Official", srv.URL) + + for i := 0; i < 3; i++ { + if _, err := s.Catalog(context.Background(), nil); err != nil { + t.Fatalf("catalog: %v", err) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("within TTL want 1 fetch, got %d", got) + } + s.Refresh() + if _, err := s.Catalog(context.Background(), nil); err != nil { + t.Fatalf("catalog: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 2 { + t.Fatalf("after refresh want 2 fetches, got %d", got) + } +} + +func TestDownloadRejectsNon200AndOversized(t *testing.T) { + s := newTestService(t) + + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(bad.Close) + if _, err := s.download(context.Background(), bad.URL); err == nil { + t.Fatalf("expected non-200 to error") + } + + // A body over maxIndexBytes must be rejected (the memory-exhaustion bound): + // io.LimitReader lets ReadAll see one byte past the cap, tripping the guard. + oversized := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(make([]byte, maxIndexBytes+10)) + })) + t.Cleanup(oversized.Close) + if _, err := s.download(context.Background(), oversized.URL); err == nil { + t.Fatalf("expected oversized body to error") + } +} + +func TestCatalogPreservesIconURL(t *testing.T) { + body := indexJSON(`{"id":"withicon","name":"With Icon","version":"1.0.0","stars":1,` + + `"icon_url":"https://cdn.example/icon.svg","package_url":"https://ex/withicon.tar.gz"},` + + entryJSON("noicon", "1.0.0", 1)) + srv := serve(t, body) + s := newTestService(t) + _ = s.store.EnsureBuiltin("Official", srv.URL) + + result, err := s.Catalog(context.Background(), nil) + if err != nil { + t.Fatalf("catalog: %v", err) + } + if got := findEntry(result.Plugins, "withicon"); got == nil || got.IconURL != "https://cdn.example/icon.svg" { + t.Fatalf("icon_url not preserved: %+v", got) + } + if got := findEntry(result.Plugins, "noicon"); got == nil || got.IconURL != "" { + t.Fatalf("missing icon should decode to empty, got %+v", got) + } +} + +func TestApplyQueryFilterAndSort(t *testing.T) { + entries := []CatalogEntry{ + {IndexEntry: IndexEntry{ID: "zed", Name: "Zed Stats", Description: "analytics", Categories: []string{"analytics"}, Stars: ip(5), UpdatedAt: "2026-01-01"}}, + {IndexEntry: IndexEntry{ID: "abc", Name: "Abc Chat", Description: "messaging", Categories: []string{"chat"}, Stars: ip(50), UpdatedAt: "2026-06-01"}}, + } + // Default sort = stars desc. + byStars := ApplyQuery(entries, Query{}) + if byStars[0].ID != "abc" { + t.Fatalf("stars sort: want abc first, got %s", byStars[0].ID) + } + // Name sort asc. + byName := ApplyQuery(entries, Query{Sort: "name"}) + if byName[0].ID != "abc" { + t.Fatalf("name sort: want abc first, got %s", byName[0].ID) + } + // Recent sort desc by updated_at. + byRecent := ApplyQuery(entries, Query{Sort: "recent"}) + if byRecent[0].ID != "abc" { + t.Fatalf("recent sort: want abc first, got %s", byRecent[0].ID) + } + // Text filter. + text := ApplyQuery(entries, Query{Text: "stats"}) + if len(text) != 1 || text[0].ID != "zed" { + t.Fatalf("text filter failed: %+v", text) + } + // Category filter. + cat := ApplyQuery(entries, Query{Category: "chat"}) + if len(cat) != 1 || cat[0].ID != "abc" { + t.Fatalf("category filter failed: %+v", cat) + } +} + +func findEntry(entries []CatalogEntry, id string) *CatalogEntry { + for i := range entries { + if entries[i].ID == id { + return &entries[i] + } + } + return nil +} + +func findSource(sources []SourceStatus, name string) *SourceStatus { + for i := range sources { + if sources[i].Name == name { + return &sources[i] + } + } + return nil +} + +func assertState(t *testing.T, entries []CatalogEntry, id string, want InstallState) { + t.Helper() + e := findEntry(entries, id) + if e == nil { + t.Fatalf("entry %q not found", id) + } + if e.InstallState != want { + t.Fatalf("entry %q: want state %q, got %q", id, want, e.InstallState) + } +} diff --git a/apps/backend/internal/plugins/marketplace/sourcestore.go b/apps/backend/internal/plugins/marketplace/sourcestore.go new file mode 100644 index 0000000000..8ca3f4ad81 --- /dev/null +++ b/apps/backend/internal/plugins/marketplace/sourcestore.go @@ -0,0 +1,246 @@ +package marketplace + +import ( + "database/sql" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +// officialSourceID is the stable primary key of the built-in kandev source, +// so EnsureBuiltin is idempotent across restarts and can keep the row's URL in +// sync with the OfficialSourceURL constant without ever duplicating it. +const officialSourceID = "official" + +// ErrSourceNotFound is returned by Get/Update/Delete for an unknown id. +var ErrSourceNotFound = errors.New("marketplace source not found") + +// ErrBuiltinImmutable is returned when an operator tries to delete the +// built-in official source (it may be disabled, but never removed). +var ErrBuiltinImmutable = errors.New("the built-in source cannot be deleted") + +// ErrDuplicateSource is returned by Add when a source with the same URL is +// already configured (maps to 409, not a leaked raw SQL constraint error). +var ErrDuplicateSource = errors.New("a source with this url already exists") + +// SourceStore persists configured marketplace sources in the +// plugin_marketplace_source table. +type SourceStore struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewSourceStore creates a SourceStore and initializes its schema. +func NewSourceStore(pool *db.Pool) (*SourceStore, error) { + s := &SourceStore{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, fmt.Errorf("marketplace source schema init: %w", err) + } + return s, nil +} + +func (s *SourceStore) initSchema() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS plugin_marketplace_source ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + `) + return err +} + +// EnsureBuiltin upserts the official source row. It keeps name/url/builtin in +// sync with the passed values (so changing the constant re-points the builtin +// on next boot) but never touches `enabled`, so an operator who disabled the +// official source keeps it disabled across restarts. +func (s *SourceStore) EnsureBuiltin(name, url string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(s.db.Rebind(` + INSERT INTO plugin_marketplace_source (id, name, url, enabled, builtin, created_at) + VALUES (?, ?, ?, 1, 1, ?) + ON CONFLICT(id) DO UPDATE SET name = excluded.name, url = excluded.url, builtin = 1 + `), officialSourceID, name, url, now) + return err +} + +// List returns every configured source, built-in first, then by creation time. +func (s *SourceStore) List() ([]SourceRecord, error) { + rows, err := s.ro.Query(` + SELECT id, name, url, enabled, builtin, created_at + FROM plugin_marketplace_source + ORDER BY builtin DESC, created_at ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var out []SourceRecord + for rows.Next() { + rec, err := scanSource(rows) + if err != nil { + return nil, err + } + out = append(out, rec) + } + return out, rows.Err() +} + +// Get returns one source by id, or ErrSourceNotFound. +func (s *SourceStore) Get(id string) (*SourceRecord, error) { + row := s.ro.QueryRow(s.ro.Rebind(` + SELECT id, name, url, enabled, builtin, created_at + FROM plugin_marketplace_source WHERE id = ? + `), id) + rec, err := scanSource(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSourceNotFound + } + if err != nil { + return nil, err + } + return &rec, nil +} + +// Add inserts a new operator-configured (non-builtin) source. url must be a +// non-empty http(s) URL; a duplicate url returns an error. +func (s *SourceStore) Add(name, url string) (*SourceRecord, error) { + name, url = strings.TrimSpace(name), strings.TrimSpace(url) + if err := validateSourceURL(url); err != nil { + return nil, err + } + if name == "" { + name = url + } + if s.urlExists(url) { + return nil, ErrDuplicateSource + } + id := uuid.New().String() + now := time.Now().UTC() + _, err := s.db.Exec(s.db.Rebind(` + INSERT INTO plugin_marketplace_source (id, name, url, enabled, builtin, created_at) + VALUES (?, ?, ?, 1, 0, ?) + `), id, name, url, now.Format(time.RFC3339)) + if err != nil { + // A concurrent insert may have won the UNIQUE(url) race; report that as + // a duplicate rather than leaking the raw driver constraint message. + if s.urlExists(url) { + return nil, ErrDuplicateSource + } + return nil, errors.New("failed to add marketplace source") + } + return &SourceRecord{ID: id, Name: name, URL: url, Enabled: true, CreatedAt: now}, nil +} + +// urlExists reports whether a source with the given URL is already stored. +func (s *SourceStore) urlExists(url string) bool { + var one int + err := s.ro.QueryRow(s.ro.Rebind(` + SELECT 1 FROM plugin_marketplace_source WHERE url = ? + `), url).Scan(&one) + return err == nil +} + +// Update changes a source's name and/or enabled flag. nil fields are left +// unchanged. The url and builtin flag are immutable. +func (s *SourceStore) Update(id string, name *string, enabled *bool) (*SourceRecord, error) { + rec, err := s.Get(id) + if err != nil { + return nil, err + } + if name != nil && strings.TrimSpace(*name) != "" { + rec.Name = strings.TrimSpace(*name) + } + if enabled != nil { + rec.Enabled = *enabled + } + if _, err := s.db.Exec(s.db.Rebind(` + UPDATE plugin_marketplace_source SET name = ?, enabled = ? WHERE id = ? + `), rec.Name, boolToInt(rec.Enabled), id); err != nil { + return nil, err + } + return rec, nil +} + +// Delete removes a non-builtin source. Deleting the built-in source returns +// ErrBuiltinImmutable. +func (s *SourceStore) Delete(id string) error { + rec, err := s.Get(id) + if err != nil { + return err + } + if rec.Builtin { + return ErrBuiltinImmutable + } + _, err = s.db.Exec(s.db.Rebind(`DELETE FROM plugin_marketplace_source WHERE id = ?`), id) + return err +} + +// rowScanner is satisfied by both *sql.Row and *sql.Rows. +type rowScanner interface { + Scan(dest ...any) error +} + +func scanSource(sc rowScanner) (SourceRecord, error) { + var ( + rec SourceRecord + enabled, builtin int + createdAt string + ) + if err := sc.Scan(&rec.ID, &rec.Name, &rec.URL, &enabled, &builtin, &createdAt); err != nil { + return SourceRecord{}, err + } + rec.Enabled = enabled != 0 + rec.Builtin = builtin != 0 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + rec.CreatedAt = t + } + return rec, nil +} + +func validateSourceURL(raw string) error { + if raw == "" { + return errors.New("source url is required") + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return errors.New("source url must be an http(s) URL") + } + // A remote plain-http source can be MITM'd to inject catalog entries + // (including a package_url pointing at a trojanized tarball), so require + // https for non-loopback hosts. Loopback http is allowed for local dev/e2e + // fixtures. + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return errors.New("remote source urls must use https") + } + return nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/apps/backend/internal/plugins/marketplace/sourcestore_test.go b/apps/backend/internal/plugins/marketplace/sourcestore_test.go new file mode 100644 index 0000000000..287d5f040c --- /dev/null +++ b/apps/backend/internal/plugins/marketplace/sourcestore_test.go @@ -0,0 +1,141 @@ +package marketplace + +import ( + "errors" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" +) + +func newTestSourceStore(t *testing.T) *SourceStore { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + store, err := NewSourceStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new source store: %v", err) + } + return store +} + +func TestEnsureBuiltinIsIdempotentAndListedFirst(t *testing.T) { + s := newTestSourceStore(t) + if err := s.EnsureBuiltin(OfficialSourceName, OfficialSourceURL); err != nil { + t.Fatalf("ensure builtin: %v", err) + } + // A second call (e.g. next boot) must not duplicate the row. + if err := s.EnsureBuiltin(OfficialSourceName, OfficialSourceURL); err != nil { + t.Fatalf("ensure builtin again: %v", err) + } + if _, err := s.Add("Acme", "https://acme.example/index.json"); err != nil { + t.Fatalf("add: %v", err) + } + + sources, err := s.List() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(sources) != 2 { + t.Fatalf("want 2 sources, got %d", len(sources)) + } + if !sources[0].Builtin || sources[0].Name != OfficialSourceName { + t.Fatalf("builtin source should be listed first, got %+v", sources[0]) + } +} + +func TestEnsureBuiltinRepointsURLButKeepsDisabled(t *testing.T) { + s := newTestSourceStore(t) + if err := s.EnsureBuiltin(OfficialSourceName, "https://old.example/index.json"); err != nil { + t.Fatalf("ensure builtin: %v", err) + } + sources, _ := s.List() + disabled := false + if _, err := s.Update(sources[0].ID, nil, &disabled); err != nil { + t.Fatalf("disable builtin: %v", err) + } + // Re-seed with a new URL (constant changed): url updates, disabled sticks. + if err := s.EnsureBuiltin(OfficialSourceName, "https://new.example/index.json"); err != nil { + t.Fatalf("re-ensure builtin: %v", err) + } + got, _ := s.List() + if got[0].URL != "https://new.example/index.json" { + t.Fatalf("url not repointed: %q", got[0].URL) + } + if got[0].Enabled { + t.Fatalf("operator-disabled builtin was re-enabled by EnsureBuiltin") + } +} + +func TestAddRejectsBadAndDuplicateURL(t *testing.T) { + s := newTestSourceStore(t) + if _, err := s.Add("bad", "ftp://nope"); err == nil { + t.Fatalf("expected non-http url to be rejected") + } + if _, err := s.Add("empty", ""); err == nil { + t.Fatalf("expected empty url to be rejected") + } + // Remote plain-http is rejected (MITM risk); loopback http is allowed. + if _, err := s.Add("remote-http", "http://registry.example/index.json"); err == nil { + t.Fatalf("expected remote http url to be rejected") + } + if _, err := s.Add("local-http", "http://127.0.0.1:8080/index.json"); err != nil { + t.Fatalf("loopback http should be allowed, got %v", err) + } + if _, err := s.Add("first", "https://dup.example/index.json"); err != nil { + t.Fatalf("add: %v", err) + } + if _, err := s.Add("second", "https://dup.example/index.json"); !errors.Is(err, ErrDuplicateSource) { + t.Fatalf("expected ErrDuplicateSource, got %v", err) + } +} + +func TestDeleteBuiltinIsRefused(t *testing.T) { + s := newTestSourceStore(t) + _ = s.EnsureBuiltin(OfficialSourceName, OfficialSourceURL) + sources, _ := s.List() + if err := s.Delete(sources[0].ID); !errors.Is(err, ErrBuiltinImmutable) { + t.Fatalf("want ErrBuiltinImmutable, got %v", err) + } +} + +func TestDeleteAndGetUnknownSource(t *testing.T) { + s := newTestSourceStore(t) + rec, err := s.Add("Acme", "https://acme.example/index.json") + if err != nil { + t.Fatalf("add: %v", err) + } + if err := s.Delete(rec.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.Get(rec.ID); !errors.Is(err, ErrSourceNotFound) { + t.Fatalf("want ErrSourceNotFound, got %v", err) + } + if err := s.Delete("does-not-exist"); !errors.Is(err, ErrSourceNotFound) { + t.Fatalf("want ErrSourceNotFound for unknown delete, got %v", err) + } +} + +func TestUpdateRenamesAndToggles(t *testing.T) { + s := newTestSourceStore(t) + rec, _ := s.Add("Acme", "https://acme.example/index.json") + newName := "Acme Internal" + disabled := false + updated, err := s.Update(rec.ID, &newName, &disabled) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Name != newName || updated.Enabled { + t.Fatalf("update not applied: %+v", updated) + } + got, _ := s.Get(rec.ID) + if got.Name != newName || got.Enabled { + t.Fatalf("update not persisted: %+v", got) + } +} diff --git a/apps/backend/internal/plugins/marketplace/types.go b/apps/backend/internal/plugins/marketplace/types.go new file mode 100644 index 0000000000..006f50f087 --- /dev/null +++ b/apps/backend/internal/plugins/marketplace/types.go @@ -0,0 +1,128 @@ +// Package marketplace implements the plugin marketplace: a discoverable, +// curated catalog of installable plugins assembled from one or more +// git-hosted "sources" (each an index.json document, the official kandev +// source plus any operator-added team/corporate registries). +// +// The marketplace is a discovery layer only — it never installs, spawns, or +// mutates a plugin. Installing a catalog entry reuses the existing +// POST /api/plugins/install pipeline with the entry's package_url. See +// docs/specs/plugins/marketplace.md. +package marketplace + +import "time" + +// OfficialSourceName / OfficialSourceURL identify the built-in kandev source +// seeded on first boot. The URL is overridable at boot via +// KANDEV_PLUGIN_MARKETPLACE_URL (used by e2e to point at a local fixture). +const ( + OfficialSourceName = "Kandev Official" + OfficialSourceURL = "https://kdlbs.github.io/kandev/plugins/index.json" +) + +// InstallState is the derived state of a catalog entry relative to what is +// installed locally (docs/specs/plugins/marketplace.md → "State machine"). +type InstallState string + +const ( + // StateAvailable: no installed plugin shares this entry's id. + StateAvailable InstallState = "available" + // StateInstalled: installed at a version >= the catalog version. + StateInstalled InstallState = "installed" + // StateUpdateAvailable: installed at a version < the catalog version. + StateUpdateAvailable InstallState = "update_available" +) + +// IndexSource is the self-describing header of an index.json document. +type IndexSource struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// IndexEntry is one plugin as published in a source's index.json. This is the +// hard fetch contract shared with the registry's index-build Action +// (plugin-registry/build-index.mjs); additional/corporate sources must serve +// the same shape. Missing/`null` optional fields decode to their zero value. +type IndexEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Author string `json:"author"` + Categories []string `json:"categories"` + // IconURL is an absolute URL to the plugin's icon, resolved by the + // registry index-build from the plugin manifest's `icon` path. Empty + // when the plugin ships no icon; the UI falls back to a letter tile. + IconURL string `json:"icon_url"` + RepoURL string `json:"repo_url"` + Version string `json:"version"` + MinKandevVersion string `json:"min_kandev_version"` + PackageURL string `json:"package_url"` + PackageSHA256 string `json:"package_sha256"` + // Stars is a pointer so a `null` in index.json (the registry build emits + // null when a repo's star lookup failed) stays unknown rather than + // decoding to 0 — a known-zero repo and an unknown one must not collapse, + // and unknown sorts last rather than corrupting the ranking. + Stars *int `json:"stars"` + UpdatedAt string `json:"updated_at"` +} + +// IndexDocument is a full index.json fetched from a source URL. +type IndexDocument struct { + SchemaVersion int `json:"schema_version"` + GeneratedAt string `json:"generated_at"` + Source IndexSource `json:"source"` + Plugins []IndexEntry `json:"plugins"` +} + +// CatalogEntry is an IndexEntry annotated with the source it came from and +// its install state relative to local installs. +type CatalogEntry struct { + IndexEntry + InstallState InstallState `json:"install_state"` + InstalledVersion string `json:"installed_version,omitempty"` + SourceID string `json:"source_id"` + SourceName string `json:"source_name"` +} + +// SourceStatus is a configured source plus its live fetch health, returned in +// every catalog response so the UI can flag a degraded (unreachable/malformed) +// source without failing the whole catalog. +type SourceStatus struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + Builtin bool `json:"builtin"` + Healthy bool `json:"healthy"` + Error string `json:"error,omitempty"` +} + +// CatalogResult is the merged, deduped catalog across all enabled sources. +type CatalogResult struct { + Plugins []CatalogEntry `json:"plugins"` + Sources []SourceStatus `json:"sources"` +} + +// InstalledPlugin is the minimal installed-plugin fact the catalog needs to +// compute install state (id + version), decoupling this package from the +// plugins store.Record type. +type InstalledPlugin struct { + ID string + Version string +} + +// SourceRecord is a configured marketplace source (SQLite row). +type SourceRecord struct { + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + Enabled bool `json:"enabled"` + Builtin bool `json:"builtin"` + CreatedAt time.Time `json:"created_at"` +} + +// Query is the filter/sort applied to a merged catalog by the HTTP handler. +type Query struct { + Text string + Category string + Sort string // "stars" (default) | "name" | "recent" +} diff --git a/apps/backend/internal/plugins/marketplace_handlers.go b/apps/backend/internal/plugins/marketplace_handlers.go new file mode 100644 index 0000000000..83a3a200d2 --- /dev/null +++ b/apps/backend/internal/plugins/marketplace_handlers.go @@ -0,0 +1,160 @@ +package plugins + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/plugins/marketplace" +) + +// registerMarketplaceRoutes wires the marketplace catalog + source-management +// surface onto the /api/plugins group. Kept separate from RegisterRoutes so +// the marketplace HTTP surface stays self-contained. +func (c *Controller) registerMarketplaceRoutes(api *gin.RouterGroup) { + api.GET("/marketplace", c.marketplaceCatalog) + api.POST("/marketplace/refresh", c.marketplaceRefresh) + api.GET("/marketplace/sources", c.listSources) + api.POST("/marketplace/sources", c.addSource) + api.PATCH("/marketplace/sources/:sid", c.updateSource) + api.DELETE("/marketplace/sources/:sid", c.deleteSource) +} + +// addSourceRequest is the POST /api/plugins/marketplace/sources body. +type addSourceRequest struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// updateSourceRequest is the PATCH body; nil fields are left unchanged. +type updateSourceRequest struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` +} + +// mkt returns the attached marketplace service, writing a 503 and returning +// nil when the subsystem is unavailable. +func (c *Controller) mkt(ctx *gin.Context) *marketplace.Service { + m := c.svc.Marketplace() + if m == nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "marketplace is unavailable"}) + return nil + } + return m +} + +// marketplaceCatalog serves GET /api/plugins/marketplace: the merged catalog +// across all enabled sources, filtered/sorted by the query params +// (?q, ?category, ?sort), each entry annotated with install_state. +func (c *Controller) marketplaceCatalog(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + result, err := m.Catalog(ctx.Request.Context(), c.svc.InstalledForMarketplace()) + if err != nil { + c.log.Warn("marketplace catalog error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + result.Plugins = marketplace.ApplyQuery(result.Plugins, marketplace.Query{ + Text: ctx.Query("q"), + Category: ctx.Query("category"), + Sort: ctx.Query("sort"), + }) + ctx.JSON(http.StatusOK, result) +} + +// marketplaceRefresh serves POST /api/plugins/marketplace/refresh: drops the +// cached index documents so the next catalog fetch re-hits every source. +func (c *Controller) marketplaceRefresh(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + m.Refresh() + ctx.JSON(http.StatusOK, gin.H{"refreshed": true}) +} + +func (c *Controller) listSources(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + sources, err := m.Sources() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{"sources": sources}) +} + +func (c *Controller) addSource(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + var req addSourceRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + rec, err := m.AddSource(req.Name, req.URL) + if err != nil { + if errors.Is(err, marketplace.ErrDuplicateSource) { + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + // Remaining Add errors are input validation (bad/empty URL) with clean, + // non-leaking messages. + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusCreated, rec) +} + +func (c *Controller) updateSource(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + var req updateSourceRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + rec, err := m.UpdateSource(ctx.Param("sid"), req.Name, req.Enabled) + if err != nil { + c.writeSourceError(ctx, err) + return + } + ctx.JSON(http.StatusOK, rec) +} + +func (c *Controller) deleteSource(ctx *gin.Context) { + m := c.mkt(ctx) + if m == nil { + return + } + if err := m.DeleteSource(ctx.Param("sid")); err != nil { + c.writeSourceError(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// writeSourceError maps source CRUD errors to HTTP statuses: unknown id -> 404, +// deleting the built-in source -> 409, anything else -> 500. +func (c *Controller) writeSourceError(ctx *gin.Context, err error) { + switch { + case errors.Is(err, marketplace.ErrSourceNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, marketplace.ErrBuiltinImmutable): + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.log.Warn("marketplace source error", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } +} diff --git a/apps/backend/internal/plugins/marketplace_handlers_test.go b/apps/backend/internal/plugins/marketplace_handlers_test.go new file mode 100644 index 0000000000..7e34fbf3f4 --- /dev/null +++ b/apps/backend/internal/plugins/marketplace_handlers_test.go @@ -0,0 +1,138 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/plugins/marketplace" +) + +// attachMarketplaceWithSource wires an in-memory marketplace onto svc whose +// built-in official source points at officialURL. +func attachMarketplaceWithSource(t *testing.T, svc *Service, officialURL string) { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + store, err := marketplace.NewSourceStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("source store: %v", err) + } + if err := store.EnsureBuiltin(marketplace.OfficialSourceName, officialURL); err != nil { + t.Fatalf("ensure builtin: %v", err) + } + svc.SetMarketplace(marketplace.NewService(store, testLogger(t))) +} + +func fixtureIndexServer(t *testing.T) *httptest.Server { + t.Helper() + body := `{"schema_version":1,"generated_at":"2026-07-18T00:00:00Z",` + + `"source":{"name":"Kandev Official","url":""},"plugins":[` + + `{"id":"agent-stats","name":"Agent Stats","categories":["analytics"],` + + `"version":"1.0.0","stars":42,"package_url":"https://ex/agent-stats-1.0.0.tar.gz"}]}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestMarketplaceCatalogUnavailableReturns503(t *testing.T) { + router, _ := newTestRouter(t) // no marketplace attached + rec := doRequest(router, http.MethodGet, "/api/plugins/marketplace", "", nil) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("want 503, got %d", rec.Code) + } +} + +func TestMarketplaceCatalogReturnsAnnotatedEntries(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _, _ := newTestService(t) + attachMarketplaceWithSource(t, svc, fixtureIndexServer(t).URL) + router := gin.New() + RegisterRoutes(router, svc, nil, testLogger(t)) + + rec := doRequest(router, http.MethodGet, "/api/plugins/marketplace?category=analytics", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", rec.Code, rec.Body.String()) + } + var body marketplace.CatalogResult + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Plugins) != 1 || body.Plugins[0].ID != "agent-stats" { + t.Fatalf("unexpected catalog plugins: %+v", body.Plugins) + } + if body.Plugins[0].InstallState != marketplace.StateAvailable { + t.Fatalf("want available, got %q", body.Plugins[0].InstallState) + } + if len(body.Sources) != 1 || !body.Sources[0].Healthy { + t.Fatalf("want 1 healthy source, got %+v", body.Sources) + } +} + +func TestMarketplaceSourceCRUD(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _, _ := newTestService(t) + attachMarketplaceWithSource(t, svc, "https://official.example/index.json") + router := gin.New() + RegisterRoutes(router, svc, nil, testLogger(t)) + + // List returns the built-in source. + list := doRequest(router, http.MethodGet, "/api/plugins/marketplace/sources", "", nil) + if list.Code != http.StatusOK { + t.Fatalf("list: want 200, got %d", list.Code) + } + + // Add a corporate source. + add := doRequest(router, http.MethodPost, "/api/plugins/marketplace/sources", + `{"name":"Acme","url":"https://acme.example/index.json"}`, + map[string]string{"Content-Type": "application/json"}) + if add.Code != http.StatusCreated { + t.Fatalf("add: want 201, got %d (%s)", add.Code, add.Body.String()) + } + var added marketplace.SourceRecord + if err := json.Unmarshal(add.Body.Bytes(), &added); err != nil { + t.Fatalf("decode add: %v", err) + } + + // Disable it. + patch := doRequest(router, http.MethodPatch, "/api/plugins/marketplace/sources/"+added.ID, + `{"enabled":false}`, map[string]string{"Content-Type": "application/json"}) + if patch.Code != http.StatusOK { + t.Fatalf("patch: want 200, got %d", patch.Code) + } + + // Delete it. + del := doRequest(router, http.MethodDelete, "/api/plugins/marketplace/sources/"+added.ID, "", nil) + if del.Code != http.StatusOK { + t.Fatalf("delete: want 200, got %d", del.Code) + } +} + +func TestMarketplaceDeleteBuiltinReturns409(t *testing.T) { + gin.SetMode(gin.TestMode) + svc, _, _ := newTestService(t) + attachMarketplaceWithSource(t, svc, "https://official.example/index.json") + router := gin.New() + RegisterRoutes(router, svc, nil, testLogger(t)) + + sources, err := svc.Marketplace().Sources() + if err != nil { + t.Fatalf("sources: %v", err) + } + rec := doRequest(router, http.MethodDelete, "/api/plugins/marketplace/sources/"+sources[0].ID, "", nil) + if rec.Code != http.StatusConflict { + t.Fatalf("delete builtin: want 409, got %d", rec.Code) + } +} diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index 7ad8469b7c..576ce72d93 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -2,17 +2,25 @@ package plugins import ( "fmt" + "os" "path/filepath" + "go.uber.org/zap" + "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/plugins/marketplace" "github.com/kandev/kandev/internal/plugins/runtime" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/store" ) +// marketplaceURLEnv overrides the built-in official marketplace source URL at +// boot (used by e2e to point the catalog at a local fixture server). +const marketplaceURLEnv = "KANDEV_PLUGIN_MARKETPLACE_URL" + // pluginsSubdir is the directory name under the Kandev home dir plugins // live under: records (".yml"/".config.yml"), extracted packages // ("//..."), and per-plugin writable data @@ -63,6 +71,12 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus svc.SetSecrets(secrets) svc.SetPluginsDir(dir) + if err := attachMarketplace(svc, dbPool, log); err != nil { + // Non-fatal: the rest of the plugin system still works without the + // discovery catalog (install-by-URL/upload is unaffected). + log.Warn("plugins: marketplace init failed (non-fatal)", zap.Error(err)) + } + rt := runtime.NewManager(dir, svc.handleStatusChange, log) svc.SetRuntime(rt) @@ -72,3 +86,22 @@ func Provide(cfg *config.Config, dbPool *db.Pool, secrets SecretVault, eventBus } return svc, cleanup, nil } + +// attachMarketplace builds the marketplace source store, seeds the built-in +// official source (URL overridable via KANDEV_PLUGIN_MARKETPLACE_URL), and +// attaches the catalog service to svc. +func attachMarketplace(svc *Service, dbPool *db.Pool, log *logger.Logger) error { + sourceStore, err := marketplace.NewSourceStore(dbPool) + if err != nil { + return fmt.Errorf("marketplace source store: %w", err) + } + officialURL := marketplace.OfficialSourceURL + if override := os.Getenv(marketplaceURLEnv); override != "" { + officialURL = override + } + if err := sourceStore.EnsureBuiltin(marketplace.OfficialSourceName, officialURL); err != nil { + return fmt.Errorf("marketplace seed builtin: %w", err) + } + svc.SetMarketplace(marketplace.NewService(sourceStore, log)) + return nil +} diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index e7f8d9b997..5601020e9d 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/internal/plugins/marketplace" "github.com/kandev/kandev/internal/plugins/pkgtar" "github.com/kandev/kandev/internal/plugins/state" "github.com/kandev/kandev/internal/plugins/store" @@ -101,6 +102,10 @@ type Service struct { kandevVersion string httpClient *http.Client + + // marketplace is the plugin-discovery catalog service (nil until + // SetMarketplace is called by Provide). See marketplace.go. + marketplace *marketplace.Service } // NewService wires a Service from its already-constructed dependencies. diff --git a/apps/web/components/settings/plugins/marketplace-browser.tsx b/apps/web/components/settings/plugins/marketplace-browser.tsx new file mode 100644 index 0000000000..5eb2c84950 --- /dev/null +++ b/apps/web/components/settings/plugins/marketplace-browser.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { useMarketplace } from "@/hooks/domains/plugins/use-marketplace"; +import type { CatalogQuery } from "@/lib/api/domains/marketplace-api"; +import type { MarketplaceCatalog, MarketplaceEntry } from "@/lib/types/plugins"; +import { MarketplaceEntryRow } from "./marketplace-entry-row"; +import { MarketplaceSourcesDialog } from "./marketplace-sources-dialog"; + +const ALL_CATEGORIES = "__all__"; + +type MarketplaceBrowserProps = { + /** Installs a plugin from its package URL; resolves when the install settles. */ + onInstallUrl: (url: string) => Promise; +}; + +/** The "Browse" tab: search/filter/sort the catalog and install from it. */ +export function MarketplaceBrowser({ onInstallUrl }: MarketplaceBrowserProps) { + const [text, setText] = useState(""); + const [category, setCategory] = useState(ALL_CATEGORIES); + const [sort, setSort] = useState("stars"); + const [sourcesOpen, setSourcesOpen] = useState(false); + const [installingId, setInstallingId] = useState(null); + + // Category is filtered client-side (not sent to the server): otherwise a + // category-narrowed catalog would shrink the category dropdown to just the + // selected value, trapping the user. Search + sort stay server-side. + const { catalog, loading, error, reload, softReload } = useMarketplace({ + q: text.trim() || undefined, + sort, + }); + const categories = useCatalogCategories(catalog); + const visible = + category === ALL_CATEGORIES + ? catalog.plugins + : catalog.plugins.filter((entry) => entry.categories.includes(category)); + + const install = async (entry: MarketplaceEntry) => { + setInstallingId(entry.id); + try { + await onInstallUrl(entry.package_url); + softReload(); + } finally { + setInstallingId(null); + } + }; + + return ( +
+ setSourcesOpen(true)} + onRefresh={reload} + /> + + + + + + +
+ ); +} + +function useCatalogCategories(catalog: MarketplaceCatalog): string[] { + return useMemo(() => { + const set = new Set(); + for (const entry of catalog.plugins) { + for (const cat of entry.categories) set.add(cat); + } + return Array.from(set).sort(); + }, [catalog.plugins]); +} + +type ToolbarProps = { + text: string; + onText: (v: string) => void; + category: string; + onCategory: (v: string) => void; + categories: string[]; + sort: CatalogQuery["sort"]; + onSort: (v: CatalogQuery["sort"]) => void; + onManageSources: () => void; + onRefresh: () => void; +}; + +function MarketplaceToolbar(props: ToolbarProps) { + return ( +
+ props.onText(e.target.value)} + className="max-w-xs" + data-testid="marketplace-search" + /> + + +
+ + +
+
+ ); +} + +function DegradedSourcesBanner({ catalog }: { catalog: MarketplaceCatalog }) { + const degraded = catalog.sources.filter((s) => s.enabled && s.healthy === false); + if (degraded.length === 0) return null; + return ( +
+ {degraded.map((s) => ( +
+ {s.name} is unreachable + {s.error ? `: ${s.error}` : ""} — its plugins are hidden. +
+ ))} +
+ ); +} + +type ListProps = { + entries: MarketplaceEntry[]; + loading: boolean; + error: string | null; + filtered: boolean; + installingId: string | null; + onInstall: (entry: MarketplaceEntry) => void; +}; + +function MarketplaceList({ + entries, + loading, + error, + filtered, + installingId, + onInstall, +}: ListProps) { + if (error) { + return ( +
+ {error} +
+ ); + } + if (loading && entries.length === 0) { + return ( +
+ Loading marketplace… +
+ ); + } + if (entries.length === 0) { + return ( +
+ {filtered + ? "No plugins match your search. Try clearing the filters." + : "No plugins available yet. Check back soon, or add a marketplace source with your own."} +
+ ); + } + return ( +
+

+ {entries.length} {entries.length === 1 ? "plugin" : "plugins"} available +

+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/settings/plugins/marketplace-entry-row.test.tsx b/apps/web/components/settings/plugins/marketplace-entry-row.test.tsx new file mode 100644 index 0000000000..47246056cc --- /dev/null +++ b/apps/web/components/settings/plugins/marketplace-entry-row.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MarketplaceEntryRow } from "./marketplace-entry-row"; +import type { MarketplaceEntry } from "@/lib/types/plugins"; + +afterEach(() => cleanup()); + +function entry(overrides: Partial = {}): MarketplaceEntry { + return { + id: "acme", + name: "Acme", + description: "desc", + author: "acme", + categories: [], + icon_url: "", + repo_url: "https://github.com/kdlbs/kandev-plugin-acme", + version: "1.0.0", + min_kandev_version: "", + package_url: "https://ex/acme-1.0.0.tar.gz", + package_sha256: "", + stars: 3, + updated_at: "", + install_state: "available", + source_id: "official", + source_name: "Kandev Official", + ...overrides, + }; +} + +const noop = () => undefined; + +describe("MarketplaceEntryRow repo link", () => { + it("renders an http(s) repo link", () => { + render(); + const link = screen.getByText("Repo").closest("a"); + expect(link?.getAttribute("href")).toBe("https://github.com/kdlbs/kandev-plugin-acme"); + }); + + it("does NOT render a repo link for a javascript: (or other non-http) scheme", () => { + // A malicious/compromised source could set repo_url to a javascript: URL. + render( + , + ); + expect(screen.queryByText("Repo")).toBeNull(); + }); + + it("renders unknown (null) stars as a dash, not zero", () => { + render(); + expect(screen.getByText("—")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/settings/plugins/marketplace-entry-row.tsx b/apps/web/components/settings/plugins/marketplace-entry-row.tsx new file mode 100644 index 0000000000..86bc2863d8 --- /dev/null +++ b/apps/web/components/settings/plugins/marketplace-entry-row.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; +import { IconArrowUpCircle, IconCheck, IconExternalLink, IconStar } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import type { MarketplaceEntry } from "@/lib/types/plugins"; + +// Id of the built-in official source (marketplace.officialSourceID). Entries +// from any other source get a source badge; the official one does not. +const OFFICIAL_SOURCE_ID = "official"; + +/** + * Guard for rendering a catalog-supplied URL as an . repo_url comes + * from an untrusted index.json (an operator-added or compromised source), so + * only http(s) is allowed — a `javascript:` (or other) scheme would execute in + * the operator's session on click. rel/target do NOT block `javascript:`. + */ +function isHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url.trim()); +} + +type MarketplaceEntryRowProps = { + entry: MarketplaceEntry; + busy: boolean; + onInstall: (entry: MarketplaceEntry) => void; +}; + +/** One catalog card: a neutral tile, metadata, stars, and an install-state action. */ +export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntryRowProps) { + return ( + + ); +} + +/** + * The plugin's icon when it ships one, falling back to a neutral letter tile + * (also used when the icon URL fails to load). + */ +function PluginTile({ entry }: { entry: MarketplaceEntry }) { + const [failed, setFailed] = useState(false); + const tileClass = + "flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border/60 bg-muted"; + + if (entry.icon_url && !failed) { + return ( +
+ setFailed(true)} + /> +
+ ); + } + return ( +
+ {(entry.name || entry.id).charAt(0).toUpperCase()} +
+ ); +} + +function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowProps) { + if (entry.install_state === "installed") { + return ( + + + Installed + + ); + } + if (entry.install_state === "update_available") { + return ( + + ); + } + return ( + + ); +} diff --git a/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx b/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx new file mode 100644 index 0000000000..35ad73f151 --- /dev/null +++ b/apps/web/components/settings/plugins/marketplace-sources-dialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState } from "react"; +import { IconTrash } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Input } from "@kandev/ui/input"; +import { Switch } from "@kandev/ui/switch"; +import { Badge } from "@kandev/ui/badge"; +import { + addMarketplaceSource, + deleteMarketplaceSource, + updateMarketplaceSource, +} from "@/lib/api/domains/marketplace-api"; +import type { MarketplaceSource } from "@/lib/types/plugins"; + +type MarketplaceSourcesDialogProps = { + open: boolean; + sources: MarketplaceSource[]; + onOpenChange: (open: boolean) => void; + onChanged: () => void; +}; + +/** + * Manage marketplace sources: the built-in kandev source (toggle only) plus + * operator-added team/corporate registries (add / toggle / delete). Every + * mutation calls onChanged so the catalog re-fetches. + */ +export function MarketplaceSourcesDialog({ + open, + sources, + onOpenChange, + onChanged, +}: MarketplaceSourcesDialogProps) { + return ( + + + + Marketplace sources + + Add a team or corporate registry to browse its plugins alongside the official catalog. + Each source serves an index.json document. + + + +
+ {sources.map((source) => ( + + ))} +
+ + +
+
+ ); +} + +function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChanged: () => void }) { + const [busy, setBusy] = useState(false); + + const toggle = async (enabled: boolean) => { + setBusy(true); + try { + await updateMarketplaceSource(source.id, { enabled }); + onChanged(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to update source"); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + try { + await deleteMarketplaceSource(source.id); + onChanged(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to remove source"); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+ {source.name} + {source.builtin && ( + + Official + + )} + {source.healthy === false && ( + + Unreachable + + )} +
+ {source.url} +
+
+ + {!source.builtin && ( + + )} +
+
+ ); +} + +function AddSourceForm({ onChanged }: { onChanged: () => void }) { + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (!url.trim()) return; + setBusy(true); + try { + await addMarketplaceSource(name.trim(), url.trim()); + setName(""); + setUrl(""); + onChanged(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add source"); + } finally { + setBusy(false); + } + }; + + return ( +
+ setName(e.target.value)} + data-testid="marketplace-add-source-name" + /> +
+ setUrl(e.target.value)} + data-testid="marketplace-add-source-url" + /> + +
+
+ ); +} diff --git a/apps/web/components/settings/plugins/plugin-row.test.tsx b/apps/web/components/settings/plugins/plugin-row.test.tsx new file mode 100644 index 0000000000..bcbe879a4f --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-row.test.tsx @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { PluginRow } from "./plugin-row"; +import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; + +afterEach(() => cleanup()); + +function plugin(overrides: Partial = {}): PluginRecord { + return { + id: "acme", + api_version: 1, + version: "1.0.0", + display_name: "Acme", + description: "", + author: "acme", + categories: [], + capabilities: {}, + status: "active", + install_path: "/p", + signed: true, + installed_at: "2026-01-01T00:00:00Z", + restart_count: 0, + ...overrides, + }; +} + +function updateEntry(): MarketplaceEntry { + return { + id: "acme", + name: "Acme", + description: "", + author: "acme", + categories: [], + icon_url: "", + repo_url: "", + version: "2.0.0", + min_kandev_version: "", + package_url: "https://ex/acme-2.0.0.tar.gz", + package_sha256: "", + stars: 0, + updated_at: "", + install_state: "update_available", + source_id: "official", + source_name: "Kandev Official", + }; +} + +const noop = () => undefined; + +describe("PluginRow update button", () => { + it("shows an Update button with the new version and fires onUpdate", () => { + const onUpdate = vi.fn(); + render( + , + ); + const button = screen.getByTestId("plugin-update-acme"); + expect(button.textContent).toContain("Update to v2.0.0"); + fireEvent.click(button); + expect(onUpdate).toHaveBeenCalledWith(updateEntry()); + }); + + it("renders no Update button when there is no pending update", () => { + render( + , + ); + expect(screen.queryByTestId("plugin-update-acme")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/plugins/plugin-row.tsx b/apps/web/components/settings/plugins/plugin-row.tsx index c9948928bf..12d91733b1 100644 --- a/apps/web/components/settings/plugins/plugin-row.tsx +++ b/apps/web/components/settings/plugins/plugin-row.tsx @@ -1,17 +1,21 @@ "use client"; +import { IconArrowUpCircle } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import Link from "@/components/routing/app-link"; import { PluginStatusBadge } from "./plugin-status-badge"; -import type { PluginRecord } from "@/lib/types/plugins"; +import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; type PluginRowProps = { plugin: PluginRecord; busy: boolean; + /** Set when the marketplace has a newer version than the installed one. */ + update?: MarketplaceEntry; onEnable: (plugin: PluginRecord) => void; onDisable: (plugin: PluginRecord) => void; onUninstall: (plugin: PluginRecord) => void; + onUpdate?: (entry: MarketplaceEntry) => void; }; /** @@ -19,7 +23,15 @@ type PluginRowProps = { * on narrow viewports and inside the mobile settings sheet — no separate * mobile layout needed. */ -export function PluginRow({ plugin, busy, onEnable, onDisable, onUninstall }: PluginRowProps) { +export function PluginRow({ + plugin, + busy, + update, + onEnable, + onDisable, + onUninstall, + onUpdate, +}: PluginRowProps) { const canEnable = plugin.status === "disabled" || plugin.status === "registered"; const canDisable = plugin.status === "active" || plugin.status === "error"; @@ -66,40 +78,87 @@ export function PluginRow({ plugin, busy, onEnable, onDisable, onUninstall }: Pl )} -
- {canEnable && ( - - )} - {canDisable && ( - - )} - -
+ ); } + +type PluginRowActionsProps = Omit & { + canEnable: boolean; + canDisable: boolean; + onEnable: (plugin: PluginRecord) => void; + onDisable: (plugin: PluginRecord) => void; + onUninstall: (plugin: PluginRecord) => void; +}; + +function PluginRowActions({ + plugin, + busy, + update, + canEnable, + canDisable, + onEnable, + onDisable, + onUninstall, + onUpdate, +}: PluginRowActionsProps) { + return ( +
+ {update && onUpdate && ( + + )} + {canEnable && ( + + )} + {canDisable && ( + + )} + +
+ ); +} diff --git a/apps/web/components/settings/plugins/plugins-settings.tsx b/apps/web/components/settings/plugins/plugins-settings.tsx index 5f80fb880f..be512f2fe8 100644 --- a/apps/web/components/settings/plugins/plugins-settings.tsx +++ b/apps/web/components/settings/plugins/plugins-settings.tsx @@ -1,78 +1,80 @@ "use client"; +import { useState } from "react"; import { IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; +import { usePluginUpdates } from "@/hooks/domains/plugins/use-plugin-updates"; +import type { MarketplaceEntry } from "@/lib/types/plugins"; import { InstallPluginDialog } from "./install-plugin-dialog"; +import { MarketplaceBrowser } from "./marketplace-browser"; import { PluginRow } from "./plugin-row"; import { UninstallPluginDialog } from "./uninstall-plugin-dialog"; import { usePluginActions } from "./use-plugin-actions"; /** - * Operator UI to install/list/enable/disable/uninstall plugins - * (docs/plans/plugins/task-20-mgmt-page.md, reworked for the install-based - * flow in docs/plans/plugins/GRPC-CONTRACT.md §7). Gated on the `plugins` - * feature flag by the page-level default export. + * Operator UI to browse, install, enable, disable, uninstall, and update kandev + * plugins (docs/specs/plugins/marketplace.md). Gated on the `plugins` feature + * flag by the page-level default export. */ export function PluginsSettings() { - const { items, loaded, loading, error } = usePlugins(); + const list = usePlugins(); const actions = usePluginActions(); + const { updates, reload: reloadUpdates } = usePluginUpdates(); + const [updatingId, setUpdatingId] = useState(null); + + // Update = install the newer package over the current one (marketplaceInstall + // upserts the new record into the store, so the row's version refreshes), + // then re-check the catalog so the resolved update drops off the row. + const handleUpdate = async (entry: MarketplaceEntry) => { + setUpdatingId(entry.id); + try { + await actions.marketplaceInstall(entry.package_url); + reloadUpdates(); + } finally { + setUpdatingId(null); + } + }; return ( undefined} showSaveButton={false} > -
-
-
Installed plugins
-
- - -
-
- - {actions.syncErrors.length > 0 && ( -
+ + - {actions.syncErrors.map((err) => ( -
- {err.path}: {err.reason} -
- ))} -
- )} + Installed + + + Browse + + - -
+ + + + + + + + ["items"]; - loaded: boolean; - loading: boolean; - error: string | null; +type InstalledTabProps = { + list: ReturnType; actions: ReturnType; + updates: Map; + updatingId: string | null; + onUpdate: (entry: MarketplaceEntry) => void; }; -function PluginList({ items, loaded, loading, error, actions }: PluginListProps) { +/** The Installed tab: sync/install toolbar, sync errors, and the plugin list. */ +function InstalledTab({ list, actions, updates, updatingId, onUpdate }: InstalledTabProps) { + return ( + <> +
+
Installed plugins
+
+ + +
+
+ + {actions.syncErrors.length > 0 && ( +
+ {actions.syncErrors.map((err) => ( +
+ {err.path}: {err.reason} +
+ ))} +
+ )} + + + + ); +} + +type PluginListProps = { + list: ReturnType; + actions: ReturnType; + updates: Map; + updatingId: string | null; + onUpdate: (entry: MarketplaceEntry) => void; +}; + +function PluginList({ list, actions, updates, updatingId, onUpdate }: PluginListProps) { + const { items, loaded, loading, error } = list; + if (error) { return (
@@ -120,7 +183,7 @@ function PluginList({ items, loaded, loading, error, actions }: PluginListProps) if (loaded && items.length === 0) { return (
- No plugins yet. Install a plugin package to get started. + No plugins yet. Install one from the Browse tab, or upload a package.
); } @@ -131,10 +194,12 @@ function PluginList({ items, loaded, loading, error, actions }: PluginListProps) ))}
diff --git a/apps/web/components/settings/plugins/use-plugin-actions.ts b/apps/web/components/settings/plugins/use-plugin-actions.ts index 4506fd068b..301ea360c5 100644 --- a/apps/web/components/settings/plugins/use-plugin-actions.ts +++ b/apps/web/components/settings/plugins/use-plugin-actions.ts @@ -156,6 +156,21 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) { } }; + // marketplaceInstall runs the same post-install effect as the dialog + // (upsert + hot-load if active + success toast), but surfaces failures as a + // toast rather than the dialog-scoped installError region — the Browse tab + // has no such region. It resolves even on failure (after toasting) so its + // fire-and-forget onClick callers never leak an unhandled rejection; their + // try/finally still clears per-entry busy state. + const marketplaceInstall = async (url: string) => { + try { + const result = await installPluginFromUrl(url); + await afterInstall(result); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to install plugin"); + } + }; + return { installOpen, openInstall: () => setInstallOpenState(true), @@ -164,6 +179,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) { installError, submitInstallUrl: (url: string) => runInstall(() => installPluginFromUrl(url)), submitInstallFile: (file: File) => runInstall(() => installPluginUpload(file)), + marketplaceInstall, closeInstallDialog, }; } diff --git a/apps/web/hooks/domains/plugins/use-marketplace.test.ts b/apps/web/hooks/domains/plugins/use-marketplace.test.ts new file mode 100644 index 0000000000..fce65f14d9 --- /dev/null +++ b/apps/web/hooks/domains/plugins/use-marketplace.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; + +const getMarketplaceCatalog = vi.fn(); +const refreshMarketplace = vi.fn(); + +vi.mock("@/lib/api/domains/marketplace-api", () => ({ + getMarketplaceCatalog: (...args: unknown[]) => getMarketplaceCatalog(...args), + refreshMarketplace: (...args: unknown[]) => refreshMarketplace(...args), +})); + +import { useMarketplace } from "./use-marketplace"; + +const CATALOG = { plugins: [], sources: [] }; + +beforeEach(() => { + getMarketplaceCatalog.mockReset().mockResolvedValue(CATALOG); + refreshMarketplace.mockReset().mockResolvedValue({ refreshed: true }); +}); + +afterEach(() => cleanup()); + +describe("useMarketplace", () => { + it("fetches the catalog on mount with the query", async () => { + const { result } = renderHook(() => useMarketplace({ q: "stats", sort: "stars" })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(getMarketplaceCatalog).toHaveBeenCalledWith({ + q: "stats", + category: undefined, + sort: "stars", + }); + expect(result.current.catalog).toEqual(CATALOG); + expect(result.current.error).toBeNull(); + }); + + it("re-fetches when the query changes", async () => { + const { result, rerender } = renderHook((query) => useMarketplace(query), { + initialProps: { q: "a" } as { q?: string }, + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1); + + rerender({ q: "b" }); + await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(2)); + expect(getMarketplaceCatalog).toHaveBeenLastCalledWith({ + q: "b", + category: undefined, + sort: undefined, + }); + }); + + it("reload refreshes the backend cache then re-fetches", async () => { + const { result } = renderHook(() => useMarketplace({})); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.reload(); + }); + + expect(refreshMarketplace).toHaveBeenCalledTimes(1); + await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(2)); + }); + + it("surfaces a fetch error", async () => { + getMarketplaceCatalog.mockRejectedValueOnce(new Error("boom")); + const { result } = renderHook(() => useMarketplace({})); + await waitFor(() => expect(result.current.error).toBe("boom")); + }); +}); diff --git a/apps/web/hooks/domains/plugins/use-marketplace.ts b/apps/web/hooks/domains/plugins/use-marketplace.ts new file mode 100644 index 0000000000..8951063d3e --- /dev/null +++ b/apps/web/hooks/domains/plugins/use-marketplace.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + getMarketplaceCatalog, + refreshMarketplace, + type CatalogQuery, +} from "@/lib/api/domains/marketplace-api"; +import type { MarketplaceCatalog } from "@/lib/types/plugins"; + +const EMPTY_CATALOG: MarketplaceCatalog = { plugins: [], sources: [] }; + +/** + * Fetches the marketplace catalog for the given query, re-fetching whenever + * the query changes. `reload` force-refreshes the backend source cache before + * re-fetching (use after adding/removing a source); `softReload` re-fetches + * without dropping the cache (use after an install, when only install_state + * changed). The catalog is a transient browse-time cache — it lives in local + * hook state, not the global store. + */ +export function useMarketplace(query: CatalogQuery) { + const [catalog, setCatalog] = useState(EMPTY_CATALOG); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + + const { q, category, sort } = query; + + useEffect(() => { + let cancelled = false; + setLoading(true); + getMarketplaceCatalog({ q, category, sort }) + .then((result) => { + if (cancelled) return; + setCatalog(result); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load marketplace"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [q, category, sort, reloadKey]); + + const softReload = useCallback(() => setReloadKey((key) => key + 1), []); + + const reload = useCallback(async () => { + await refreshMarketplace().catch(() => undefined); + setReloadKey((key) => key + 1); + }, []); + + return { catalog, loading, error, reload, softReload }; +} diff --git a/apps/web/hooks/domains/plugins/use-plugin-updates.test.ts b/apps/web/hooks/domains/plugins/use-plugin-updates.test.ts new file mode 100644 index 0000000000..4e7f75e17b --- /dev/null +++ b/apps/web/hooks/domains/plugins/use-plugin-updates.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; + +const getMarketplaceCatalog = vi.fn(); + +vi.mock("@/lib/api/domains/marketplace-api", () => ({ + getMarketplaceCatalog: (...args: unknown[]) => getMarketplaceCatalog(...args), +})); + +import { usePluginUpdates } from "./use-plugin-updates"; + +function entry(id: string, install_state: string, version = "2.0.0") { + return { id, install_state, version, package_url: `https://ex/${id}.tar.gz` }; +} + +beforeEach(() => { + getMarketplaceCatalog.mockReset(); +}); + +afterEach(() => cleanup()); + +describe("usePluginUpdates", () => { + it("keeps only update_available entries, keyed by id", async () => { + getMarketplaceCatalog.mockResolvedValue({ + plugins: [entry("a", "update_available"), entry("b", "installed"), entry("c", "available")], + sources: [], + }); + + const { result } = renderHook(() => usePluginUpdates()); + + await waitFor(() => expect(result.current.updates.size).toBe(1)); + expect(result.current.updates.has("a")).toBe(true); + expect(result.current.updates.get("a")?.version).toBe("2.0.0"); + expect(result.current.updates.has("b")).toBe(false); + }); + + it("reload re-fetches the catalog", async () => { + getMarketplaceCatalog.mockResolvedValue({ plugins: [], sources: [] }); + const { result } = renderHook(() => usePluginUpdates()); + await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1)); + + act(() => result.current.reload()); + await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(2)); + }); + + it("swallows a catalog fetch error (no updates, no throw)", async () => { + getMarketplaceCatalog.mockRejectedValue(new Error("offline")); + const { result } = renderHook(() => usePluginUpdates()); + await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalled()); + expect(result.current.updates.size).toBe(0); + }); +}); diff --git a/apps/web/hooks/domains/plugins/use-plugin-updates.ts b/apps/web/hooks/domains/plugins/use-plugin-updates.ts new file mode 100644 index 0000000000..1331f8d6f8 --- /dev/null +++ b/apps/web/hooks/domains/plugins/use-plugin-updates.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { getMarketplaceCatalog } from "@/lib/api/domains/marketplace-api"; +import type { MarketplaceEntry } from "@/lib/types/plugins"; + +/** + * Cross-references installed plugins against the marketplace catalog to find + * which have a newer version available, keyed by plugin id. Used by the + * Installed tab to show an Update button on rows with a pending update. + * + * The catalog already computes install_state per entry (the backend joins the + * catalog against installed records by id+version), so this hook just keeps the + * `update_available` entries. Failures are swallowed — a missing/offline + * marketplace must never break installed-plugin management; it just means no + * update badges are shown. + */ +export function usePluginUpdates() { + const [updates, setUpdates] = useState>(new Map()); + const [reloadKey, setReloadKey] = useState(0); + + useEffect(() => { + let cancelled = false; + getMarketplaceCatalog() + .then((catalog) => { + if (cancelled) return; + const next = new Map(); + for (const entry of catalog.plugins) { + if (entry.install_state === "update_available") next.set(entry.id, entry); + } + setUpdates(next); + }) + .catch(() => { + if (!cancelled) setUpdates(new Map()); + }); + return () => { + cancelled = true; + }; + }, [reloadKey]); + + const reload = useCallback(() => setReloadKey((key) => key + 1), []); + + return { updates, reload }; +} diff --git a/apps/web/lib/api/domains/marketplace-api.test.ts b/apps/web/lib/api/domains/marketplace-api.test.ts new file mode 100644 index 0000000000..4172592db1 --- /dev/null +++ b/apps/web/lib/api/domains/marketplace-api.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { + addMarketplaceSource, + deleteMarketplaceSource, + getMarketplaceCatalog, + listMarketplaceSources, + refreshMarketplace, + updateMarketplaceSource, +} from "./marketplace-api"; +import { ApiError } from "../client"; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise>(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function entry(overrides: Record = {}) { + return { + id: "agent-stats", + name: "Agent Stats", + description: "", + author: "kandev", + categories: ["analytics"], + repo_url: "https://github.com/kdlbs/kandev-plugin-agent-stats", + version: "1.0.0", + min_kandev_version: "", + package_url: "https://ex/agent-stats-1.0.0.tar.gz", + package_sha256: "", + stars: 42, + updated_at: "2026-07-17T00:00:00Z", + install_state: "available", + source_id: "official", + source_name: "Kandev Official", + ...overrides, + }; +} + +describe("getMarketplaceCatalog", () => { + it("GETs the catalog and encodes the query params", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ plugins: [entry()], sources: [] })); + + const result = await getMarketplaceCatalog({ q: "stats", category: "analytics", sort: "name" }); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe( + "http://api.test/api/plugins/marketplace?q=stats&category=analytics&sort=name", + ); + expect(result.plugins).toHaveLength(1); + expect(result.plugins[0].id).toBe("agent-stats"); + }); + + it("omits empty query params and defaults missing arrays", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + const result = await getMarketplaceCatalog(); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace"); + expect(result).toEqual({ plugins: [], sources: [] }); + }); +}); + +describe("marketplace sources", () => { + it("lists sources, unwrapping the sources array", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + sources: [ + { id: "official", name: "Kandev Official", url: "u", enabled: true, builtin: true }, + ], + }), + ); + + const result = await listMarketplaceSources(); + + const [url] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace/sources"); + expect(result[0].builtin).toBe(true); + }); + + it("POSTs a new source with name+url as JSON", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse( + { id: "s1", name: "Acme", url: "https://acme/index.json", enabled: true, builtin: false }, + 201, + ), + ); + + const result = await addMarketplaceSource("Acme", "https://acme/index.json"); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace/sources"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ + name: "Acme", + url: "https://acme/index.json", + }); + expect(result.id).toBe("s1"); + }); + + it("propagates a 400 (bad/duplicate url) as an ApiError", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ error: "source url must be an http(s) URL" }, 400), + ); + + await expect(addMarketplaceSource("x", "ftp://nope")).rejects.toBeInstanceOf(ApiError); + }); + + it("PATCHes a source with the partial patch body", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ id: "s1", name: "Acme", url: "u", enabled: false, builtin: false }), + ); + + await updateMarketplaceSource("s1", { enabled: false }); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace/sources/s1"); + expect(init?.method).toBe("PATCH"); + expect(JSON.parse(String(init?.body))).toEqual({ enabled: false }); + }); + + it("DELETEs a source and propagates a 409 for the built-in source", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); + await deleteMarketplaceSource("s1"); + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace/sources/s1"); + expect(init?.method).toBe("DELETE"); + + fetchSpy.mockResolvedValueOnce( + jsonResponse({ error: "the built-in source cannot be deleted" }, 409), + ); + await expect(deleteMarketplaceSource("official")).rejects.toBeInstanceOf(ApiError); + }); +}); + +describe("refreshMarketplace", () => { + it("POSTs to the refresh endpoint", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ refreshed: true })); + + const result = await refreshMarketplace(); + + const [url, init] = fetchSpy.mock.calls.at(-1) ?? []; + expect(String(url)).toBe("http://api.test/api/plugins/marketplace/refresh"); + expect(init?.method).toBe("POST"); + expect(result.refreshed).toBe(true); + }); +}); diff --git a/apps/web/lib/api/domains/marketplace-api.ts b/apps/web/lib/api/domains/marketplace-api.ts new file mode 100644 index 0000000000..c030434eb6 --- /dev/null +++ b/apps/web/lib/api/domains/marketplace-api.ts @@ -0,0 +1,96 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { MarketplaceCatalog, MarketplaceSource } from "@/lib/types/plugins"; + +const BASE = "/api/plugins/marketplace"; + +// CatalogQuery is the filter/sort applied server-side to the merged catalog. +export type CatalogQuery = { + q?: string; + category?: string; + sort?: "stars" | "name" | "recent"; +}; + +function toQueryString(query?: CatalogQuery): string { + if (!query) return ""; + const params = new URLSearchParams(); + if (query.q) params.set("q", query.q); + if (query.category) params.set("category", query.category); + if (query.sort) params.set("sort", query.sort); + const s = params.toString(); + return s ? `?${s}` : ""; +} + +// getMarketplaceCatalog fetches the merged catalog across every enabled +// source (GET /api/plugins/marketplace), filtered/sorted by the query. Each +// entry is annotated with its install_state; degraded sources are reported in +// `sources` with healthy=false and omit their entries. +export async function getMarketplaceCatalog( + query?: CatalogQuery, + options?: ApiRequestOptions, +): Promise { + const res = await fetchJson(`${BASE}${toQueryString(query)}`, { + ...options, + cache: "no-store", + }); + return { plugins: res.plugins ?? [], sources: res.sources ?? [] }; +} + +// listMarketplaceSources returns every configured source, built-in first +// (GET /api/plugins/marketplace/sources). +export async function listMarketplaceSources( + options?: ApiRequestOptions, +): Promise { + const res = await fetchJson<{ sources?: MarketplaceSource[] }>(`${BASE}/sources`, options); + return res.sources ?? []; +} + +// addMarketplaceSource registers a new operator source +// (POST /api/plugins/marketplace/sources). Throws ApiError on an invalid or +// duplicate URL (400). +export async function addMarketplaceSource( + name: string, + url: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/sources`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ name, url }) }, + }); +} + +// updateMarketplaceSource renames and/or enables/disables a source +// (PATCH /api/plugins/marketplace/sources/:id). +export async function updateMarketplaceSource( + id: string, + patch: { name?: string; enabled?: boolean }, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/sources/${encodeURIComponent(id)}`, { + ...options, + init: { ...(options?.init ?? {}), method: "PATCH", body: JSON.stringify(patch) }, + }); +} + +// deleteMarketplaceSource removes a non-builtin source +// (DELETE /api/plugins/marketplace/sources/:id). Throws ApiError 409 for the +// built-in official source. +export async function deleteMarketplaceSource( + id: string, + options?: ApiRequestOptions, +): Promise<{ deleted: boolean }> { + return fetchJson<{ deleted: boolean }>(`${BASE}/sources/${encodeURIComponent(id)}`, { + ...options, + init: { ...(options?.init ?? {}), method: "DELETE" }, + }); +} + +// refreshMarketplace drops the backend's cached source documents so the next +// catalog fetch re-hits every source (POST /api/plugins/marketplace/refresh). +export async function refreshMarketplace( + options?: ApiRequestOptions, +): Promise<{ refreshed: boolean }> { + return fetchJson<{ refreshed: boolean }>(`${BASE}/refresh`, { + ...options, + init: { ...(options?.init ?? {}), method: "POST" }, + }); +} diff --git a/apps/web/lib/types/plugins.ts b/apps/web/lib/types/plugins.ts index 8e0d6f5b42..66ac6901a4 100644 --- a/apps/web/lib/types/plugins.ts +++ b/apps/web/lib/types/plugins.ts @@ -67,6 +67,64 @@ export interface PluginRecord { last_health_check?: string | null; } +/** + * Derived install state of a marketplace catalog entry relative to what is + * installed locally. Mirrors marketplace.InstallState + * (apps/backend/internal/plugins/marketplace/types.go). + */ +export type MarketplaceInstallState = "available" | "installed" | "update_available"; + +/** + * One plugin in the marketplace catalog: the published index entry annotated + * with the source it came from and its install state. Mirrors + * marketplace.CatalogEntry. + */ +export interface MarketplaceEntry { + id: string; + name: string; + description: string; + author: string; + categories: string[]; + /** Absolute URL to the plugin's icon; empty when it ships none. */ + icon_url: string; + repo_url: string; + version: string; + min_kandev_version: string; + package_url: string; + package_sha256: string; + /** Null when the registry couldn't read the repo's star count. */ + stars: number | null; + updated_at: string; + install_state: MarketplaceInstallState; + installed_version?: string; + source_id: string; + source_name: string; +} + +/** + * A configured marketplace source plus its live fetch health, as returned in + * every catalog response. Mirrors marketplace.SourceStatus. The `id`/`name`/ + * `url`/`enabled`/`builtin` fields also match the SourceRecord returned by the + * source-management endpoints. + */ +export interface MarketplaceSource { + id: string; + name: string; + url: string; + enabled: boolean; + builtin: boolean; + /** Present in catalog responses (SourceStatus); absent from bare SourceRecord. */ + healthy?: boolean; + error?: string; + created_at?: string; +} + +/** The merged, deduped catalog across all enabled sources. */ +export interface MarketplaceCatalog { + plugins: MarketplaceEntry[]; + sources: MarketplaceSource[]; +} + /** * One entry of SyncResult.errors: a filesystem path the sync scan rejected * (or skipped), plus a human-readable reason. Mirrors diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 9dbcaafd18..6caae01932 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -37,6 +37,7 @@ Product-wide capabilities that are not tied to a single feature area. | Spec | Status | |---|---| | [plugins](plugins/spec.md) | draft | +| [plugins — marketplace](plugins/marketplace.md) | building | ## tasks/ — task & workflow model diff --git a/docs/specs/plugins/marketplace.md b/docs/specs/plugins/marketplace.md new file mode 100644 index 0000000000..944a1b83a5 --- /dev/null +++ b/docs/specs/plugins/marketplace.md @@ -0,0 +1,341 @@ +--- +status: building +created: 2026-07-18 +owner: jcfs +--- + +# Plugin Marketplace + +> **Implementation status (2026-07-18):** backend catalog + source store + HTTP +> API and the in-app Browse UI are implemented and unit/integration-tested; the +> git-hosted registry scaffolding (`plugin-registry/` + the index-build / +> star-refresh GitHub Actions) is committed. Remaining: a full browser E2E +> (needs a reachable fixture `index.json` + `KANDEV_PLUGIN_MARKETPLACE_URL`), +> and the four "Open questions" below (Pages domain, private-source auth, +> cross-source id collisions, provenance enforcement). + +## Why + +Today a user can only install a plugin if they already know its release tarball URL +or have a `.tar.gz` to upload (see [plugins spec](spec.md) → "Install pipeline"). +There is no way to *discover* plugins from inside kandev, no curated list of what +exists, and no signal for which plugins are worth trusting. Plugin authors have +nowhere to publish, and teams have no sanctioned way to share an internal set of +plugins. This feature adds a discoverable, curated catalog — kandev's marketplace — +while keeping install-by-URL and sideloading as escape hatches. + +## What + +- Users SHALL be able to browse a catalog of available plugins from inside kandev + (Settings > Plugins > **Browse**) without knowing any URL in advance. +- The catalog SHALL be **searchable** (by name/description) and **filterable by + category**, and SHALL be **sorted by GitHub stars descending by default**. Stars are + a **sort hint, not a quality score** — no open-source first-party store actually + ranks by stars (Obsidian ranks by downloads), so the catalog SHOULD also expose + **"recently updated"** ordering (from each repo's last release / `pushed_at`) so new + or actively-maintained plugins are not buried under older high-star incumbents. +- Each catalog entry shows: display name, description, author, categories, the source + repository link, the latest published version, and its star count. +- Installing from the catalog SHALL be **one click**: it resolves to the plugin's + latest release tarball URL and runs the existing verified install pipeline + (`POST /api/plugins/install`). No new install mechanism is introduced. +- A catalog entry for a plugin that is already installed SHALL show an **Installed** + state; when the catalog's latest version is newer than the installed version, it + SHALL show an **Update available** affordance (which reinstalls the newer tarball). +- The catalog SHALL be assembled from **one or more marketplace sources**. kandev + ships with the **official kandev source** enabled by default; operators MAY add + **additional sources** (a team or corporate registry) and the catalog merges them. +- Ranking SHALL use **GitHub stars only**. kandev collects **no download or usage + telemetry** and exposes no "most installed" metric. +- The public registry is **curated**: a plugin appears in the official source only + after a maintainer-approved pull request adds it to the registry list. Anyone can + still install a non-listed plugin by URL/upload (sideloading is unaffected). + +### Ecosystem shape (context, not a runtime contract) + +The marketplace is backed by a git-hosted, PR-curated registry. The closest prior +art is **Obsidian's community plugins** (a central pointer list — +`community-plugins.json` — plus PR-to-add, with plugin code living in each author's +own GitHub releases), extended with **Homebrew's tap model** for third-party/corporate +sources and **`formulae.brew.sh`'s build pipeline** (a scheduled Action pulls the +source list, enriches it, emits a static JSON API, and serves it from GitHub Pages): + +- **One Git repository per plugin** (`kdlbs/kandev-plugin-`), each publishing + the plugin package as a GitHub **Release** asset in the existing + `-.tar.gz` format. A `kdlbs/kandev-plugin-template` starter repo is the + recommended way to bootstrap one. +- **The registry list** lives in the main kandev repo under `plugin-registry/` + (`plugins.yaml` + `schema.json`). It records *which repos* are in the official + catalog — not the plugin metadata itself, which is read from each plugin's own + release. +- **A GitHub Action builds `index.json`** from `plugins.yaml` (resolving each repo's + latest release, its manifest metadata, tarball URL + checksum, and current star + count) and publishes it to **GitHub Pages**. A scheduled run refreshes star counts. +- kandev fetches `index.json` — the official one plus any operator-added source URLs + pointing at the same-shaped document. + +The per-repo publishing convention and the two Actions are an operational contract, +not a user-facing API; their normative shape is the `schema.json` and the +`index.json` document format defined under "Data model". + +## Data model + +### `plugins.yaml` (registry list — the curated, human-edited source of truth) + +Lives at `plugin-registry/plugins.yaml` in the main kandev repo. A list of entries, +each pointing at one plugin repository. Deliberately minimal — descriptive metadata +is read from the plugin's own release manifest at build time so it can never drift +from what actually ships. + +```yaml +plugins: + - id: agent-stats # MUST equal the plugin manifest `id` + repo: kdlbs/kandev-plugin-agent-stats # owner/name; the release source + # optional overrides / curation-only fields: + featured: false # maintainer may pin an entry above star order +``` + +Constraints: +- `id` unique across the file; MUST match the manifest `id` of the repo's latest + release (CI validates this). +- `repo` MUST be a resolvable `owner/name` with at least one release whose asset set + passes the existing package integrity gate (`checksums.txt`). +- The file MUST validate against `plugin-registry/schema.json` (enforced in CI on + every PR). + +### `index.json` (generated catalog document — what kandev fetches) + +Built from `plugins.yaml` by the index-build Action and served from GitHub Pages. +This is the fetch contract between a marketplace source and kandev. Additional +(corporate/team) sources MUST serve a document of this shape to be consumable. + +```jsonc +{ + "schema_version": 1, + "generated_at": "2026-07-18T10:00:00Z", + "source": { "name": "Kandev Official", "url": "https:///plugins/index.json" }, + "plugins": [ + { + "id": "agent-stats", + "name": "Agent Stats", // from manifest display_name + "description": "Per-session token & LOC dashboard", + "author": "kandev", + "categories": ["analytics"], + "icon_url": "https://raw.githubusercontent.com/kdlbs/kandev-plugin-agent-stats/v1.4.0/icon.svg", + "repo_url": "https://github.com/kdlbs/kandev-plugin-agent-stats", + "version": "1.4.0", // latest release + "min_kandev_version": "0.9.0", // from manifest, nullable + "package_url": "https://github.com/.../agent-stats-1.4.0.tar.gz", + "package_sha256": "…", // expected tarball digest (provenance) + "stars": 128, // GitHub stargazers at last refresh + "updated_at": "2026-07-17T12:00:00Z" + } + ] +} +``` + +Constraints: +- `id` unique within a document. When the same `id` appears in more than one + configured source, the **first configured source wins** and later duplicates are + hidden (the official source is always first). +- `package_url` MUST point at a release asset in the existing package format. +- `icon_url`, when present, is an absolute URL to the plugin's icon (rendered on + the catalog card; the UI falls back to a letter tile when empty). It is + resolved by the index-build from the plugin manifest's optional `icon` field + (a package-relative path such as `icon.svg`) → a `raw.githubusercontent.com` + URL pinned to the release tag. +- `package_sha256`, when present, is the digest the client MAY use to confirm the + downloaded tarball matches what was curated, in addition to the package's own + internal `checksums.txt` gate. + +### Marketplace sources (client-side configuration — SQLite) + +The set of source documents kandev fetches. Stored as an application setting. + +``` +plugin_marketplace_source + id string PK + name string human label (e.g. "Acme Internal") + url string absolute URL of an index.json document + enabled bool default true + builtin bool true for the official source (present by default, not deletable) + created_at timestamp +``` + +The official source is seeded as a `builtin` row (its URL is a build/config +constant). Operators can add, enable/disable, and delete non-builtin sources. + +## API surface + +The catalog is fetched and merged **server-side** so that (a) cross-origin fetching +and per-source caching are handled in one place, (b) private corporate sources can +carry auth without exposing tokens to the browser, and (c) the "already installed / +update available" join against local records happens once. The browser talks only to +kandev; kandev talks to the source URLs. + +``` +GET /api/plugins/marketplace # Merged, deduped catalog across all enabled sources + # query: ?q=&category=&sort=stars|name|recent + # stars (default): star count desc, unknown (null) last + # name: display name asc (case-insensitive) + # recent: updated_at desc (last release / repo push) + # each entry annotated with install_state: + # available | installed | update_available +GET /api/plugins/marketplace/sources # List configured sources +POST /api/plugins/marketplace/sources # Add a source {name, url} +PATCH /api/plugins/marketplace/sources/{id} # Enable/disable/rename a source +DELETE /api/plugins/marketplace/sources/{id} # Remove a non-builtin source +POST /api/plugins/marketplace/refresh # Force a re-fetch of all sources (bypass cache) +``` + +Installing from the catalog reuses the existing contract unchanged: +`POST /api/plugins/install` with `{"url": ""}` (see +[plugins spec](spec.md) → "Plugin management API"). The marketplace endpoints are +read/config only; they never spawn or mutate a plugin process. + +`GET /api/plugins/marketplace` responses are cached per source with a short TTL; +`refresh` and adding/removing a source invalidate the cache. A source that fails to +fetch or parse is reported as a degraded source in the response and its entries are +omitted — it never aborts the merge of the healthy sources. + +## State machine + +Catalog entries have a derived **install_state** computed by joining a catalog entry +against local plugin records (by `id`): + +| install_state | condition | +|--------------------|-----------| +| `available` | no installed record with this `id` | +| `installed` | installed record exists, version ≥ catalog `version` | +| `update_available` | installed record exists, installed version < catalog `version` | + +Installing an `available` entry, or updating an `update_available` one, both go +through `POST /api/plugins/install`; the plugin's own lifecycle +(`registered → active → error`, enable/disable/uninstall) is unchanged from the +[plugins spec](spec.md) → "State machine". The marketplace adds no new plugin states. + +A **source** is `enabled` or `disabled`; only `enabled` sources contribute entries. A +transiently unreachable enabled source is surfaced as `degraded` in the catalog +response but stays `enabled`. + +## Permissions + +- Browsing the catalog and installing from it require the same operator authority as + the existing plugin-management API — marketplace endpoints sit behind the same + `Features.Plugins` gate and auth as `POST /api/plugins/install`. +- Managing marketplace sources (add/remove/enable) is an operator action; adding a + source is an explicit act of trust in that source's maintainer, equivalent to + `brew tap`-ing a third-party tap. +- Appearing in the **official** source requires a maintainer to approve the + registry-list PR. Third-party/corporate sources are governed by whoever controls + that source repo, not by kandev. + +## Failure modes + +- **A source URL is unreachable or returns malformed JSON.** That source is marked + `degraded` in the `GET /api/plugins/marketplace` response, its entries are omitted, + and the healthy sources still return. No error is surfaced for the catalog as a + whole unless *every* source failed. +- **A catalog `package_url` 404s or the download fails at install time.** The + existing install pipeline surfaces the install error unchanged; the catalog entry + is unaffected. +- **`package_sha256` mismatch** between the catalog and the downloaded tarball: + once digest enforcement is enabled (a planned option — see "Open questions"), + install fails closed with a provenance error even though the tarball's own + `checksums.txt` may be internally consistent. In v1 the digest is advisory and + install proceeds through the existing pipeline unchanged. +- **Duplicate `id` across sources:** the first configured source wins; later + duplicates are silently hidden (documented, not an error). +- **GitHub star-refresh Action hits API rate limits:** the previous star counts in + `index.json` are retained (stale, not zeroed); the build must never publish `0` + stars for a repo it failed to query. (The Action should batch star lookups via the + GraphQL API under an authenticated PAT/App token — authenticated REST is 5,000 + req/hr, unauthenticated only 60/hr, and the default `GITHUB_TOKEN` cannot reliably + read arbitrary external repos — so it stays well inside limits even as the list + grows.) +- **A listed repo deletes its latest release / the tarball asset:** the index-build + Action drops that entry (or keeps the last good version) and logs it; a plugin with + no installable release never appears with a dead `package_url`. + +## Persistence guarantees + +- Configured marketplace sources survive restart (SQLite). +- The fetched catalog is a **cache**, not durable state: it does not survive restart + and is re-fetched on demand. Star counts and versions are always as fresh as the + last successful source fetch, never authoritative locally. +- Installed plugins are unaffected by marketplace state — uninstalling every source, + or the official source going offline, never disables or removes an installed + plugin. + +## Scenarios + +- **GIVEN** the official source is reachable, **WHEN** the user opens Settings > + Plugins > Browse, **THEN** the plugins are listed sorted by star count descending, + each showing name, description, author, categories, version, and stars. +- **GIVEN** the catalog is showing, **WHEN** the user types a query, **THEN** only + entries whose name or description matches remain; **AND WHEN** the user picks a + category filter, **THEN** only entries in that category remain. +- **GIVEN** a catalog entry with `install_state: available`, **WHEN** the user clicks + Install, **THEN** kandev calls `POST /api/plugins/install` with the entry's + `package_url` and the plugin installs via the normal verified pipeline. +- **GIVEN** a plugin is already installed at the catalog's latest version, **WHEN** + the catalog renders, **THEN** its entry shows **Installed** and no Install button. +- **GIVEN** a plugin is installed at a version older than the catalog's, **WHEN** the + catalog renders, **THEN** its entry shows **Update available**, and clicking it + installs the newer tarball. +- **GIVEN** an operator adds a source `{name: "Acme Internal", url: …}`, **WHEN** the + catalog is next fetched, **THEN** Acme's plugins appear alongside the official ones, + and an Acme entry with an id that also exists in the official source is hidden in + favor of the official one. +- **GIVEN** a configured source is unreachable, **WHEN** the catalog is fetched, + **THEN** the response marks that source `degraded`, omits its entries, and still + returns entries from the reachable sources. +- **GIVEN** digest enforcement is enabled (planned) and a catalog entry whose + `package_sha256` does not match the downloaded tarball, **WHEN** the user installs + it, **THEN** the install fails closed with a provenance error and no plugin process + is spawned. (In v1 the digest is advisory; install is unchanged.) +- **GIVEN** the marketplace is empty or entirely offline, **WHEN** the user opens the + install dialog, **THEN** install-by-URL and upload still work exactly as before. +- **GIVEN** a PR that adds an entry to `plugins.yaml` whose `id` does not match the + target repo's latest-release manifest `id`, **WHEN** registry CI runs, **THEN** the + schema/consistency check fails and the PR is blocked. + +## Out of scope + +- **Download / usage / install telemetry.** No counters, no "most installed", no + phone-home. Ranking is GitHub stars only. (Explicit product decision; revisit only + with opt-in telemetry — see the telemetry direction ADR/plan.) +- **Automatic background plugin updates.** v1 surfaces **Update available** and + requires an explicit click. No auto-update, no update channels. +- **Ratings, reviews, comments, or a paid/commercial plugin tier.** +- **A hosted web marketplace browse *site*.** GitHub Pages serves the `index.json` + document; a rich public browsing website is a later phase (the in-app catalog is the + v1 surface). +- **Signature *requirement*.** Package signing/provenance stays optional exactly as in + the [plugins spec](spec.md); the marketplace adds an *advisory* `package_sha256` but + does not make signing mandatory. +- **Runtime hardening / sandboxing of plugin JS.** Unchanged from the plugins spec; + the marketplace does not alter the trust boundary of an installed plugin. + +## Open questions + +- **Pages host / canonical official URL.** `kdlbs.github.io/kandev/plugins/index.json` + vs. a branded `plugins.kandev.*` domain — pick before wiring the builtin source + constant. +- **Private corporate sources & auth.** Do we support authenticated source URLs + (token/header) in v1, or only public `index.json` documents? Affects the source + data model (an optional credential ref) and the backend fetch. +- **Star refresh cadence & storage.** Confirm the schedule (daily?) and that star + counts live only in the generated `index.json` (not committed back to + `plugins.yaml`, to avoid noisy commits). +- **`package_sha256` provenance.** Confirm the index build pins the digest and the + client enforces it (recommended above) vs. relying solely on the package's internal + `checksums.txt`. +- **Cross-source id collisions.** v1 hides later-source duplicates (official wins). Do + we instead want Homebrew-style **fully-qualified `source/id`** names so a corporate + source can intentionally ship its own build of an official plugin without it being + silently hidden? +- **Anti-typosquatting / verified authorship.** Worth adopting Open VSX's + verified-namespace idea (a "verified" badge only when repo ownership is proven) and + cheap publish-time scans in the registry-validation Action (secret-leak / blocklist / + typosquat checks on the PR)? Deferred, but flagged as the natural next trust layer. diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 0e3c054228..c17951e1c5 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -635,9 +635,11 @@ restart with backoff (max 5 attempts, then `error`). Next successful handshake/` - **In-process backend plugins.** Plugin *backends* remain out-of-process — no Go plugin loading via `plugin.Open`, no WASM, no shared-memory communication. (This is distinct from the frontend bundle, which does load into the SPA.) -- **Plugin marketplace or registry.** No central catalog, no one-click install, no - automatic discovery. Install-by-URL/upload is a manual, single-plugin action; a - searchable catalog is a later phase. +- **Plugin marketplace or registry.** Out of scope *for this spec*: this spec covers + install-by-URL/upload as a manual, single-plugin action. The discoverable, curated + catalog (central registry, one-click install, star ranking, third-party sources) is + a sibling feature specified in [marketplace.md](marketplace.md) and built on top of + this spec's install pipeline. - **Mandatory package signing.** `checksums.txt.sig` verification is supported when present, but signing is optional in v1 — an unsigned package installs with a warning rather than being rejected. Requiring signatures is future work. diff --git a/plugin-registry/.gitignore b/plugin-registry/.gitignore new file mode 100644 index 0000000000..133d529c43 --- /dev/null +++ b/plugin-registry/.gitignore @@ -0,0 +1,2 @@ +# Generated by build-index.mjs in CI and deployed to Pages — never commit. +index.json diff --git a/plugin-registry/README.md b/plugin-registry/README.md new file mode 100644 index 0000000000..72dbe342cd --- /dev/null +++ b/plugin-registry/README.md @@ -0,0 +1,86 @@ +# Kandev plugin registry + +This directory is the **official Kandev marketplace source** — the curated, +PR-edited list of plugins that show up when a user opens **Settings > Plugins > +Browse** inside Kandev. + +It is deliberately minimal. It records *which repositories* are in the official +catalog; it does **not** store plugin metadata (display name, description, +author, version, tarball URL, checksum, stars). All of that is read from each +plugin's own GitHub release when the catalog index is built, so the listing can +never drift from what actually ships. + +## Files + +| File | Purpose | +| --- | --- | +| `plugins.yaml` | The curated pointer list — one entry per plugin repo. Human-edited via PR. | +| `schema.json` | JSON Schema (draft 2020-12) that `plugins.yaml` MUST validate against. Enforced in CI. | +| `build-index.mjs` | Build script (zero-dependency Node) that resolves each entry to a full catalog record and emits `index.json`. | +| `build-index.test.mjs` | `node --test` coverage for the build script's parser and enrichment. | + +The generated `index.json` is published to GitHub Pages by the +[`plugin-registry-index`](../.github/workflows/plugin-registry-index.yml) +workflow and served as the fetch contract Kandev consumes. + +## One repository per plugin + +Each plugin lives in its **own** public GitHub repository, named +`kdlbs/kandev-plugin-` for first-party plugins (third parties may use any +`owner/name`). Every plugin repo publishes its package as a GitHub **Release** +asset in the standard Kandev package format: + +- `-.tar.gz` — the plugin package, and +- `checksums.txt` — the integrity manifest the install pipeline verifies. + +The `kdlbs/kandev-plugin-template` starter repo is the recommended way to +bootstrap a new plugin with the right layout and a release workflow. + +The catalog ranks plugins by **GitHub stars** (with a "recently updated" +alternative from each repo's last release). Kandev collects **no download or +usage telemetry** — there is no "most installed" metric, by design. + +## Submitting a plugin to the official catalog + +1. **Publish your plugin.** Push it to a public GitHub repository and cut a + GitHub **Release** whose assets include `-.tar.gz` and + `checksums.txt`. The release must pass the standard package integrity gate. +2. **Fork this repo** (`kdlbs/kandev`). +3. **Add one entry** to `plugin-registry/plugins.yaml` pointing at your public + repo: + + ```yaml + - id: my-plugin # MUST equal the `id` in your plugin manifest + repo: your-org/your-plugin-repo + categories: [productivity] + ``` + + The `id` **MUST match the `id` in your plugin manifest** and be unique across + the file. `repo` is `owner/name`. `featured` is a maintainer-only pin and + should be left out of submissions. +4. **Open a pull request.** CI validates `plugins.yaml` against `schema.json` + and checks that your `id` matches the target repo's latest-release manifest + `id`. +5. **A maintainer reviews and merges.** Once merged, the index-build workflow + picks up your entry and your plugin appears in the in-app catalog on the next + build. + +## Teams and corporates: host your own source instead + +You do **not** have to PR into this registry to share plugins internally. Kandev +supports **additional marketplace sources**: any URL that serves an `index.json` +document of the same shape can be added under **Settings > Plugins** as an extra +source, and its plugins are merged into the catalog alongside the official ones. + +This is the recommended path for a team or corporate registry — host your own +`index.json` (for example by copying this directory's `plugins.yaml` + +`build-index.mjs` + workflow into your own repo and pointing Kandev at your Pages +URL). Adding a source is an explicit act of trust in that source's maintainer, +much like `brew tap`-ing a third-party tap. When the same `id` appears in more +than one source, the official source wins and later duplicates are hidden. + +## Sideloading is always available + +Appearing in this registry is only about **discovery**. Anyone can still install +an unlisted plugin by URL or by uploading a `.tar.gz` directly — the marketplace +never removes those escape hatches. diff --git a/plugin-registry/build-index.mjs b/plugin-registry/build-index.mjs new file mode 100644 index 0000000000..37a90935f4 --- /dev/null +++ b/plugin-registry/build-index.mjs @@ -0,0 +1,336 @@ +// Build the Kandev marketplace catalog index (index.json) from plugins.yaml. +// +// This is the "formulae.brew.sh"-style enrichment step of the plugin +// marketplace: it reads the curated pointer list (plugins.yaml), resolves each +// entry against the GitHub API to discover its latest release, package asset, +// star count, last-push time, and manifest presentation metadata, and emits a +// single static index.json document. That document is the fetch contract Kandev +// consumes (docs/specs/plugins/marketplace.md → "Data model" → index.json); +// additional corporate/team sources serve the same shape. +// +// Zero dependencies: Node stdlib + global fetch only, matching the repo's other +// automation scripts (e.g. scripts/validate-public-docs.mjs). plugins.yaml is a +// small, schema-constrained pointer list (plugin-registry/schema.json), so a +// focused parser reads it without pulling a YAML library into CI. +// +// Robustness: one bad entry (missing release, deleted asset, API hiccup) never +// fails the whole build — its error is logged to stderr and it is skipped. A +// repo whose star lookup fails is emitted with `stars: null`, never `0`, so a +// transient outage can't corrupt the catalog's ranking. +// +// Auth: GitHub API calls use GITHUB_TOKEN when present (in CI, secrets.GITHUB_TOKEN). +// That works for the public repos here; at larger scale a PAT with `public_repo` +// scope set as GITHUB_TOKEN gives higher, more predictable rate limits. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const GITHUB_API = "https://api.github.com"; +const API_VERSION = "2022-11-28"; +const USER_AGENT = "kandev-plugin-registry-index-builder"; + +// Bump only with a coordinated backend parser change — this is a hard contract. +const SCHEMA_VERSION = 1; +const SOURCE_NAME = "Kandev Official"; +// The canonical Pages URL is filled in by the client from its source config, so +// the document does not need to know where it is hosted. +const SOURCE_URL = ""; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PLUGINS_YAML = path.join(HERE, "plugins.yaml"); +const OUTPUT_JSON = path.join(HERE, "index.json"); + +// --- Minimal plugins.yaml parser -------------------------------------------- + +/** + * Parse the curated plugins list. plugins.yaml is intentionally a flat + * `plugins:` sequence of maps with only scalar / inline-array fields + * (id, repo, featured, categories), enforced by plugin-registry/schema.json — + * so this focused reader is sufficient and avoids a YAML dependency in CI. + * + * @param {string} text Raw plugins.yaml contents. + * @returns {Array>} The parsed plugin specs. + */ +export function parsePluginsYaml(text) { + const specs = []; + let current = null; + let inPlugins = false; + + for (const rawLine of text.split("\n")) { + const line = stripComment(rawLine); + if (line.trim() === "") continue; + + if (!inPlugins) { + if (line.trim() === "plugins:") inPlugins = true; + continue; + } + + const item = line.match(/^\s*-\s*(.*)$/); + if (item) { + current = {}; + specs.push(current); + assignField(current, item[1]); + continue; + } + if (current && /^\s+\S/.test(line)) assignField(current, line.trim()); + } + return specs; +} + +/** Drop a trailing `# comment` (values here never contain `#`). */ +function stripComment(line) { + const hash = line.indexOf("#"); + return hash === -1 ? line : line.slice(0, hash); +} + +/** Parse a `key: value` fragment onto target, coercing scalars/inline arrays. */ +function assignField(target, fragment) { + const colon = fragment.indexOf(":"); + if (colon === -1) return; + const key = fragment.slice(0, colon).trim(); + if (!key) return; + target[key] = parseScalar(fragment.slice(colon + 1).trim()); +} + +function parseScalar(value) { + if (value === "") return ""; + if (value === "true") return true; + if (value === "false") return false; + if (value.startsWith("[") && value.endsWith("]")) { + return value + .slice(1, -1) + .split(",") + .map((part) => unquote(part.trim())) + .filter((part) => part !== ""); + } + return unquote(value); +} + +function unquote(value) { + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) { + return value.slice(1, -1); + } + return value; +} + +// --- GitHub helpers ---------------------------------------------------------- + +async function githubJson(apiPath) { + const url = apiPath.startsWith("http") ? apiPath : `${GITHUB_API}${apiPath}`; + const headers = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": API_VERSION, + "User-Agent": USER_AGENT, + }; + if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + const response = await fetchWithTimeout(url, { headers }); + if (!response.ok) throw new Error(`GET ${url} -> ${response.status}`); + return response.json(); +} + +async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +const rawUrl = (repo, ref, filePath) => + `https://raw.githubusercontent.com/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; + +/** + * Fetch the plugin's manifest.yaml at the release tag for presentation metadata + * (display_name/description/categories/min_kandev_version/icon). Returns {} on + * any failure — the manifest only enriches presentation, so a miss must never + * fail the entry. + */ +async function fetchManifest(repo, ref) { + try { + const response = await fetchWithTimeout(rawUrl(repo, ref, "manifest.yaml"), { + headers: { "User-Agent": USER_AGENT }, + }); + if (!response.ok) throw new Error(`status ${response.status}`); + return parseManifestFields(await response.text()); + } catch (error) { + console.error(`warning: ${repo}: manifest.yaml not read (${error.message})`); + return {}; + } +} + +/** + * Read just the top-level manifest fields the index needs. The manifest is a + * larger YAML doc, so this only extracts the flat scalar keys we care about + * (display_name, description, author, min_kandev_version, icon) plus the + * `categories` inline/simple list — enough for presentation without a YAML lib. + */ +export function parseManifestFields(text) { + const out = {}; + const scalarKeys = ["display_name", "description", "author", "min_kandev_version", "icon"]; + let inCategoryBlock = false; + for (const rawLine of text.split("\n")) { + const line = stripComment(rawLine); + // Collect indented block-sequence items while inside `categories:`. + const item = line.match(/^\s+-\s*(.+)$/); + if (inCategoryBlock && item) { + out.categories.push(unquote(item[1].trim())); + continue; + } + const match = line.match(/^([a-z_]+):\s*(.*)$/); + if (!match) continue; + inCategoryBlock = false; + const [, key, value] = match; + const v = value.trim(); + if (scalarKeys.includes(key) && v !== "") { + out[key] = unquote(v); + } else if (key === "categories") { + if (v.startsWith("[")) { + out.categories = parseScalar(v); // inline: [a, b] + } else { + out.categories = []; // block sequence: subsequent `- item` lines + inCategoryBlock = true; + } + } + } + return out; +} + +// --- Enrichment -------------------------------------------------------------- + +/** Strip a leading `v` from a release tag so versions compare cleanly. */ +function normalizeVersion(tag) { + return tag && /^v\d/.test(tag) ? tag.slice(1) : tag; +} + +/** Pick this plugin's package tarball from the release assets. */ +function pickPackageAsset(assets, pluginId, version) { + const tarballs = assets.filter((a) => (a.name || "").endsWith(".tar.gz")); + if (tarballs.length === 0) return { error: "release has no .tar.gz asset" }; + const exact = tarballs.find((a) => a.name === `${pluginId}-${version}.tar.gz`); + return { asset: exact || tarballs[0] }; +} + +/** "agent-stats" -> "Agent Stats", the fallback when no manifest name is known. */ +function humanize(pluginId) { + return pluginId + .split("-") + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" "); +} + +/** + * Resolve a single plugins.yaml spec into a full index.json record. + * @returns {Promise<{record?: object, error?: string}>} + */ +export async function buildEntry(spec) { + const pluginId = spec.id; + const repo = spec.repo; + if (!pluginId || !repo) return { error: `entry missing id/repo: ${JSON.stringify(spec)}` }; + + let release; + try { + release = await githubJson(`/repos/${repo}/releases/latest`); + } catch (error) { + return { error: `${pluginId}: no latest release (${error.message})` }; + } + + const tag = release.tag_name || ""; + const version = normalizeVersion(tag); + const { asset, error: assetError } = pickPackageAsset(release.assets || [], pluginId, version); + if (assetError) return { error: `${pluginId}: ${assetError}` }; + + const manifest = tag ? await fetchManifest(repo, tag) : {}; + const iconUrl = manifest.icon && tag ? rawUrl(repo, tag, manifest.icon) : null; + const meta = await fetchRepoMeta(repo, pluginId); + + const record = { + id: pluginId, + // Presentation prefers the plugin's manifest, then id-derived / release / + // plugins.yaml fallbacks, so the contract shape is stable even on a miss. + name: manifest.display_name || humanize(pluginId), + description: manifest.description || release.name || "", + author: manifest.author || meta.author, + categories: manifest.categories || spec.categories || [], + icon_url: iconUrl, + repo_url: `https://github.com/${repo}`, + version: version || null, + min_kandev_version: manifest.min_kandev_version ?? null, + package_url: asset.browser_download_url, + // Advisory provenance digest; null is a valid, documented value. + package_sha256: null, + stars: meta.stars, + updated_at: meta.updatedAt || release.published_at || null, + }; + return { record }; +} + +/** Repo metadata → stars (null on failure, never 0), last-push time, owner. */ +async function fetchRepoMeta(repo, pluginId) { + try { + const repoMeta = await githubJson(`/repos/${repo}`); + return { + stars: Number.isInteger(repoMeta.stargazers_count) ? repoMeta.stargazers_count : null, + updatedAt: repoMeta.pushed_at || null, + author: repoMeta.owner?.login || repo.split("/", 1)[0], + }; + } catch (error) { + console.error(`warning: ${pluginId}: star/metadata lookup failed, emitting stars=null (${error.message})`); + return { stars: null, updatedAt: null, author: repo.split("/", 1)[0] }; + } +} + +// --- Orchestration ----------------------------------------------------------- + +export async function buildIndex(specs) { + const records = []; + const errors = []; + for (const spec of specs) { + const { record, error } = await buildEntry(spec); + if (error) { + errors.push(error); + console.error(`skip: ${error}`); + continue; + } + records.push(record); + } + const document = { + schema_version: SCHEMA_VERSION, + generated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + source: { name: SOURCE_NAME, url: SOURCE_URL }, + plugins: records, + }; + return { document, errors }; +} + +async function main() { + const text = await fs.readFile(PLUGINS_YAML, "utf8"); + const specs = parsePluginsYaml(text); + // An empty list is expected at launch (no plugin repos yet) — it produces a + // valid, empty index.json and is NOT an error. Only a non-empty list that + // resolves to zero entries (below) indicates a real failure. + const { document, errors } = await buildIndex(specs); + await fs.writeFile(OUTPUT_JSON, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + + console.error( + `Built index.json: ${document.plugins.length} built, ${errors.length} skipped, ` + + `${specs.length} listed. Output: ${OUTPUT_JSON}`, + ); + // A non-empty list that produced zero entries is almost certainly a bad token + // or total outage — fail so CI never publishes an empty catalog over a good one. + if (specs.length > 0 && document.plugins.length === 0) { + console.error("error: no entries could be built; refusing to publish empty index"); + process.exitCode = 1; + } +} + +// Run only when invoked directly (not when imported by a test). +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + main().catch((error) => { + console.error(`fatal: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/plugin-registry/build-index.test.mjs b/plugin-registry/build-index.test.mjs new file mode 100644 index 0000000000..b0d4680bde --- /dev/null +++ b/plugin-registry/build-index.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import { + buildEntry, + buildIndex, + parseManifestFields, + parsePluginsYaml, +} from "./build-index.mjs"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +/** Stub global fetch, routing by URL to a release / manifest / repo response. */ +function stubGitHub({ release, manifestText, repoMeta }) { + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/releases/latest")) return jsonResponse(release); + if (u.includes("/manifest.yaml")) return textResponse(manifestText ?? ""); + if (u.includes("/repos/")) return jsonResponse(repoMeta); + throw new Error(`unexpected fetch: ${u}`); + }; +} + +const jsonResponse = (body, ok = body !== null) => ({ + ok, + status: ok ? 200 : 500, + json: async () => body ?? {}, + text: async () => JSON.stringify(body ?? {}), +}); +const textResponse = (text) => ({ ok: text !== "", status: text ? 200 : 404, text: async () => text }); + +test("parsePluginsYaml reads the constrained pointer list", () => { + const specs = parsePluginsYaml( + [ + "# a comment", + "plugins:", + " - id: hello", + " repo: kdlbs/kandev-plugin-hello", + " featured: true", + " - id: agent-stats", + " repo: kdlbs/kandev-plugin-agent-stats", + " categories: [analytics, ops]", + ].join("\n"), + ); + assert.equal(specs.length, 2); + assert.deepEqual(specs[0], { id: "hello", repo: "kdlbs/kandev-plugin-hello", featured: true }); + assert.deepEqual(specs[1].categories, ["analytics", "ops"]); +}); + +test("parseManifestFields extracts presentation keys and ignores the rest", () => { + const fields = parseManifestFields( + [ + "id: hello", + "api_version: 1", + 'display_name: "Hello"', + "description: A starter plugin", + "icon: icon.svg", + "categories: [getting-started]", + "min_kandev_version: 0.72.0", + "capabilities:", + " state: true", + ].join("\n"), + ); + assert.equal(fields.display_name, "Hello"); + assert.equal(fields.description, "A starter plugin"); + assert.equal(fields.icon, "icon.svg"); + assert.equal(fields.min_kandev_version, "0.72.0"); + assert.deepEqual(fields.categories, ["getting-started"]); + assert.equal("id" in fields, false); +}); + +test("parseManifestFields reads block-sequence categories", () => { + const fields = parseManifestFields( + ["display_name: Multi", "categories:", " - integrations", " - analytics", "author: kandev"].join( + "\n", + ), + ); + assert.deepEqual(fields.categories, ["integrations", "analytics"]); + assert.equal(fields.author, "kandev"); +}); + +test("buildEntry resolves release, manifest, icon_url and stars", async () => { + stubGitHub({ + release: { + tag_name: "v1.2.0", + name: "Release notes", + published_at: "2026-01-01T00:00:00Z", + assets: [{ name: "foo-1.2.0.tar.gz", browser_download_url: "https://dl/foo-1.2.0.tar.gz" }], + }, + manifestText: "display_name: Foo\ndescription: A foo\nicon: icon.svg\ncategories: [x]", + repoMeta: { stargazers_count: 42, pushed_at: "2026-02-02T00:00:00Z", owner: { login: "acme" } }, + }); + + const { record, error } = await buildEntry({ id: "foo", repo: "acme/foo" }); + assert.equal(error, undefined); + assert.equal(record.name, "Foo"); + assert.equal(record.version, "1.2.0"); + assert.equal(record.package_url, "https://dl/foo-1.2.0.tar.gz"); + assert.equal(record.icon_url, "https://raw.githubusercontent.com/acme/foo/v1.2.0/icon.svg"); + assert.equal(record.stars, 42); + assert.equal(record.updated_at, "2026-02-02T00:00:00Z"); + assert.deepEqual(record.categories, ["x"]); +}); + +test("buildEntry errors (not throws) when there is no installable release", async () => { + stubGitHub({ release: null }); + const { record, error } = await buildEntry({ id: "foo", repo: "acme/foo" }); + assert.equal(record, undefined); + assert.match(error, /no latest release/); +}); + +test("buildEntry keeps stars null (never 0) when repo metadata lookup fails", async () => { + stubGitHub({ + release: { tag_name: "1.0.0", assets: [{ name: "foo-1.0.0.tar.gz", browser_download_url: "https://dl/foo.tar.gz" }] }, + manifestText: "", + repoMeta: null, // -> !ok -> throw -> caught + }); + const { record } = await buildEntry({ id: "foo", repo: "acme/foo" }); + assert.equal(record.stars, null); + assert.equal(record.icon_url, null); // no manifest icon +}); + +test("empty plugins.yaml parses to no specs and builds a valid empty index", async () => { + assert.deepEqual(parsePluginsYaml("plugins: []"), []); + const { document, errors } = await buildIndex([]); + assert.equal(document.plugins.length, 0); + assert.equal(errors.length, 0); + assert.equal(document.schema_version, 1); + assert.equal(document.source.name, "Kandev Official"); +}); + +test("buildIndex skips bad entries but still builds the good ones", async () => { + // First entry has a release, second does not. + let call = 0; + globalThis.fetch = async (url) => { + const u = String(url); + if (u.includes("/releases/latest")) { + call += 1; + return call === 1 + ? jsonResponse({ tag_name: "1.0.0", assets: [{ name: "a-1.0.0.tar.gz", browser_download_url: "https://dl/a" }] }) + : jsonResponse(null); + } + if (u.includes("/manifest.yaml")) return textResponse(""); + return jsonResponse({ stargazers_count: 1, owner: { login: "o" } }); + }; + + const { document, errors } = await buildIndex([ + { id: "a", repo: "o/a" }, + { id: "b", repo: "o/b" }, + ]); + assert.equal(document.plugins.length, 1); + assert.equal(document.plugins[0].id, "a"); + assert.equal(errors.length, 1); + assert.equal(document.schema_version, 1); +}); diff --git a/plugin-registry/plugins.yaml b/plugin-registry/plugins.yaml new file mode 100644 index 0000000000..eaed705b7b --- /dev/null +++ b/plugin-registry/plugins.yaml @@ -0,0 +1,32 @@ +# Kandev plugin registry — the curated, human-edited source of truth. +# +# This file is the official Kandev marketplace source: a minimal pointer list of +# plugin repositories. It records *which repos* are in the official catalog, not +# the plugin metadata itself (name, description, version, tarball URL, stars) — +# that is read from each plugin's own GitHub release when the index is built. +# +# Curation model: +# - A plugin appears in the official catalog only after a maintainer approves a +# pull request that adds an entry here. +# - Every PR is validated against plugin-registry/schema.json in CI, and the +# `id` is checked against the target repo's latest-release manifest `id`. +# - See plugin-registry/README.md for how to submit a plugin. +# +# Field reference (full contract in schema.json): +# id required MUST equal the plugin manifest `id`; unique in this file. +# repo required owner/name of the public GitHub repo that publishes releases. +# featured optional maintainer may pin an entry above star order (default false). +# categories optional category tags used for filtering in the catalog. + +# The catalog starts empty — there are no first-party plugin repos at launch. +# Entries are added by PR as plugins are published (see the template below). +# An empty list builds a valid, empty index.json; the marketplace shows an +# "no plugins yet" state until the first entry lands. +plugins: [] +# Example entry (copy, uncomment, and fill in when adding a plugin): +# +# plugins: +# - id: my-plugin # MUST equal the plugin manifest `id` +# repo: owner/kandev-plugin-my-plugin +# featured: false # optional; maintainers may pin above star order +# categories: [integrations] # optional diff --git a/plugin-registry/schema.json b/plugin-registry/schema.json new file mode 100644 index 0000000000..93982f04ca --- /dev/null +++ b/plugin-registry/schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/kdlbs/kandev/blob/main/plugin-registry/schema.json", + "title": "Kandev plugin registry list", + "description": "Schema for plugin-registry/plugins.yaml — the curated, PR-edited pointer list of plugin repositories in the official Kandev marketplace source. Validated in CI on every PR. Descriptive metadata (name, description, author, version, package URL) is NOT stored here; it is read from each plugin's own GitHub release at index-build time so it can never drift from what actually ships.", + "type": "object", + "additionalProperties": false, + "required": ["plugins"], + "properties": { + "plugins": { + "type": "array", + "description": "The curated list of plugin repositories, one entry per plugin.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "repo"], + "properties": { + "id": { + "type": "string", + "description": "Stable plugin identifier. MUST equal the plugin manifest `id` of the repo's latest release. Unique across the file.", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "repo": { + "type": "string", + "description": "GitHub repository in owner/name form; the release source for this plugin.", + "pattern": "^[^/]+/[^/]+$" + }, + "featured": { + "type": "boolean", + "description": "When true, a maintainer has pinned this entry above star order in the catalog.", + "default": false + }, + "categories": { + "type": "array", + "description": "Curation-only category tags used for filtering in the catalog.", + "items": { + "type": "string" + } + } + } + } + } + } +}