mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
chore: publish from main
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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).`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user