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,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