diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index 03e8e192..f9932072 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -6,6 +6,7 @@ on: - ".cursor-plugin/marketplace.json" - "**/plugin.json" - "schemas/**" + - "scripts/**" jobs: validate: diff --git a/scripts/validate-plugins.mjs b/scripts/validate-plugins.mjs index 6a787085..06c0b180 100644 --- a/scripts/validate-plugins.mjs +++ b/scripts/validate-plugins.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { readFileSync, existsSync } from "fs"; -import { resolve, dirname } from "path"; +import { existsSync, readFileSync } from "fs"; +import { dirname, relative, resolve } from "path"; import { fileURLToPath } from "url"; import Ajv from "ajv"; import addFormats from "ajv-formats"; @@ -31,6 +31,82 @@ function fail(message) { errors++; } +// Fields whose declared value is a path (or glob) relative to the plugin +// directory. `hooks`/`mcpServers` may also be inline objects and `logo` may +// be an absolute URL per the schema — those are skipped by the caller. +const COMPONENT_PATH_FIELDS = ["skills", "agents", "commands", "rules"]; +const COMPONENT_SINGLE_PATH_FIELDS = ["hooks", "mcpServers", "logo"]; + +function isAbsoluteUrl(value) { + return /^(?:https?:|data:)/i.test(value); +} + +// Verify that a path declared in plugin.json resolves to an existing file or +// directory under the plugin directory. Schema validation only checks the +// shape of plugin.json, so a typo'd component path (e.g. "skils/" or a +// renamed hook file) currently passes CI and silently fails to load in +// Cursor. Glob patterns are checked against their static directory prefix so +// a typo in the base directory is still caught. +function checkReferencedPath(pluginName, pluginDir, field, declared) { + if (typeof declared !== "string") return; + + if (declared.trim().length === 0) { + fail(`Plugin "${pluginName}": ${field} path must not be empty`); + return; + } + + // The schema allows `logo` to be an absolute URL; every other component + // field is a local path, so a remote-looking value there is a mistake the + // existence check would otherwise silently skip. + if (isAbsoluteUrl(declared)) { + if (field === "logo") return; + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must be a local path relative to the plugin directory` + ); + return; + } + + const normalized = declared.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!normalized || normalized.startsWith("/")) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must be relative to the plugin directory` + ); + return; + } + if (normalized.split(/[\\/]/).includes("..")) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" must not escape the plugin directory` + ); + return; + } + + const globIndex = normalized.search(/[*?[\]]/); + const staticPart = + globIndex >= 0 + ? normalized.slice(0, globIndex).replace(/\/+$/, "") + : normalized; + const fullPath = resolve(pluginDir, staticPart); + if (!existsSync(fullPath)) { + fail( + `Plugin "${pluginName}": ${field} path "${declared}" does not exist (resolved to ${relative(root, fullPath)})` + ); + } +} + +// Check every component path declared by a plugin.json. Strings that may also +// be inline objects (hooks/mcpServers) are skipped, not treated as paths. +function checkPluginComponentPaths(pluginName, pluginDir, pluginJson) { + for (const field of [...COMPONENT_PATH_FIELDS, ...COMPONENT_SINGLE_PATH_FIELDS]) { + const value = pluginJson[field]; + if (value === undefined) continue; + const patterns = Array.isArray(value) ? value : [value]; + for (const pattern of patterns) { + if (typeof pattern !== "string") continue; + checkReferencedPath(pluginName, pluginDir, field, pattern); + } + } +} + // 1. Validate marketplace.json const marketplacePath = resolve(root, ".cursor-plugin/marketplace.json"); @@ -90,6 +166,11 @@ for (const entry of marketplace.plugins ?? []) { `Plugin "${entry.name}": marketplace name does not match plugin.json name "${pluginJson.name}"` ); } + + // Check that component paths declared in plugin.json exist under the plugin + // directory. A typo'd skill/hook path passes schema validation but silently + // fails to load in Cursor, so catch it at review time. + checkPluginComponentPaths(entry.name, pluginDir, pluginJson); } // 3. Report results