Files
kandev/plugin-registry/build-index.mjs
T
João Salavisa bc2f310e73 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>
2026-07-19 17:10:53 +01:00

337 lines
12 KiB
JavaScript

// 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;
});
}