272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
// Validates the three host catalogs:
|
|
// .github/plugin/marketplace.json (GitHub Copilot)
|
|
// .claude-plugin/marketplace.json (Claude Code)
|
|
// .agents/plugins/marketplace.json (OpenAI Codex)
|
|
//
|
|
// - All three list the same plugins, from the same repo at the same pinned commit.
|
|
// - Every entry resolves: the repo, commit, path, and the host's plugin manifest
|
|
// exist, and the manifest's name and version match the catalog.
|
|
// Local entries (./path in this repo) are checked against the working tree.
|
|
// Set GITHUB_TOKEN to avoid API rate limits. CATALOG_ROOT points the check at
|
|
// another tree (used by scripts/tests/test-catalog-tools.mjs).
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = process.env.CATALOG_ROOT
|
|
? path.resolve(process.env.CATALOG_ROOT)
|
|
: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
const errors = [];
|
|
const fail = (msg) => errors.push(msg);
|
|
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
const SHA = /^[0-9a-f]{40}$/;
|
|
|
|
const AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
|
|
// Manifest lookup order per host. `portable` entries only count when the file
|
|
// declares the Agent Plugins schema.
|
|
const hosts = {
|
|
copilot: {
|
|
file: ".github/plugin/marketplace.json",
|
|
// Copilot accepts a manifest at the plugin root or in a host folder.
|
|
manifests: ["plugin.json", ".github/plugin/plugin.json", ".claude-plugin/plugin.json"],
|
|
catalogVersion: (c) => c.metadata?.version,
|
|
},
|
|
claude: {
|
|
file: ".claude-plugin/marketplace.json",
|
|
manifests: [".claude-plugin/plugin.json"],
|
|
catalogVersion: (c) => c.version,
|
|
},
|
|
codex: {
|
|
file: ".agents/plugins/marketplace.json",
|
|
// openai/codex utils/plugins/src/plugin_namespace.rs: a portable root
|
|
// plugin.json first, then the legacy host folders.
|
|
manifests: [{ file: "plugin.json", portable: true }, ".codex-plugin/plugin.json", ".claude-plugin/plugin.json"],
|
|
catalogVersion: () => undefined,
|
|
},
|
|
};
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), "utf8"));
|
|
} catch (e) {
|
|
fail(`${file}: cannot read JSON (${e.message})`);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
const trimSlashes = (p) => (p ?? "").replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
|
|
function repoFromUrl(url) {
|
|
const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i.exec(url ?? "");
|
|
return m ? m[1] : undefined;
|
|
}
|
|
|
|
// Normalize a host-specific source into { kind: "local", path } or
|
|
// { kind: "remote", repo, path, sha, ref }.
|
|
function normalizeSource(src) {
|
|
if (typeof src === "string") {
|
|
return src.startsWith("./") ? { kind: "local", path: trimSlashes(src) } : { kind: "unknown", raw: src };
|
|
}
|
|
if (!src || typeof src !== "object") return { kind: "unknown", raw: src };
|
|
switch (src.source) {
|
|
case "local":
|
|
return { kind: "local", path: trimSlashes(src.path) };
|
|
case "github":
|
|
return { kind: "remote", repo: src.repo, path: trimSlashes(src.path), sha: src.sha, ref: src.ref };
|
|
case "url":
|
|
case "git-subdir":
|
|
return { kind: "remote", repo: repoFromUrl(src.url), path: trimSlashes(src.path), sha: src.sha ?? src.rev, ref: src.ref };
|
|
default:
|
|
return { kind: "unknown", raw: src };
|
|
}
|
|
}
|
|
|
|
async function gh(url) {
|
|
const headers = { "User-Agent": "win-dev-skills-catalog-check", Accept: "application/vnd.github+json" };
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
for (let attempt = 1; ; attempt++) {
|
|
const res = await fetch(url, { headers });
|
|
if (res.status < 500 || attempt === 3) return res;
|
|
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
}
|
|
}
|
|
|
|
const commitCache = new Map();
|
|
async function commitExists(repo, sha) {
|
|
const key = `${repo}@${sha}`;
|
|
if (!commitCache.has(key)) {
|
|
commitCache.set(key, gh(`https://api.github.com/repos/${repo}/commits/${sha}`).then((r) => r.status));
|
|
}
|
|
return commitCache.get(key);
|
|
}
|
|
|
|
async function readRemoteFile(repo, sha, file) {
|
|
const res = await gh(`https://api.github.com/repos/${repo}/contents/${file}?ref=${sha}`);
|
|
if (res.status === 404) return undefined;
|
|
if (!res.ok) throw new Error(`GET ${repo}/${file}@${sha}: HTTP ${res.status}`);
|
|
const body = await res.json();
|
|
if (Array.isArray(body)) return { dir: true };
|
|
return { text: Buffer.from(body.content, "base64").toString("utf8") };
|
|
}
|
|
|
|
function readLocalFile(file) {
|
|
const full = path.join(repoRoot, file);
|
|
if (!fs.existsSync(full)) return undefined;
|
|
if (fs.statSync(full).isDirectory()) return { dir: true };
|
|
return { text: fs.readFileSync(full, "utf8") };
|
|
}
|
|
|
|
async function checkEntry(host, entry, src, expectedVersion) {
|
|
const where = `${hosts[host].file} → ${entry.name}`;
|
|
const read = src.kind === "local"
|
|
? async (f) => readLocalFile(f)
|
|
: async (f) => readRemoteFile(src.repo, src.sha, f);
|
|
const label = src.kind === "local" ? `./${src.path}` : `${src.repo}/${src.path}@${src.sha.slice(0, 12)}`;
|
|
|
|
const root = src.path ? await read(src.path) : { dir: true };
|
|
if (!root?.dir) return fail(`${where}: path '${src.path}' not found in ${label}`);
|
|
|
|
let manifest;
|
|
let manifestFile;
|
|
for (const candidate of hosts[host].manifests) {
|
|
const rel = typeof candidate === "string" ? candidate : candidate.file;
|
|
manifestFile = src.path ? `${src.path}/${rel}` : rel;
|
|
const file = await read(manifestFile);
|
|
if (file?.text) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(file.text);
|
|
} catch (e) {
|
|
return fail(`${where}: ${manifestFile} is not valid JSON (${e.message})`);
|
|
}
|
|
if (candidate.portable && parsed.$schema !== AGENT_PLUGINS_SCHEMA) continue;
|
|
manifest = parsed;
|
|
break;
|
|
}
|
|
}
|
|
if (!manifest) {
|
|
const names = hosts[host].manifests.map((c) => (typeof c === "string" ? c : c.file));
|
|
return fail(`${where}: no plugin manifest (${names.join(", ")}) under ${label}`);
|
|
}
|
|
let ok = true;
|
|
if (manifest.name !== entry.name) {
|
|
ok = false;
|
|
fail(`${where}: manifest ${manifestFile} has name '${manifest.name}', catalog says '${entry.name}'`);
|
|
}
|
|
if (manifest.version !== expectedVersion) {
|
|
ok = false;
|
|
fail(`${where}: manifest ${manifestFile} has version '${manifest.version}', catalog says '${expectedVersion}'`);
|
|
}
|
|
if (ok) console.log(`ok ${host.padEnd(7)} ${entry.name}@${manifest.version} ← ${label} (${manifestFile})`);
|
|
}
|
|
|
|
const catalogs = Object.fromEntries(Object.entries(hosts).map(([h, cfg]) => [h, readJson(cfg.file)]));
|
|
|
|
// One catalog name everywhere, so `<plugin>@<catalog>` works on every host.
|
|
const catalogNames = new Set(Object.values(catalogs).map((c) => c.name));
|
|
if (catalogNames.size !== 1) {
|
|
fail(`Catalog names differ across hosts: ${Object.entries(catalogs).map(([h, c]) => `${h}=${c.name}`).join(", ")}`);
|
|
}
|
|
|
|
// Catalog-level version: Copilot and Claude carry one; keep them in sync.
|
|
const catalogVersions = ["copilot", "claude"].map((h) => hosts[h].catalogVersion(catalogs[h]));
|
|
if (!catalogVersions.every((v) => typeof v === "string" && SEMVER.test(v))) {
|
|
fail(`Catalog version must be X.Y.Z in ${hosts.copilot.file} (metadata.version) and ${hosts.claude.file} (version); got ${catalogVersions.join(", ")}`);
|
|
} else if (catalogVersions[0] !== catalogVersions[1]) {
|
|
fail(`Catalog versions differ: Copilot ${catalogVersions[0]}, Claude ${catalogVersions[1]}`);
|
|
}
|
|
|
|
// Index entries by name per host.
|
|
const byHost = {};
|
|
for (const host of Object.keys(hosts)) {
|
|
const plugins = catalogs[host].plugins;
|
|
if (!Array.isArray(plugins)) {
|
|
fail(`${hosts[host].file}: plugins must be an array`);
|
|
byHost[host] = new Map();
|
|
continue;
|
|
}
|
|
byHost[host] = new Map();
|
|
for (const entry of plugins) {
|
|
if (byHost[host].has(entry.name)) fail(`${hosts[host].file}: duplicate plugin '${entry.name}'`);
|
|
byHost[host].set(entry.name, entry);
|
|
}
|
|
}
|
|
|
|
const allNames = new Set(Object.values(byHost).flatMap((m) => [...m.keys()]));
|
|
const checks = [];
|
|
for (const name of [...allNames].sort()) {
|
|
const missing = Object.keys(hosts).filter((h) => !byHost[h].has(name));
|
|
if (missing.length) {
|
|
fail(`Plugin '${name}' is missing from: ${missing.map((h) => hosts[h].file).join(", ")}`);
|
|
continue;
|
|
}
|
|
|
|
const errorsBefore = errors.length;
|
|
const version = byHost.copilot.get(name).version;
|
|
if (typeof version !== "string" || !SEMVER.test(version)) {
|
|
fail(`${hosts.copilot.file} → ${name}: version must be X.Y.Z, got '${version}'`);
|
|
}
|
|
if (byHost.claude.get(name).version !== version) {
|
|
fail(`Plugin '${name}': Claude catalog version '${byHost.claude.get(name).version}' != Copilot catalog version '${version}'`);
|
|
}
|
|
|
|
const sources = Object.fromEntries(Object.keys(hosts).map((h) => [h, normalizeSource(byHost[h].get(name).source)]));
|
|
for (const [h, s] of Object.entries(sources)) {
|
|
if (s.kind === "unknown") fail(`${hosts[h].file} → ${name}: unsupported source ${JSON.stringify(s.raw)}`);
|
|
if (s.kind === "remote") {
|
|
if (!s.repo) fail(`${hosts[h].file} → ${name}: remote source must be a github.com repository`);
|
|
if (!SHA.test(s.sha ?? "")) fail(`${hosts[h].file} → ${name}: remote source must pin a full 40-character commit sha`);
|
|
}
|
|
}
|
|
const kinds = new Set(Object.values(sources).map((s) => s.kind));
|
|
if (kinds.size > 1) {
|
|
fail(`Plugin '${name}': hosts disagree on local vs remote source`);
|
|
continue;
|
|
}
|
|
if (kinds.has("remote")) {
|
|
const pins = new Set(Object.values(sources).map((s) => `${(s.repo ?? "").toLowerCase()}@${s.sha}`));
|
|
if (pins.size > 1) {
|
|
fail(`Plugin '${name}': hosts pin different repos/commits: ${[...pins].join(", ")}`);
|
|
continue;
|
|
}
|
|
const refs = new Set(Object.values(sources).map((s) => s.ref ?? ""));
|
|
if (refs.size > 1) {
|
|
fail(`Plugin '${name}': hosts use different refs: ${[...refs].map((r) => r || "(none)").join(", ")}`);
|
|
continue;
|
|
}
|
|
}
|
|
if (errors.length > errorsBefore) continue;
|
|
|
|
if (kinds.has("remote")) {
|
|
const { repo, sha, ref } = sources.copilot;
|
|
checks.push((async () => {
|
|
const status = await commitExists(repo, sha);
|
|
if (status !== 200) return fail(`Plugin '${name}': commit ${repo}@${sha} not found (HTTP ${status})`);
|
|
if (ref) {
|
|
// A ref (normally a release tag) must still point at the pinned commit.
|
|
const res = await gh(`https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`);
|
|
const resolved = res.ok ? (await res.json()).sha : undefined;
|
|
if (resolved !== sha) {
|
|
return fail(`Plugin '${name}': ref '${ref}' in ${repo} resolves to ${resolved ?? `nothing (HTTP ${res.status})`}, but the catalogs pin ${sha}`);
|
|
}
|
|
}
|
|
await Promise.all(Object.entries(sources).map(([h, s]) =>
|
|
checkEntry(h, byHost[h].get(name), s, version).catch((e) => fail(`${hosts[h].file} → ${name}: ${e.message}`))));
|
|
})().catch((e) => fail(`Plugin '${name}': ${e.message}`)));
|
|
} else {
|
|
for (const [h, s] of Object.entries(sources)) {
|
|
checks.push(checkEntry(h, byHost[h].get(name), s, version).catch((e) => fail(`${hosts[h].file} → ${name}: ${e.message}`)));
|
|
}
|
|
}
|
|
}
|
|
await Promise.all(checks);
|
|
|
|
if (errors.length) {
|
|
for (const e of errors) console.log(`::error::${e}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`All ${allNames.size} plugin(s) resolve and match across the three catalogs.`);
|
|
}
|