#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; import process from "node:process"; const root = process.cwd(); const errors = []; const warnings = []; const namePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; const banned = [/git\.maks-it\.com/i, /C:\\Users\\maksym/i, /C:\/Users\/maksym/i]; function addError(message) { errors.push(message); } function addWarning(message) { warnings.push(message); } async function pathExists(target) { try { await fs.access(target); return true; } catch { return false; } } async function readJson(filePath, context) { let raw; try { raw = await fs.readFile(filePath, "utf8"); } catch { addError(`${context} is missing: ${filePath}`); return null; } try { return JSON.parse(raw); } catch (error) { addError(`${context} contains invalid JSON (${filePath}): ${error.message}`); return null; } } function parseFrontmatter(content) { const normalized = content.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) return null; const closingIndex = normalized.indexOf("\n---\n", 4); if (closingIndex === -1) return null; const fields = {}; for (const line of normalized.slice(4, closingIndex).split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const separator = line.indexOf(":"); if (separator === -1) continue; fields[line.slice(0, separator).trim()] = line.slice(separator + 1).trim(); } return fields; } async function walk(dir, acc = []) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) await walk(full, acc); else acc.push(full); } return acc; } async function main() { const pluginPath = path.join(root, ".cursor-plugin", "plugin.json"); const plugin = await readJson(pluginPath, "Plugin manifest"); if (plugin) { if (typeof plugin.name !== "string" || !namePattern.test(plugin.name)) { addError(`plugin.json name must be lowercase kebab-case, got ${JSON.stringify(plugin.name)}`); } if (!plugin.displayName) addWarning("plugin.json is missing displayName"); if (!plugin.description) addError("plugin.json is missing description"); if (!plugin.license) addError("plugin.json is missing license"); if (!plugin.author?.name) addError("plugin.json is missing author.name"); if (!plugin.author?.email) addError("plugin.json is missing author.email"); if (!plugin.homepage) addError("plugin.json is missing homepage"); if (!plugin.repository) addError("plugin.json is missing repository"); if (plugin.logo) { const logoPath = path.join(root, plugin.logo); if (!(await pathExists(logoPath))) addError(`logo not found: ${plugin.logo}`); } const vars = plugin.variables?.properties || {}; for (const required of plugin.variables?.required || []) { if (!vars[required]) addError(`variables.required lists ${required} but it is not in properties`); } } const mcp = await readJson(path.join(root, "mcp.json"), "mcp.json"); if (mcp?.mcpServers?.gitea) { const env = mcp.mcpServers.gitea.env || {}; if (env.GITEA_URL !== "${GITEA_URL}") addError("mcp.json must use ${GITEA_URL} placeholder"); if (env.GITEA_TOKEN !== "${GITEA_TOKEN}") addError("mcp.json must use ${GITEA_TOKEN} placeholder"); const args = mcp.mcpServers.gitea.args || []; if (!args.some((arg) => String(arg).includes("server/dist/index.js"))) { addError("mcp.json should launch ${PLUGIN_ROOT}/server/dist/index.js"); } } else { addError("mcp.json must define mcpServers.gitea"); } if (!(await pathExists(path.join(root, "server", "dist", "index.js")))) { addError("server/dist/index.js is missing; run npm run build before publishing"); } if (!(await pathExists(path.join(root, "LICENSE")))) addError("LICENSE is missing"); if (!(await pathExists(path.join(root, "README.md")))) addError("README.md is missing"); if (!(await pathExists(path.join(root, "SECURITY.md")))) addError("SECURITY.md is missing"); const skillDirs = await fs.readdir(path.join(root, "skills")).catch(() => []); for (const name of skillDirs) { const skillFile = path.join(root, "skills", name, "SKILL.md"); if (!(await pathExists(skillFile))) { addError(`skill ${name} is missing SKILL.md`); continue; } const fields = parseFrontmatter(await fs.readFile(skillFile, "utf8")); if (!fields?.name || !fields?.description) { addError(`skills/${name}/SKILL.md needs name and description frontmatter`); } } for (const folder of ["commands", "agents"]) { const dir = path.join(root, folder); if (!(await pathExists(dir))) continue; const files = (await fs.readdir(dir)).filter((name) => name.endsWith(".md")); for (const file of files) { const fields = parseFrontmatter(await fs.readFile(path.join(dir, file), "utf8")); if (!fields?.name || !fields?.description) { addError(`${folder}/${file} needs name and description frontmatter`); } } } const ruleFile = path.join(root, "rules", "gitea-workflow.mdc"); if (await pathExists(ruleFile)) { const fields = parseFrontmatter(await fs.readFile(ruleFile, "utf8")); if (!fields?.description) addError("rules/gitea-workflow.mdc needs description frontmatter"); } const files = await walk(root); for (const file of files) { if (!/\.(md|mdc|json|ts|mjs|example)$/i.test(file)) continue; if (file.endsWith(`validate-plugin.mjs`)) continue; const text = await fs.readFile(file, "utf8"); for (const pattern of banned) { if (pattern.test(text)) { addError(`banned personal host/path in ${path.relative(root, file)}`); } } } for (const warning of warnings) console.warn(`warning: ${warning}`); for (const error of errors) console.error(`error: ${error}`); if (errors.length) { console.error(`\nvalidate-plugin failed with ${errors.length} error(s).`); process.exit(1); } console.log(`validate-plugin passed with ${warnings.length} warning(s).`); } main().catch((error) => { console.error(error); process.exit(1); });