feat: add public external plugin workflows (#1723)

* 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>
This commit is contained in:
Aaron Powell
2026-05-15 15:37:18 +10:00
committed by GitHub
parent ca8412356a
commit e66aa80240
13 changed files with 2767 additions and 102 deletions
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import {
EXTERNAL_PLUGINS_FILE,
readExternalPlugins,
validateExternalPlugins,
} from "./external-plugin-validation.mjs";
import { evaluateExternalPluginIssue } from "./external-plugin-intake.mjs";
export const DECISION_COMMANDS = Object.freeze({
approve: "/approve",
reject: "/reject",
});
function normalizeValue(value) {
return String(value ?? "").trim().toLowerCase();
}
function normalizeRepositoryUrl(value) {
const normalized = normalizeValue(value);
if (!normalized) {
return undefined;
}
return normalized
.replace(/^https:\/\/github\.com\//, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
}
function normalizePathValue(value) {
return String(value ?? "")
.trim()
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
}
export function parseDecisionCommand(body) {
const match = String(body ?? "").match(/(?:^|\n)\s*\/(approve|reject)(?=\s|$)([\s\S]*)$/i);
if (!match) {
return undefined;
}
const command = match[1].toLowerCase();
const reason = match[2]?.trim() || undefined;
return {
command,
reason: command === "reject" ? reason : undefined,
};
}
export function slugifyPluginName(value) {
const slug = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "external-plugin";
}
function readLocalPluginNames() {
const pluginsDir = path.join(ROOT_FOLDER, "plugins");
if (!fs.existsSync(pluginsDir)) {
return [];
}
return fs.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
function pluginsMatch(left, right) {
const leftName = normalizeValue(left?.name);
const rightName = normalizeValue(right?.name);
const leftRepo = normalizeValue(left?.source?.repo);
const rightRepo = normalizeValue(right?.source?.repo);
const leftPath = normalizePathValue(left?.source?.path);
const rightPath = normalizePathValue(right?.source?.path);
const leftRepository = normalizeRepositoryUrl(left?.repository);
const rightRepository = normalizeRepositoryUrl(right?.repository);
if (leftName && rightName && leftName === rightName) {
return true;
}
const repoMatches = leftRepo && rightRepo && leftRepo === rightRepo;
const repositoryMatches = leftRepository && rightRepository && leftRepository === rightRepository;
const pathKnown = Boolean(leftPath || rightPath);
const pathMatches = leftPath === rightPath;
if ((repoMatches || repositoryMatches) && pathKnown && pathMatches) {
return true;
}
return false;
}
export function upsertExternalPlugin(plugin, { filePath = EXTERNAL_PLUGINS_FILE } = {}) {
const { plugins, errors } = readExternalPlugins({
filePath,
localPluginNames: readLocalPluginNames(),
policy: "marketplace",
});
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
const updatedPlugins = [...plugins];
const existingIndex = updatedPlugins.findIndex((existingPlugin) => pluginsMatch(existingPlugin, plugin));
const action = existingIndex === -1 ? "inserted" : "updated";
if (existingIndex === -1) {
updatedPlugins.push(plugin);
} else {
updatedPlugins[existingIndex] = plugin;
}
updatedPlugins.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
const { errors: validationErrors } = validateExternalPlugins(updatedPlugins, {
localPluginNames: readLocalPluginNames(),
policy: "marketplace",
});
if (validationErrors.length > 0) {
throw new Error(validationErrors.join("\n"));
}
const changed = JSON.stringify(updatedPlugins) !== JSON.stringify(plugins);
if (changed) {
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
}
return {
action,
changed,
plugin,
};
}
function readCliArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (!key.startsWith("--")) {
continue;
}
args[key.slice(2)] = argv[index + 1];
index += 1;
}
return args;
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const [command, eventPath] = process.argv.slice(2);
if (command !== "approve" || !eventPath) {
console.error("Usage: node ./eng/external-plugin-approval.mjs approve <github-event.json> [--file <path>]");
process.exit(1);
}
const args = readCliArgs(process.argv.slice(4));
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
const evaluation = await evaluateExternalPluginIssue({
issue: event.issue,
token: process.env.GITHUB_TOKEN,
});
if (!evaluation.valid) {
console.error(evaluation.errors.join("\n"));
process.exit(1);
}
const result = upsertExternalPlugin(evaluation.plugin, { filePath: args.file });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
+369
View File
@@ -0,0 +1,369 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs";
const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const REQUIRED_CHECKLIST_ITEMS = [
"The plugin lives in a public GitHub repository.",
"The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.",
"This submission follows this repository's contribution, security, and responsible AI policies.",
"This plugin is not already listed in the Awesome Copilot marketplace.",
];
const FIELD_TITLES = Object.freeze({
pluginName: "Plugin name",
shortDescription: "Short description",
githubRepository: "GitHub repository",
pluginPath: "Plugin path inside the repository",
immutableRef: "Immutable ref to review",
version: "Version",
license: "License identifier",
authorName: "Author name",
authorUrl: "Author URL",
homepageUrl: "Homepage URL",
keywords: "Keywords",
additionalNotes: "Additional notes for reviewers",
submissionChecklist: "Submission checklist",
});
function normalizeMultilineText(value) {
return String(value ?? "").replace(/\r\n/g, "\n");
}
function stripNoResponse(value) {
if (value === undefined) {
return undefined;
}
const normalized = normalizeMultilineText(value).trim();
if (!normalized || normalized === "_No response_") {
return undefined;
}
return normalized;
}
function parseIssueFormSections(body) {
const normalized = normalizeMultilineText(body);
const sections = new Map();
const matches = [...normalized.matchAll(/^###\s+(.+)$/gm)];
for (let index = 0; index < matches.length; index += 1) {
const heading = matches[index][1].trim();
const start = matches[index].index + matches[index][0].length;
const end = index + 1 < matches.length ? matches[index + 1].index : normalized.length;
const content = normalized.slice(start, end).trim();
sections.set(heading, content);
}
return sections;
}
function normalizeGitHubRepo(value) {
if (!value) {
return undefined;
}
const trimmed = value.trim();
const urlMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i);
if (urlMatch) {
return urlMatch[1];
}
return trimmed.replace(/^github\.com\//i, "").replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
}
function parseKeywords(value) {
const normalized = stripNoResponse(value);
if (!normalized) {
return undefined;
}
const keywords = normalized
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter(Boolean);
return keywords.length > 0 ? keywords : undefined;
}
function parseChecklist(value) {
const checked = new Set();
const normalized = normalizeMultilineText(value);
for (const match of normalized.matchAll(/^- \[(x|X)\] (.+)$/gm)) {
checked.add(match[2].trim());
}
return checked;
}
function readLocalPluginNames() {
if (!fs.existsSync(PLUGINS_DIR)) {
return [];
}
return fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
}
function toSubmissionError(message) {
return message.replace(/^external\.json\[0\]:\s*/, "submission: ");
}
async function fetchGitHubJson(apiPath, token) {
const response = await fetch(`https://api.github.com${apiPath}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "awesome-copilot-external-plugin-intake",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (response.status === 404) {
return { ok: false, status: 404, data: null };
}
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return {
ok: response.ok,
status: response.status,
data,
};
}
function encodeRepoPath(repo) {
const [owner, name] = String(repo).split("/");
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
}
async function validateRemoteRepository(repo, ref, errors, warnings, token) {
const encodedRepo = encodeRepoPath(repo);
const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token);
if (!repositoryResponse.ok) {
if (repositoryResponse.status === 404) {
errors.push(`submission: GitHub repository "${repo}" was not found`);
} else {
errors.push(`submission: could not inspect GitHub repository "${repo}" (HTTP ${repositoryResponse.status})`);
}
return;
}
if (repositoryResponse.data?.private) {
errors.push(`submission: GitHub repository "${repo}" must be public`);
}
if (repositoryResponse.data?.archived) {
warnings.push(`submission: GitHub repository "${repo}" is archived`);
}
if (!ref) {
return;
}
if (/^[0-9a-f]{40}$/i.test(ref)) {
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(ref)}`, token);
if (!commitResponse.ok) {
errors.push(`submission: commit "${ref}" was not found in GitHub repository "${repo}"`);
}
return;
}
const tagName = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref;
const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token);
if (tagResponse.ok) {
return;
}
if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) {
errors.push('submission: commit SHAs in "Immutable ref to review" must use the full 40-character SHA');
return;
}
if (!tagResponse.ok) {
errors.push(`submission: tag "${ref}" was not found in GitHub repository "${repo}"`);
}
}
export function parseExternalPluginIssueBody(body) {
const sections = parseIssueFormSections(body);
const errors = [];
function requiredField(title) {
const value = stripNoResponse(sections.get(title));
if (!value) {
errors.push(`submission: "${title}" is required`);
}
return value;
}
const pluginName = requiredField(FIELD_TITLES.pluginName);
const shortDescription = requiredField(FIELD_TITLES.shortDescription);
const repoInput = normalizeGitHubRepo(requiredField(FIELD_TITLES.githubRepository));
const immutableRef = requiredField(FIELD_TITLES.immutableRef);
const version = requiredField(FIELD_TITLES.version);
const license = requiredField(FIELD_TITLES.license);
const authorName = requiredField(FIELD_TITLES.authorName);
const pluginPath = stripNoResponse(sections.get(FIELD_TITLES.pluginPath));
const authorUrl = stripNoResponse(sections.get(FIELD_TITLES.authorUrl));
const homepageUrl = stripNoResponse(sections.get(FIELD_TITLES.homepageUrl));
const keywords = parseKeywords(sections.get(FIELD_TITLES.keywords));
const additionalNotes = stripNoResponse(sections.get(FIELD_TITLES.additionalNotes));
const checkedItems = parseChecklist(sections.get(FIELD_TITLES.submissionChecklist));
for (const item of REQUIRED_CHECKLIST_ITEMS) {
if (!checkedItems.has(item)) {
errors.push(`submission: checklist item must be checked: "${item}"`);
}
}
const plugin = {
name: pluginName,
description: shortDescription,
version,
author: {
name: authorName,
...(authorUrl ? { url: authorUrl } : {}),
},
repository: repoInput ? `https://github.com/${repoInput}` : undefined,
...(homepageUrl ? { homepage: homepageUrl } : {}),
...(license ? { license } : {}),
...(keywords ? { keywords } : {}),
source: {
source: "github",
repo: repoInput,
...(pluginPath ? { path: pluginPath } : {}),
...(immutableRef ? { ref: immutableRef } : {}),
},
};
return {
markerPresent: normalizeMultilineText(body).includes(ISSUE_FORM_MARKER),
errors,
plugin,
additionalNotes,
};
}
export async function evaluateExternalPluginIssue({ issue, token } = {}) {
const issueBody = issue?.body ?? "";
const parsed = parseExternalPluginIssueBody(issueBody);
const errors = [...parsed.errors];
const warnings = [];
const localPluginNames = readLocalPluginNames();
const { plugins: existingExternalPlugins } = readExternalPlugins({ policy: "marketplace" });
const duplicateNames = [
...localPluginNames,
...existingExternalPlugins.map((plugin) => plugin.name).filter(Boolean),
];
const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" });
errors.push(...validationResult.errors.map(toSubmissionError));
warnings.push(...validationResult.warnings.map(toSubmissionError));
if (parsed.plugin?.name) {
const matchingName = duplicateNames.find(
(name) => String(name).toLowerCase() === String(parsed.plugin.name).toLowerCase(),
);
if (matchingName) {
errors.push(`submission: plugin name "${parsed.plugin.name}" conflicts with existing plugin "${matchingName}"`);
}
}
if (parsed.plugin?.source?.repo && parsed.plugin?.source?.ref) {
await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source.ref, errors, warnings, token);
}
const dedupedErrors = [...new Set(errors)];
const dedupedWarnings = [...new Set(warnings)];
const valid = dedupedErrors.length === 0;
const marker = "<!-- external-plugin-intake -->";
const normalizedKeywords = parsed.plugin?.keywords?.length ? parsed.plugin.keywords.join(", ") : "_None provided_";
const notes = parsed.additionalNotes ?? "_No additional notes provided._";
const payload = parsed.plugin
? [
"```json",
JSON.stringify(parsed.plugin, null, 2),
"```",
].join("\n")
: "```json\n{}\n```";
const commentBody = valid
? [
marker,
"## ✅ External plugin intake passed",
"",
`This submission passed automated intake validation and is ready for maintainer review.`,
"",
`- **Plugin:** ${parsed.plugin.name}`,
`- **Repository:** ${parsed.plugin.repository}`,
`- **Ref:** ${parsed.plugin.source.ref}`,
`- **Keywords:** ${normalizedKeywords}`,
"",
"### Canonical external.json payload",
"",
payload,
"",
"### Reviewer notes",
"",
notes,
dedupedWarnings.length > 0
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
: "",
].filter(Boolean).join("\n")
: [
marker,
"## ❌ External plugin intake failed",
"",
"This submission did not pass automated intake validation, so the issue has been closed.",
"Update the issue form, then reopen the issue to run intake validation again.",
"",
"### Required fixes",
"",
...dedupedErrors.map((error) => `- ${error}`),
dedupedWarnings.length > 0
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
: "",
].filter(Boolean).join("\n");
return {
valid,
markerPresent: parsed.markerPresent,
errors: dedupedErrors,
warnings: dedupedWarnings,
plugin: parsed.plugin,
commentBody,
commentMarker: marker,
};
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const eventPath = process.argv[2];
if (!eventPath) {
console.error("Usage: node ./eng/external-plugin-intake.mjs <github-event.json>");
process.exit(1);
}
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
const result = await evaluateExternalPluginIssue({ issue: event.issue, token: process.env.GITHUB_TOKEN });
process.stdout.write(JSON.stringify(result));
}
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { EXTERNAL_PLUGINS_FILE, readExternalPlugins } from "./external-plugin-validation.mjs";
import { parseExternalPluginIssueBody } from "./external-plugin-intake.mjs";
export const REREVIEW_REPORT_MARKER = "<!-- external-plugin-rereview-report -->";
export const REREVIEW_LABELS = Object.freeze({
due: "re-review-due",
followUp: "re-review-follow-up",
removed: "removed",
});
export const REREVIEW_COMMANDS = Object.freeze({
keep: "/re-review-keep",
needsChanges: "/re-review-needs-changes",
remove: "/re-review-remove",
});
function normalizeValue(value) {
return String(value ?? "").trim().toLowerCase();
}
function normalizeRepositoryUrl(value) {
const normalized = normalizeValue(value);
if (!normalized) {
return undefined;
}
return normalized
.replace(/^https:\/\/github\.com\//, "")
.replace(/\.git$/i, "")
.replace(/^\/+|\/+$/g, "");
}
function normalizePathValue(value) {
return String(value ?? "")
.trim()
.replace(/^\/+|\/+$/g, "")
.toLowerCase();
}
function stripIssueTitlePrefix(title) {
return String(title ?? "")
.trim()
.replace(/^\[\s*external plugin\s*\]\s*:\s*/i, "")
.replace(/^(external plugin(?: submission)?|public external plugin)(?:\s*[:-]\s*|\s+)/i, "")
.trim();
}
function firstMatch(body, patterns) {
for (const pattern of patterns) {
const match = body.match(pattern);
if (match?.[1]) {
return match[1].trim();
}
}
return undefined;
}
function fallbackSubmissionData(issue) {
const body = String(issue?.body ?? "");
const title = stripIssueTitlePrefix(issue?.title);
const sourceRepo = firstMatch(body, [
/https:\/\/github\.com\/([^/\s]+\/[^/\s)]+)/i,
/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/,
]);
return {
pluginName: title || undefined,
sourceRepo: sourceRepo ? normalizeRepositoryUrl(sourceRepo) : undefined,
repository: sourceRepo ? `https://github.com/${normalizeRepositoryUrl(sourceRepo)}` : undefined,
};
}
export function extractSubmissionData(issue) {
const parsed = parseExternalPluginIssueBody(issue?.body ?? "");
const fallback = fallbackSubmissionData(issue);
const plugin = parsed.plugin ?? {};
return {
pluginName: plugin.name ?? fallback.pluginName,
sourceRepo: plugin.source?.repo ?? fallback.sourceRepo,
sourcePath: plugin.source?.path,
repository: plugin.repository ?? fallback.repository,
ref: plugin.source?.ref,
};
}
function pluginMatchesSubmission(plugin, submission) {
const pluginName = normalizeValue(plugin?.name);
const submissionName = normalizeValue(submission.pluginName);
const pluginRepo = normalizeValue(plugin?.source?.repo);
const submissionRepo = normalizeValue(submission.sourceRepo);
const pluginPath = normalizePathValue(plugin?.source?.path);
const submissionPath = normalizePathValue(submission.sourcePath);
const pluginRepository = normalizeRepositoryUrl(plugin?.repository);
const submissionRepository = normalizeRepositoryUrl(submission.repository);
const nameMatch = pluginName && submissionName && pluginName === submissionName;
const repoMatch = pluginRepo && submissionRepo && pluginRepo === submissionRepo;
const repositoryMatch = pluginRepository && submissionRepository && pluginRepository === submissionRepository;
const pathProvided = Boolean(submissionPath);
const pathMatch = pluginPath === submissionPath;
if (nameMatch && pathProvided) {
return pathMatch && (repoMatch || repositoryMatch || !submissionRepo);
}
if (nameMatch && (repoMatch || repositoryMatch || !submissionRepo)) {
return true;
}
if ((repoMatch || repositoryMatch) && pathProvided) {
return pathMatch && (!submissionName || nameMatch);
}
if ((repoMatch || repositoryMatch) && submissionName && nameMatch) {
return true;
}
return false;
}
export function matchExternalPluginForIssue(issue, plugins) {
const submission = extractSubmissionData(issue);
const exactMatch = plugins.find((plugin) => pluginMatchesSubmission(plugin, submission));
if (exactMatch) {
return {
plugin: exactMatch,
submission,
matchReason: "exact",
};
}
const byName = submission.pluginName
? plugins.find((plugin) => normalizeValue(plugin?.name) === normalizeValue(submission.pluginName))
: undefined;
if (byName) {
return {
plugin: byName,
submission,
matchReason: "name",
};
}
const repoMatches = submission.sourceRepo
? plugins.filter((plugin) => normalizeValue(plugin?.source?.repo) === normalizeValue(submission.sourceRepo))
: [];
if (repoMatches.length === 1) {
return {
plugin: repoMatches[0],
submission,
matchReason: "repo",
};
}
return {
plugin: undefined,
submission,
matchReason: "none",
};
}
export function parseRereviewCommand(body) {
const match = String(body ?? "").match(/(?:^|\n)\s*\/re-review-(keep|needs-changes|remove)(?=\s|$)/i);
if (!match) {
return undefined;
}
switch (match[1].toLowerCase()) {
case "keep":
return "keep";
case "needs-changes":
return "needs-changes";
case "remove":
return "remove";
default:
return undefined;
}
}
export function slugifyPluginName(value) {
const slug = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "external-plugin";
}
export function removePluginFromExternalJson({ pluginName, sourceRepo, filePath = EXTERNAL_PLUGINS_FILE } = {}) {
const { plugins, errors } = readExternalPlugins({ filePath, policy: "marketplace" });
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
const normalizedPluginName = normalizeValue(pluginName);
const normalizedSourceRepo = normalizeValue(sourceRepo);
const matchIndex = plugins.findIndex((plugin) => {
const nameMatches = normalizedPluginName && normalizeValue(plugin?.name) === normalizedPluginName;
const repoMatches = normalizedSourceRepo && normalizeValue(plugin?.source?.repo) === normalizedSourceRepo;
if (normalizedPluginName && normalizedSourceRepo) {
return nameMatches && repoMatches;
}
return Boolean(nameMatches || repoMatches);
});
if (matchIndex === -1) {
throw new Error(`Could not find external plugin "${pluginName || sourceRepo}" in ${path.relative(process.cwd(), filePath)}`);
}
const updatedPlugins = [...plugins];
const [removedPlugin] = updatedPlugins.splice(matchIndex, 1);
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
return removedPlugin;
}
function readCliArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const key = argv[index];
if (!key.startsWith("--")) {
continue;
}
const value = argv[index + 1];
args[key.slice(2)] = value;
index += 1;
}
return args;
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const [command] = process.argv.slice(2);
if (command !== "remove") {
console.error("Usage: node ./eng/external-plugin-rereview.mjs remove --plugin-name <name> [--source-repo <owner/repo>] [--file <path>]");
process.exit(1);
}
const args = readCliArgs(process.argv.slice(3));
if (!args["plugin-name"] && !args["source-repo"]) {
console.error("Provide --plugin-name or --source-repo when removing an external plugin.");
process.exit(1);
}
const removedPlugin = removePluginFromExternalJson({
pluginName: args["plugin-name"],
sourceRepo: args["source-repo"],
filePath: args.file,
});
process.stdout.write(`${JSON.stringify(removedPlugin, null, 2)}\n`);
}
+377
View File
@@ -0,0 +1,377 @@
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 };
}
+12 -81
View File
@@ -3,84 +3,11 @@
import fs from "fs";
import path from "path";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json");
/**
* Validate an external plugin entry has required fields and a non-local source
* @param {object} plugin - External plugin entry
* @param {number} index - Index in the array (for error messages)
* @returns {string[]} - Array of validation error messages
*/
function validateExternalPlugin(plugin, index) {
const errors = [];
const prefix = `external.json[${index}]`;
if (!plugin.name || typeof plugin.name !== "string") {
errors.push(`${prefix}: "name" is required and must be a string`);
}
if (!plugin.description || typeof plugin.description !== "string") {
errors.push(`${prefix}: "description" is required and must be a string`);
}
if (!plugin.version || typeof plugin.version !== "string") {
errors.push(`${prefix}: "version" is required and must be a string`);
}
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 (typeof plugin.source === "object") {
if (!plugin.source.source) {
errors.push(`${prefix}: "source.source" is required (e.g. "github", "url", "npm", "pip")`);
}
} else {
errors.push(`${prefix}: "source" must be an object`);
}
return errors;
}
/**
* Read external plugin entries from external.json
* @returns {Array} - Array of external plugin entries (merged as-is)
*/
function readExternalPlugins() {
if (!fs.existsSync(EXTERNAL_PLUGINS_FILE)) {
return [];
}
try {
const content = fs.readFileSync(EXTERNAL_PLUGINS_FILE, "utf8");
const plugins = JSON.parse(content);
if (!Array.isArray(plugins)) {
console.warn("Warning: external.json must contain an array");
return [];
}
// Validate each entry
let hasErrors = false;
for (let i = 0; i < plugins.length; i++) {
const errors = validateExternalPlugin(plugins[i], i);
if (errors.length > 0) {
errors.forEach(e => console.error(`Error: ${e}`));
hasErrors = true;
}
}
if (hasErrors) {
console.error("Error: external.json contains invalid entries");
process.exit(1);
}
return plugins;
} catch (error) {
console.error(`Error reading external.json: ${error.message}`);
return [];
}
}
/**
* Read plugin metadata from plugin.json file
* @param {string} pluginDir - Path to plugin directory
@@ -142,16 +69,20 @@ function generateMarketplace() {
}
// Read external plugins and merge as-is
const externalPlugins = readExternalPlugins();
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
localPluginNames: plugins.map((plugin) => plugin.name),
policy: "marketplace",
});
externalWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
if (externalErrors.length > 0) {
externalErrors.forEach((error) => console.error(`Error: ${error}`));
console.error("Error: external.json contains invalid entries");
process.exit(1);
}
if (externalPlugins.length > 0) {
console.log(`\nFound ${externalPlugins.length} external plugins`);
// Warn on duplicate names
const localNames = new Set(plugins.map(p => p.name));
for (const ext of externalPlugins) {
if (localNames.has(ext.name)) {
console.warn(`Warning: external plugin "${ext.name}" has the same name as a local plugin`);
}
plugins.push(ext);
console.log(`✓ Added external plugin: ${ext.name}`);
}
+18 -1
View File
@@ -3,6 +3,7 @@
import fs from "fs";
import path from "path";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
@@ -222,8 +223,24 @@ function validatePlugins() {
}
}
console.log("\nValidating external plugin catalog...");
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
localPluginNames: pluginDirs,
policy: "marketplace",
});
externalWarnings.forEach((warning) => console.warn(`⚠️ ${warning}`));
if (externalErrors.length > 0) {
console.error("❌ external.json:");
externalErrors.forEach((error) => console.error(` - ${error}`));
hasErrors = true;
} else {
console.log(`✅ external.json is valid (${externalPlugins.length} external plugins)`);
}
if (!hasErrors) {
console.log(`\n✅ All ${pluginDirs.length} plugins are valid`);
console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`);
}
return !hasErrors;