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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Generated by build-index.mjs in CI and deployed to Pages — never commit.
|
||||
index.json
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user