feat(plugins): add plugin marketplace with curated registry and updates (#1798)

* feat(plugins): add plugin marketplace with curated registry and updates

Discover and install plugins from inside kandev instead of only by URL/upload.

- Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge
  (dedup, install_state, degraded-source handling), and the /api/plugins/
  marketplace[/sources] API. Install reuses the existing install-by-URL pipeline.
- Frontend: Settings > Plugins gains a Browse tab (search/category/star sort),
  source management, one-click install, and an Update button on the Installed
  tab when the catalog has a newer version. Plugins may ship an icon (manifest
  `icon` -> catalog icon_url) with a letter-tile fallback.
- Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two
  GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages,
  daily star refresh). Ships empty at launch; the empty catalog is valid.
- Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address marketplace PR review feedback

- CodeQL: stub fetch routers keyed off a path segment, not a hostname substring.
- Nullable stars end to end: a failed star lookup stays unknown (not 0) and
  sorts last, instead of decoding null -> 0 and corrupting ranking.
- Fetch marketplace sources concurrently and dedupe identical in-flight
  downloads (singleflight), so a slow source can't serialize the Browse tab.
- Register /marketplace routes before the /:id wildcard (gin tree ordering).
- Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error.
- Browse: filter category client-side so the category dropdown never collapses.
- marketplaceInstall swallows after toasting (no unhandled promise rejection).
- Source badge keys off source_id, not a name substring.
- build-index: read block-sequence manifest categories, not just inline.
- Registry index workflow validates on pull_request; Pages deploy gated to
  push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): guard catalog repo_url against javascript: href injection

repo_url comes from an untrusted index.json, so a malicious/compromised source
could set a javascript: URL that runs in the operator's session when the Repo
link is clicked (rel/target don't block it). Only render the anchor for http(s)
schemes. Adds regression tests for the scheme guard and null-star rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): require https for remote sources; test oversized download

- Reject remote plain-http marketplace sources (MITM catalog-poisoning risk);
  loopback http stays allowed for local dev/e2e fixtures.
- Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized
  so the maxIndexBytes memory bound has coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): detach source download from singleflight leader's context

