maksit-gitea-cursor/scripts/validate-plugin.mjs
2026-08-26 21:00:11 +02:00

175 lines
6.2 KiB
JavaScript

#!/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);
});