mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-27 17:11:44 +00:00
e66aa80240
* feat: add external plugin submission workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * minor adjustment to contributing guide * fix: address external plugin review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reverting some changes to the readme.agents.md file * fix: address follow-up review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: tighten external plugin workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
378 lines
11 KiB
JavaScript
378 lines
11 KiB
JavaScript
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,
|
|
}),
|
|
});
|
|
|
|
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 validateRelativePath(pathValue, prefix, errors) {
|
|
if (!isNonEmptyString(pathValue)) {
|
|
errors.push(`${prefix}: "source.path" must be a non-empty string when provided`);
|
|
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`);
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|