Two concurrent Browse opens after a cache Refresh share one singleflight
download; if the leader's request context is canceled (it navigates away), the
waiters must not inherit that cancellation. Use context.WithoutCancel so values
are preserved but the leader can't cancel everyone; the client's 20s timeout
still bounds the download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-19 17:10:53 +01:00
committed by GitHub
parent ac8401b709
commit bc2f310e73
38 changed files with 4130 additions and 95 deletions
+2
View File
@@ -0,0 +1,2 @@
# Generated by build-index.mjs in CI and deployed to Pages — never commit.
index.json
+86
View File
@@ -0,0 +1,86 @@
# Kandev plugin registry
This directory is the **official Kandev marketplace source** — the curated,
PR-edited list of plugins that show up when a user opens **Settings > Plugins >
Browse** inside Kandev.
It is deliberately minimal. It records *which repositories* are in the official
catalog; it does **not** store plugin metadata (display name, description,
author, version, tarball URL, checksum, stars). All of that is read from each
plugin's own GitHub release when the catalog index is built, so the listing can
never drift from what actually ships.
## Files
| File | Purpose |
| --- | --- |
| `plugins.yaml` | The curated pointer list — one entry per plugin repo. Human-edited via PR. |
| `schema.json` | JSON Schema (draft 2020-12) that `plugins.yaml` MUST validate against. Enforced in CI. |
| `build-index.mjs` | Build script (zero-dependency Node) that resolves each entry to a full catalog record and emits `index.json`. |
| `build-index.test.mjs` | `node --test` coverage for the build script's parser and enrichment. |
The generated `index.json` is published to GitHub Pages by the
[`plugin-registry-index`](../.github/workflows/plugin-registry-index.yml)
workflow and served as the fetch contract Kandev consumes.
## One repository per plugin
Each plugin lives in its **own** public GitHub repository, named
`kdlbs/kandev-plugin-<name>` for first-party plugins (third parties may use any
`owner/name`). Every plugin repo publishes its package as a GitHub **Release**
asset in the standard Kandev package format:
- `<id>-<version>.tar.gz` — the plugin package, and
- `checksums.txt` — the integrity manifest the install pipeline verifies.
The `kdlbs/kandev-plugin-template` starter repo is the recommended way to
bootstrap a new plugin with the right layout and a release workflow.
The catalog ranks plugins by **GitHub stars** (with a "recently updated"
alternative from each repo's last release). Kandev collects **no download or
usage telemetry** — there is no "most installed" metric, by design.
## Submitting a plugin to the official catalog
1. **Publish your plugin.** Push it to a public GitHub repository and cut a
GitHub **Release** whose assets include `<id>-<version>.tar.gz` and
`checksums.txt`. The release must pass the standard package integrity gate.
2. **Fork this repo** (`kdlbs/kandev`).
3. **Add one entry** to `plugin-registry/plugins.yaml` pointing at your public
repo:
```yaml
- id: my-plugin # MUST equal the `id` in your plugin manifest
repo: your-org/your-plugin-repo
categories: [productivity]
```
The `id` **MUST match the `id` in your plugin manifest** and be unique across
the file. `repo` is `owner/name`. `featured` is a maintainer-only pin and
should be left out of submissions.
4. **Open a pull request.** CI validates `plugins.yaml` against `schema.json`
and checks that your `id` matches the target repo's latest-release manifest
`id`.
5. **A maintainer reviews and merges.** Once merged, the index-build workflow
picks up your entry and your plugin appears in the in-app catalog on the next
build.
## Teams and corporates: host your own source instead
You do **not** have to PR into this registry to share plugins internally. Kandev
supports **additional marketplace sources**: any URL that serves an `index.json`
document of the same shape can be added under **Settings > Plugins** as an extra
source, and its plugins are merged into the catalog alongside the official ones.
This is the recommended path for a team or corporate registry — host your own
`index.json` (for example by copying this directory's `plugins.yaml` +
`build-index.mjs` + workflow into your own repo and pointing Kandev at your Pages
URL). Adding a source is an explicit act of trust in that source's maintainer,
much like `brew tap`-ing a third-party tap. When the same `id` appears in more
than one source, the official source wins and later duplicates are hidden.
## Sideloading is always available
Appearing in this registry is only about **discovery**. Anyone can still install
an unlisted plugin by URL or by uploading a `.tar.gz` directly — the marketplace
never removes those escape hatches.
+336
View File
@@ -0,0 +1,336 @@
// Build the Kandev marketplace catalog index (index.json) from plugins.yaml.
//
// This is the "formulae.brew.sh"-style enrichment step of the plugin
// marketplace: it reads the curated pointer list (plugins.yaml), resolves each
// entry against the GitHub API to discover its latest release, package asset,
// star count, last-push time, and manifest presentation metadata, and emits a
// single static index.json document. That document is the fetch contract Kandev
// consumes (docs/specs/plugins/marketplace.md → "Data model" → index.json);
// additional corporate/team sources serve the same shape.
//
// Zero dependencies: Node stdlib + global fetch only, matching the repo's other
// automation scripts (e.g. scripts/validate-public-docs.mjs). plugins.yaml is a
// small, schema-constrained pointer list (plugin-registry/schema.json), so a
// focused parser reads it without pulling a YAML library into CI.
//
// Robustness: one bad entry (missing release, deleted asset, API hiccup) never
// fails the whole build — its error is logged to stderr and it is skipped. A
// repo whose star lookup fails is emitted with `stars: null`, never `0`, so a
// transient outage can't corrupt the catalog's ranking.
//
// Auth: GitHub API calls use GITHUB_TOKEN when present (in CI, secrets.GITHUB_TOKEN).
// That works for the public repos here; at larger scale a PAT with `public_repo`
// scope set as GITHUB_TOKEN gives higher, more predictable rate limits.
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const GITHUB_API = "https://api.github.com";
const API_VERSION = "2022-11-28";
const USER_AGENT = "kandev-plugin-registry-index-builder";
// Bump only with a coordinated backend parser change — this is a hard contract.
const SCHEMA_VERSION = 1;
const SOURCE_NAME = "Kandev Official";
// The canonical Pages URL is filled in by the client from its source config, so
// the document does not need to know where it is hosted.
const SOURCE_URL = "";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGINS_YAML = path.join(HERE, "plugins.yaml");
const OUTPUT_JSON = path.join(HERE, "index.json");
// --- Minimal plugins.yaml parser --------------------------------------------
/**
* Parse the curated plugins list. plugins.yaml is intentionally a flat
* `plugins:` sequence of maps with only scalar / inline-array fields
* (id, repo, featured, categories), enforced by plugin-registry/schema.json —
* so this focused reader is sufficient and avoids a YAML dependency in CI.
*
* @param {string} text Raw plugins.yaml contents.
* @returns {Array<Record<string, unknown>>} The parsed plugin specs.
*/
export function parsePluginsYaml(text) {
const specs = [];
let current = null;
let inPlugins = false;
for (const rawLine of text.split("\n")) {
const line = stripComment(rawLine);
if (line.trim() === "") continue;
if (!inPlugins) {
if (line.trim() === "plugins:") inPlugins = true;
continue;
}
const item = line.match(/^\s*-\s*(.*)$/);
if (item) {
current = {};
specs.push(current);
assignField(current, item[1]);
continue;
}
if (current && /^\s+\S/.test(line)) assignField(current, line.trim());
}
return specs;
}
/** Drop a trailing `# comment` (values here never contain `#`). */
function stripComment(line) {
const hash = line.indexOf("#");
return hash === -1 ? line : line.slice(0, hash);
}
/** Parse a `key: value` fragment onto target, coercing scalars/inline arrays. */
function assignField(target, fragment) {
const colon = fragment.indexOf(":");
if (colon === -1) return;
const key = fragment.slice(0, colon).trim();
if (!key) return;
target[key] = parseScalar(fragment.slice(colon + 1).trim());
}
function parseScalar(value) {
if (value === "") return "";
if (value === "true") return true;
if (value === "false") return false;
if (value.startsWith("[") && value.endsWith("]")) {
return value
.slice(1, -1)
.split(",")
.map((part) => unquote(part.trim()))
.filter((part) => part !== "");
}
return unquote(value);
}
function unquote(value) {
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
return value.slice(1, -1);
}
return value;
}
// --- GitHub helpers ----------------------------------------------------------
async function githubJson(apiPath) {
const url = apiPath.startsWith("http") ? apiPath : `${GITHUB_API}${apiPath}`;
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": API_VERSION,
"User-Agent": USER_AGENT,
};
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const response = await fetchWithTimeout(url, { headers });
if (!response.ok) throw new Error(`GET ${url} -> ${response.status}`);
return response.json();
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
const rawUrl = (repo, ref, filePath) =>
`https://raw.githubusercontent.com/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`;
/**
* Fetch the plugin's manifest.yaml at the release tag for presentation metadata
* (display_name/description/categories/min_kandev_version/icon). Returns {} on
* any failure — the manifest only enriches presentation, so a miss must never
* fail the entry.
*/
async function fetchManifest(repo, ref) {
try {
const response = await fetchWithTimeout(rawUrl(repo, ref, "manifest.yaml"), {
headers: { "User-Agent": USER_AGENT },
});
if (!response.ok) throw new Error(`status ${response.status}`);
return parseManifestFields(await response.text());
} catch (error) {
console.error(`warning: ${repo}: manifest.yaml not read (${error.message})`);
return {};
}
}
/**
* Read just the top-level manifest fields the index needs. The manifest is a
* larger YAML doc, so this only extracts the flat scalar keys we care about
* (display_name, description, author, min_kandev_version, icon) plus the
* `categories` inline/simple list — enough for presentation without a YAML lib.
*/
export function parseManifestFields(text) {
const out = {};
const scalarKeys = ["display_name", "description", "author", "min_kandev_version", "icon"];
let inCategoryBlock = false;
for (const rawLine of text.split("\n")) {
const line = stripComment(rawLine);
// Collect indented block-sequence items while inside `categories:`.
const item = line.match(/^\s+-\s*(.+)$/);
if (inCategoryBlock && item) {
out.categories.push(unquote(item[1].trim()));
continue;
}
const match = line.match(/^([a-z_]+):\s*(.*)$/);
if (!match) continue;
inCategoryBlock = false;
const [, key, value] = match;
const v = value.trim();
if (scalarKeys.includes(key) && v !== "") {
out[key] = unquote(v);
} else if (key === "categories") {
if (v.startsWith("[")) {
out.categories = parseScalar(v); // inline: [a, b]
} else {
out.categories = []; // block sequence: subsequent `- item` lines
inCategoryBlock = true;
}
}
}
return out;
}
// --- Enrichment --------------------------------------------------------------
/** Strip a leading `v` from a release tag so versions compare cleanly. */
function normalizeVersion(tag) {
return tag && /^v\d/.test(tag) ? tag.slice(1) : tag;
}
/** Pick this plugin's package tarball from the release assets. */
function pickPackageAsset(assets, pluginId, version) {
const tarballs = assets.filter((a) => (a.name || "").endsWith(".tar.gz"));
if (tarballs.length === 0) return { error: "release has no .tar.gz asset" };
const exact = tarballs.find((a) => a.name === `${pluginId}-${version}.tar.gz`);
return { asset: exact || tarballs[0] };
}
/** "agent-stats" -> "Agent Stats", the fallback when no manifest name is known. */
function humanize(pluginId) {
return pluginId
.split("-")
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
}
/**
* Resolve a single plugins.yaml spec into a full index.json record.
* @returns {Promise<{record?: object, error?: string}>}
*/
export async function buildEntry(spec) {
const pluginId = spec.id;
const repo = spec.repo;
if (!pluginId || !repo) return { error: `entry missing id/repo: ${JSON.stringify(spec)}` };
let release;
try {
release = await githubJson(`/repos/${repo}/releases/latest`);
} catch (error) {
return { error: `${pluginId}: no latest release (${error.message})` };
}
const tag = release.tag_name || "";
const version = normalizeVersion(tag);
const { asset, error: assetError } = pickPackageAsset(release.assets || [], pluginId, version);
if (assetError) return { error: `${pluginId}: ${assetError}` };
const manifest = tag ? await fetchManifest(repo, tag) : {};
const iconUrl = manifest.icon && tag ? rawUrl(repo, tag, manifest.icon) : null;
const meta = await fetchRepoMeta(repo, pluginId);
const record = {
id: pluginId,
// Presentation prefers the plugin's manifest, then id-derived / release /
// plugins.yaml fallbacks, so the contract shape is stable even on a miss.
name: manifest.display_name || humanize(pluginId),
description: manifest.description || release.name || "",
author: manifest.author || meta.author,
categories: manifest.categories || spec.categories || [],
icon_url: iconUrl,
repo_url: `https://github.com/${repo}`,
version: version || null,
min_kandev_version: manifest.min_kandev_version ?? null,
package_url: asset.browser_download_url,
// Advisory provenance digest; null is a valid, documented value.
package_sha256: null,
stars: meta.stars,
updated_at: meta.updatedAt || release.published_at || null,
};
return { record };
}
/** Repo metadata → stars (null on failure, never 0), last-push time, owner. */
async function fetchRepoMeta(repo, pluginId) {
try {
const repoMeta = await githubJson(`/repos/${repo}`);
return {
stars: Number.isInteger(repoMeta.stargazers_count) ? repoMeta.stargazers_count : null,
updatedAt: repoMeta.pushed_at || null,
author: repoMeta.owner?.login || repo.split("/", 1)[0],
};
} catch (error) {
console.error(`warning: ${pluginId}: star/metadata lookup failed, emitting stars=null (${error.message})`);
return { stars: null, updatedAt: null, author: repo.split("/", 1)[0] };
}
}
// --- Orchestration -----------------------------------------------------------
export async function buildIndex(specs) {
const records = [];
const errors = [];
for (const spec of specs) {
const { record, error } = await buildEntry(spec);
if (error) {
errors.push(error);
console.error(`skip: ${error}`);
continue;
}
records.push(record);
}
const document = {
schema_version: SCHEMA_VERSION,
generated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
source: { name: SOURCE_NAME, url: SOURCE_URL },
plugins: records,
};
return { document, errors };
}
async function main() {
const text = await fs.readFile(PLUGINS_YAML, "utf8");
const specs = parsePluginsYaml(text);
// An empty list is expected at launch (no plugin repos yet) — it produces a
// valid, empty index.json and is NOT an error. Only a non-empty list that
// resolves to zero entries (below) indicates a real failure.
const { document, errors } = await buildIndex(specs);
await fs.writeFile(OUTPUT_JSON, `${JSON.stringify(document, null, 2)}\n`, "utf8");
console.error(
`Built index.json: ${document.plugins.length} built, ${errors.length} skipped, ` +
`${specs.length} listed. Output: ${OUTPUT_JSON}`,
);
// A non-empty list that produced zero entries is almost certainly a bad token
// or total outage — fail so CI never publishes an empty catalog over a good one.
if (specs.length > 0 && document.plugins.length === 0) {
console.error("error: no entries could be built; refusing to publish empty index");
process.exitCode = 1;
}
}
// Run only when invoked directly (not when imported by a test).
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
main().catch((error) => {
console.error(`fatal: ${error.message}`);
process.exitCode = 1;
});
}
+157
View File
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test, { afterEach } from "node:test";
import {
buildEntry,
buildIndex,
parseManifestFields,
parsePluginsYaml,
} from "./build-index.mjs";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
/** Stub global fetch, routing by URL to a release / manifest / repo response. */
function stubGitHub({ release, manifestText, repoMeta }) {
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/releases/latest")) return jsonResponse(release);
if (u.includes("/manifest.yaml")) return textResponse(manifestText ?? "");
if (u.includes("/repos/")) return jsonResponse(repoMeta);
throw new Error(`unexpected fetch: ${u}`);
};
}
const jsonResponse = (body, ok = body !== null) => ({
ok,
status: ok ? 200 : 500,
json: async () => body ?? {},
text: async () => JSON.stringify(body ?? {}),
});
const textResponse = (text) => ({ ok: text !== "", status: text ? 200 : 404, text: async () => text });
test("parsePluginsYaml reads the constrained pointer list", () => {
const specs = parsePluginsYaml(
[
"# a comment",
"plugins:",
" - id: hello",
" repo: kdlbs/kandev-plugin-hello",
" featured: true",
" - id: agent-stats",
" repo: kdlbs/kandev-plugin-agent-stats",
" categories: [analytics, ops]",
].join("\n"),
);
assert.equal(specs.length, 2);
assert.deepEqual(specs[0], { id: "hello", repo: "kdlbs/kandev-plugin-hello", featured: true });
assert.deepEqual(specs[1].categories, ["analytics", "ops"]);
});
test("parseManifestFields extracts presentation keys and ignores the rest", () => {
const fields = parseManifestFields(
[
"id: hello",
"api_version: 1",
'display_name: "Hello"',
"description: A starter plugin",
"icon: icon.svg",
"categories: [getting-started]",
"min_kandev_version: 0.72.0",
"capabilities:",
" state: true",
].join("\n"),
);
assert.equal(fields.display_name, "Hello");
assert.equal(fields.description, "A starter plugin");
assert.equal(fields.icon, "icon.svg");
assert.equal(fields.min_kandev_version, "0.72.0");
assert.deepEqual(fields.categories, ["getting-started"]);
assert.equal("id" in fields, false);
});
test("parseManifestFields reads block-sequence categories", () => {
const fields = parseManifestFields(
["display_name: Multi", "categories:", " - integrations", " - analytics", "author: kandev"].join(
"\n",
),
);
assert.deepEqual(fields.categories, ["integrations", "analytics"]);
assert.equal(fields.author, "kandev");
});
test("buildEntry resolves release, manifest, icon_url and stars", async () => {
stubGitHub({
release: {
tag_name: "v1.2.0",
name: "Release notes",
published_at: "2026-01-01T00:00:00Z",
assets: [{ name: "foo-1.2.0.tar.gz", browser_download_url: "https://dl/foo-1.2.0.tar.gz" }],
},
manifestText: "display_name: Foo\ndescription: A foo\nicon: icon.svg\ncategories: [x]",
repoMeta: { stargazers_count: 42, pushed_at: "2026-02-02T00:00:00Z", owner: { login: "acme" } },
});
const { record, error } = await buildEntry({ id: "foo", repo: "acme/foo" });
assert.equal(error, undefined);
assert.equal(record.name, "Foo");
assert.equal(record.version, "1.2.0");
assert.equal(record.package_url, "https://dl/foo-1.2.0.tar.gz");
assert.equal(record.icon_url, "https://raw.githubusercontent.com/acme/foo/v1.2.0/icon.svg");
assert.equal(record.stars, 42);
assert.equal(record.updated_at, "2026-02-02T00:00:00Z");
assert.deepEqual(record.categories, ["x"]);
});
test("buildEntry errors (not throws) when there is no installable release", async () => {
stubGitHub({ release: null });
const { record, error } = await buildEntry({ id: "foo", repo: "acme/foo" });
assert.equal(record, undefined);
assert.match(error, /no latest release/);
});
test("buildEntry keeps stars null (never 0) when repo metadata lookup fails", async () => {
stubGitHub({
release: { tag_name: "1.0.0", assets: [{ name: "foo-1.0.0.tar.gz", browser_download_url: "https://dl/foo.tar.gz" }] },
manifestText: "",
repoMeta: null, // -> !ok -> throw -> caught
});
const { record } = await buildEntry({ id: "foo", repo: "acme/foo" });
assert.equal(record.stars, null);
assert.equal(record.icon_url, null); // no manifest icon
});
test("empty plugins.yaml parses to no specs and builds a valid empty index", async () => {
assert.deepEqual(parsePluginsYaml("plugins: []"), []);
const { document, errors } = await buildIndex([]);
assert.equal(document.plugins.length, 0);
assert.equal(errors.length, 0);
assert.equal(document.schema_version, 1);
assert.equal(document.source.name, "Kandev Official");
});
test("buildIndex skips bad entries but still builds the good ones", async () => {
// First entry has a release, second does not.
let call = 0;
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes("/releases/latest")) {
call += 1;
return call === 1
? jsonResponse({ tag_name: "1.0.0", assets: [{ name: "a-1.0.0.tar.gz", browser_download_url: "https://dl/a" }] })
: jsonResponse(null);
}
if (u.includes("/manifest.yaml")) return textResponse("");
return jsonResponse({ stargazers_count: 1, owner: { login: "o" } });
};
const { document, errors } = await buildIndex([
{ id: "a", repo: "o/a" },
{ id: "b", repo: "o/b" },
]);
assert.equal(document.plugins.length, 1);
assert.equal(document.plugins[0].id, "a");
assert.equal(errors.length, 1);
assert.equal(document.schema_version, 1);
});
+32
View File
@@ -0,0 +1,32 @@
# Kandev plugin registry — the curated, human-edited source of truth.
#
# This file is the official Kandev marketplace source: a minimal pointer list of
# plugin repositories. It records *which repos* are in the official catalog, not
# the plugin metadata itself (name, description, version, tarball URL, stars) —
# that is read from each plugin's own GitHub release when the index is built.
#
# Curation model:
# - A plugin appears in the official catalog only after a maintainer approves a
# pull request that adds an entry here.
# - Every PR is validated against plugin-registry/schema.json in CI, and the
# `id` is checked against the target repo's latest-release manifest `id`.
# - See plugin-registry/README.md for how to submit a plugin.
#
# Field reference (full contract in schema.json):
# id required MUST equal the plugin manifest `id`; unique in this file.
# repo required owner/name of the public GitHub repo that publishes releases.
# featured optional maintainer may pin an entry above star order (default false).
# categories optional category tags used for filtering in the catalog.
# The catalog starts empty — there are no first-party plugin repos at launch.
# Entries are added by PR as plugins are published (see the template below).
# An empty list builds a valid, empty index.json; the marketplace shows an
# "no plugins yet" state until the first entry lands.
plugins: []
# Example entry (copy, uncomment, and fill in when adding a plugin):
#
# plugins:
# - id: my-plugin # MUST equal the plugin manifest `id`
# repo: owner/kandev-plugin-my-plugin
# featured: false # optional; maintainers may pin above star order
# categories: [integrations] # optional
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/kdlbs/kandev/blob/main/plugin-registry/schema.json",
"title": "Kandev plugin registry list",
"description": "Schema for plugin-registry/plugins.yaml — the curated, PR-edited pointer list of plugin repositories in the official Kandev marketplace source. Validated in CI on every PR. Descriptive metadata (name, description, author, version, package URL) is NOT stored here; it is read from each plugin's own GitHub release at index-build time so it can never drift from what actually ships.",
"type": "object",
"additionalProperties": false,
"required": ["plugins"],
"properties": {
"plugins": {
"type": "array",
"description": "The curated list of plugin repositories, one entry per plugin.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "repo"],
"properties": {
"id": {
"type": "string",
"description": "Stable plugin identifier. MUST equal the plugin manifest `id` of the repo's latest release. Unique across the file.",
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"repo": {
"type": "string",
"description": "GitHub repository in owner/name form; the release source for this plugin.",
"pattern": "^[^/]+/[^/]+$"
},
"featured": {
"type": "boolean",
"description": "When true, a maintainer has pinned this entry above star order in the catalog.",
"default": false
},
"categories": {
"type": "array",
"description": "Curation-only category tags used for filtering in the catalog.",
"items": {
"type": "string"
}
}
}
}
}
}
}