#!/usr/bin/env node // Marketplace version-consistency gate. // // For every plugin in catalog/.claude-plugin/marketplace.json, compare its catalog `ref` // against the plugin repo found as a sibling directory (../): // - catalog ref must point at an existing git tag → else ERROR (install-breaking) // - plugin.json version must equal the README version-badge → else ERROR (internal) // - catalog README per-plugin label must equal catalog ref → else ERROR (doc misstates install) // - catalog ref should equal plugin.json version → else WARN (catalog lags / unreleased bump) // - sibling repo missing → SKIP // // Exit 1 if any ERROR (or any WARN under --strict); 0 otherwise. // Usage: node scripts/check-versions.mjs [--strict] import { readFileSync, existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; export function normalizeVersion(v) { return String(v).trim().replace(/^v/, ''); } export function extractBadgeVersion(readmeText) { const m = /badge\/version-(\d+\.\d+\.\d+)/.exec(readmeText || ''); return m ? m[1] : null; } // Read the catalog README's per-plugin version label, e.g. // ### [Config-Audit](https://.../open/config-audit) `v5.7.0` // Matches the heading line by its `/open/)` link and returns the FIRST `vX.Y.Z` // token on it (so a trailing `🇳🇴 Norwegian`-style badge is ignored). null if no entry. export function extractCatalogLabel(readmeText, name) { for (const line of String(readmeText || '').split('\n')) { if (line.includes(`/open/${name})`)) { const m = /`v(\d+\.\d+\.\d+)`/.exec(line); return m ? m[1] : null; } } return null; } // Pure classifier — all I/O is resolved into the input shape before this is called. // tags === null means "repo not inspected" (missing locally); [] means "no tags". export function classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags, catalogLabel = null }) { if (pluginVersion === null && tags === null) { return { name, status: 'SKIP', findings: [{ level: 'SKIP', msg: 'plugin repo not found locally — cannot verify' }] }; } const findings = []; if (pluginVersion === null) { findings.push({ level: 'ERROR', msg: 'plugin.json version not found or unparseable' }); } // 1. dangling ref — catalog points at a tag that does not exist in the plugin repo if (tags !== null && !tags.includes(catalogRef)) { findings.push({ level: 'ERROR', msg: `catalog ref ${catalogRef} has no matching git tag in the plugin repo (install-breaking)` }); } // 2. internal consistency — plugin.json version vs README version-badge if (pluginVersion !== null && readmeBadge !== null && pluginVersion !== readmeBadge) { findings.push({ level: 'ERROR', msg: `plugin.json version ${pluginVersion} != README version-badge ${readmeBadge}` }); } // 3. catalog README label vs catalog ref — the human-facing doc must match what installs. // Unlike ref-vs-plugin.json, there is no legitimate transient state where these differ. if (catalogLabel !== null && normalizeVersion(catalogRef) !== catalogLabel) { findings.push({ level: 'ERROR', msg: `catalog README label v${catalogLabel} != catalog ref ${catalogRef} (README misstates the installed version)` }); } // 4. catalog ref vs plugin.json version if (pluginVersion !== null && normalizeVersion(catalogRef) !== pluginVersion) { const releasedTag = tags !== null && tags.includes('v' + pluginVersion); const reason = releasedTag ? `released version v${pluginVersion} exists as a tag — bump catalog ref` : `no tag for v${pluginVersion} — unreleased bump, or a release that was never tagged`; findings.push({ level: 'WARN', msg: `catalog ref ${catalogRef} != plugin.json version ${pluginVersion} (${reason})` }); } const status = findings.some(f => f.level === 'ERROR') ? 'ERROR' : findings.some(f => f.level === 'WARN') ? 'WARN' : 'OK'; if (findings.length === 0) findings.push({ level: 'OK', msg: `consistent at ${pluginVersion}` }); return { name, status, findings }; } function gitTags(repoDir) { try { const out = execFileSync('git', ['-C', repoDir, 'tag', '--list', 'v*'], { encoding: 'utf8' }); return out.split('\n').map(s => s.trim()).filter(Boolean); } catch { return null; } } // I/O shell — resolve one plugin's observed state from disk, then classify. export function inspectPlugin(catalogDir, plugin) { const name = plugin.name; const catalogRef = plugin.source?.ref ?? null; const repoDir = join(catalogDir, '..', name); if (!existsSync(repoDir)) { return classifyPlugin({ name, catalogRef, pluginVersion: null, readmeBadge: null, tags: null }); } let pluginVersion = null; try { const pj = JSON.parse(readFileSync(join(repoDir, '.claude-plugin', 'plugin.json'), 'utf8')); pluginVersion = pj.version ?? null; } catch { /* leave null → flagged */ } let readmeBadge = null; try { readmeBadge = extractBadgeVersion(readFileSync(join(repoDir, 'README.md'), 'utf8')); } catch { /* no README → badge check skipped */ } let catalogLabel = null; try { catalogLabel = extractCatalogLabel(readFileSync(join(catalogDir, 'README.md'), 'utf8'), name); } catch { /* no catalog README → label check skipped */ } return classifyPlugin({ name, catalogRef, pluginVersion, readmeBadge, tags: gitTags(repoDir), catalogLabel }); } export function runGate(catalogDir, { strict = false } = {}) { const mkt = JSON.parse(readFileSync(join(catalogDir, '.claude-plugin', 'marketplace.json'), 'utf8')); const results = (mkt.plugins || []).map(p => inspectPlugin(catalogDir, p)); const hasError = results.some(r => r.status === 'ERROR'); const hasWarn = results.some(r => r.status === 'WARN'); return { results, hasError, hasWarn, failed: hasError || (strict && hasWarn) }; } const __filename = fileURLToPath(import.meta.url); if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { const strict = process.argv.includes('--strict'); const catalogDir = join(dirname(__filename), '..'); const { results, failed } = runGate(catalogDir, { strict }); const icon = { OK: '✓', WARN: '⚠', ERROR: '✗', SKIP: '–' }; for (const r of results) { console.log(`${icon[r.status] || '?'} ${r.status.padEnd(5)} ${r.name}`); for (const f of r.findings) if (f.level !== 'OK') console.log(` ${f.msg}`); } const count = (s) => results.filter(r => r.status === s).length; console.log(''); console.log(`${results.length} plugins — ${count('OK')} OK, ${count('WARN')} WARN, ${count('ERROR')} ERROR, ${count('SKIP')} SKIP${strict ? ' (strict)' : ''}`); process.exit(failed ? 1 : 0); }