chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-03 02:25:08 +00:00
parent c919c0c570
commit 700e6f19dd
27 changed files with 748 additions and 687 deletions
+44 -29
View File
@@ -6,6 +6,7 @@ import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json");
/**
@@ -30,43 +31,58 @@ function readPluginMetadata(pluginDir) {
}
}
function collectLocalPluginsFromRoot(rootDir, sourcePrefix, includeEntry = () => true) {
if (!fs.existsSync(rootDir)) {
return [];
}
const entries = fs.readdirSync(rootDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.filter(entry => includeEntry(entry.name))
.map(entry => entry.name)
.sort();
const plugins = [];
for (const dirName of entries) {
const pluginPath = path.join(rootDir, dirName);
const metadata = readPluginMetadata(pluginPath);
if (!metadata) {
continue;
}
plugins.push({
name: metadata.name,
source: `${sourcePrefix}/${dirName}`,
description: metadata.description,
version: metadata.version || "1.0.0"
});
}
return plugins;
}
/**
* Generate marketplace.json from plugin directories
*/
function generateMarketplace() {
console.log("Generating marketplace.json...");
if (!fs.existsSync(PLUGINS_DIR)) {
console.error(`Error: Plugins directory not found at ${PLUGINS_DIR}`);
if (!fs.existsSync(PLUGINS_DIR) && !fs.existsSync(EXTENSIONS_DIR)) {
console.error(`Error: Neither plugins directory (${PLUGINS_DIR}) nor extensions directory (${EXTENSIONS_DIR}) was found`);
process.exit(1);
}
// Read all plugin directories
const pluginDirs = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
const plugins = [
...collectLocalPluginsFromRoot(PLUGINS_DIR, "plugins"),
...collectLocalPluginsFromRoot(
EXTENSIONS_DIR,
"extensions",
(entryName) => fs.existsSync(path.join(EXTENSIONS_DIR, entryName, "extension.mjs"))
)
];
console.log(`Found ${pluginDirs.length} plugin directories`);
// Read metadata for each plugin
const plugins = [];
for (const dirName of pluginDirs) {
const pluginPath = path.join(PLUGINS_DIR, dirName);
const metadata = readPluginMetadata(pluginPath);
if (metadata) {
plugins.push({
name: metadata.name,
source: dirName,
description: metadata.description,
version: metadata.version || "1.0.0"
});
console.log(`✓ Added plugin: ${metadata.name}`);
} else {
console.log(`✗ Skipped: ${dirName} (no valid plugin.json)`);
}
}
console.log(`Found ${plugins.length} local plugin manifests`);
// Read external plugins and merge as-is
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
@@ -96,8 +112,7 @@ function generateMarketplace() {
name: "awesome-copilot",
metadata: {
description: "Community-driven collection of GitHub Copilot plugins, agents, prompts, and skills",
version: "1.0.0",
pluginRoot: "./plugins"
version: "1.0.0"
},
owner: {
name: "GitHub",
+190 -85
View File
@@ -560,6 +560,7 @@ function getAgentFiles(agentDir, pluginRootPath) {
*/
function generatePluginsData(gitDates) {
const plugins = [];
const extensionEntriesByName = new Map();
if (!fs.existsSync(PLUGINS_DIR)) {
return { items: [], filters: { tags: [] } };
@@ -569,6 +570,53 @@ function generatePluginsData(gitDates) {
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory());
if (fs.existsSync(EXTENSIONS_DIR)) {
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => {
if (!entry.isDirectory()) return false;
return fs.existsSync(path.join(EXTENSIONS_DIR, entry.name, "extension.mjs"));
})
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
for (const extensionDirName of extensionDirs) {
const extensionDir = path.join(EXTENSIONS_DIR, extensionDirName);
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
if (!fs.existsSync(pluginJsonPath)) {
continue;
}
try {
const extensionPlugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"));
const pluginName = normalizeText(extensionPlugin.name, extensionDirName);
const pluginDescription = normalizeText(extensionPlugin.description, "Canvas extension");
const extensionKeywords = Array.isArray(extensionPlugin.keywords)
? [...new Set(extensionPlugin.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: [];
const relPath = `extensions/${extensionDirName}`;
const extensionItem = {
kind: "extension",
path: relPath,
};
extensionEntriesByName.set(pluginName, {
id: pluginName,
name: pluginName,
description: pluginDescription,
path: relPath,
tags: extensionKeywords,
itemCount: 1,
items: [extensionItem],
generatedFromExtension: true,
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
searchText: `${pluginName} ${pluginDescription} ${extensionKeywords.join(" ")} canvas extension`.toLowerCase(),
});
} catch (e) {
console.warn(`Failed to parse extension plugin manifest for ${extensionDirName}: ${e.message}`);
}
}
}
for (const dir of pluginDirs) {
const pluginDir = path.join(PLUGINS_DIR, dir.name);
const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
@@ -578,7 +626,18 @@ function generatePluginsData(gitDates) {
try {
const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
const relPath = `plugins/${dir.name}`;
const dates = gitDates[relPath] || gitDates[`${relPath}/`] || {};
const extensionRefs = Array.isArray(data?.["x-awesome-copilot"]?.extensions)
? data["x-awesome-copilot"].extensions
: [];
const extensionItems = extensionRefs
.map((entry) => normalizeText(entry))
.filter(Boolean)
.map((entry) => entry.replace(/^\.\/+/, "").replace(/\/$/, ""))
.filter((entry) => entry.startsWith("extensions/"))
.map((entry) => ({
kind: "extension",
path: entry,
}));
const agentItems = (data.agents || []).flatMap((agent) => {
const agentPath = agent.replace("./", "");
@@ -601,27 +660,34 @@ function generatePluginsData(gitDates) {
...agentItems,
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
...extensionItems,
];
const tags = data.keywords || data.tags || [];
const pluginName = data.name || dir.name;
plugins.push({
id: dir.name,
name: data.name || dir.name,
name: pluginName,
description: data.description || "",
path: relPath,
tags: tags,
itemCount: items.length,
items: items,
lastUpdated: dates.lastModified || null,
searchText: `${data.name || dir.name} ${data.description || ""
lastUpdated: getDirectoryLastUpdated(gitDates, relPath),
searchText: `${pluginName} ${data.description || ""
} ${tags.join(" ")}`.toLowerCase(),
});
extensionEntriesByName.delete(pluginName);
} catch (e) {
console.warn(`Failed to parse plugin: ${dir.name}`, e.message);
}
}
for (const extensionPlugin of extensionEntriesByName.values()) {
plugins.push(extensionPlugin);
}
// Load external plugins from plugins/external.json
const externalJsonPath = path.join(PLUGINS_DIR, "external.json");
if (fs.existsSync(externalJsonPath)) {
@@ -994,6 +1060,94 @@ function normalizeExternalScreenshotRole(value, ref) {
};
}
function normalizeExtensionScreenshotRole(value, relPath, ref) {
if (!value) return null;
if (typeof value === "string") {
if (/^https?:\/\//i.test(value)) {
return {
path: null,
type: getImageMimeType(value),
imageUrl: value,
};
}
const normalized = value.replace(/\\/g, "/").replace(/^\.\/+/, "");
const repoPath = normalized.startsWith(`${relPath}/`) ? normalized : `${relPath}/${normalized}`;
return {
path: repoPath,
type: getImageMimeType(repoPath),
imageUrl: buildRepoImageUrl(repoPath, ref),
};
}
const pathValue = normalizeText(value.path);
const urlValue = normalizeText(value.url);
if (!pathValue && !urlValue) return null;
const pathEntry = pathValue
? normalizeExtensionScreenshotRole(pathValue, relPath, ref)
: null;
const urlEntry = urlValue
? normalizeExtensionScreenshotRole(urlValue, relPath, ref)
: null;
return {
path: pathEntry?.path || null,
type: normalizeText(value.type) || pathEntry?.type || urlEntry?.type || null,
imageUrl: urlEntry?.imageUrl || pathEntry?.imageUrl || null,
};
}
function resolveExtensionScreenshots(pluginJson, extensionDir, relPath, ref) {
const inferredAssets = getExtensionAssetInfo(extensionDir, relPath, ref);
const inferredIcon = inferredAssets?.screenshots?.icon
? {
path: inferredAssets.screenshots.icon.path,
type: inferredAssets.screenshots.icon.type,
imageUrl: inferredAssets.screenshots.icon.path
? buildRepoImageUrl(inferredAssets.screenshots.icon.path, ref)
: null,
}
: null;
const inferredGallery = inferredAssets?.screenshots?.gallery
? {
path: inferredAssets.screenshots.gallery.path,
type: inferredAssets.screenshots.gallery.type,
imageUrl: inferredAssets.screenshots.gallery.path
? buildRepoImageUrl(inferredAssets.screenshots.gallery.path, ref)
: null,
}
: null;
const logoEntry = normalizeExtensionScreenshotRole(pluginJson?.logo, relPath, ref);
const screenshotConfig = pluginJson?.["x-awesome-copilot"]?.screenshots || {};
const iconEntry = normalizeExtensionScreenshotRole(screenshotConfig.icon, relPath, ref);
const galleryRaw = screenshotConfig.gallery;
const firstGalleryEntry = Array.isArray(galleryRaw) ? galleryRaw[0] : galleryRaw;
const galleryEntry = normalizeExtensionScreenshotRole(firstGalleryEntry, relPath, ref);
const finalIcon = iconEntry || logoEntry || inferredIcon;
const finalGallery = galleryEntry || logoEntry || inferredGallery || finalIcon;
return {
screenshots: {
icon: finalIcon
? {
path: finalIcon.path,
type: finalIcon.type,
}
: null,
gallery: finalGallery
? {
path: finalGallery.path,
type: finalGallery.type,
}
: null,
},
assetPath: finalIcon?.path || inferredAssets?.assetPath || null,
imageUrl: finalIcon?.imageUrl || inferredAssets?.imageUrl || null,
};
}
function generateCanvasManifest(gitDates, commitSha) {
const items = [];
@@ -1021,17 +1175,28 @@ function generateCanvasManifest(gitDates, commitSha) {
const packageJson = fs.existsSync(packageJsonPath)
? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
: {};
const canvasJsonPath = path.join(extensionDir, "canvas.json");
const canvasJson = fs.existsSync(canvasJsonPath)
? JSON.parse(fs.readFileSync(canvasJsonPath, "utf-8"))
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
const pluginJson = fs.existsSync(pluginJsonPath)
? JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"))
: {};
const keywords = Array.isArray(packageJson.keywords)
? [...new Set(packageJson.keywords.filter((keyword) => typeof keyword === "string").map((keyword) => keyword.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b))
: [];
const extensionDescription = normalizeText(packageJson.description, "Canvas extension");
const extensionName = normalizeText(packageJson.name, dir.name);
const extensionVersion = normalizeText(packageJson.version, "1.0.0");
const screenshots = getExtensionAssetInfo(extensionDir, relPath, commitSha);
const keywordsSource = Array.isArray(pluginJson.keywords)
? pluginJson.keywords
: Array.isArray(packageJson.keywords)
? packageJson.keywords
: [];
const keywords = [...new Set(
keywordsSource
.filter((keyword) => typeof keyword === "string")
.map((keyword) => keyword.trim())
.filter(Boolean)
)].sort((a, b) => a.localeCompare(b));
const extensionDescription = normalizeText(
pluginJson.description,
normalizeText(packageJson.description, "Canvas extension")
);
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
const screenshots = resolveExtensionScreenshots(pluginJson, extensionDir, relPath, commitSha);
const canvasFiles = getExtensionCanvasFiles(extensionDir);
const canvases = [];
for (const canvasFile of canvasFiles) {
@@ -1045,6 +1210,7 @@ function generateCanvasManifest(gitDates, commitSha) {
/\\/g,
"/"
)}`;
const installCommand = `copilot plugin install ${extensionName}@awesome-copilot`;
for (const canvas of canvasEntries) {
const canvasId = normalizeText(canvas.id, dir.name);
@@ -1055,6 +1221,7 @@ function generateCanvasManifest(gitDates, commitSha) {
canvasId,
extensionId: dir.name,
extensionName,
pluginName: extensionName,
name: canvasName,
version: extensionVersion,
description: canvasDescription,
@@ -1065,9 +1232,10 @@ function generateCanvasManifest(gitDates, commitSha) {
imageUrl: screenshots?.imageUrl || null,
assetPath: screenshots?.assetPath || null,
installUrl,
installCommand,
sourceUrl: null,
external: false,
author: normalizeAuthor(canvasJson.author),
author: normalizeAuthor(pluginJson.author),
keywords,
});
}
@@ -1128,6 +1296,7 @@ function generateCanvasManifest(gitDates, commitSha) {
canvasId,
extensionId: id,
extensionName: name,
pluginName: null,
name,
version: normalizeText(ext?.version, "1.0.0"),
description: normalizeText(ext?.description, "External canvas extension"),
@@ -1138,6 +1307,7 @@ function generateCanvasManifest(gitDates, commitSha) {
imageUrl,
assetPath,
installUrl,
installCommand: null,
sourceUrl: sourceUrl || null,
external: true,
author: normalizeAuthor(ext?.author),
@@ -1163,12 +1333,12 @@ function generateCanvasManifest(gitDates, commitSha) {
};
}
function generateExtensionsData(canvasManifestData) {
if (!canvasManifestData || !Array.isArray(canvasManifestData.items)) {
function generateExtensionsData(extensionManifestData) {
if (!extensionManifestData || !Array.isArray(extensionManifestData.items)) {
return { items: [], filters: { keywords: [] } };
}
const items = canvasManifestData.items.map((item) => ({
const items = extensionManifestData.items.map((item) => ({
...item,
keywords: Array.isArray(item.keywords) ? item.keywords : [],
screenshots: item.screenshots || { icon: null, gallery: null },
@@ -1182,69 +1352,6 @@ function generateExtensionsData(canvasManifestData) {
return { items, filters };
}
function writePerExtensionCanvasManifests(canvasManifestData) {
const manifests = new Map();
function toExtensionRelativePath(assetPath, extensionId) {
const normalizedPath = normalizeText(assetPath).replace(/\\/g, "/");
if (!normalizedPath) return null;
const prefix = `extensions/${extensionId}/`;
return normalizedPath.startsWith(prefix)
? normalizedPath.slice(prefix.length)
: normalizedPath;
}
function toRelativeScreenshots(screenshots, extensionId) {
if (!screenshots) return { icon: null, gallery: null };
const toRelativeEntry = (entry) =>
entry
? {
...entry,
path: toExtensionRelativePath(entry.path, extensionId),
}
: null;
return {
icon: toRelativeEntry(screenshots.icon),
gallery: toRelativeEntry(screenshots.gallery),
};
}
for (const item of canvasManifestData.items || []) {
if (!item || item.external || !item.extensionId || !item.path) {
continue;
}
// We assume one canvas per extension folder.
if (manifests.has(item.extensionId)) {
continue;
}
manifests.set(item.extensionId, {
id: item.canvasId || item.id,
name: item.name,
description: item.description || "Canvas extension",
version: item.version || "1.0.0",
...(item.author ? { author: item.author } : {}),
keywords: Array.isArray(item.keywords)
? [...new Set(item.keywords)].sort((a, b) => a.localeCompare(b))
: [],
screenshots: toRelativeScreenshots(
item.screenshots || { icon: null, gallery: null },
item.extensionId
),
});
}
for (const [extensionId, manifest] of manifests.entries()) {
const canvasManifestPath = path.join(
EXTENSIONS_DIR,
extensionId,
"canvas.json"
);
fs.writeFileSync(canvasManifestPath, JSON.stringify(manifest, null, 2));
}
}
/**
* Generate tools metadata from website/data/tools.yml
*/
@@ -1585,8 +1692,8 @@ async function main() {
`✓ Generated ${plugins.length} plugins (${pluginsData.filters.tags.length} tags)`
);
const canvasManifestData = generateCanvasManifest(gitDates, commitSha);
const extensionsData = generateExtensionsData(canvasManifestData);
const extensionManifestData = generateCanvasManifest(gitDates, commitSha);
const extensionsData = generateExtensionsData(extensionManifestData);
const extensions = extensionsData.items;
console.log(
`✓ Generated ${extensions.length} extensions (${extensionsData.filters.keywords.length} keywords)`
@@ -1655,8 +1762,6 @@ async function main() {
JSON.stringify(extensionsData, null, 2)
);
writePerExtensionCanvasManifests(canvasManifestData);
fs.writeFileSync(
path.join(WEBSITE_DATA_DIR, "tools.json"),
JSON.stringify(toolsData, null, 2)
+35 -1
View File
@@ -38,6 +38,10 @@ function resolveSource(relPath) {
const skillName = relPath.replace(/^\.\/skills\//, "").replace(/\/$/, "");
return path.join(ROOT_FOLDER, "skills", skillName);
}
if (relPath.startsWith("./extensions/")) {
const extensionName = relPath.replace(/^\.\/extensions\//, "").replace(/\/$/, "");
return path.join(ROOT_FOLDER, "extensions", extensionName);
}
return null;
}
@@ -56,6 +60,7 @@ function materializePlugins() {
let totalAgents = 0;
let totalSkills = 0;
let totalExtensions = 0;
let warnings = 0;
let errors = 0;
@@ -119,6 +124,27 @@ function materializePlugins() {
}
}
// Process extension references from x-awesome-copilot.extensions
const extensionRefs = Array.isArray(metadata?.["x-awesome-copilot"]?.extensions)
? metadata["x-awesome-copilot"].extensions
: [];
for (const relPath of extensionRefs) {
const src = resolveSource(relPath);
if (!src) {
console.warn(`${pluginName}: Unknown extension path format: ${relPath}`);
warnings++;
continue;
}
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
console.warn(`${pluginName}: Extension source directory not found: ${src}`);
warnings++;
continue;
}
const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, ""));
copyDirRecursive(src, dest);
totalExtensions++;
}
// Rewrite plugin.json to use folder paths instead of individual file paths.
// On staged, paths like ./agents/foo.md point to individual source files.
// On main, after materialization, we only need the containing directory.
@@ -139,6 +165,13 @@ function materializePlugins() {
changed = true;
}
if (Array.isArray(rewritten?.["x-awesome-copilot"]?.extensions) &&
rewritten["x-awesome-copilot"].extensions.length > 0) {
rewritten["x-awesome-copilot"].extensions =
rewritten["x-awesome-copilot"].extensions.map((p) => p.replace(/\/$/, ""));
changed = true;
}
if (changed) {
fs.writeFileSync(pluginJsonPath, JSON.stringify(rewritten, null, 2) + "\n", "utf8");
}
@@ -146,12 +179,13 @@ function materializePlugins() {
const counts = [];
if (metadata.agents?.length) counts.push(`${metadata.agents.length} agents`);
if (metadata.skills?.length) counts.push(`${metadata.skills.length} skills`);
if (extensionRefs.length) counts.push(`${extensionRefs.length} extensions`);
if (counts.length) {
console.log(`${pluginName}: ${counts.join(", ")}`);
}
}
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills.`);
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`);
if (warnings > 0) {
console.log(`${warnings} warning(s).`);
}
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env node
import fs from "fs";
import path from "path";
import Ajv from "ajv";
import addFormats from "ajv-formats";
function parseArgs(argv) {
const args = { schema: null, data: null };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--schema") {
args.schema = argv[i + 1] || null;
i += 1;
} else if (arg === "--data") {
args.data = argv[i + 1] || null;
i += 1;
}
}
return args;
}
function readJson(filePath) {
const content = fs.readFileSync(filePath, "utf8");
return JSON.parse(content);
}
const args = parseArgs(process.argv);
if (!args.schema) {
console.error("Missing required argument: --schema <path>");
process.exit(1);
}
const schemaPath = path.resolve(process.cwd(), args.schema);
let schema;
try {
schema = readJson(schemaPath);
} catch (error) {
console.error(`Invalid schema JSON at ${args.schema}: ${error.message}`);
process.exit(1);
}
const ajv = new Ajv({ strict: false, allErrors: true });
addFormats(ajv);
let validate;
try {
validate = ajv.compile(schema);
} catch (error) {
console.error(`Invalid schema at ${args.schema}: ${error.message}`);
process.exit(1);
}
if (!args.data) {
console.log(`Schema is valid: ${args.schema}`);
process.exit(0);
}
const dataPath = path.resolve(process.cwd(), args.data);
let data;
try {
data = readJson(dataPath);
} catch (error) {
console.error(`Invalid data JSON at ${args.data}: ${error.message}`);
process.exit(1);
}
const valid = validate(data);
if (!valid) {
const message = ajv.errorsText(validate.errors, { separator: "; " });
console.error(`Schema validation failed for ${args.data}: ${message}`);
process.exit(1);
}
console.log(`Schema validation passed: ${args.data}`);
+182 -20
View File
@@ -6,6 +6,7 @@ import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
// Validation functions
function validateName(name, folderName) {
@@ -77,6 +78,29 @@ function sortPluginEntries(entries) {
return [...entries].sort((left, right) => left.localeCompare(right));
}
function parseJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
return { parseError: err.message };
}
}
function getExtensionFolderNames() {
if (!fs.existsSync(EXTENSIONS_DIR)) {
return [];
}
return fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => {
if (!entry.isDirectory()) return false;
const extensionEntryPoint = path.join(EXTENSIONS_DIR, entry.name, "extension.mjs");
return fs.existsSync(extensionEntryPoint);
})
.map((entry) => entry.name)
.sort();
}
function validateSpecPaths(plugin) {
const errors = [];
const specs = {
@@ -131,9 +155,51 @@ function validateSpecPaths(plugin) {
return errors;
}
function validateCuratedPluginExtensionRefs(plugin) {
const errors = [];
const extensionRefs = plugin?.["x-awesome-copilot"]?.extensions;
if (extensionRefs === undefined) {
return errors;
}
if (!Array.isArray(extensionRefs)) {
errors.push('x-awesome-copilot.extensions must be an array');
return errors;
}
if (!arraysEqual(extensionRefs, sortPluginEntries(extensionRefs))) {
errors.push('x-awesome-copilot.extensions must be sorted alphabetically');
}
const knownExtensions = new Set(getExtensionFolderNames());
for (let i = 0; i < extensionRefs.length; i++) {
const ref = extensionRefs[i];
if (typeof ref !== "string") {
errors.push(`x-awesome-copilot.extensions[${i}] must be a string`);
continue;
}
if (!ref.startsWith("./extensions/")) {
errors.push(`x-awesome-copilot.extensions[${i}] must start with "./extensions/"`);
continue;
}
const normalized = ref.replace(/^\.\/extensions\//, "").replace(/\/$/, "");
if (!normalized) {
errors.push(`x-awesome-copilot.extensions[${i}] must include an extension folder name`);
continue;
}
if (!knownExtensions.has(normalized)) {
errors.push(`x-awesome-copilot.extensions[${i}] source not found: extensions/${normalized}`);
}
}
return errors;
}
function validatePlugin(folderName) {
const pluginDir = path.join(PLUGINS_DIR, folderName);
const errors = [];
let parsedPlugin = null;
// Rule 1: Must have .github/plugin/plugin.json
const pluginJsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
@@ -153,9 +219,10 @@ function validatePlugin(folderName) {
try {
const raw = fs.readFileSync(pluginJsonPath, "utf-8");
plugin = JSON.parse(raw);
parsedPlugin = plugin;
} catch (err) {
errors.push(`failed to parse plugin.json: ${err.message}`);
return errors;
return { errors, plugin: parsedPlugin };
}
// Rule 3 & 4: name, description, version
@@ -176,35 +243,101 @@ function validatePlugin(folderName) {
const specErrors = validateSpecPaths(plugin);
errors.push(...specErrors);
return errors;
const extensionRefErrors = validateCuratedPluginExtensionRefs(plugin);
errors.push(...extensionRefErrors);
return { errors, plugin: parsedPlugin };
}
function validateExtensionScreenshotPath(extensionDir, pathValue, fieldName, errors) {
if (!pathValue || typeof pathValue !== "string") {
errors.push(`${fieldName} must be a string path`);
return;
}
const normalizedPath = pathValue.replace(/^\.\/+/, "");
const absolutePath = path.join(extensionDir, normalizedPath);
if (!fs.existsSync(absolutePath)) {
errors.push(`${fieldName} not found: ${normalizedPath}`);
}
}
function validateExtensionManifest(folderName) {
const extensionDir = path.join(EXTENSIONS_DIR, folderName);
const errors = [];
let parsedPlugin = null;
const pluginJsonPath = path.join(extensionDir, ".github/plugin", "plugin.json");
if (!fs.existsSync(pluginJsonPath)) {
errors.push("missing required file: .github/plugin/plugin.json");
return { errors, plugin: parsedPlugin };
}
const parsed = parseJsonFile(pluginJsonPath);
if (parsed.parseError) {
errors.push(`failed to parse plugin.json: ${parsed.parseError}`);
return { errors, plugin: parsedPlugin };
}
parsedPlugin = parsed;
const nameErrors = validateName(parsed.name, folderName);
errors.push(...nameErrors);
const descError = validateDescription(parsed.description);
if (descError) errors.push(descError);
const versionError = validateVersion(parsed.version);
if (versionError) errors.push(versionError);
const keywordsError = validateKeywords(parsed.keywords ?? parsed.tags);
if (keywordsError) errors.push(keywordsError);
// Extension convention: logo must be exactly "assets/preview.png"
if (parsed.logo !== "assets/preview.png") {
errors.push('logo must be exactly "assets/preview.png" (extension convention)');
} else {
validateExtensionScreenshotPath(extensionDir, parsed.logo, "logo", errors);
}
// Extension convention: x-awesome-copilot must not be present
if (parsed["x-awesome-copilot"] !== undefined) {
errors.push("x-awesome-copilot field must not be present (use convention-based logo instead)");
}
// Extension convention: extensions field must be "."
if (parsed.extensions !== ".") {
errors.push('extensions field must be exactly "." (extension convention)');
}
return { errors, plugin: parsedPlugin };
}
// Main validation function
function validatePlugins() {
if (!fs.existsSync(PLUGINS_DIR)) {
console.log("No plugins directory found - validation skipped");
return true;
}
const pluginDirs = fs.existsSync(PLUGINS_DIR)
? fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
: [];
const extensionDirs = getExtensionFolderNames();
const pluginDirs = fs
.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
if (pluginDirs.length === 0) {
console.log("No plugin directories found - validation skipped");
if (pluginDirs.length === 0 && extensionDirs.length === 0) {
console.log("No plugins or extension plugin manifests found - validation skipped");
return true;
}
console.log(`Validating ${pluginDirs.length} plugins...\n`);
console.log(`Validating ${extensionDirs.length} extensions as plugin sources...\n`);
let hasErrors = false;
const seenNames = new Set();
const localPluginNames = [];
for (const dir of pluginDirs) {
console.log(`Validating ${dir}...`);
const errors = validatePlugin(dir);
const { errors, plugin } = validatePlugin(dir);
if (errors.length > 0) {
console.error(`${dir}:`);
@@ -214,18 +347,47 @@ function validatePlugins() {
console.log(`${dir} is valid`);
}
// Rule 10: duplicate names
if (seenNames.has(dir)) {
console.error(`❌ Duplicate plugin name "${dir}"`);
if (plugin?.name) {
if (seenNames.has(plugin.name)) {
console.error(`❌ Duplicate plugin name "${plugin.name}"`);
hasErrors = true;
} else {
seenNames.add(plugin.name);
localPluginNames.push(plugin.name);
}
}
}
if (extensionDirs.length > 0) {
console.log("");
}
for (const dir of extensionDirs) {
console.log(`Validating extension ${dir}...`);
const { errors, plugin } = validateExtensionManifest(dir);
if (errors.length > 0) {
console.error(`❌ extension ${dir}:`);
errors.forEach((e) => console.error(` - ${e}`));
hasErrors = true;
} else {
seenNames.add(dir);
console.log(`✅ extension ${dir} is valid`);
}
if (plugin?.name) {
if (seenNames.has(plugin.name)) {
console.error(`❌ Duplicate plugin name "${plugin.name}"`);
hasErrors = true;
} else {
seenNames.add(plugin.name);
localPluginNames.push(plugin.name);
}
}
}
console.log("\nValidating external plugin catalog...");
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
localPluginNames: pluginDirs,
localPluginNames,
policy: "marketplace",
});
@@ -240,7 +402,7 @@ function validatePlugins() {
}
if (!hasErrors) {
console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`);
console.log(`\n✅ All ${pluginDirs.length} plugins, ${extensionDirs.length} extensions, and the external catalog are valid`);
}
return !hasErrors;