import fs from "fs"; import path from "path"; import { ROOT_FOLDER } from "./constants.mjs"; export const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json"); export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({ marketplace: Object.freeze({ allowedSourceTypes: ["github"], requireAuthor: true, requireRepository: true, requireKeywords: true, requireLicense: false, requireImmutableRef: false, }), publicSubmission: Object.freeze({ allowedSourceTypes: ["github"], requireAuthor: true, requireRepository: true, requireKeywords: true, requireLicense: true, requireImmutableRef: true, }), }); const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ "plugin.json", ".github/plugin/plugin.json", ".plugin/plugin.json", ]); function resolvePolicy(policy) { if (!policy) { return EXTERNAL_PLUGIN_POLICIES.marketplace; } if (typeof policy === "string") { const resolved = EXTERNAL_PLUGIN_POLICIES[policy]; if (!resolved) { throw new Error(`Unknown external plugin validation policy "${policy}"`); } return resolved; } return { ...EXTERNAL_PLUGIN_POLICIES.marketplace, ...policy, }; } function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } function validatePluginName(name, prefix, errors) { if (!isNonEmptyString(name)) { errors.push(`${prefix}: "name" is required and must be a non-empty string`); return; } if (name.length > 50) { errors.push(`${prefix}: "name" must be 50 characters or fewer`); } if (!/^[a-z0-9-]+$/.test(name)) { errors.push(`${prefix}: "name" must contain only lowercase letters, numbers, and hyphens`); } } function validateDescription(description, prefix, errors) { if (!isNonEmptyString(description)) { errors.push(`${prefix}: "description" is required and must be a non-empty string`); return; } if (description.length > 500) { errors.push(`${prefix}: "description" must be 500 characters or fewer`); } } function validateVersion(version, prefix, errors) { if (!isNonEmptyString(version)) { errors.push(`${prefix}: "version" is required and must be a non-empty string`); return; } if (version.length > 100) { errors.push(`${prefix}: "version" must be 100 characters or fewer`); } } function validateKeywords(keywords, prefix, errors, warnings, required) { if (keywords === undefined) { if (required) { errors.push(`${prefix}: "keywords" is required and must be an array of lowercase tags`); } return; } if (!Array.isArray(keywords)) { errors.push(`${prefix}: "keywords" must be an array`); return; } if (keywords.length > 10) { errors.push(`${prefix}: "keywords" must contain no more than 10 entries`); } for (let i = 0; i < keywords.length; i++) { const keyword = keywords[i]; if (!isNonEmptyString(keyword)) { errors.push(`${prefix}: "keywords[${i}]" must be a non-empty string`); continue; } if (!/^[a-z0-9-]+$/.test(keyword)) { errors.push(`${prefix}: "keywords[${i}]" must contain only lowercase letters, numbers, and hyphens`); } if (keyword.length > 30) { errors.push(`${prefix}: "keywords[${i}]" must be 30 characters or fewer`); } } if (keywords.length === 0) { if (required) { errors.push(`${prefix}: "keywords" must contain at least one entry`); } else { warnings.push(`${prefix}: "keywords" is empty; at least one keyword is recommended for discovery`); } } } function validateHttpsUrl(value, fieldName, prefix, errors, options = {}) { if (!isNonEmptyString(value)) { errors.push(`${prefix}: "${fieldName}" must be a non-empty string`); return; } let parsed; try { parsed = new URL(value); } catch { errors.push(`${prefix}: "${fieldName}" must be a valid URL`); return; } if (parsed.protocol !== "https:") { errors.push(`${prefix}: "${fieldName}" must use https`); } if (options.githubOnly && parsed.hostname !== "github.com") { errors.push(`${prefix}: "${fieldName}" must point to https://github.com/...`); } } function validateAuthor(author, prefix, errors, required) { if (author === undefined) { if (required) { errors.push(`${prefix}: "author" is required`); } return; } if (!author || typeof author !== "object" || Array.isArray(author)) { errors.push(`${prefix}: "author" must be an object`); return; } if (!isNonEmptyString(author.name)) { errors.push(`${prefix}: "author.name" is required and must be a non-empty string`); } if (author.url !== undefined) { validateHttpsUrl(author.url, "author.url", prefix, errors); } } function validateLicense(license, prefix, errors, required) { if (license === undefined) { if (required) { errors.push(`${prefix}: "license" is required`); } return; } if (!isNonEmptyString(license)) { errors.push(`${prefix}: "license" must be a non-empty string`); } } function validateRepository(repository, prefix, errors, required) { if (repository === undefined) { if (required) { errors.push(`${prefix}: "repository" is required`); } return; } validateHttpsUrl(repository, "repository", prefix, errors, { githubOnly: true }); } function validateHomepage(homepage, prefix, errors) { if (homepage === undefined) { return; } validateHttpsUrl(homepage, "homepage", prefix, errors); } function formatExpectedPluginRootMessage() { return EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS.map((manifestPath) => `"${manifestPath}"`).join(", "); } function validateRelativePath(pathValue, prefix, errors) { if (!isNonEmptyString(pathValue)) { errors.push(`${prefix}: "source.path" must be a non-empty string when provided`); return; } if (pathValue === "/") { return; } const normalized = path.posix.normalize(pathValue); const segments = pathValue.split("/"); if (pathValue.startsWith("/") || pathValue.startsWith("../") || normalized !== pathValue || segments.includes("..")) { errors.push(`${prefix}: "source.path" must be a safe relative path inside the repository`); } if (pathValue.includes("\\")) { errors.push(`${prefix}: "source.path" must use forward slashes`); } if (normalized === ".") { errors.push(`${prefix}: "source.path" must be "/" for the repository root or a plugin root directory relative to the repository root`); } if (path.posix.basename(normalized) === "plugin.json") { errors.push( `${prefix}: "source.path" must point to the plugin root directory, not the manifest file; relative to "source.path", expected one of ${formatExpectedPluginRootMessage()}` ); } } function validateImmutableRef(ref, prefix, errors) { if (!isNonEmptyString(ref)) { errors.push(`${prefix}: "source.ref" must be a non-empty string when provided`); return; } if (ref.startsWith("refs/heads/")) { errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch ref`); return; } if (["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) { errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch name`); } if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) { errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`); } } function validateGitHubSource(source, prefix, errors, requireImmutableRef) { if (!source || typeof source !== "object" || Array.isArray(source)) { errors.push(`${prefix}: "source" must be an object`); return; } if (source.source !== "github") { errors.push(`${prefix}: "source.source" must be "github"`); } if (!isNonEmptyString(source.repo)) { errors.push(`${prefix}: "source.repo" is required and must be a non-empty string`); } else if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) { errors.push(`${prefix}: "source.repo" must be in "owner/repo" format`); } if (source.path !== undefined) { validateRelativePath(source.path, prefix, errors); } if (source.ref !== undefined) { validateImmutableRef(source.ref, prefix, errors); } else if (requireImmutableRef) { errors.push(`${prefix}: "source.ref" is required for public external plugin submissions`); } } export function validateExternalPlugin(plugin, index, options = {}) { const policy = resolvePolicy(options.policy ?? options); const errors = []; const warnings = []; const prefix = `external.json[${index}]`; if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) { return { errors: [`${prefix}: entry must be an object`], warnings, }; } validatePluginName(plugin.name, prefix, errors); validateDescription(plugin.description, prefix, errors); validateVersion(plugin.version, prefix, errors); validateAuthor(plugin.author, prefix, errors, policy.requireAuthor); validateRepository(plugin.repository, prefix, errors, policy.requireRepository); validateHomepage(plugin.homepage, prefix, errors); validateLicense(plugin.license, prefix, errors, policy.requireLicense); validateKeywords(plugin.keywords ?? plugin.tags, prefix, errors, warnings, policy.requireKeywords); if (plugin.tags !== undefined && plugin.keywords === undefined) { warnings.push(`${prefix}: prefer "keywords" over legacy "tags"`); } if (!plugin.source) { errors.push(`${prefix}: "source" is required`); } else if (typeof plugin.source === "string") { errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`); } else if (!policy.allowedSourceTypes.includes(plugin.source.source)) { errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`); } else if (plugin.source.source === "github") { validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableRef); } return { errors, warnings }; } export function validateExternalPlugins(plugins, options = {}) { const policy = resolvePolicy(options.policy ?? options); const errors = []; const warnings = []; const localNames = new Map( (options.localPluginNames ?? []).map((name) => [String(name).toLowerCase(), String(name)]) ); const seenExternalNames = new Map(); if (!Array.isArray(plugins)) { return { errors: ["external.json must contain an array"], warnings, }; } plugins.forEach((plugin, index) => { const result = validateExternalPlugin(plugin, index, { policy }); errors.push(...result.errors); warnings.push(...result.warnings); if (!isNonEmptyString(plugin?.name)) { return; } const normalizedName = plugin.name.toLowerCase(); const duplicateIndex = seenExternalNames.get(normalizedName); if (duplicateIndex !== undefined) { errors.push(`external.json[${index}]: duplicate plugin name "${plugin.name}" already used by external.json[${duplicateIndex}]`); } else { seenExternalNames.set(normalizedName, index); } const localDuplicate = localNames.get(normalizedName); if (localDuplicate) { errors.push(`external.json[${index}]: plugin name "${plugin.name}" conflicts with local plugin "${localDuplicate}"`); } }); return { errors, warnings }; } export function readExternalPlugins(options = {}) { const filePath = options.filePath ?? EXTERNAL_PLUGINS_FILE; if (!fs.existsSync(filePath)) { return { plugins: [], errors: [], warnings: [], }; } let plugins; try { const content = fs.readFileSync(filePath, "utf8"); plugins = JSON.parse(content); } catch (error) { return { plugins: [], errors: [`Error reading ${path.basename(filePath)}: ${error.message}`], warnings: [], }; } const { errors, warnings } = validateExternalPlugins(plugins, options); return { plugins, errors, warnings }; }