377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Marketplace Validation Script
|
|
*
|
|
* Validates:
|
|
* - marketplace.json schema and structure
|
|
* - plugin.json for each plugin
|
|
* - SKILL.md frontmatter (name, version, description)
|
|
* - File structure conventions
|
|
*
|
|
* Usage: bun run shared/scripts/validate-marketplace.ts
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import { isAbsolute, join, resolve } from "node:path";
|
|
|
|
/**
|
|
* Result of validating a marketplace component.
|
|
*/
|
|
interface ValidationResult {
|
|
/** Whether validation passed without errors */
|
|
passed: boolean;
|
|
/** Critical validation errors */
|
|
errors: string[];
|
|
/** Non-critical validation warnings */
|
|
warnings: string[];
|
|
}
|
|
|
|
/**
|
|
* Structure of marketplace.json configuration.
|
|
*/
|
|
interface MarketplaceJson {
|
|
/** Marketplace name */
|
|
name: string;
|
|
/** Marketplace owner info */
|
|
owner?: { name: string; email?: string };
|
|
/** Marketplace metadata */
|
|
metadata?: { description?: string; version?: string };
|
|
/** Plugins available in this marketplace */
|
|
plugins: Array<{
|
|
name: string;
|
|
source: string;
|
|
description?: string;
|
|
version?: string;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* Structure of plugin.json configuration.
|
|
*/
|
|
interface PluginJson {
|
|
/** Plugin name */
|
|
name: string;
|
|
/** Semantic version */
|
|
version: string;
|
|
/** Plugin description */
|
|
description: string;
|
|
/** Plugin author info */
|
|
author?: { name: string; email?: string; url?: string };
|
|
/** Keywords for discovery */
|
|
keywords?: string[];
|
|
}
|
|
|
|
const REPO_ROOT = resolve(import.meta.dirname, "../..");
|
|
|
|
function log(
|
|
message: string,
|
|
type: "info" | "error" | "warning" | "success" = "info",
|
|
): void {
|
|
const prefix = {
|
|
info: " ",
|
|
error: "✗ ",
|
|
warning: "△ ",
|
|
success: "✓ ",
|
|
}[type];
|
|
console.log(`${prefix}${message}`);
|
|
}
|
|
|
|
function parseYamlFrontmatter(content: string): Record<string, string> | null {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!match) return null;
|
|
|
|
const frontmatter: Record<string, string> = {};
|
|
const lines = match[1].split("\n");
|
|
|
|
for (const line of lines) {
|
|
const colonIndex = line.indexOf(":");
|
|
if (colonIndex > 0) {
|
|
const key = line.slice(0, colonIndex).trim();
|
|
const value = line.slice(colonIndex + 1).trim();
|
|
frontmatter[key] = value;
|
|
}
|
|
}
|
|
|
|
return frontmatter;
|
|
}
|
|
|
|
function validateMarketplaceJson(): ValidationResult {
|
|
const result: ValidationResult = { passed: true, errors: [], warnings: [] };
|
|
const marketplacePath = join(REPO_ROOT, ".claude-plugin/marketplace.json");
|
|
|
|
if (!existsSync(marketplacePath)) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
"marketplace.json not found at .claude-plugin/marketplace.json",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(marketplacePath, "utf-8");
|
|
const marketplace: MarketplaceJson = JSON.parse(content);
|
|
|
|
// Required fields
|
|
if (!marketplace.name) {
|
|
result.passed = false;
|
|
result.errors.push("marketplace.json: missing 'name' field");
|
|
}
|
|
|
|
if (!marketplace.plugins || !Array.isArray(marketplace.plugins)) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
"marketplace.json: missing or invalid 'plugins' array",
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// Validate each plugin entry
|
|
for (const plugin of marketplace.plugins) {
|
|
if (!plugin.name) {
|
|
result.passed = false;
|
|
result.errors.push("marketplace.json: plugin entry missing 'name'");
|
|
}
|
|
if (!plugin.source) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
`marketplace.json: plugin '${plugin.name}' missing 'source'`,
|
|
);
|
|
}
|
|
if (!plugin.version) {
|
|
result.warnings.push(
|
|
`marketplace.json: plugin '${plugin.name}' missing 'version'`,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
result.passed = false;
|
|
result.errors.push(`marketplace.json: invalid JSON - ${error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validatePluginJson(
|
|
pluginPath: string,
|
|
pluginName: string,
|
|
): ValidationResult {
|
|
const result: ValidationResult = { passed: true, errors: [], warnings: [] };
|
|
const pluginJsonPath = join(pluginPath, ".claude-plugin/plugin.json");
|
|
|
|
if (!existsSync(pluginJsonPath)) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}: missing .claude-plugin/plugin.json`);
|
|
return result;
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(pluginJsonPath, "utf-8");
|
|
const pluginJson: PluginJson = JSON.parse(content);
|
|
|
|
// Required fields
|
|
if (!pluginJson.name) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}/plugin.json: missing 'name'`);
|
|
}
|
|
if (!pluginJson.version) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}/plugin.json: missing 'version'`);
|
|
}
|
|
if (!pluginJson.description) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}/plugin.json: missing 'description'`);
|
|
}
|
|
|
|
// Recommended fields
|
|
if (!pluginJson.author) {
|
|
result.warnings.push(`${pluginName}/plugin.json: missing 'author'`);
|
|
}
|
|
if (!pluginJson.keywords || pluginJson.keywords.length === 0) {
|
|
result.warnings.push(`${pluginName}/plugin.json: missing 'keywords'`);
|
|
}
|
|
} catch (error) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}/plugin.json: invalid JSON - ${error}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateSkillFrontmatter(
|
|
skillPath: string,
|
|
pluginName: string,
|
|
skillName: string,
|
|
): ValidationResult {
|
|
const result: ValidationResult = { passed: true, errors: [], warnings: [] };
|
|
const skillMdPath = join(skillPath, "SKILL.md");
|
|
|
|
if (!existsSync(skillMdPath)) {
|
|
result.passed = false;
|
|
result.errors.push(`${pluginName}/${skillName}: missing SKILL.md`);
|
|
return result;
|
|
}
|
|
|
|
const content = readFileSync(skillMdPath, "utf-8");
|
|
const frontmatter = parseYamlFrontmatter(content);
|
|
|
|
if (!frontmatter) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
`${pluginName}/${skillName}/SKILL.md: missing YAML frontmatter`,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// Required frontmatter fields (per agentskills.io spec: name + description only)
|
|
if (!frontmatter.name) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
`${pluginName}/${skillName}/SKILL.md: frontmatter missing 'name'`,
|
|
);
|
|
}
|
|
if (!frontmatter.description) {
|
|
result.passed = false;
|
|
result.errors.push(
|
|
`${pluginName}/${skillName}/SKILL.md: frontmatter missing 'description'`,
|
|
);
|
|
}
|
|
|
|
// Check description quality (should have trigger keywords)
|
|
if (frontmatter.description && frontmatter.description.length < 50) {
|
|
result.warnings.push(
|
|
`${pluginName}/${skillName}/SKILL.md: description seems short (< 50 chars)`,
|
|
);
|
|
}
|
|
|
|
// Check file size (skills should be < 500 lines)
|
|
const lines = content.split("\n").length;
|
|
if (lines > 500) {
|
|
result.warnings.push(
|
|
`${pluginName}/${skillName}/SKILL.md: ${lines} lines (recommended < 500)`,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validatePlugin(
|
|
pluginPath: string,
|
|
pluginName: string,
|
|
): ValidationResult {
|
|
const result: ValidationResult = { passed: true, errors: [], warnings: [] };
|
|
|
|
// Validate plugin.json
|
|
const pluginJsonResult = validatePluginJson(pluginPath, pluginName);
|
|
result.errors.push(...pluginJsonResult.errors);
|
|
result.warnings.push(...pluginJsonResult.warnings);
|
|
if (!pluginJsonResult.passed) result.passed = false;
|
|
|
|
// Validate skills
|
|
const skillsPath = join(pluginPath, "skills");
|
|
if (existsSync(skillsPath)) {
|
|
const skills = readdirSync(skillsPath, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name);
|
|
|
|
for (const skill of skills) {
|
|
const skillResult = validateSkillFrontmatter(
|
|
join(skillsPath, skill),
|
|
pluginName,
|
|
skill,
|
|
);
|
|
result.errors.push(...skillResult.errors);
|
|
result.warnings.push(...skillResult.warnings);
|
|
if (!skillResult.passed) result.passed = false;
|
|
}
|
|
}
|
|
|
|
// Check for README
|
|
if (!existsSync(join(pluginPath, "README.md"))) {
|
|
result.warnings.push(`${pluginName}: missing README.md`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
console.log("\n🔍 Validating Outfitter Marketplace\n");
|
|
|
|
let totalErrors = 0;
|
|
let totalWarnings = 0;
|
|
|
|
// Validate marketplace.json
|
|
console.log("Checking marketplace.json...");
|
|
const marketplaceResult = validateMarketplaceJson();
|
|
for (const error of marketplaceResult.errors) log(error, "error");
|
|
for (const warning of marketplaceResult.warnings) log(warning, "warning");
|
|
totalErrors += marketplaceResult.errors.length;
|
|
totalWarnings += marketplaceResult.warnings.length;
|
|
|
|
if (!marketplaceResult.passed) {
|
|
console.log("\n❌ Marketplace validation failed. Fix errors above.\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load marketplace to get plugin list
|
|
const marketplacePath = join(REPO_ROOT, ".claude-plugin/marketplace.json");
|
|
const marketplace: MarketplaceJson = JSON.parse(
|
|
readFileSync(marketplacePath, "utf-8"),
|
|
);
|
|
|
|
// Validate each plugin
|
|
for (const plugin of marketplace.plugins) {
|
|
// Prevent path traversal attacks
|
|
const normalizedSource = plugin.source.replace(/^\.\//, "");
|
|
if (
|
|
normalizedSource.includes("..") ||
|
|
isAbsolute(normalizedSource) ||
|
|
normalizedSource.startsWith("/")
|
|
) {
|
|
log(`Invalid plugin source path: ${plugin.source}`, "error");
|
|
totalErrors++;
|
|
continue;
|
|
}
|
|
|
|
const pluginPath = join(REPO_ROOT, normalizedSource);
|
|
|
|
// Additional check: ensure resolved path stays within repo
|
|
if (!pluginPath.startsWith(REPO_ROOT)) {
|
|
log(`Plugin path escapes repository: ${plugin.source}`, "error");
|
|
totalErrors++;
|
|
continue;
|
|
}
|
|
|
|
console.log(`\nChecking ${plugin.name}...`);
|
|
|
|
if (!existsSync(pluginPath)) {
|
|
log(`Plugin directory not found: ${plugin.source}`, "error");
|
|
totalErrors++;
|
|
continue;
|
|
}
|
|
|
|
const pluginResult = validatePlugin(pluginPath, plugin.name);
|
|
for (const error of pluginResult.errors) log(error, "error");
|
|
for (const warning of pluginResult.warnings) log(warning, "warning");
|
|
totalErrors += pluginResult.errors.length;
|
|
totalWarnings += pluginResult.warnings.length;
|
|
|
|
if (pluginResult.passed && pluginResult.errors.length === 0) {
|
|
log(`${plugin.name} passed`, "success");
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log(`\n${"─".repeat(50)}`);
|
|
if (totalErrors === 0) {
|
|
console.log(`\n✅ Validation passed with ${totalWarnings} warning(s)\n`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(
|
|
`\n❌ Validation failed: ${totalErrors} error(s), ${totalWarnings} warning(s)\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|