mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-14 01:51:02 +00:00
Migrate extension metadata to plugin.json and enforce conventions (#2177)
* Remove pluginRoots property from marketplace.json The pluginRoots property is not used by install tooling and was only informational about the extension/plugin source directories. Removing it simplifies the marketplace.json structure while maintaining all functionality. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Migrate java-modernization-studio to plugin.json and update validation workflow - Create .github/plugin/plugin.json for java-modernization-studio extension - Remove legacy canvas.json from java-modernization-studio - Update validate-canvas-extensions.yml workflow to check for plugin.json instead of canvas.json - Update workflow to trigger on .schemas/plugin.schema.json changes (instead of canvas.schema.json) - Remove schema validation logic that relied on canvas.schema.json - All 12 extensions now use plugin.json for metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add extensions field to all extension plugin.json files Per https://github.com/github/copilot-agent-runtime/pull/9929, plugins that ship extensions need to include an extensions field specifying where the extension code is located. All 12 extensions now have extensions set to '.' to reference the current directory where extension.mjs is located. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Enforce convention-based extension metadata and remove x-awesome-copilot - Remove x-awesome-copilot.screenshots from all extension plugin.json files - Enforce logo=assets/preview.png convention for all extensions - Enforce extensions=. per copilot-agent-runtime#9929 - Update validate-plugins.mjs to enforce conventions - Update validate-canvas-extensions.yml workflow with convention checks - Update AGENTS.md and CONTRIBUTING.md documentation All 12 extensions validated successfully. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Use standard plugin validation for extensions Remove the custom extension schema and schema validation helper, and validate extension plugin.json files through the existing plugin validator instead. Update workflows to stop depending on the removed schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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