mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-21 21:04:13 +00:00
Merge branch 'main' into fenzho/upgrade-modernize-1.22.0
This commit is contained in:
+3
-1
@@ -46,6 +46,8 @@
|
||||
|
||||
# queston - intentional misspelling example in skills/arize-dataset/SKILL.md demonstrating typo detection in field names
|
||||
|
||||
# extenions - intentional misspelled key name used in plugin validators to detect invalid manifests
|
||||
|
||||
# nin - MongoDB $nin operator in security instructions NoSQL injection detection regex
|
||||
|
||||
# Vertexes - FreeCAD shape sub-elements used as property of obj.Shape
|
||||
@@ -60,7 +62,7 @@
|
||||
|
||||
# Vally/vally - Name of product
|
||||
|
||||
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally
|
||||
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally
|
||||
|
||||
# Skip certain files and directories
|
||||
# *.tm7 - MTM DataContract exports; embedded base64 icon blobs trigger false positives
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"name": "accessibility-kanban",
|
||||
"source": "extensions/accessibility-kanban",
|
||||
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "acreadiness-cockpit",
|
||||
@@ -85,13 +85,13 @@
|
||||
"name": "apng-studio",
|
||||
"source": "extensions/apng-studio",
|
||||
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "arcade-canvas",
|
||||
"source": "extensions/arcade-canvas",
|
||||
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "arch",
|
||||
@@ -158,7 +158,7 @@
|
||||
"name": "backlog-swipe-triage",
|
||||
"source": "extensions/backlog-swipe-triage",
|
||||
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "cast-imaging",
|
||||
@@ -195,7 +195,7 @@
|
||||
"name": "chromium-control-canvas",
|
||||
"source": "extensions/chromium-control-canvas",
|
||||
"description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "clojure-interactive-programming",
|
||||
@@ -239,13 +239,13 @@
|
||||
"name": "color-orb",
|
||||
"source": "extensions/color-orb",
|
||||
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "connector-namespaces",
|
||||
"source": "extensions/connector-namespaces",
|
||||
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
|
||||
"version": "1.1.0"
|
||||
"version": "1.1.2"
|
||||
},
|
||||
{
|
||||
"name": "context-engineering",
|
||||
@@ -373,7 +373,7 @@
|
||||
"name": "diagram-viewer",
|
||||
"source": "extensions/diagram-viewer",
|
||||
"description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "dotnet",
|
||||
@@ -520,7 +520,7 @@
|
||||
"name": "feedback-themes",
|
||||
"source": "extensions/feedback-themes",
|
||||
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "figma",
|
||||
@@ -593,7 +593,7 @@
|
||||
"name": "gesture-review",
|
||||
"source": "extensions/gesture-review",
|
||||
"description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "gh-skills-builder",
|
||||
@@ -701,7 +701,7 @@
|
||||
"name": "java-modernization-studio",
|
||||
"source": "extensions/java-modernization-studio",
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "kotlin-mcp-development",
|
||||
@@ -770,7 +770,7 @@
|
||||
{
|
||||
"name": "modernize-dotnet",
|
||||
"description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.",
|
||||
"version": "1.0.1157-preview1",
|
||||
"version": "1.0.1161-preview1",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
@@ -787,7 +787,8 @@
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "dotnet/modernize-dotnet",
|
||||
"path": "plugins/modernize-dotnet"
|
||||
"path": "plugins/modernize-dotnet",
|
||||
"sha": "ce4ec09678498da38099ca9169c1624c59c2a89a"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -979,13 +980,13 @@
|
||||
"name": "release-notes-showcase",
|
||||
"source": "extensions/release-notes-showcase",
|
||||
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "repo-actions-hub",
|
||||
"source": "extensions/repo-actions-hub",
|
||||
"description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "roundup",
|
||||
@@ -1027,7 +1028,7 @@
|
||||
"name": "site-studio",
|
||||
"source": "extensions/site-studio",
|
||||
"description": "Plan, draft, and track a personal website section by section — a shared canvas where you and your agent author content, watch progress, and review every change.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "skill-image-gen",
|
||||
@@ -1117,13 +1118,13 @@
|
||||
"name": "tiny-tool-town-submitter",
|
||||
"source": "extensions/tiny-tool-town-submitter",
|
||||
"description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "token-pacman",
|
||||
"source": "extensions/token-pacman",
|
||||
"description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "typescript-mcp-development",
|
||||
@@ -1246,6 +1247,30 @@
|
||||
"sha": "80f2d93287054f9d30dd990e842e15bcfca581c9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upgrade-agent",
|
||||
"description": "GitHub Copilot upgrade is an AI-powered agent that helps you upgrade applications to newer versions of languages, frameworks, and runtimes. It assesses your application, creates an upgrade plan, applies code changes, and validates the results through an interactive upgrade workflow.",
|
||||
"version": "1.1.222",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
},
|
||||
"repository": "https://github.com/microsoft/upgrade-agent-plugins",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"modernization",
|
||||
"upgrade",
|
||||
"migration",
|
||||
"dotnet",
|
||||
"canvas"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "microsoft/upgrade-agent-plugins",
|
||||
"path": "plugins/upgrade-agent",
|
||||
"sha": "379d344e42823b25223f878c002f38fb3a2c1d2b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vercel-plugin",
|
||||
"description": "Build and deploy web apps and agents. Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.",
|
||||
@@ -1310,7 +1335,7 @@
|
||||
"name": "where-was-i",
|
||||
"source": "extensions/where-was-i",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
},
|
||||
{
|
||||
"name": "winappcli",
|
||||
@@ -1374,7 +1399,7 @@
|
||||
"name": "work-hub",
|
||||
"source": "extensions/work-hub",
|
||||
"description": "Generic cross-repo command center canvas for GitHub Copilot with onboarding, focus planning, repo health, work signals, and session cleanup.",
|
||||
"version": "1.0.0"
|
||||
"version": "1.0.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -753,6 +753,7 @@ jobs:
|
||||
vally_lint_status: 'infra_error',
|
||||
smoke_status: 'infra_error',
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
failure_class: 'infra',
|
||||
summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.',
|
||||
};
|
||||
@@ -764,6 +765,7 @@ jobs:
|
||||
vally_lint_status: 'infra_error',
|
||||
smoke_status: 'infra_error',
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
failure_class: 'infra',
|
||||
summary: 'Quality-gate workflow did not return results. Re-run intake to retry.',
|
||||
};
|
||||
|
||||
@@ -116,6 +116,7 @@ jobs:
|
||||
vally_lint_status: 'infra_error',
|
||||
smoke_status: 'infra_error',
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
failure_class: 'infra',
|
||||
summary: 'Quality-gate workflow failed unexpectedly. Re-run intake to retry.',
|
||||
};
|
||||
@@ -127,6 +128,7 @@ jobs:
|
||||
vally_lint_status: 'infra_error',
|
||||
smoke_status: 'infra_error',
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
failure_class: 'infra',
|
||||
summary: 'Quality-gate workflow did not return results. Re-run intake to retry.',
|
||||
};
|
||||
|
||||
@@ -158,6 +158,7 @@ jobs:
|
||||
failure_class: 'infra',
|
||||
checked_plugins: [],
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
summary: 'External plugin PR change detection failed unexpectedly. Re-run this workflow.',
|
||||
};
|
||||
} else if (shouldRun) {
|
||||
@@ -167,6 +168,7 @@ jobs:
|
||||
failure_class: 'infra',
|
||||
checked_plugins: [],
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
summary: 'External plugin PR quality checks failed unexpectedly. Re-run this workflow.',
|
||||
};
|
||||
} else if (process.env.QUALITY_RESULT_JSON) {
|
||||
@@ -177,6 +179,7 @@ jobs:
|
||||
failure_class: 'infra',
|
||||
checked_plugins: [],
|
||||
version_match_status: 'infra_error',
|
||||
canvas_structure_status: 'infra_error',
|
||||
summary: 'External plugin PR quality checks did not return a result payload.',
|
||||
};
|
||||
}
|
||||
@@ -245,15 +248,16 @@ jobs:
|
||||
const sourceUrl = String(entry?.source_tree_url || '');
|
||||
const locator = String(entry?.source?.sha || entry?.source?.ref || 'repository');
|
||||
const sourceCell = sourceUrl ? `[${locator}](${sourceUrl})` : locator;
|
||||
return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`;
|
||||
return `| ${name} | ${quality.vally_lint_status || 'not_run'} | ${quality.smoke_status || 'not_run'} | ${quality.version_match_status || 'not_run'} | ${quality.canvas_structure_status || 'not_run'} | ${quality.overall_status || 'not_run'} | ${sourceCell} |`;
|
||||
})
|
||||
: ['| _none_ | not_run | not_run | not_run | not_run | _n/a_ |'];
|
||||
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
|
||||
const failureDetails = checkedPlugins.flatMap((entry) => {
|
||||
const name = String(entry?.name || 'unknown');
|
||||
const quality = entry?.quality || {};
|
||||
const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0;
|
||||
const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0;
|
||||
const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0;
|
||||
const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0;
|
||||
|
||||
const details = [];
|
||||
if (shouldShowVally) {
|
||||
@@ -265,6 +269,9 @@ jobs:
|
||||
if (shouldShowVersionMatch) {
|
||||
details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output));
|
||||
}
|
||||
if (shouldShowCanvasStructure) {
|
||||
details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output));
|
||||
}
|
||||
return details;
|
||||
});
|
||||
|
||||
@@ -277,8 +284,8 @@ jobs:
|
||||
'',
|
||||
'### Per-plugin quality summary',
|
||||
'',
|
||||
'| Plugin | vally lint | install smoke test | version match | overall | source tree |',
|
||||
'|---|---|---|---|---|---|',
|
||||
'| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |',
|
||||
'|---|---|---|---|---|---|---|',
|
||||
...rows,
|
||||
'',
|
||||
...(failureDetails.length > 0
|
||||
|
||||
@@ -89,7 +89,7 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must
|
||||
- Extension `plugin.json` **must** follow the convention:
|
||||
- `name`, `description`, `version` are required
|
||||
- `logo` **must** be exactly `"assets/preview.png"` (enforced convention)
|
||||
- `extensions` **must** be exactly `"."` (per [copilot-agent-runtime#9929](https://github.com/github/copilot-agent-runtime/pull/9929))
|
||||
- `extensions` **must** be exactly `"."` in source manifests (materialization rewrites this to `"extensions"` for distribution output)
|
||||
- Optional: `author`, `keywords` fields
|
||||
- **Must not** include `x-awesome-copilot` field (use convention-based `assets/preview.png` only)
|
||||
- Each extension must have `assets/preview.png` as the primary visual asset
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "url";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
const MATERIALIZED_SPECS = {
|
||||
agents: {
|
||||
path: "agents",
|
||||
@@ -27,6 +28,41 @@ const MATERIALIZED_SPECS = {
|
||||
},
|
||||
};
|
||||
|
||||
function copyDirRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveEntry(srcPath, destPath) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
try {
|
||||
fs.renameSync(srcPath, destPath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!["EXDEV", "EEXIST", "ENOTEMPTY", "EPERM"].includes(error?.code)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = fs.statSync(srcPath);
|
||||
if (stats.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
fs.rmSync(srcPath, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
fs.rmSync(srcPath, { force: true });
|
||||
}
|
||||
|
||||
export function restoreManifestFromMaterializedFiles(pluginPath) {
|
||||
const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
@@ -89,6 +125,68 @@ function cleanPlugin(pluginPath) {
|
||||
return { removed, manifestUpdated };
|
||||
}
|
||||
|
||||
export function cleanMaterializedExtensionPlugin(extensionPath) {
|
||||
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
|
||||
let manifestUpdated = false;
|
||||
if (fs.existsSync(pluginJsonPath)) {
|
||||
const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
const extensionBundlePrefix = `extensions/${path.basename(extensionPath)}/`;
|
||||
if (plugin.extensions === "extensions") {
|
||||
plugin.extensions = ".";
|
||||
manifestUpdated = true;
|
||||
}
|
||||
if (typeof plugin.logo === "string" && plugin.logo.startsWith(extensionBundlePrefix)) {
|
||||
plugin.logo = plugin.logo.slice(extensionBundlePrefix.length);
|
||||
manifestUpdated = true;
|
||||
}
|
||||
if (manifestUpdated) {
|
||||
fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8");
|
||||
console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`);
|
||||
}
|
||||
}
|
||||
|
||||
const target = path.join(extensionPath, "extensions");
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
|
||||
return { removed: 0, manifestUpdated };
|
||||
}
|
||||
|
||||
const bundleRoot = path.join(target, path.basename(extensionPath));
|
||||
const count = countFiles(target);
|
||||
if (fs.existsSync(bundleRoot) && fs.statSync(bundleRoot).isDirectory()) {
|
||||
for (const entry of fs.readdirSync(bundleRoot, { withFileTypes: true })) {
|
||||
moveEntry(path.join(bundleRoot, entry.name), path.join(extensionPath, entry.name));
|
||||
}
|
||||
console.log(` Restored ${path.basename(extensionPath)}/ from materialized extensions bundle`);
|
||||
}
|
||||
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
console.log(` Removed ${path.basename(extensionPath)}/extensions/ (${count} files)`);
|
||||
return { removed: count, manifestUpdated };
|
||||
}
|
||||
|
||||
function isExtensionPluginDirectory(extensionPath) {
|
||||
if (fs.existsSync(path.join(extensionPath, "extension.mjs"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bundleEntry = path.join(extensionPath, "extensions", path.basename(extensionPath), "extension.mjs");
|
||||
if (fs.existsSync(bundleEntry)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
return plugin.extensions === "extensions";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function countFiles(dir) {
|
||||
let count = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
@@ -171,6 +269,25 @@ function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(EXTENSIONS_DIR)) {
|
||||
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const dirName of extensionDirs) {
|
||||
const extensionPath = path.join(EXTENSIONS_DIR, dirName);
|
||||
if (!isExtensionPluginDirectory(extensionPath)) {
|
||||
continue;
|
||||
}
|
||||
const { removed, manifestUpdated } = cleanMaterializedExtensionPlugin(extensionPath);
|
||||
total += removed;
|
||||
if (manifestUpdated) {
|
||||
manifestsUpdated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (total === 0 && manifestsUpdated === 0) {
|
||||
console.log("✅ No materialized files found. Plugins are already clean.");
|
||||
|
||||
@@ -459,6 +459,55 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.extenions !== undefined) {
|
||||
errors.push(
|
||||
`submission: plugins tagged with "canvas" must use "extensions" (found misspelled key "extenions") in "${manifestPath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.extensions !== undefined && manifest.extensions !== "extensions") {
|
||||
errors.push(
|
||||
`submission: plugins tagged with "canvas" may omit "extensions", but if provided it must be "extensions" in "${manifestPath}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionContainerPath = joinRepoPath(pluginRoot, "extensions");
|
||||
const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token);
|
||||
if (extensionContainerResponse.kind === "notFound") {
|
||||
errors.push(
|
||||
`submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`,
|
||||
);
|
||||
} else if (extensionContainerResponse.kind === "apiError") {
|
||||
warnings.push(
|
||||
`submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
||||
);
|
||||
} else if (
|
||||
!(
|
||||
extensionContainerResponse.data?.type === "dir"
|
||||
|| Array.isArray(extensionContainerResponse.data)
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs");
|
||||
const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token);
|
||||
if (extensionEntryResponse.kind === "notFound") {
|
||||
errors.push(
|
||||
`submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`,
|
||||
);
|
||||
} else if (extensionEntryResponse.kind === "apiError") {
|
||||
warnings.push(
|
||||
`submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
||||
);
|
||||
} else if (extensionEntryResponse.data?.type !== "file") {
|
||||
errors.push(
|
||||
`submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH);
|
||||
const previewResponse = await fetchGitHubFile(repo, previewPath, releaseLocator, token);
|
||||
if (previewResponse.kind === "notFound") {
|
||||
@@ -580,11 +629,13 @@ function normalizeQualityGateResult(rawResult) {
|
||||
vally_lint_status: "not_run",
|
||||
smoke_status: "not_run",
|
||||
version_match_status: "not_run",
|
||||
canvas_structure_status: "not_run",
|
||||
failure_class: "none",
|
||||
summary: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
version_match_output: "",
|
||||
canvas_structure_output: "",
|
||||
};
|
||||
|
||||
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
|
||||
@@ -601,6 +652,7 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
const vallyState = qualityResult.vally_lint_status || "not_run";
|
||||
const smokeState = qualityResult.smoke_status || "not_run";
|
||||
const versionMatchState = qualityResult.version_match_status || "not_run";
|
||||
const canvasStructureState = qualityResult.canvas_structure_status || "not_run";
|
||||
const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._";
|
||||
|
||||
const sections = [
|
||||
@@ -611,6 +663,7 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
`| vally lint | ${vallyState} |`,
|
||||
`| install smoke test | ${smokeState} |`,
|
||||
`| version match | ${versionMatchState} |`,
|
||||
`| canvas structure | ${canvasStructureState} |`,
|
||||
"",
|
||||
summaryText,
|
||||
];
|
||||
@@ -660,6 +713,21 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
);
|
||||
}
|
||||
|
||||
const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim();
|
||||
if (canvasStructureOutput) {
|
||||
sections.push(
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Canvas structure output</summary>",
|
||||
"",
|
||||
"```text",
|
||||
canvasStructureOutput,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function runExternalPluginPrQualityGates(plugins) {
|
||||
? "No changed external plugin entries were detected in plugins/external.json."
|
||||
: checkedPlugins
|
||||
.map((entry) =>
|
||||
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, overall=${entry.quality.overall_status}`
|
||||
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { spawnSync } from "child_process";
|
||||
import { runLint, LintConsoleReporter } from "@microsoft/vally";
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 12000;
|
||||
const EXTERNAL_CANVAS_KEYWORD = "canvas";
|
||||
|
||||
const INFRA_ERROR_PATTERNS = [
|
||||
/\b401\b/,
|
||||
@@ -69,6 +70,12 @@ function normalizePluginPath(pluginPath) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hasCanvasKeyword(plugin) {
|
||||
return (plugin?.keywords ?? []).some(
|
||||
(keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFetchSpec(pluginSource) {
|
||||
if (pluginSource.sha) {
|
||||
return pluginSource.sha;
|
||||
@@ -467,6 +474,148 @@ function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) {
|
||||
};
|
||||
}
|
||||
|
||||
function checkPathExistsAtLocator(repoDir, locator, repoPath, expectedType) {
|
||||
const result = runCommand("git", ["cat-file", "-e", `${locator}:${repoPath}`], { cwd: repoDir });
|
||||
if (result.exitCode === 0) {
|
||||
if (!expectedType) {
|
||||
return { exists: true, output: "" };
|
||||
}
|
||||
|
||||
const typeResult = runCommand("git", ["cat-file", "-t", `${locator}:${repoPath}`], { cwd: repoDir });
|
||||
if (typeResult.exitCode !== 0) {
|
||||
return {
|
||||
exists: false,
|
||||
output: `Unable to verify path "${repoPath}" type at "${locator}": ${typeResult.output}`,
|
||||
};
|
||||
}
|
||||
|
||||
const actualType = String(typeResult.stdout ?? "").trim();
|
||||
if (actualType !== expectedType) {
|
||||
return {
|
||||
exists: false,
|
||||
output: "",
|
||||
kindMismatch: true,
|
||||
actualType,
|
||||
};
|
||||
}
|
||||
|
||||
return { exists: true, output: "" };
|
||||
}
|
||||
|
||||
const normalizedOutput = String(result.output ?? "").toLowerCase();
|
||||
if (
|
||||
normalizedOutput.includes("not a valid object name")
|
||||
|| normalizedOutput.includes("path '")
|
||||
|| normalizedOutput.includes("does not exist")
|
||||
) {
|
||||
return { exists: false, output: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
exists: false,
|
||||
output: `Unable to verify path "${repoPath}" at "${locator}": ${result.output}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
||||
if (!hasCanvasKeyword(plugin)) {
|
||||
return {
|
||||
status: "not_run",
|
||||
output: "Canvas structure gate skipped because plugin is not tagged with \"canvas\".",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/");
|
||||
const locators = [plugin?.source?.ref, plugin?.source?.sha]
|
||||
.filter((value) => typeof value === "string" && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
.filter((value, index, values) => values.indexOf(value) === index);
|
||||
|
||||
if (locators.length === 0) {
|
||||
return {
|
||||
status: "not_run",
|
||||
output: "Canvas structure gate skipped because neither source.ref nor source.sha was provided.",
|
||||
};
|
||||
}
|
||||
|
||||
const extensionsDir = toPosixPath(normalizedPluginPath, "extensions");
|
||||
const extensionEntryPoint = toPosixPath(extensionsDir, "extension.mjs");
|
||||
|
||||
let hasFailure = false;
|
||||
let hasInfraError = false;
|
||||
const messages = [];
|
||||
|
||||
for (const locator of locators) {
|
||||
if (locator !== primaryFetchSpec) {
|
||||
const fetchResult = fetchLocatorIntoRepo(repoDir, locator);
|
||||
if (fetchResult.status === "fail") {
|
||||
hasFailure = true;
|
||||
messages.push(`- ${locator}: ${fetchResult.output}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fetchResult.status === "infra_error") {
|
||||
hasInfraError = true;
|
||||
messages.push(`- ${locator}: ${fetchResult.output}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const extensionDirCheck = checkPathExistsAtLocator(repoDir, locator, extensionsDir, "tree");
|
||||
if (extensionDirCheck.output) {
|
||||
hasInfraError = true;
|
||||
messages.push(`- ${locator}: ${extensionDirCheck.output}`);
|
||||
continue;
|
||||
}
|
||||
if (!extensionDirCheck.exists) {
|
||||
hasFailure = true;
|
||||
if (extensionDirCheck.kindMismatch) {
|
||||
messages.push(`- ${locator}: "${extensionsDir}" must be a directory.`);
|
||||
} else {
|
||||
messages.push(`- ${locator}: missing required canvas extension directory "${extensionsDir}".`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, locator, extensionEntryPoint, "blob");
|
||||
if (extensionEntryCheck.output) {
|
||||
hasInfraError = true;
|
||||
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
|
||||
continue;
|
||||
}
|
||||
if (!extensionEntryCheck.exists) {
|
||||
hasFailure = true;
|
||||
if (extensionEntryCheck.kindMismatch) {
|
||||
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
|
||||
} else {
|
||||
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
|
||||
}
|
||||
|
||||
if (hasInfraError) {
|
||||
return {
|
||||
status: "infra_error",
|
||||
output: messages.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasFailure) {
|
||||
return {
|
||||
status: "fail",
|
||||
output: messages.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "pass",
|
||||
output: messages.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function toOverallStatus(states) {
|
||||
if (states.includes("infra_error")) {
|
||||
return "infra_error";
|
||||
@@ -497,11 +646,13 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
vally_lint_status: "not_run",
|
||||
smoke_status: "not_run",
|
||||
version_match_status: "not_run",
|
||||
canvas_structure_status: "not_run",
|
||||
failure_class: "none",
|
||||
summary: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
version_match_output: "",
|
||||
canvas_structure_output: "",
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -513,10 +664,14 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.vally_lint_status = "fail";
|
||||
result.smoke_status = "fail";
|
||||
result.version_match_status = "fail";
|
||||
result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run";
|
||||
result.overall_status = "fail";
|
||||
result.failure_class = "submitter_fixes";
|
||||
result.summary = `Plugin path "${plugin.source?.path || "/"}" was not found in the submitted repository snapshot.`;
|
||||
result.version_match_output = result.summary;
|
||||
if (hasCanvasKeyword(plugin)) {
|
||||
result.canvas_structure_output = result.summary;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -524,6 +679,10 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.version_match_status = versionMatchResult.status;
|
||||
result.version_match_output = versionMatchResult.output;
|
||||
|
||||
const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec);
|
||||
result.canvas_structure_status = canvasStructureResult.status;
|
||||
result.canvas_structure_output = canvasStructureResult.output;
|
||||
|
||||
const vallyResult = await runVallyLintGate(pluginRoot);
|
||||
result.vally_lint_status = vallyResult.status;
|
||||
result.vally_lint_output = vallyResult.output;
|
||||
@@ -532,12 +691,18 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.smoke_status = smokeResult.status;
|
||||
result.smoke_output = smokeResult.output;
|
||||
|
||||
result.overall_status = toOverallStatus([result.vally_lint_status, result.smoke_status, result.version_match_status]);
|
||||
result.overall_status = toOverallStatus([
|
||||
result.vally_lint_status,
|
||||
result.smoke_status,
|
||||
result.version_match_status,
|
||||
result.canvas_structure_status,
|
||||
]);
|
||||
result.failure_class = toFailureClass(result.overall_status);
|
||||
result.summary = [
|
||||
`- vally lint: ${result.vally_lint_status}`,
|
||||
`- install smoke test: ${result.smoke_status}`,
|
||||
`- version match: ${result.version_match_status}`,
|
||||
`- canvas structure: ${result.canvas_structure_status}`,
|
||||
`- overall: ${result.overall_status}`,
|
||||
].join("\n");
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { after, test } from "node:test";
|
||||
import { runCanvasStructureGate } from "./external-plugin-quality-gates.mjs";
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function runGit(repoDir, ...args) {
|
||||
const result = spawnSync("git", args, { cwd: repoDir, encoding: "utf8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(" ")} failed: ${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
return String(result.stdout ?? "").trim();
|
||||
}
|
||||
|
||||
function createTempRepo() {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "external-plugin-quality-"));
|
||||
tempDirs.push(repoDir);
|
||||
|
||||
runGit(repoDir, "init", "-q");
|
||||
runGit(repoDir, "config", "user.name", "Copilot Test");
|
||||
runGit(repoDir, "config", "user.email", "copilot@example.com");
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function commitAll(repoDir, message) {
|
||||
runGit(repoDir, "add", "-A");
|
||||
runGit(repoDir, "commit", "-m", message, "--quiet");
|
||||
return runGit(repoDir, "rev-parse", "HEAD");
|
||||
}
|
||||
|
||||
test("runCanvasStructureGate passes when extensions/extension.mjs exists", () => {
|
||||
const repoDir = createTempRepo();
|
||||
fs.mkdirSync(path.join(repoDir, "extensions"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs"), "export default {};\n");
|
||||
const sha = commitAll(repoDir, "Add canvas extension container");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.match(result.output, /found "extensions"/);
|
||||
});
|
||||
|
||||
test("runCanvasStructureGate fails when extension entrypoint is only at repo root", () => {
|
||||
const repoDir = createTempRepo();
|
||||
fs.writeFileSync(path.join(repoDir, "extension.mjs"), "export default {};\n");
|
||||
const sha = commitAll(repoDir, "Add root extension entrypoint");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "fail");
|
||||
assert.match(result.output, /missing required canvas extension directory "extensions"/);
|
||||
});
|
||||
|
||||
test("runCanvasStructureGate fails when extension entrypoint path is a directory", () => {
|
||||
const repoDir = createTempRepo();
|
||||
fs.mkdirSync(path.join(repoDir, "extensions", "extension.mjs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoDir, "extensions", "extension.mjs", "placeholder.txt"), "not-a-module\n");
|
||||
const sha = commitAll(repoDir, "Add invalid extension entrypoint directory");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "fail");
|
||||
assert.match(result.output, /"extensions\/extension\.mjs" must be a file/);
|
||||
});
|
||||
@@ -62,6 +62,16 @@ function collectLocalPluginsFromRoot(rootDir, sourcePrefix, includeEntry = () =>
|
||||
return plugins;
|
||||
}
|
||||
|
||||
function hasExtensionEntryPoint(extensionDir, extensionName) {
|
||||
const candidateEntryPoints = [
|
||||
path.join(extensionDir, "extension.mjs"),
|
||||
path.join(extensionDir, "extensions", "extension.mjs"),
|
||||
path.join(extensionDir, "extensions", extensionName, "extension.mjs"),
|
||||
];
|
||||
|
||||
return candidateEntryPoints.some((entryPointPath) => fs.existsSync(entryPointPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate marketplace.json from plugin directories
|
||||
*/
|
||||
@@ -78,7 +88,7 @@ function generateMarketplace() {
|
||||
...collectLocalPluginsFromRoot(
|
||||
EXTENSIONS_DIR,
|
||||
"extensions",
|
||||
(entryName) => fs.existsSync(path.join(EXTENSIONS_DIR, entryName, "extension.mjs"))
|
||||
(entryName) => hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entryName), entryName)
|
||||
)
|
||||
];
|
||||
|
||||
|
||||
@@ -35,6 +35,16 @@ const WEBSITE_SOURCE_DATA_DIR = path.join(WEBSITE_DIR, "data");
|
||||
const EXTERNAL_CANVAS_KEYWORD = "canvas";
|
||||
const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png";
|
||||
|
||||
function hasExtensionEntryPoint(extensionDir, extensionName) {
|
||||
const candidateEntryPoints = [
|
||||
path.join(extensionDir, "extension.mjs"),
|
||||
path.join(extensionDir, "extensions", "extension.mjs"),
|
||||
path.join(extensionDir, "extensions", extensionName, "extension.mjs"),
|
||||
];
|
||||
|
||||
return candidateEntryPoints.some((entryPointPath) => fs.existsSync(entryPointPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the output directory exists
|
||||
*/
|
||||
@@ -544,7 +554,7 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
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"));
|
||||
return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name);
|
||||
})
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
@@ -1235,12 +1245,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
.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);
|
||||
return hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
|
||||
+122
-2
@@ -2,9 +2,11 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
@@ -22,6 +24,36 @@ function copyDirRecursive(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
function moveEntry(srcPath, destPath) {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
try {
|
||||
fs.renameSync(srcPath, destPath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const stats = fs.statSync(srcPath);
|
||||
if (stats.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
fs.rmSync(srcPath, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
fs.rmSync(srcPath, { force: true });
|
||||
}
|
||||
|
||||
function isRelativeAssetPath(assetPath) {
|
||||
return typeof assetPath === "string" &&
|
||||
assetPath.length > 0 &&
|
||||
!/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(assetPath) &&
|
||||
!assetPath.startsWith("data:") &&
|
||||
!path.isAbsolute(assetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a plugin-relative path to the repo-root source file.
|
||||
*
|
||||
@@ -45,6 +77,59 @@ function resolveSource(relPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function materializeExtensionPlugin(extensionPath) {
|
||||
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
return { movedEntries: 0, manifestUpdated: false, skipped: true };
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ${pluginJsonPath}: ${err.message}`);
|
||||
}
|
||||
|
||||
const extensionContainerPath = path.join(extensionPath, "extensions");
|
||||
const extensionBundlePath = path.join(extensionContainerPath, path.basename(extensionPath));
|
||||
fs.rmSync(extensionContainerPath, { recursive: true, force: true });
|
||||
fs.mkdirSync(extensionBundlePath, { recursive: true });
|
||||
|
||||
let movedEntries = 0;
|
||||
for (const entry of fs.readdirSync(extensionPath, { withFileTypes: true })) {
|
||||
if (entry.name === ".github" || entry.name === "extensions") {
|
||||
continue;
|
||||
}
|
||||
|
||||
moveEntry(
|
||||
path.join(extensionPath, entry.name),
|
||||
path.join(extensionBundlePath, entry.name)
|
||||
);
|
||||
movedEntries++;
|
||||
}
|
||||
|
||||
if (isRelativeAssetPath(metadata.logo)) {
|
||||
const normalizedLogoPath = metadata.logo.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
const bundledLogoPath = path.join(extensionBundlePath, normalizedLogoPath);
|
||||
if (fs.existsSync(bundledLogoPath)) {
|
||||
const rootLogoPath = path.join(extensionPath, normalizedLogoPath);
|
||||
fs.mkdirSync(path.dirname(rootLogoPath), { recursive: true });
|
||||
fs.copyFileSync(bundledLogoPath, rootLogoPath);
|
||||
}
|
||||
}
|
||||
|
||||
let manifestUpdated = false;
|
||||
if (metadata.extensions !== "extensions") {
|
||||
metadata.extensions = "extensions";
|
||||
manifestUpdated = true;
|
||||
}
|
||||
if (manifestUpdated) {
|
||||
fs.writeFileSync(pluginJsonPath, JSON.stringify(metadata, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
return { movedEntries, manifestUpdated, skipped: false };
|
||||
}
|
||||
|
||||
function materializePlugins() {
|
||||
console.log("Materializing plugin files...\n");
|
||||
|
||||
@@ -61,6 +146,8 @@ function materializePlugins() {
|
||||
let totalAgents = 0;
|
||||
let totalSkills = 0;
|
||||
let totalExtensions = 0;
|
||||
let totalExtensionPlugins = 0;
|
||||
let totalExtensionPluginEntries = 0;
|
||||
let warnings = 0;
|
||||
let errors = 0;
|
||||
|
||||
@@ -185,7 +272,36 @@ function materializePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`);
|
||||
if (fs.existsSync(EXTENSIONS_DIR)) {
|
||||
const extensionDirs = fs.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const dirName of extensionDirs) {
|
||||
const extensionPath = path.join(EXTENSIONS_DIR, dirName);
|
||||
if (!fs.existsSync(path.join(extensionPath, "extension.mjs"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = materializeExtensionPlugin(extensionPath);
|
||||
if (result.skipped) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalExtensionPlugins++;
|
||||
totalExtensionPluginEntries += result.movedEntries;
|
||||
console.log(`✓ ${dirName}: materialized extension bundle into ./extensions (${result.movedEntries} entries)`);
|
||||
} catch (err) {
|
||||
console.error(`Error: Failed to materialize extension plugin ${dirName}: ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} plugin extension refs.`);
|
||||
console.log(`Materialized ${totalExtensionPlugins} extension plugins (${totalExtensionPluginEntries} top-level entries).`);
|
||||
if (warnings > 0) {
|
||||
console.log(`${warnings} warning(s).`);
|
||||
}
|
||||
@@ -195,4 +311,8 @@ function materializePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
materializePlugins();
|
||||
export { materializePlugins };
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
materializePlugins();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { after, test } from "node:test";
|
||||
import { materializeExtensionPlugin } from "./materialize-plugins.mjs";
|
||||
import { cleanMaterializedExtensionPlugin } from "./clean-materialized-plugins.mjs";
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeExtensionPlugin writes extension bundles to ./extensions and preserves root logo assets", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "materialize-extension-plugin-"));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const pluginDir = path.join(tempDir, "extension-plugin");
|
||||
fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({
|
||||
name: "test-extension-plugin",
|
||||
description: "test plugin",
|
||||
version: "1.0.0",
|
||||
logo: "assets/preview.png",
|
||||
extensions: ".",
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(pluginDir, "extension.mjs"), "export default {};\n");
|
||||
fs.writeFileSync(path.join(pluginDir, "README.md"), "# test\n");
|
||||
fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes");
|
||||
|
||||
const result = materializeExtensionPlugin(pluginDir);
|
||||
const bundleRoot = path.join(pluginDir, "extensions", "extension-plugin");
|
||||
|
||||
assert.equal(result.skipped, false);
|
||||
assert.equal(result.manifestUpdated, true);
|
||||
assert.equal(result.movedEntries, 3);
|
||||
assert.equal(fs.existsSync(path.join(bundleRoot, "extension.mjs")), true);
|
||||
assert.equal(fs.existsSync(path.join(bundleRoot, "assets", "preview.png")), true);
|
||||
assert.equal(fs.existsSync(path.join(bundleRoot, "README.md")), true);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "extensions", ".github")), false);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), false);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), false);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true);
|
||||
|
||||
const pluginManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8")
|
||||
);
|
||||
assert.equal(pluginManifest.extensions, "extensions");
|
||||
assert.equal(pluginManifest.logo, "assets/preview.png");
|
||||
});
|
||||
|
||||
test("cleanMaterializedExtensionPlugin restores moved extension files to root", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "clean-materialized-extension-plugin-"));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const pluginDir = path.join(tempDir, "extension-plugin");
|
||||
fs.mkdirSync(path.join(pluginDir, ".github", "plugin"), { recursive: true });
|
||||
fs.mkdirSync(path.join(pluginDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), JSON.stringify({
|
||||
name: "test-extension-plugin",
|
||||
description: "test plugin",
|
||||
version: "1.0.0",
|
||||
logo: "assets/preview.png",
|
||||
extensions: ".",
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(pluginDir, "extension.mjs"), "export default {};\n");
|
||||
fs.writeFileSync(path.join(pluginDir, "README.md"), "# test\n");
|
||||
fs.writeFileSync(path.join(pluginDir, "assets", "preview.png"), "fake-image-bytes");
|
||||
|
||||
materializeExtensionPlugin(pluginDir);
|
||||
const result = cleanMaterializedExtensionPlugin(pluginDir);
|
||||
|
||||
assert.equal(result.removed, 3);
|
||||
assert.equal(result.manifestUpdated, true);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "extension.mjs")), true);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "README.md")), true);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "assets", "preview.png")), true);
|
||||
assert.equal(fs.existsSync(path.join(pluginDir, "extensions")), false);
|
||||
|
||||
const pluginManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginDir, ".github", "plugin", "plugin.json"), "utf8")
|
||||
);
|
||||
assert.equal(pluginManifest.extensions, ".");
|
||||
assert.equal(pluginManifest.logo, "assets/preview.png");
|
||||
});
|
||||
@@ -305,9 +305,14 @@ function validateExtensionManifest(folderName) {
|
||||
errors.push("x-awesome-copilot field must not be present (use convention-based logo instead)");
|
||||
}
|
||||
|
||||
// Extension convention: extensions field must be "."
|
||||
if (parsed.extenions !== undefined) {
|
||||
errors.push('use "extensions" field (found misspelled key "extenions")');
|
||||
}
|
||||
|
||||
// Extension convention: source manifests keep extensions at repository root.
|
||||
// Materialization rewrites this to "extensions" on distribution branches.
|
||||
if (parsed.extensions !== ".") {
|
||||
errors.push('extensions field must be exactly "." (extension convention)');
|
||||
errors.push('extensions field must be exactly "." in source manifests (extension convention)');
|
||||
}
|
||||
|
||||
return { errors, plugin: parsedPlugin };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "accessibility-kanban",
|
||||
"description": "Kanban board to manage accessibility issues, allow you to plan, track, and complete remediation work.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "apng-studio",
|
||||
"description": "Interactive GitHub Copilot app canvas extension for building Animated PNG (APNG) files from frames. Draw or upload frames, tune per-frame timing and compositing, preview live, send the result to your phone by QR, and export an animated .png.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Andrea Griffiths",
|
||||
"url": "https://github.com/AndreaGriffiths11"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "arcade-canvas",
|
||||
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Dan Wahlin",
|
||||
"url": "https://github.com/DanWahlin"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "backlog-swipe-triage",
|
||||
"description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "James Montemagno",
|
||||
"url": "https://github.com/jamesmontemagno"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chromium-control-canvas",
|
||||
"description": "Opens a real Chromium window you can navigate and interact with from a Copilot canvas control panel and agent actions.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Andrea Griffiths",
|
||||
"url": "https://github.com/AndreaGriffiths11"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "color-orb",
|
||||
"description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "connector-namespaces",
|
||||
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.2",
|
||||
"author": {
|
||||
"name": "Alex Yang",
|
||||
"url": "https://github.com/alexyaang"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "diagram-viewer",
|
||||
"description": "Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "feedback-themes",
|
||||
"description": "Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gesture-review",
|
||||
"description": "Review pull requests with a live camera feed and approve or reject using thumbs-up/thumbs-down gestures.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "java-modernization-studio",
|
||||
"description": "Drive the GitHub Copilot App Modernization for Java workflow from an interactive canvas: environment readiness, repo assessment, prioritized plan and progress, validation gates, and one-click predefined-task runs grounded in the repo's real artifacts.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "release-notes-showcase",
|
||||
"description": "Compose and refine launch-ready release notes with contributor callouts and export-friendly output.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Kayla Cinnamon",
|
||||
"url": "https://github.com/cinnamon-msft"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "repo-actions-hub",
|
||||
"description": "Browse repository GitHub Actions workflows, inspect recent runs, and trigger manual workflow_dispatch runs from a Copilot canvas.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "James Montemagno",
|
||||
"url": "https://github.com/jamesmontemagno"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "site-studio",
|
||||
"description": "Plan, draft, and track a personal website section by section — a shared canvas where you and your agent author content, watch progress, and review every change.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Ayan Gupta",
|
||||
"url": "https://github.com/ayangupt"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tiny-tool-town-submitter",
|
||||
"description": "Inspect a repository, improve Tiny Tool Town readiness, submit its listing issue, and launch remediation work.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "James Montemagno",
|
||||
"url": "https://github.com/jamesmontemagno"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "token-pacman",
|
||||
"description": "Visualizes live session AI-credit usage as a Pac-Man board with pellets, ghosts, fruit milestones, and game-over limits.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "James Montemagno",
|
||||
"url": "https://github.com/jamesmontemagno"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "where-was-i",
|
||||
"description": "Reconstruct your dev context (branch, commits, uncommitted work, PR clues) and trigger a resume prompt to continue quickly.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "Aaron Powell",
|
||||
"url": "https://github.com/aaronpowell"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "work-hub",
|
||||
"description": "Generic cross-repo command center canvas for GitHub Copilot with onboarding, focus planning, repo health, work signals, and session cleanup.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"author": {
|
||||
"name": "James Montemagno",
|
||||
"url": "https://github.com/jamesmontemagno"
|
||||
|
||||
+27
-2
@@ -502,7 +502,7 @@
|
||||
{
|
||||
"name": "modernize-dotnet",
|
||||
"description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.",
|
||||
"version": "1.0.1157-preview1",
|
||||
"version": "1.0.1161-preview1",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
@@ -519,7 +519,8 @@
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "dotnet/modernize-dotnet",
|
||||
"path": "plugins/modernize-dotnet"
|
||||
"path": "plugins/modernize-dotnet",
|
||||
"sha": "ce4ec09678498da38099ca9169c1624c59c2a89a"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -732,6 +733,30 @@
|
||||
"sha": "80f2d93287054f9d30dd990e842e15bcfca581c9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "upgrade-agent",
|
||||
"description": "GitHub Copilot upgrade is an AI-powered agent that helps you upgrade applications to newer versions of languages, frameworks, and runtimes. It assesses your application, creates an upgrade plan, applies code changes, and validates the results through an interactive upgrade workflow.",
|
||||
"version": "1.1.222",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
},
|
||||
"repository": "https://github.com/microsoft/upgrade-agent-plugins",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"modernization",
|
||||
"upgrade",
|
||||
"migration",
|
||||
"dotnet",
|
||||
"canvas"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "microsoft/upgrade-agent-plugins",
|
||||
"path": "plugins/upgrade-agent",
|
||||
"sha": "379d344e42823b25223f878c002f38fb3a2c1d2b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vercel-plugin",
|
||||
"description": "Build and deploy web apps and agents. Comprehensive Vercel ecosystem plugin — relational knowledge graph, skills for every major product, specialized agents, and Vercel conventions. Turns any AI agent into a Vercel expert.",
|
||||
|
||||
Reference in New Issue
Block a user