feat: add canvas marketplace distribution and sharing (#3603)
This commit is contained in:
+26
-11
@@ -1,20 +1,19 @@
|
||||
# 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 >
|
||||
PR-edited list of plugins and canvases 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.
|
||||
catalog and registry-owned preview images. Release metadata (display name,
|
||||
description, author, version, tarball URL, package digest, stars) is read from
|
||||
the package and GitHub release when the catalog index is built.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `plugins.yaml` | The curated pointer list — one entry per plugin repo. Human-edited via PR. |
|
||||
| `plugins.yaml` | The curated pointer list — one entry per plugin or canvas 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. |
|
||||
@@ -23,9 +22,9 @@ 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
|
||||
## One repository per plugin or canvas
|
||||
|
||||
Each plugin lives in its **own** public GitHub repository, named
|
||||
Each plugin or canvas 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:
|
||||
@@ -42,7 +41,7 @@ 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
|
||||
## Submitting a plugin or canvas 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`. A separate
|
||||
@@ -51,7 +50,7 @@ usage telemetry** — there is no "most installed" metric, by design.
|
||||
integrity gate.
|
||||
2. **Fork this repo** (`kdlbs/kandev`).
|
||||
3. **Add one entry** to `plugin-registry/plugins.yaml` pointing at your public
|
||||
repo:
|
||||
repo. Native plugins keep the short form:
|
||||
|
||||
```yaml
|
||||
- id: my-plugin # MUST equal the `id` in your plugin manifest
|
||||
@@ -59,6 +58,20 @@ usage telemetry** — there is no "most installed" metric, by design.
|
||||
categories: [productivity]
|
||||
```
|
||||
|
||||
A portable canvas declares its kind and at least one ordered preview. The
|
||||
first image is the catalog cover. Preview URLs are direct HTTPS image URLs
|
||||
and are not package files:
|
||||
|
||||
```yaml
|
||||
- id: sprint-board
|
||||
repo: your-org/sprint-board
|
||||
kind: canvas
|
||||
categories: [productivity]
|
||||
previews:
|
||||
- url: https://cdn.example.com/sprint-board-cover.webp
|
||||
alt: Sprint board overview
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -71,7 +84,9 @@ usage telemetry** — there is no "most installed" metric, by design.
|
||||
`schema.json` pointer-list contract.
|
||||
5. **A maintainer reviews and merges** — maintainer approval is what gates the
|
||||
official catalog. Once merged, the index-build workflow picks up your entry
|
||||
and your plugin appears in the in-app catalog on the next build.
|
||||
and your plugin or canvas appears in the in-app catalog on the next build.
|
||||
The canvas inspector validates the exact release asset, identity, source mode,
|
||||
permissions, and archive digest without executing package code.
|
||||
|
||||
## Teams and corporates: host your own source instead
|
||||
|
||||
|
||||
+203
-18
@@ -14,16 +14,22 @@
|
||||
// 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.
|
||||
// fails a scheduled build — its error is logged to stderr and it is skipped. A
|
||||
// pull request fails when it introduces an invalid canvas entry, so registry
|
||||
// validation cannot silently omit a reviewed canvas. 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 { createHash } from "node:crypto";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const GITHUB_API = "https://api.github.com";
|
||||
@@ -40,6 +46,9 @@ 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");
|
||||
const execFile = promisify(execFileCallback);
|
||||
const MAX_CANVAS_PACKAGE_BYTES = 10 * 1024 * 1024;
|
||||
const CANVAS_INSPECTOR_TIMEOUT_MS = 30_000;
|
||||
|
||||
// --- Minimal plugins.yaml parser --------------------------------------------
|
||||
|
||||
@@ -56,6 +65,9 @@ export function parsePluginsYaml(text) {
|
||||
const specs = [];
|
||||
let current = null;
|
||||
let inPlugins = false;
|
||||
let inPreviews = false;
|
||||
let currentPreview = null;
|
||||
let previewItemIndent = 0;
|
||||
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = stripComment(rawLine);
|
||||
@@ -66,14 +78,42 @@ export function parsePluginsYaml(text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = line.match(/^\s*-\s*(.*)$/);
|
||||
const item = line.match(/^(\s*)-\s*(.*)$/);
|
||||
if (item) {
|
||||
if (inPreviews && current && item[1].length > 2) {
|
||||
currentPreview = {};
|
||||
current.previews ??= [];
|
||||
current.previews.push(currentPreview);
|
||||
previewItemIndent = item[1].length;
|
||||
assignField(currentPreview, item[2]);
|
||||
continue;
|
||||
}
|
||||
inPreviews = false;
|
||||
currentPreview = null;
|
||||
previewItemIndent = 0;
|
||||
current = {};
|
||||
specs.push(current);
|
||||
assignField(current, item[1]);
|
||||
assignField(current, item[2]);
|
||||
continue;
|
||||
}
|
||||
if (current && /^\s+\S/.test(line)) assignField(current, line.trim());
|
||||
if (current && /^\s+\S/.test(line)) {
|
||||
const value = line.trim();
|
||||
if (value === "previews:") {
|
||||
current.previews = [];
|
||||
inPreviews = true;
|
||||
currentPreview = null;
|
||||
previewItemIndent = 0;
|
||||
continue;
|
||||
}
|
||||
if (inPreviews && currentPreview && line.search(/\S/) > previewItemIndent) {
|
||||
assignField(currentPreview, value);
|
||||
continue;
|
||||
}
|
||||
inPreviews = false;
|
||||
currentPreview = null;
|
||||
previewItemIndent = 0;
|
||||
assignField(current, value);
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
@@ -213,6 +253,37 @@ function pickPackageAsset(assets, pluginId, version) {
|
||||
return { asset: exact || tarballs[0] };
|
||||
}
|
||||
|
||||
function pickCanvasPackageAsset(assets, pluginId, version) {
|
||||
const exactName = `${pluginId}-${version}.tar.gz`;
|
||||
const exact = assets.find((asset) => asset.name === exactName);
|
||||
return exact ? { asset: exact } : { error: `release has no exact ${exactName} asset` };
|
||||
}
|
||||
|
||||
function validatePreviews(previews, required) {
|
||||
if (required && (!Array.isArray(previews) || previews.length === 0)) {
|
||||
return "canvas entries require at least one preview image";
|
||||
}
|
||||
if (previews === undefined) return undefined;
|
||||
if (!Array.isArray(previews) || previews.length > 8) return "preview image count must be between 1 and 8";
|
||||
for (const preview of previews) {
|
||||
if (!preview || typeof preview.url !== "string" || typeof preview.alt !== "string") {
|
||||
return "preview images require url and alt";
|
||||
}
|
||||
const alt = preview.alt.trim();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(preview.url);
|
||||
} catch {
|
||||
return "preview URLs must be absolute HTTPS URLs";
|
||||
}
|
||||
if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password || parsed.hash || preview.url.length > 2048) {
|
||||
return "preview URLs must be HTTPS without credentials or fragments";
|
||||
}
|
||||
if (alt.length < 1 || alt.length > 300) return "preview alt text must be 1 to 300 characters";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** "agent-stats" -> "Agent Stats", the fallback when no manifest name is known. */
|
||||
function humanize(pluginId) {
|
||||
return pluginId
|
||||
@@ -230,6 +301,10 @@ 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)}` };
|
||||
const kind = spec.kind || "plugin";
|
||||
const previewError = validatePreviews(spec.previews, kind === "canvas");
|
||||
if (previewError) return { error: `${pluginId}: ${previewError}` };
|
||||
if (kind !== "plugin" && kind !== "canvas") return { error: `${pluginId}: unsupported kind ${kind}` };
|
||||
|
||||
let release;
|
||||
try {
|
||||
@@ -240,34 +315,138 @@ export async function buildEntry(spec) {
|
||||
|
||||
const tag = release.tag_name || "";
|
||||
const version = normalizeVersion(tag);
|
||||
const { asset, error: assetError } = pickPackageAsset(release.assets || [], pluginId, version);
|
||||
const picker = kind === "canvas" ? pickCanvasPackageAsset : pickPackageAsset;
|
||||
const { asset, error: assetError } = picker(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);
|
||||
|
||||
let packageSHA256 = null;
|
||||
let inspectedDescriptor = null;
|
||||
if (kind === "canvas") {
|
||||
const inspected = await inspectCanvasAsset(asset.browser_download_url, pluginId);
|
||||
if (inspected.error) return { error: `${pluginId}: ${inspected.error}` };
|
||||
if (inspected.descriptor.id !== pluginId || inspected.descriptor.version !== version || inspected.descriptor.kind !== "canvas") {
|
||||
return { error: `${pluginId}: inspected package identity does not match the registry entry` };
|
||||
}
|
||||
inspectedDescriptor = inspected.descriptor;
|
||||
packageSHA256 = inspected.digest;
|
||||
}
|
||||
|
||||
const canonicalRepoURL = `https://github.com/${repo}`;
|
||||
if (kind === "canvas" && inspectedDescriptor.repo_url) {
|
||||
if (canonicalizeRepoURL(inspectedDescriptor.repo_url) !== canonicalizeRepoURL(canonicalRepoURL)) {
|
||||
return { error: `${pluginId}: inspected package repository does not match the registry entry` };
|
||||
}
|
||||
}
|
||||
const presentation = kind === "canvas" ? { ...manifest, ...inspectedDescriptor } : manifest;
|
||||
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,
|
||||
kind,
|
||||
// Canvas presentation comes from the inspected archive. Plugins retain
|
||||
// the existing manifest-first projection and fallback behavior.
|
||||
name: presentation.display_name || humanize(pluginId),
|
||||
description: presentation.description || release.name || "",
|
||||
author: presentation.author || meta.author,
|
||||
categories: manifest.categories || spec.categories || [],
|
||||
icon_url: iconUrl,
|
||||
repo_url: `https://github.com/${repo}`,
|
||||
repo_url: presentation.repo_url || canonicalRepoURL,
|
||||
version: version || null,
|
||||
min_kandev_version: manifest.min_kandev_version ?? null,
|
||||
min_kandev_version: presentation.min_kandev_version ?? null,
|
||||
...(kind === "canvas" ? { license: inspectedDescriptor.license } : {}),
|
||||
package_url: asset.browser_download_url,
|
||||
// Advisory provenance digest; null is a valid, documented value.
|
||||
package_sha256: null,
|
||||
package_sha256: packageSHA256,
|
||||
...(kind === "canvas"
|
||||
? {
|
||||
permissions: {
|
||||
reads: inspectedDescriptor.api_read || [],
|
||||
writes: inspectedDescriptor.api_write || [],
|
||||
events: inspectedDescriptor.events || [],
|
||||
shared_state: Boolean(inspectedDescriptor.state),
|
||||
external_origins: inspectedDescriptor.network_origins || [],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(spec.previews ? { previews: spec.previews } : {}),
|
||||
stars: meta.stars,
|
||||
updated_at: meta.updatedAt || release.published_at || null,
|
||||
};
|
||||
return { record };
|
||||
}
|
||||
|
||||
async function inspectCanvasAsset(assetURL, pluginId) {
|
||||
const inspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
|
||||
if (!inspector) return { error: "canvas package inspector is not configured" };
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithTimeout(assetURL, { headers: { Accept: "application/gzip", "User-Agent": USER_AGENT } });
|
||||
} catch (error) {
|
||||
return { error: `canvas package download failed (${error.message})` };
|
||||
}
|
||||
if (!response.ok) return { error: `canvas package download returned ${response.status}` };
|
||||
let body;
|
||||
try {
|
||||
body = await readBoundedResponse(response, MAX_CANVAS_PACKAGE_BYTES);
|
||||
} catch {
|
||||
return { error: "canvas package exceeds the package size limit" };
|
||||
}
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-canvas-registry-"));
|
||||
const packagePath = path.join(directory, `${pluginId}.tar.gz`);
|
||||
try {
|
||||
await fs.writeFile(packagePath, body, { mode: 0o600 });
|
||||
const result = await execFile(inspector, ["--file", packagePath], {
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: CANVAS_INSPECTOR_TIMEOUT_MS,
|
||||
});
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
return { error: "canvas package inspector returned invalid JSON" };
|
||||
}
|
||||
return { descriptor, digest };
|
||||
} catch {
|
||||
return { error: "canvas package inspection failed" };
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedResponse(response, limit) {
|
||||
if (!response.body?.getReader) throw new Error("response body stream unavailable");
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return Buffer.concat(chunks);
|
||||
const chunk = Buffer.from(value);
|
||||
total += chunk.length;
|
||||
if (total > limit) {
|
||||
await reader.cancel();
|
||||
throw new Error("response exceeds limit");
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalizeRepoURL(value) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.username || parsed.password || parsed.search || parsed.hash) return "";
|
||||
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Repo metadata → stars (null on failure, never 0), last-push time, owner. */
|
||||
async function fetchRepoMeta(repo, pluginId) {
|
||||
try {
|
||||
@@ -288,10 +467,12 @@ async function fetchRepoMeta(repo, pluginId) {
|
||||
export async function buildIndex(specs) {
|
||||
const records = [];
|
||||
const errors = [];
|
||||
const canvasErrors = [];
|
||||
for (const spec of specs) {
|
||||
const { record, error } = await buildEntry(spec);
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
if ((spec.kind || "plugin") === "canvas") canvasErrors.push(error);
|
||||
console.error(`skip: ${error}`);
|
||||
continue;
|
||||
}
|
||||
@@ -303,7 +484,7 @@ export async function buildIndex(specs) {
|
||||
source: { name: SOURCE_NAME, url: SOURCE_URL },
|
||||
plugins: records,
|
||||
};
|
||||
return { document, errors };
|
||||
return { document, errors, canvasErrors };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -312,7 +493,7 @@ async function main() {
|
||||
// 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);
|
||||
const { document, errors, canvasErrors } = await buildIndex(specs);
|
||||
await fs.writeFile(OUTPUT_JSON, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
||||
|
||||
console.error(
|
||||
@@ -325,6 +506,10 @@ async function main() {
|
||||
console.error("error: no entries could be built; refusing to publish empty index");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (process.env.GITHUB_EVENT_NAME === "pull_request" && canvasErrors.length > 0) {
|
||||
console.error("error: pull-request validation found invalid canvas entries");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Run only when invoked directly (not when imported by a test).
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import test, { afterEach } from "node:test";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildEntry,
|
||||
buildIndex,
|
||||
@@ -30,6 +34,16 @@ const jsonResponse = (body, ok = body !== null) => ({
|
||||
text: async () => JSON.stringify(body ?? {}),
|
||||
});
|
||||
const textResponse = (text) => ({ ok: text !== "", status: text ? 200 : 404, text: async () => text });
|
||||
const binaryResponse = (body) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(body);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
test("parsePluginsYaml reads the constrained pointer list", () => {
|
||||
const specs = parsePluginsYaml(
|
||||
@@ -49,6 +63,28 @@ test("parsePluginsYaml reads the constrained pointer list", () => {
|
||||
assert.deepEqual(specs[1].categories, ["analytics", "ops"]);
|
||||
});
|
||||
|
||||
test("parsePluginsYaml reads ordered canvas previews", () => {
|
||||
const specs = parsePluginsYaml(
|
||||
[
|
||||
"plugins:",
|
||||
" - id: board",
|
||||
" repo: acme/board",
|
||||
" kind: canvas",
|
||||
" previews:",
|
||||
" - url: https://cdn.example/cover.webp",
|
||||
" alt: Board cover",
|
||||
" - url: https://cdn.example/detail.webp",
|
||||
" alt: Board detail",
|
||||
" featured: true",
|
||||
].join("\n"),
|
||||
);
|
||||
assert.deepEqual(specs[0].previews, [
|
||||
{ url: "https://cdn.example/cover.webp", alt: "Board cover" },
|
||||
{ url: "https://cdn.example/detail.webp", alt: "Board detail" },
|
||||
]);
|
||||
assert.equal(specs[0].featured, true);
|
||||
});
|
||||
|
||||
test("parseManifestFields extracts presentation keys and ignores the rest", () => {
|
||||
const fields = parseManifestFields(
|
||||
[
|
||||
@@ -124,6 +160,94 @@ test("buildEntry keeps stars null (never 0) when repo metadata lookup fails", as
|
||||
assert.equal(record.icon_url, null); // no manifest icon
|
||||
});
|
||||
|
||||
test("buildEntry uses inspected canvas presentation metadata and preserves preview order", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-registry-test-"));
|
||||
const inspector = path.join(directory, "inspector.mjs");
|
||||
await fs.writeFile(
|
||||
inspector,
|
||||
`#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify({id:"board",version:"1.0.0",kind:"canvas",display_name:"Inspected Board",description:"From archive",author:"archive-author",min_kandev_version:"2.0.0",repo_url:"https://github.com/acme/board",license:"MIT"}));\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const previousInspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
|
||||
process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = inspector;
|
||||
const packageBytes = new Uint8Array([1, 2, 3, 4]);
|
||||
globalThis.fetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/releases/latest")) {
|
||||
return jsonResponse({
|
||||
tag_name: "v1.0.0",
|
||||
assets: [{ name: "board-1.0.0.tar.gz", browser_download_url: "https://dl.example/board.tar.gz" }],
|
||||
});
|
||||
}
|
||||
if (value.includes("/manifest.yaml")) return textResponse("display_name: Board\ndescription: A board\n");
|
||||
if (value.includes("/repos/")) return jsonResponse({ stargazers_count: 2, owner: { login: "acme" } });
|
||||
if (value === "https://dl.example/board.tar.gz") return binaryResponse(packageBytes);
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
};
|
||||
try {
|
||||
const result = await buildEntry({
|
||||
id: "board",
|
||||
repo: "acme/board",
|
||||
kind: "canvas",
|
||||
previews: [{ url: "https://cdn.example/cover.webp", alt: "Board cover" }],
|
||||
});
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.record.kind, "canvas");
|
||||
assert.equal(result.record.name, "Inspected Board");
|
||||
assert.equal(result.record.description, "From archive");
|
||||
assert.equal(result.record.author, "archive-author");
|
||||
assert.equal(result.record.min_kandev_version, "2.0.0");
|
||||
assert.deepEqual(result.record.previews, [{ url: "https://cdn.example/cover.webp", alt: "Board cover" }]);
|
||||
assert.equal(result.record.package_sha256, createHash("sha256").update(packageBytes).digest("hex"));
|
||||
} finally {
|
||||
if (previousInspector === undefined) delete process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
|
||||
else process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = previousInspector;
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildEntry bounds streamed canvas package responses", async () => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-registry-stream-test-"));
|
||||
const inspector = path.join(directory, "inspector.mjs");
|
||||
await fs.writeFile(inspector, `process.stdout.write(JSON.stringify({id:"board",version:"1.0.0",kind:"canvas"}));`, { mode: 0o755 });
|
||||
const previousInspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
|
||||
process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = inspector;
|
||||
globalThis.fetch = async (url) => {
|
||||
const value = String(url);
|
||||
if (value.includes("/releases/latest")) return jsonResponse({ tag_name: "v1.0.0", assets: [{ name: "board-1.0.0.tar.gz", browser_download_url: "https://dl.example/board.tar.gz" }] });
|
||||
if (value.includes("/manifest.yaml")) return textResponse("");
|
||||
if (value.includes("/repos/")) return jsonResponse({ stargazers_count: 1, owner: { login: "acme" } });
|
||||
if (value === "https://dl.example/board.tar.gz") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(5 * 1024 * 1024));
|
||||
controller.enqueue(new Uint8Array(5 * 1024 * 1024 + 1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${value}`);
|
||||
};
|
||||
try {
|
||||
const result = await buildEntry({ id: "board", repo: "acme/board", kind: "canvas", previews: [{ url: "https://cdn.example/cover.webp", alt: "Board" }] });
|
||||
assert.match(result.error, /exceeds the package size limit/);
|
||||
} finally {
|
||||
if (previousInspector === undefined) delete process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
|
||||
else process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = previousInspector;
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildEntry rejects a canvas without a preview before fetching a release", async () => {
|
||||
const result = await buildEntry({ id: "board", repo: "acme/board", kind: "canvas" });
|
||||
assert.match(result.error, /at least one preview/);
|
||||
});
|
||||
|
||||
test("empty plugins.yaml parses to no specs and builds a valid empty index", async () => {
|
||||
assert.deepEqual(parsePluginsYaml("plugins: []"), []);
|
||||
const { document, errors } = await buildIndex([]);
|
||||
@@ -155,5 +279,6 @@ test("buildIndex skips bad entries but still builds the good ones", async () =>
|
||||
assert.equal(document.plugins.length, 1);
|
||||
assert.equal(document.plugins[0].id, "a");
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal((await buildIndex([{ id: "bad-canvas", repo: "o/bad-canvas", kind: "canvas" }])).canvasErrors.length, 1);
|
||||
assert.equal(document.schema_version, 1);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$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.",
|
||||
"title": "Kandev marketplace registry list",
|
||||
"description": "Schema for plugin-registry/plugins.yaml, the curated pointer list of plugin and canvas repositories. Release metadata is read from the package at index-build time; preview images are registry-owned presentation metadata.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["plugins"],
|
||||
@@ -36,7 +36,56 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["plugin", "canvas"],
|
||||
"default": "plugin",
|
||||
"description": "Omitted kind means plugin. Canvas entries are validated as portable canvas packages."
|
||||
},
|
||||
"previews": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {
|
||||
"$ref": "#/$defs/preview"
|
||||
},
|
||||
"description": "Ordered listing images. Optional for plugins and required for canvas entries."
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["kind"],
|
||||
"properties": {"kind": {"const": "canvas"}}
|
||||
},
|
||||
"then": {
|
||||
"required": ["previews"],
|
||||
"properties": {"previews": {"minItems": 1}}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"preview": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["url", "alt"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://",
|
||||
"maxLength": 2048,
|
||||
"description": "Direct HTTPS listing image URL without credentials or a fragment."
|
||||
},
|
||||
"alt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300,
|
||||
"pattern": "\\S",
|
||||
"description": "Accessible description of the listing image."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user