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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-19 17:10:53 +01:00
committed by GitHub
parent ac8401b709
commit bc2f310e73
38 changed files with 4130 additions and 95 deletions
@@ -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
@@ -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
@@ -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)
@@ -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"`
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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"
}
@@ -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()})
}
}
@@ -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)
}
}
+33
View File
@@ -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 ("<id>.yml"/"<id>.config.yml"), extracted packages
// ("<id>/<version>/..."), 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
}
+5
View File
@@ -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.
@@ -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<void>;
};
/** 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<string>(ALL_CATEGORIES);
const [sort, setSort] = useState<CatalogQuery["sort"]>("stars");
const [sourcesOpen, setSourcesOpen] = useState(false);
const [installingId, setInstallingId] = useState<string | null>(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 (
<div className="space-y-4">
<MarketplaceToolbar
text={text}
onText={setText}
category={category}
onCategory={setCategory}
categories={categories}
sort={sort}
onSort={setSort}
onManageSources={() => setSourcesOpen(true)}
onRefresh={reload}
/>
<DegradedSourcesBanner catalog={catalog} />
<MarketplaceList
entries={visible}
loading={loading}
error={error}
filtered={Boolean(text.trim() || category !== ALL_CATEGORIES)}
installingId={installingId}
onInstall={install}
/>
<MarketplaceSourcesDialog
open={sourcesOpen}
sources={catalog.sources}
onOpenChange={setSourcesOpen}
onChanged={reload}
/>
</div>
);
}
function useCatalogCategories(catalog: MarketplaceCatalog): string[] {
return useMemo(() => {
const set = new Set<string>();
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 (
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search plugins…"
value={props.text}
onChange={(e) => props.onText(e.target.value)}
className="max-w-xs"
data-testid="marketplace-search"
/>
<Select value={props.category} onValueChange={props.onCategory}>
<SelectTrigger className="w-40" data-testid="marketplace-category">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CATEGORIES}>All categories</SelectItem>
{props.categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={props.sort} onValueChange={(v) => props.onSort(v as CatalogQuery["sort"])}>
<SelectTrigger className="w-36" data-testid="marketplace-sort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stars">Most stars</SelectItem>
<SelectItem value="recent">Recently updated</SelectItem>
<SelectItem value="name">Name</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={props.onRefresh} className="cursor-pointer">
<IconRefresh className="h-4 w-4" />
Refresh
</Button>
<Button
variant="outline"
onClick={props.onManageSources}
className="cursor-pointer"
data-testid="marketplace-manage-sources"
>
Sources
</Button>
</div>
</div>
);
}
function DegradedSourcesBanner({ catalog }: { catalog: MarketplaceCatalog }) {
const degraded = catalog.sources.filter((s) => s.enabled && s.healthy === false);
if (degraded.length === 0) return null;
return (
<div
data-testid="marketplace-degraded-sources"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 space-y-1"
>
{degraded.map((s) => (
<div key={s.id}>
<span className="font-medium">{s.name}</span> is unreachable
{s.error ? `: ${s.error}` : ""} — its plugins are hidden.
</div>
))}
</div>
);
}
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 (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
{error}
</div>
);
}
if (loading && entries.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
Loading marketplace…
</div>
);
}
if (entries.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-sm text-muted-foreground">
{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."}
</div>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{entries.length} {entries.length === 1 ? "plugin" : "plugins"} available
</p>
<div className="grid gap-3 md:grid-cols-2">
{entries.map((entry) => (
<MarketplaceEntryRow
key={`${entry.source_id}:${entry.id}`}
entry={entry}
busy={installingId === entry.id}
onInstall={onInstall}
/>
))}
</div>
</div>
);
}
@@ -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> = {}): 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(<MarketplaceEntryRow entry={entry()} busy={false} onInstall={noop} />);
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(
<MarketplaceEntryRow
entry={entry({ repo_url: "javascript:alert(document.cookie)" })}
busy={false}
onInstall={noop}
/>,
);
expect(screen.queryByText("Repo")).toBeNull();
});
it("renders unknown (null) stars as a dash, not zero", () => {
render(<MarketplaceEntryRow entry={entry({ stars: null })} busy={false} onInstall={noop} />);
expect(screen.getByText("—")).toBeTruthy();
});
});
@@ -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 <a href>. 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 (
<div
data-testid={`marketplace-entry-${entry.id}`}
className="group flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-4 transition-colors hover:border-border"
>
<div className="flex items-start gap-3">
<PluginTile entry={entry} />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-foreground">{entry.name}</span>
<span className="text-xs text-muted-foreground">v{entry.version}</span>
{entry.source_id !== OFFICIAL_SOURCE_ID && (
<Badge variant="outline" className="text-[10px]">
{entry.source_name}
</Badge>
)}
</div>
{entry.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">{entry.description}</p>
)}
</div>
<MarketplaceEntryAction entry={entry} busy={busy} onInstall={onInstall} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<IconStar className="h-3.5 w-3.5" />
{entry.stars === null ? "—" : entry.stars.toLocaleString()}
</span>
{entry.author && <span>by {entry.author}</span>}
{entry.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-[10px] font-normal">
{cat}
</Badge>
))}
{isHttpUrl(entry.repo_url) && (
<a
href={entry.repo_url}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 hover:text-foreground cursor-pointer"
>
<IconExternalLink className="h-3.5 w-3.5" />
Repo
</a>
)}
</div>
</div>
);
}
/**
* 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 (
<div className={tileClass}>
<img
src={entry.icon_url}
alt=""
className="h-full w-full object-contain"
loading="lazy"
onError={() => setFailed(true)}
/>
</div>
);
}
return (
<div className={`${tileClass} text-base font-medium text-muted-foreground`} aria-hidden>
{(entry.name || entry.id).charAt(0).toUpperCase()}
</div>
);
}
function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowProps) {
if (entry.install_state === "installed") {
return (
<Badge
variant="secondary"
data-testid={`marketplace-installed-${entry.id}`}
className="shrink-0 gap-1 text-muted-foreground"
>
<IconCheck className="h-3.5 w-3.5" />
Installed
</Badge>
);
}
if (entry.install_state === "update_available") {
return (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onInstall(entry)}
data-testid={`marketplace-install-${entry.id}`}
className="shrink-0 gap-1 cursor-pointer"
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? "Updating…" : "Update"}
</Button>
);
}
return (
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => onInstall(entry)}
data-testid={`marketplace-install-${entry.id}`}
className="shrink-0 cursor-pointer"
>
{busy ? "Installing…" : "Install"}
</Button>
);
}
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace sources</DialogTitle>
<DialogDescription>
Add a team or corporate registry to browse its plugins alongside the official catalog.
Each source serves an index.json document.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{sources.map((source) => (
<SourceItem key={source.id} source={source} onChanged={onChanged} />
))}
</div>
<AddSourceForm onChanged={onChanged} />
</DialogContent>
</Dialog>
);
}
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 (
<div
data-testid={`marketplace-source-${source.id}`}
className="flex items-center justify-between gap-2 rounded-md border border-border/60 p-3"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{source.name}</span>
{source.builtin && (
<Badge variant="outline" className="text-[10px]">
Official
</Badge>
)}
{source.healthy === false && (
<Badge variant="destructive" className="text-[10px]">
Unreachable
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground truncate block">{source.url}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={source.enabled} disabled={busy} onCheckedChange={toggle} />
{!source.builtin && (
<Button
variant="ghost"
size="icon"
disabled={busy}
onClick={remove}
className="cursor-pointer"
aria-label={`Remove ${source.name}`}
>
<IconTrash className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
}
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 (
<div className="space-y-2 border-t border-border/60 pt-3">
<Input
placeholder="Source name (e.g. Acme Internal)"
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="marketplace-add-source-name"
/>
<div className="flex items-center gap-2">
<Input
placeholder="https://.../index.json"
value={url}
onChange={(e) => setUrl(e.target.value)}
data-testid="marketplace-add-source-url"
/>
<Button
disabled={busy || !url.trim()}
onClick={submit}
className="cursor-pointer shrink-0"
data-testid="marketplace-add-source-submit"
>
Add
</Button>
</div>
</div>
);
}
@@ -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> = {}): 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(
<PluginRow
plugin={plugin()}
busy={false}
update={updateEntry()}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
onUpdate={onUpdate}
/>,
);
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(
<PluginRow
plugin={plugin()}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-update-acme")).toBeNull();
});
});
@@ -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
)}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
{canEnable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => onEnable(plugin)}
>
Enable
</Button>
)}
{canDisable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => onDisable(plugin)}
>
Disable
</Button>
)}
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-destructive hover:text-destructive"
disabled={busy}
onClick={() => onUninstall(plugin)}
>
Uninstall
</Button>
</div>
<PluginRowActions
plugin={plugin}
busy={busy}
update={update}
canEnable={canEnable}
canDisable={canDisable}
onEnable={onEnable}
onDisable={onDisable}
onUninstall={onUninstall}
onUpdate={onUpdate}
/>
</div>
</div>
);
}
type PluginRowActionsProps = Omit<PluginRowProps, "onEnable" | "onDisable" | "onUninstall"> & {
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 (
<div className="flex flex-wrap items-center gap-2 shrink-0">
{update && onUpdate && (
<Button
variant="outline"
size="sm"
data-testid={`plugin-update-${plugin.id}`}
className="cursor-pointer gap-1"
disabled={busy}
onClick={() => onUpdate(update)}
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? "Updating…" : `Update to v${update.version}`}
</Button>
)}
{canEnable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => onEnable(plugin)}
>
Enable
</Button>
)}
{canDisable && (
<Button
variant="outline"
size="sm"
className="cursor-pointer"
disabled={busy}
onClick={() => onDisable(plugin)}
>
Disable
</Button>
)}
<Button
variant="ghost"
size="sm"
className="cursor-pointer text-destructive hover:text-destructive"
disabled={busy}
onClick={() => onUninstall(plugin)}
>
Uninstall
</Button>
</div>
);
}
@@ -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<string | null>(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 (
<SettingsPageTemplate
title="Plugins"
description="Install, enable, disable, and uninstall kandev plugins."
description="Browse the marketplace, then install, enable, disable, and uninstall kandev plugins."
isDirty={false}
saveStatus="idle"
onSave={() => undefined}
showSaveButton={false}
>
<div className="space-y-6">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">Installed plugins</div>
<div className="flex items-center gap-2">
<Button
data-testid="plugins-sync-button"
variant="secondary"
disabled={actions.syncBusy}
onClick={actions.handleSync}
className="cursor-pointer"
>
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
Sync
</Button>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
className="cursor-pointer"
>
Install plugin
</Button>
</div>
</div>
{actions.syncErrors.length > 0 && (
<div
data-testid="plugins-sync-errors"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400 space-y-1"
<Tabs defaultValue="installed" className="space-y-6">
<TabsList>
<TabsTrigger
value="installed"
data-testid="plugins-tab-installed"
className="cursor-pointer"
>
{actions.syncErrors.map((err) => (
<div key={err.path} className="font-mono text-xs">
{err.path}: {err.reason}
</div>
))}
</div>
)}
Installed
</TabsTrigger>
<TabsTrigger value="browse" data-testid="plugins-tab-browse" className="cursor-pointer">
Browse
</TabsTrigger>
</TabsList>
<PluginList
items={items}
loaded={loaded}
loading={loading}
error={error}
actions={actions}
/>
</div>
<TabsContent value="installed" className="space-y-6">
<InstalledTab
list={list}
actions={actions}
updates={updates}
updatingId={updatingId}
onUpdate={handleUpdate}
/>
</TabsContent>
<TabsContent value="browse">
<MarketplaceBrowser onInstallUrl={actions.marketplaceInstall} />
</TabsContent>
</Tabs>
<UninstallPluginDialog
target={actions.uninstallTarget}
@@ -92,15 +94,76 @@ export function PluginsSettings() {
);
}
type PluginListProps = {
items: ReturnType<typeof usePlugins>["items"];
loaded: boolean;
loading: boolean;
error: string | null;
type InstalledTabProps = {
list: ReturnType<typeof usePlugins>;
actions: ReturnType<typeof usePluginActions>;
updates: Map<string, MarketplaceEntry>;
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 (
<>
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">Installed plugins</div>
<div className="flex items-center gap-2">
<Button
data-testid="plugins-sync-button"
variant="secondary"
disabled={actions.syncBusy}
onClick={actions.handleSync}
className="cursor-pointer"
>
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
Sync
</Button>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
className="cursor-pointer"
>
Install plugin
</Button>
</div>
</div>
{actions.syncErrors.length > 0 && (
<div
data-testid="plugins-sync-errors"
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-700 dark:text-amber-400 space-y-1"
>
{actions.syncErrors.map((err) => (
<div key={err.path} className="font-mono text-xs">
{err.path}: {err.reason}
</div>
))}
</div>
)}
<PluginList
list={list}
actions={actions}
updates={updates}
updatingId={updatingId}
onUpdate={onUpdate}
/>
</>
);
}
type PluginListProps = {
list: ReturnType<typeof usePlugins>;
actions: ReturnType<typeof usePluginActions>;
updates: Map<string, MarketplaceEntry>;
updatingId: string | null;
onUpdate: (entry: MarketplaceEntry) => void;
};
function PluginList({ list, actions, updates, updatingId, onUpdate }: PluginListProps) {
const { items, loaded, loading, error } = list;
if (error) {
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
@@ -120,7 +183,7 @@ function PluginList({ items, loaded, loading, error, actions }: PluginListProps)
if (loaded && items.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
No plugins yet. Install a plugin package to get started.
No plugins yet. Install one from the Browse tab, or upload a package.
</div>
);
}
@@ -131,10 +194,12 @@ function PluginList({ items, loaded, loading, error, actions }: PluginListProps)
<PluginRow
key={plugin.id}
plugin={plugin}
busy={actions.busyId === plugin.id}
busy={actions.busyId === plugin.id || updatingId === plugin.id}
update={updates.get(plugin.id)}
onEnable={actions.handleEnable}
onDisable={actions.handleDisable}
onUninstall={actions.openUninstall}
onUpdate={onUpdate}
/>
))}
</div>
@@ -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,
};
}
@@ -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"));
});
});
@@ -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<MarketplaceCatalog>(EMPTY_CATALOG);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}
@@ -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);
});
});
@@ -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<Map<string, MarketplaceEntry>>(new Map());
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
getMarketplaceCatalog()
.then((catalog) => {
if (cancelled) return;
const next = new Map<string, MarketplaceEntry>();
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 };
}
@@ -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<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise<Response>>();
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<string, unknown> = {}) {
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);
});
});
@@ -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<MarketplaceCatalog> {
const res = await fetchJson<MarketplaceCatalog>(`${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<MarketplaceSource[]> {
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<MarketplaceSource> {
return fetchJson<MarketplaceSource>(`${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<MarketplaceSource> {
return fetchJson<MarketplaceSource>(`${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" },
});
}
+58
View File
@@ -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
+1
View File
@@ -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
+341
View File
@@ -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-<name>`), each publishing
the plugin package as a GitHub **Release** asset in the existing
`<id>-<version>.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://<pages-host>/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=<search>&category=<cat>&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": "<package_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.
+5 -3
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
# Generated by build-index.mjs in CI and deployed to Pages — never commit.
index.json
+86
View File
@@ -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-<name>` 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:
- `<id>-<version>.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 `<id>-<version>.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.
+336
View File
@@ -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<Record<string, unknown>>} 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;
});
}
+157
View File
@@ -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);
});
+32
View File
@@ -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
+44
View File
@@ -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"
}
}
}
}
}
}
}