mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-07 18:03:02 +00:00
chore: publish from main
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ Automatically generates `.github/plugin/marketplace.json` from all plugin direct
|
||||
|
||||
**How it works:**
|
||||
- Scans all directories in `plugins/`
|
||||
- Reads each plugin's `.github/plugin/plugin.json` for metadata
|
||||
- Reads each plugin's root `plugin.json` for metadata
|
||||
- Generates a consolidated `marketplace.json` with all available plugins
|
||||
- Runs automatically as part of `npm run build`
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
|
||||
export const AGENT_PLUGIN_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
||||
export const AGENT_PLUGIN_SCHEMA = {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: AGENT_PLUGIN_SCHEMA_URL,
|
||||
type: "object",
|
||||
properties: {
|
||||
$schema: { const: AGENT_PLUGIN_SCHEMA_URL },
|
||||
name: { type: "string", minLength: 1, maxLength: 64, pattern: "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" },
|
||||
version: { type: "string" }, description: { type: "string" },
|
||||
author: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, url: { type: "string" } }, additionalProperties: false },
|
||||
homepage: { type: "string" }, repository: { type: "string" }, license: { type: "string" },
|
||||
keywords: { type: "array", items: { type: "string" } },
|
||||
extensions: { type: "object", additionalProperties: { type: "object" } },
|
||||
},
|
||||
required: ["$schema", "name"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const validate = new Ajv2020({ allErrors: true }).compile(AGENT_PLUGIN_SCHEMA);
|
||||
export function validateAgentPluginManifest(manifest) {
|
||||
return validate(manifest) ? [] : (validate.errors ?? []).map((error) =>
|
||||
`${error.instancePath || "manifest"} ${error.message}`);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ function moveEntry(srcPath, destPath) {
|
||||
}
|
||||
|
||||
export function restoreManifestFromMaterializedFiles(pluginPath) {
|
||||
const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(pluginPath, "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function restoreManifestFromMaterializedFiles(pluginPath) {
|
||||
function cleanPlugin(pluginPath) {
|
||||
const manifestUpdated = restoreManifestFromMaterializedFiles(pluginPath);
|
||||
if (manifestUpdated) {
|
||||
console.log(` Updated ${path.basename(pluginPath)}/.github/plugin/plugin.json`);
|
||||
console.log(` Updated ${path.basename(pluginPath)}/plugin.json`);
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
@@ -126,7 +126,7 @@ function cleanPlugin(pluginPath) {
|
||||
}
|
||||
|
||||
export function cleanMaterializedExtensionPlugin(extensionPath) {
|
||||
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(extensionPath, "plugin.json");
|
||||
let manifestUpdated = false;
|
||||
if (fs.existsSync(pluginJsonPath)) {
|
||||
const plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
@@ -141,7 +141,7 @@ export function cleanMaterializedExtensionPlugin(extensionPath) {
|
||||
}
|
||||
if (manifestUpdated) {
|
||||
fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8");
|
||||
console.log(` Updated ${path.basename(extensionPath)}/.github/plugin/plugin.json`);
|
||||
console.log(` Updated ${path.basename(extensionPath)}/plugin.json`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ function isExtensionPluginDirectory(extensionPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pluginJsonPath = path.join(extensionPath, ".github", "plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(extensionPath, "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+10
-4
@@ -128,11 +128,11 @@ async function createPlugin() {
|
||||
}
|
||||
|
||||
// Create directory structure
|
||||
const githubPluginDir = path.join(pluginDir, ".github", "plugin");
|
||||
fs.mkdirSync(githubPluginDir, { recursive: true });
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
// Generate plugin.json
|
||||
const pluginJson = {
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: pluginId,
|
||||
description,
|
||||
version: "1.0.0",
|
||||
@@ -140,10 +140,16 @@ async function createPlugin() {
|
||||
author: { name: "Awesome Copilot Community" },
|
||||
repository: "https://github.com/github/awesome-copilot",
|
||||
license: "MIT",
|
||||
extensions: {
|
||||
"com.github.awesome-copilot": {
|
||||
agents: [],
|
||||
skills: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(githubPluginDir, "plugin.json"),
|
||||
path.join(pluginDir, "plugin.json"),
|
||||
JSON.stringify(pluginJson, null, 2) + "\n"
|
||||
);
|
||||
|
||||
@@ -176,7 +182,7 @@ MIT
|
||||
console.log(`\n✅ Created plugin: ${pluginDir}`);
|
||||
console.log("\n📝 Next steps:");
|
||||
console.log(`1. Add agents, prompts, or instructions to plugins/${pluginId}/`);
|
||||
console.log(`2. Update plugins/${pluginId}/.github/plugin/plugin.json with your metadata`);
|
||||
console.log(`2. Update plugins/${pluginId}/plugin.json with your metadata`);
|
||||
console.log(`3. Edit plugins/${pluginId}/README.md to describe your plugin`);
|
||||
console.log("4. Run 'npm run build' to regenerate documentation");
|
||||
} catch (error) {
|
||||
|
||||
@@ -783,6 +783,7 @@ export function parseMarkReadyForReviewCommand(body) {
|
||||
function normalizeQualityGateResult(rawResult) {
|
||||
const defaults = {
|
||||
overall_status: "not_run",
|
||||
spec_compliance_status: "not_run",
|
||||
vally_lint_status: "not_run",
|
||||
smoke_status: "not_run",
|
||||
version_match_status: "not_run",
|
||||
@@ -790,6 +791,7 @@ function normalizeQualityGateResult(rawResult) {
|
||||
canvas_structure_status: "not_run",
|
||||
failure_class: "none",
|
||||
summary: "",
|
||||
spec_compliance_output: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
version_match_output: "",
|
||||
@@ -808,6 +810,21 @@ function normalizeQualityGateResult(rawResult) {
|
||||
}
|
||||
|
||||
function buildQualityGatesCommentSection(qualityResult) {
|
||||
const formatStatus = (rawStatus, gate) => {
|
||||
const status = String(rawStatus || "not_run");
|
||||
if (status === "pass") {
|
||||
return "✅ pass";
|
||||
}
|
||||
if (status === "warning" || (gate === "spec" && status === "fail")) {
|
||||
return "⚠️ warning";
|
||||
}
|
||||
if (status === "fail" || status === "infra_error") {
|
||||
return "🛑 fail";
|
||||
}
|
||||
return "⚪ not_run";
|
||||
};
|
||||
|
||||
const specState = qualityResult.spec_compliance_status || "not_run";
|
||||
const vallyState = qualityResult.vally_lint_status || "not_run";
|
||||
const smokeState = qualityResult.smoke_status || "not_run";
|
||||
const versionMatchState = qualityResult.version_match_status || "not_run";
|
||||
@@ -818,23 +835,41 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
const sections = [
|
||||
"### Quality gate summary",
|
||||
"",
|
||||
"_Legend: ✅ pass · ⚠️ warning · 🛑 fail_",
|
||||
"",
|
||||
"| Gate | Status |",
|
||||
"|---|---|",
|
||||
`| vally lint | ${vallyState} |`,
|
||||
`| install smoke test | ${smokeState} |`,
|
||||
`| version match | ${versionMatchState} |`,
|
||||
`| ref/sha consistency | ${refShaConsistencyState} |`,
|
||||
`| canvas structure | ${canvasStructureState} |`,
|
||||
`| spec compliance (non-blocking) | ${formatStatus(specState, "spec")} |`,
|
||||
`| vally lint | ${formatStatus(vallyState, "vally")} |`,
|
||||
`| install smoke test | ${formatStatus(smokeState, "smoke")} |`,
|
||||
`| version match | ${formatStatus(versionMatchState, "version match")} |`,
|
||||
`| ref/sha consistency | ${formatStatus(refShaConsistencyState, "ref/sha consistency")} |`,
|
||||
`| canvas structure | ${formatStatus(canvasStructureState, "canvas structure")} |`,
|
||||
"",
|
||||
summaryText,
|
||||
];
|
||||
|
||||
const specOutput = String(qualityResult.spec_compliance_output || "").trim();
|
||||
if (specOutput) {
|
||||
sections.push(
|
||||
"",
|
||||
"<details>",
|
||||
`<summary>spec compliance output (${formatStatus(specState, "spec")})</summary>`,
|
||||
"",
|
||||
"```text",
|
||||
specOutput,
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
const vallyOutput = String(qualityResult.vally_lint_output || "").trim();
|
||||
if (vallyOutput) {
|
||||
sections.push(
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>vally lint output</summary>",
|
||||
`<summary>vally lint output (${formatStatus(vallyState, "vally")})</summary>`,
|
||||
"",
|
||||
"```text",
|
||||
vallyOutput,
|
||||
@@ -849,7 +884,7 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
sections.push(
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>Install smoke test output</summary>",
|
||||
`<summary>install smoke test output (${formatStatus(smokeState, "smoke")})</summary>`,
|
||||
"",
|
||||
"```text",
|
||||
smokeOutput,
|
||||
@@ -932,19 +967,24 @@ function buildMergedIntakeComment(baseResult, qualityResult, runId, owner, repo)
|
||||
const qualitySection = buildQualityGatesCommentSection(qualityResult);
|
||||
const runLink = runId && owner && repo ? `_[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})_` : "";
|
||||
|
||||
const hasSpecWarnings = String(qualityResult.spec_compliance_status || "") === "warning";
|
||||
const intro =
|
||||
qualityResult.failure_class === "submitter_fixes"
|
||||
? "## ⚠️ External plugin intake requires submitter fixes"
|
||||
? "## 🛑 External plugin intake failed (submitter fixes required)"
|
||||
: qualityResult.failure_class === "infra"
|
||||
? "## ⚠️ External plugin intake could not complete quality checks"
|
||||
: "## ✅ External plugin intake passed";
|
||||
? "## 🛑 External plugin intake failed (quality checks could not complete)"
|
||||
: hasSpecWarnings
|
||||
? "## ⚠️ External plugin intake passed with spec warnings"
|
||||
: "## ✅ External plugin intake passed";
|
||||
|
||||
const statusLine =
|
||||
qualityResult.failure_class === "submitter_fixes"
|
||||
? "This submission passed metadata validation, but quality gates found issues that must be fixed before it can move to maintainer review. Update the issue details or source plugin and then comment `/rerun-intake`."
|
||||
: qualityResult.failure_class === "infra"
|
||||
? "This submission passed metadata validation, but the automated quality checks hit an infrastructure issue. A maintainer should rerun intake or use the explicit override command after review."
|
||||
: "This submission passed automated intake validation and quality checks and is ready for maintainer review.";
|
||||
: hasSpecWarnings
|
||||
? "This submission passed blocking quality checks and is ready for maintainer review, but it has non-blocking Agent Plugins spec compliance warnings."
|
||||
: "This submission passed automated intake validation and quality checks and is ready for maintainer review.";
|
||||
|
||||
return [
|
||||
marker,
|
||||
@@ -1069,7 +1109,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
|
||||
].join("\n")
|
||||
: [
|
||||
marker,
|
||||
"## ⚠️ External plugin intake requires submitter fixes",
|
||||
"## 🛑 External plugin intake failed (submitter fixes required)",
|
||||
"",
|
||||
"This submission did not pass automated intake validation and cannot move to maintainer review yet.",
|
||||
`Edit the issue form to address the fixes below. Intake reruns automatically when the issue is edited, or the issue author/maintainer can comment \`${RERUN_INTAKE_COMMAND}\` to re-run on demand.`,
|
||||
|
||||
@@ -109,7 +109,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}, ref-sha-consistency=${entry.quality.ref_sha_consistency_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
|
||||
`- ${entry.name}: spec=${entry.quality.spec_compliance_status}, vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, ref-sha-consistency=${entry.quality.ref_sha_consistency_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
|
||||
@@ -7,8 +7,23 @@ import { Writable } from "stream";
|
||||
import { spawnSync } from "child_process";
|
||||
import { runLint, LintConsoleReporter } from "@microsoft/vally";
|
||||
import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs";
|
||||
import { validateAgentPluginManifest } from "./agent-plugin-schema.mjs";
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 12000;
|
||||
const AGENT_PLUGIN_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
||||
const AGENT_PLUGIN_ALLOWED_TOP_LEVEL_FIELDS = new Set([
|
||||
"$schema",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"homepage",
|
||||
"repository",
|
||||
"license",
|
||||
"keywords",
|
||||
"extensions",
|
||||
]);
|
||||
const AGENT_PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
||||
const EXTERNAL_CANVAS_KEYWORD = "canvas";
|
||||
|
||||
const INFRA_ERROR_PATTERNS = [
|
||||
@@ -168,10 +183,131 @@ function findPluginJson(pluginRoot) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inspectAgentPluginSpecCompliance(pluginRoot) {
|
||||
const pluginJsonPath = findPluginJson(pluginRoot);
|
||||
if (!pluginJsonPath) {
|
||||
return {
|
||||
status: "warning",
|
||||
output: "No plugin.json found in a recognized location. Agent Plugins v1.0.0 expects plugin.json at the plugin root.",
|
||||
};
|
||||
}
|
||||
|
||||
const rootPluginJsonPath = path.join(pluginRoot, "plugin.json");
|
||||
const issues = [];
|
||||
if (pluginJsonPath !== rootPluginJsonPath) {
|
||||
issues.push(`manifest location is "${path.relative(pluginRoot, pluginJsonPath)}"; expected "plugin.json" at plugin root`);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "warning",
|
||||
output: `plugin.json is not valid JSON: ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
||||
issues.push("plugin.json top-level value must be a JSON object");
|
||||
} else {
|
||||
if (manifest.$schema !== AGENT_PLUGIN_SCHEMA_URL) {
|
||||
issues.push(`$schema should be "${AGENT_PLUGIN_SCHEMA_URL}"`);
|
||||
}
|
||||
|
||||
const pluginName = manifest.name;
|
||||
if (typeof pluginName !== "string") {
|
||||
issues.push('required field "name" must be a string');
|
||||
} else {
|
||||
if (pluginName.length < 1 || pluginName.length > 64) {
|
||||
issues.push('field "name" must be 1-64 characters');
|
||||
}
|
||||
if (!AGENT_PLUGIN_NAME_PATTERN.test(pluginName)) {
|
||||
issues.push('field "name" does not match Agent Plugins naming constraints');
|
||||
}
|
||||
}
|
||||
|
||||
const requiredStringFields = ["version", "description"];
|
||||
for (const field of requiredStringFields) {
|
||||
if (typeof manifest[field] !== "string" || manifest[field].trim() === "") {
|
||||
issues.push(`required field "${field}" must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
const optionalStringFields = ["homepage", "repository", "license"];
|
||||
for (const field of optionalStringFields) {
|
||||
if (manifest[field] !== undefined && typeof manifest[field] !== "string") {
|
||||
issues.push(`field "${field}" must be a string when provided`);
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.author !== undefined) {
|
||||
if (!manifest.author || typeof manifest.author !== "object" || Array.isArray(manifest.author)) {
|
||||
issues.push('field "author" must be an object when provided');
|
||||
} else {
|
||||
const allowedAuthorFields = new Set(["name", "email", "url"]);
|
||||
for (const authorField of Object.keys(manifest.author)) {
|
||||
if (!allowedAuthorFields.has(authorField)) {
|
||||
issues.push(`field "author.${authorField}" is not allowed`);
|
||||
} else if (typeof manifest.author[authorField] !== "string") {
|
||||
issues.push(`field "author.${authorField}" must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.keywords !== undefined) {
|
||||
if (!Array.isArray(manifest.keywords)) {
|
||||
issues.push('field "keywords" must be an array of strings when provided');
|
||||
} else if (manifest.keywords.some((entry) => typeof entry !== "string")) {
|
||||
issues.push('field "keywords" must contain only strings');
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest.extensions !== undefined) {
|
||||
if (!manifest.extensions || typeof manifest.extensions !== "object" || Array.isArray(manifest.extensions)) {
|
||||
issues.push('field "extensions" must be an object when provided');
|
||||
} else {
|
||||
for (const [namespace, value] of Object.entries(manifest.extensions)) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
issues.push(`field "extensions.${namespace}" must be an object`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of Object.keys(manifest)) {
|
||||
if (!AGENT_PLUGIN_ALLOWED_TOP_LEVEL_FIELDS.has(field)) {
|
||||
issues.push(`top-level field "${field}" is not part of Agent Plugins v1.0.0`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest && typeof manifest === "object" && !Array.isArray(manifest)) {
|
||||
issues.push(...validateAgentPluginManifest(manifest).map((error) => `schema validation: ${error}`));
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return {
|
||||
status: "pass",
|
||||
output: `Agent Plugins v1.0.0 manifest checks passed for ${path.relative(pluginRoot, pluginJsonPath) || "plugin.json"}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "warning",
|
||||
output: [
|
||||
"Agent Plugins v1.0.0 manifest warnings:",
|
||||
...issues.map((issue) => `- ${issue}`),
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVallyLintArgs(pluginRoot) {
|
||||
const pluginJsonPath = findPluginJson(pluginRoot);
|
||||
if (!pluginJsonPath) {
|
||||
@@ -827,6 +963,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
overall_status: "not_run",
|
||||
vally_lint_status: "not_run",
|
||||
smoke_status: "not_run",
|
||||
spec_compliance_status: "not_run",
|
||||
version_match_status: "not_run",
|
||||
ref_sha_consistency_status: "not_run",
|
||||
canvas_structure_status: "not_run",
|
||||
@@ -834,6 +971,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
summary: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
spec_compliance_output: "",
|
||||
version_match_output: "",
|
||||
ref_sha_consistency_output: "",
|
||||
canvas_structure_output: "",
|
||||
@@ -847,12 +985,14 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
if (!fs.existsSync(pluginRoot) || !fs.statSync(pluginRoot).isDirectory()) {
|
||||
result.vally_lint_status = "fail";
|
||||
result.smoke_status = "fail";
|
||||
result.spec_compliance_status = "warning";
|
||||
result.version_match_status = "fail";
|
||||
result.ref_sha_consistency_status = "not_run";
|
||||
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.spec_compliance_output = result.summary;
|
||||
result.version_match_output = result.summary;
|
||||
if (hasCanvasKeyword(plugin)) {
|
||||
result.canvas_structure_output = result.summary;
|
||||
@@ -860,6 +1000,10 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const specResult = inspectAgentPluginSpecCompliance(pluginRoot);
|
||||
result.spec_compliance_status = specResult.status;
|
||||
result.spec_compliance_output = specResult.output;
|
||||
|
||||
const versionMatchResult = runVersionMatchGate(repoDir, plugin, fetchSpec);
|
||||
result.version_match_status = versionMatchResult.status;
|
||||
result.version_match_output = versionMatchResult.output;
|
||||
@@ -889,6 +1033,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
]);
|
||||
result.failure_class = toFailureClass(result.overall_status);
|
||||
result.summary = [
|
||||
`- spec compliance: ${result.spec_compliance_status}`,
|
||||
`- vally lint: ${result.vally_lint_status}`,
|
||||
`- install smoke test: ${result.smoke_status}`,
|
||||
`- version match: ${result.version_match_status}`,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
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");
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@ const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.j
|
||||
* @returns {object|null} - Plugin metadata or null if not found
|
||||
*/
|
||||
function readPluginMetadata(pluginDir) {
|
||||
const pluginJsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(pluginDir, "plugin.json");
|
||||
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
console.warn(`Warning: No plugin.json found for ${path.basename(pluginDir)}`);
|
||||
@@ -62,35 +62,18 @@ 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
|
||||
*/
|
||||
function generateMarketplace() {
|
||||
export function generateMarketplace() {
|
||||
console.log("Generating marketplace.json...");
|
||||
|
||||
if (!fs.existsSync(PLUGINS_DIR) && !fs.existsSync(EXTENSIONS_DIR)) {
|
||||
console.error(`Error: Neither plugins directory (${PLUGINS_DIR}) nor extensions directory (${EXTENSIONS_DIR}) was found`);
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
console.error(`Error: Plugins directory (${PLUGINS_DIR}) was not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const plugins = [
|
||||
...collectLocalPluginsFromRoot(PLUGINS_DIR, "plugins"),
|
||||
...collectLocalPluginsFromRoot(
|
||||
EXTENSIONS_DIR,
|
||||
"extensions",
|
||||
(entryName) => hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entryName), entryName)
|
||||
)
|
||||
];
|
||||
const plugins = collectLocalPluginsFromRoot(PLUGINS_DIR, "plugins");
|
||||
|
||||
console.log(`Found ${plugins.length} local plugin manifests`);
|
||||
|
||||
@@ -144,5 +127,6 @@ function generateMarketplace() {
|
||||
console.log(` Location: ${MARKETPLACE_FILE}`);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateMarketplace();
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
generateMarketplace();
|
||||
}
|
||||
|
||||
@@ -540,8 +540,6 @@ function resolvePluginItem(item, resourceIndex) {
|
||||
*/
|
||||
function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
const plugins = [];
|
||||
const extensionEntriesByName = new Map();
|
||||
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
return { items: [], filters: { tags: [] } };
|
||||
}
|
||||
@@ -550,85 +548,30 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
.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 hasExtensionEntryPoint(path.join(EXTENSIONS_DIR, entry.name), entry.name);
|
||||
})
|
||||
.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 = resolvePluginItem(
|
||||
{
|
||||
kind: "extension",
|
||||
path: relPath,
|
||||
},
|
||||
resourceIndex
|
||||
);
|
||||
const extReadmePath = path.join(extensionDir, "README.md");
|
||||
const extReadmeFile = fs.existsSync(extReadmePath)
|
||||
? `${relPath}/README.md`
|
||||
: null;
|
||||
|
||||
extensionEntriesByName.set(pluginName, {
|
||||
id: pluginName,
|
||||
name: pluginName,
|
||||
description: pluginDescription,
|
||||
path: relPath,
|
||||
readmeFile: extReadmeFile,
|
||||
version: normalizeText(extensionPlugin.version, null),
|
||||
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");
|
||||
const jsonPath = path.join(pluginDir, "plugin.json");
|
||||
|
||||
if (!fs.existsSync(jsonPath)) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||
const relPath = `plugins/${dir.name}`;
|
||||
const extensionRefs = Array.isArray(data?.["x-awesome-copilot"]?.extensions)
|
||||
? data["x-awesome-copilot"].extensions
|
||||
: [];
|
||||
const composition = data.extensions?.["com.github.awesome-copilot"] ?? {};
|
||||
const extensionRefs = composition.extensions
|
||||
?.map((entry) => entry.replace(/^\.\/extensions\//, "").replace(/\/$/, ""))
|
||||
.filter(Boolean) ?? [];
|
||||
if (fs.existsSync(path.join(EXTENSIONS_DIR, dir.name, "extension.mjs")) && !extensionRefs.includes(dir.name)) {
|
||||
extensionRefs.push(dir.name);
|
||||
}
|
||||
const extensionItems = extensionRefs
|
||||
.map((entry) => normalizeText(entry))
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^\.\/+/, "").replace(/\/$/, ""))
|
||||
.filter((entry) => entry.startsWith("extensions/"))
|
||||
.filter((entry) => typeof entry === "string")
|
||||
.map((entry) => ({
|
||||
kind: "extension",
|
||||
path: entry,
|
||||
path: `extensions/${entry}`,
|
||||
}));
|
||||
|
||||
const agentItems = (data.agents || []).flatMap((agent) => {
|
||||
const agentItems = (composition.agents || []).flatMap((agent) => {
|
||||
const agentPath = agent.replace("./", "");
|
||||
const fullPath = path.join(pluginDir, agentPath);
|
||||
|
||||
@@ -646,11 +589,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
|
||||
// Parse mcpServers: supports a path to a .mcp.json file or an inline object
|
||||
const mcpItems = [];
|
||||
if (data.mcpServers) {
|
||||
if (composition.mcpServers) {
|
||||
let mcpServersObj = null;
|
||||
let mcpConfigPath = relPath;
|
||||
if (typeof data.mcpServers === "string") {
|
||||
const manifestMcpPath = data.mcpServers.replace(/^\.\//, "");
|
||||
if (typeof composition.mcpServers === "string") {
|
||||
const manifestMcpPath = composition.mcpServers.replace(/^\.\//, "");
|
||||
mcpConfigPath = manifestMcpPath ? `${relPath}/${manifestMcpPath}` : relPath;
|
||||
const mcpJsonPath = path.join(pluginDir, manifestMcpPath);
|
||||
if (fs.existsSync(mcpJsonPath)) {
|
||||
@@ -661,8 +604,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
} else if (typeof data.mcpServers === "object") {
|
||||
mcpServersObj = data.mcpServers;
|
||||
} else if (typeof composition.mcpServers === "object") {
|
||||
mcpServersObj = composition.mcpServers;
|
||||
}
|
||||
if (mcpServersObj) {
|
||||
for (const serverName of Object.keys(mcpServersObj)) {
|
||||
@@ -674,8 +617,8 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
// Build items list from spec fields (agents, commands, skills, mcpServers)
|
||||
const items = [
|
||||
...agentItems,
|
||||
...(data.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
||||
...(data.skills || []).map((p) => ({ kind: "skill", path: p })),
|
||||
...(composition.commands || []).map((p) => ({ kind: "prompt", path: p })),
|
||||
...(composition.skills || []).map((p) => ({ kind: "skill", path: p })),
|
||||
...extensionItems,
|
||||
...mcpItems,
|
||||
].map((item) => resolvePluginItem(item, resourceIndex));
|
||||
@@ -702,16 +645,11 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
|
||||
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)) {
|
||||
@@ -1204,15 +1142,13 @@ function resolveExtensionScreenshots(pluginJson, extensionDir, relPath, ref) {
|
||||
}
|
||||
: 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;
|
||||
const copilotNs = pluginJson?.extensions?.["com.github.copilot"];
|
||||
const logoEntry = normalizeExtensionScreenshotRole(
|
||||
copilotNs?.logo ?? pluginJson?.logo,
|
||||
relPath, ref
|
||||
);
|
||||
const finalIcon = logoEntry || inferredIcon;
|
||||
const finalGallery = logoEntry || inferredGallery || finalIcon;
|
||||
|
||||
return {
|
||||
screenshots: {
|
||||
@@ -1256,7 +1192,7 @@ function generateCanvasManifest(gitDates, commitSha) {
|
||||
const packageJson = fs.existsSync(packageJsonPath)
|
||||
? JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
|
||||
: {};
|
||||
const pluginJsonPath = path.join(extensionDir, ".github", "plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(PLUGINS_DIR, dir.name, "plugin.json");
|
||||
const pluginJson = fs.existsSync(pluginJsonPath)
|
||||
? JSON.parse(fs.readFileSync(pluginJsonPath, "utf-8"))
|
||||
: {};
|
||||
|
||||
+65
-175
@@ -7,6 +7,8 @@ import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
const COPILOT_NAMESPACE = "com.github.copilot";
|
||||
const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot";
|
||||
|
||||
/**
|
||||
* Recursively copy a directory.
|
||||
@@ -24,36 +26,6 @@ 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.
|
||||
*
|
||||
@@ -74,63 +46,34 @@ function resolveSource(relPath) {
|
||||
const extensionName = relPath.replace(/^\.\/extensions\//, "").replace(/\/$/, "");
|
||||
return path.join(ROOT_FOLDER, "extensions", extensionName);
|
||||
}
|
||||
if (relPath.startsWith("./hooks/")) {
|
||||
return path.join(ROOT_FOLDER, "hooks", relPath.replace(/^\.\/hooks\//, ""));
|
||||
}
|
||||
if (relPath.startsWith("./commands/")) {
|
||||
return path.join(ROOT_FOLDER, "commands", relPath.replace(/^\.\/commands\//, ""));
|
||||
}
|
||||
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 };
|
||||
function readExtensionReferences(metadata, pluginName) {
|
||||
const extensionData = metadata.extensions?.[AWESOME_COPILOT_NAMESPACE];
|
||||
const directories = extensionData?.extensions ?? [];
|
||||
if (!Array.isArray(directories) ||
|
||||
directories.some((entry) => typeof entry !== "string" || !entry.startsWith("./extensions/"))) {
|
||||
throw new Error(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must contain plugin-relative paths`);
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ${pluginJsonPath}: ${err.message}`);
|
||||
const names = new Set(directories.map((entry) =>
|
||||
entry.replace(/^\.\/extensions\//, "").replace(/\/$/, "")
|
||||
));
|
||||
if (fs.existsSync(path.join(EXTENSIONS_DIR, pluginName, "extension.mjs"))) {
|
||||
names.add(pluginName);
|
||||
}
|
||||
|
||||
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 };
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
function materializePlugins() {
|
||||
export function materializePlugins() {
|
||||
console.log("Materializing plugin files...\n");
|
||||
|
||||
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||
@@ -146,14 +89,12 @@ function materializePlugins() {
|
||||
let totalAgents = 0;
|
||||
let totalSkills = 0;
|
||||
let totalExtensions = 0;
|
||||
let totalExtensionPlugins = 0;
|
||||
let totalExtensionPluginEntries = 0;
|
||||
let warnings = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const dirName of pluginDirs) {
|
||||
const pluginPath = path.join(PLUGINS_DIR, dirName);
|
||||
const pluginJsonPath = path.join(pluginPath, ".github/plugin", "plugin.json");
|
||||
const pluginJsonPath = path.join(pluginPath, "plugin.json");
|
||||
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
continue;
|
||||
@@ -170,52 +111,37 @@ function materializePlugins() {
|
||||
|
||||
const pluginName = metadata.name || dirName;
|
||||
|
||||
// Process agents
|
||||
if (Array.isArray(metadata.agents)) {
|
||||
for (const relPath of metadata.agents) {
|
||||
const composition = metadata.extensions?.[AWESOME_COPILOT_NAMESPACE] ?? {};
|
||||
|
||||
// Process repository composition fields.
|
||||
for (const field of ["agents", "commands", "hooks", "skills"]) {
|
||||
const entries = composition[field];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (const relPath of entries) {
|
||||
const src = resolveSource(relPath);
|
||||
if (!src) {
|
||||
console.warn(` ⚠ ${pluginName}: Unknown path format: ${relPath}`);
|
||||
console.warn(` ⚠ ${pluginName}: Unknown ${field} path format: ${relPath}`);
|
||||
warnings++;
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(src)) {
|
||||
console.warn(` ⚠ ${pluginName}: Source not found: ${src}`);
|
||||
warnings++;
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(pluginPath, relPath.replace(/^\.\//, ""));
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
totalAgents++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process skills
|
||||
if (Array.isArray(metadata.skills)) {
|
||||
for (const relPath of metadata.skills) {
|
||||
const src = resolveSource(relPath);
|
||||
if (!src) {
|
||||
console.warn(` ⚠ ${pluginName}: Unknown path format: ${relPath}`);
|
||||
warnings++;
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) {
|
||||
console.warn(` ⚠ ${pluginName}: Source directory not found: ${src}`);
|
||||
console.warn(` ⚠ ${pluginName}: ${field} source not found: ${src}`);
|
||||
warnings++;
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, ""));
|
||||
copyDirRecursive(src, dest);
|
||||
totalSkills++;
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
if (fs.statSync(src).isDirectory()) copyDirRecursive(src, dest);
|
||||
else fs.copyFileSync(src, dest);
|
||||
if (field === "agents") totalAgents++;
|
||||
if (field === "skills") totalSkills++;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Process reusable extensions declared in the repository namespace.
|
||||
const extensionRefs = readExtensionReferences(metadata, pluginName);
|
||||
for (const extensionName of extensionRefs) {
|
||||
const relPath = `./extensions/${extensionName}`;
|
||||
const src = resolveSource(relPath);
|
||||
if (!src) {
|
||||
console.warn(` ⚠ ${pluginName}: Unknown extension path format: ${relPath}`);
|
||||
@@ -227,81 +153,47 @@ function materializePlugins() {
|
||||
warnings++;
|
||||
continue;
|
||||
}
|
||||
const dest = path.join(pluginPath, relPath.replace(/^\.\//, "").replace(/\/$/, ""));
|
||||
// Extensions are conventional plugin content and belong under the
|
||||
// plugin's top-level extensions directory, not the client namespace.
|
||||
const dest = path.join(pluginPath, "extensions", extensionName);
|
||||
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.
|
||||
const rewritten = { ...metadata };
|
||||
let changed = false;
|
||||
// Emit a spec-compliant served manifest for the marketplace branch.
|
||||
// Source manifests keep composition fields (agents and skills)
|
||||
// for build tooling. The served manifest retains only Agent Plugins v1.0.0 fields
|
||||
// so the runtime uses conventional directory discovery for all content.
|
||||
const SPEC_FIELDS = new Set(["$schema", "name", "version", "description", "author",
|
||||
"homepage", "repository", "license", "keywords", "extensions"]);
|
||||
const AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
||||
|
||||
for (const field of ["agents", "commands"]) {
|
||||
if (Array.isArray(rewritten[field]) && rewritten[field].length > 0) {
|
||||
const dirs = [...new Set(rewritten[field].map(p => path.dirname(p)))];
|
||||
rewritten[field] = dirs;
|
||||
changed = true;
|
||||
const served = { "$schema": AGENT_PLUGINS_SCHEMA };
|
||||
for (const [key, val] of Object.entries(metadata)) {
|
||||
if (SPEC_FIELDS.has(key) && key !== "$schema") {
|
||||
if (key === "extensions") {
|
||||
const copilot = val?.[COPILOT_NAMESPACE];
|
||||
if (copilot) {
|
||||
served.extensions = { [COPILOT_NAMESPACE]: { ...copilot } };
|
||||
}
|
||||
} else {
|
||||
served[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(rewritten.skills) && rewritten.skills.length > 0) {
|
||||
// Skills are already folder refs (./skills/name/); strip trailing slash
|
||||
rewritten.skills = rewritten.skills.map(p => p.replace(/\/$/, ""));
|
||||
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");
|
||||
}
|
||||
fs.writeFileSync(pluginJsonPath, JSON.stringify(served, null, 2) + "\n", "utf8");
|
||||
|
||||
const counts = [];
|
||||
if (metadata.agents?.length) counts.push(`${metadata.agents.length} agents`);
|
||||
if (metadata.skills?.length) counts.push(`${metadata.skills.length} skills`);
|
||||
if (composition.agents?.length) counts.push(`${composition.agents.length} agents`);
|
||||
if (composition.skills?.length) counts.push(`${composition.skills.length} skills`);
|
||||
if (extensionRefs.length) counts.push(`${extensionRefs.length} extensions`);
|
||||
if (counts.length) {
|
||||
console.log(`✓ ${pluginName}: ${counts.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
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).`);
|
||||
console.log(`\nDone. Copied ${totalAgents} agents, ${totalSkills} skills, ${totalExtensions} extensions.`);
|
||||
if (warnings > 0) {
|
||||
console.log(`${warnings} warning(s).`);
|
||||
}
|
||||
@@ -311,8 +203,6 @@ function materializePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
export { materializePlugins };
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
materializePlugins();
|
||||
}
|
||||
|
||||
@@ -1,89 +1,9 @@
|
||||
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";
|
||||
import { test } from "node:test";
|
||||
import { materializePlugins } from "./materialize-plugins.mjs";
|
||||
import { generateMarketplace } from "./generate-marketplace.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");
|
||||
test("build scripts expose callable APIs without running on import", () => {
|
||||
assert.equal(typeof materializePlugins, "function");
|
||||
assert.equal(typeof generateMarketplace, "function");
|
||||
});
|
||||
|
||||
+34
-7
@@ -29,6 +29,7 @@ import {
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
|
||||
// Cache of MCP registry server names (lower-cased) fetched from the API
|
||||
let MCP_REGISTRY_SET = null;
|
||||
@@ -715,7 +716,7 @@ function generateUnifiedModeSection(cfg) {
|
||||
* Read and parse a plugin.json file from a plugin directory.
|
||||
*/
|
||||
function readPluginJson(pluginDir) {
|
||||
const jsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
|
||||
const jsonPath = path.join(pluginDir, "plugin.json");
|
||||
if (!fs.existsSync(jsonPath)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
||||
@@ -786,10 +787,23 @@ function generatePluginsSection(pluginsDir) {
|
||||
for (const entry of sortedEntries) {
|
||||
const { plugin, dir, name, isFeatured } = entry;
|
||||
const description = formatTableCell(plugin.description || "No description");
|
||||
const composition = plugin.extensions?.["com.github.awesome-copilot"] || {};
|
||||
const extensionReferences = Array.isArray(composition.extensions)
|
||||
? composition.extensions.length
|
||||
: 0;
|
||||
const implicitExtension =
|
||||
fs.existsSync(path.join(EXTENSIONS_DIR, entry.pluginId, "extension.mjs")) &&
|
||||
!(Array.isArray(composition.extensions) && composition.extensions.some(
|
||||
(reference) => reference === `./extensions/${entry.pluginId}`
|
||||
))
|
||||
? 1
|
||||
: 0;
|
||||
const itemCount =
|
||||
(plugin.agents || []).length +
|
||||
(plugin.commands || []).length +
|
||||
(plugin.skills || []).length;
|
||||
(composition.agents || []).length +
|
||||
(composition.commands || []).length +
|
||||
(composition.skills || []).length +
|
||||
extensionReferences +
|
||||
implicitExtension;
|
||||
const keywords = plugin.keywords ? plugin.keywords.join(", ") : "";
|
||||
|
||||
const link = `../plugins/${dir}/README.md`;
|
||||
@@ -842,10 +856,23 @@ function generateFeaturedPluginsSection(pluginsDir) {
|
||||
plugin.description || "No description"
|
||||
);
|
||||
const keywords = plugin.keywords ? plugin.keywords.join(", ") : "";
|
||||
const composition = plugin.extensions?.["com.github.awesome-copilot"] || {};
|
||||
const extensionReferences = Array.isArray(composition.extensions)
|
||||
? composition.extensions.length
|
||||
: 0;
|
||||
const implicitExtension =
|
||||
fs.existsSync(path.join(EXTENSIONS_DIR, name, "extension.mjs")) &&
|
||||
!(Array.isArray(composition.extensions) && composition.extensions.some(
|
||||
(reference) => reference === `./extensions/${name}`
|
||||
))
|
||||
? 1
|
||||
: 0;
|
||||
const itemCount =
|
||||
(plugin.agents || []).length +
|
||||
(plugin.commands || []).length +
|
||||
(plugin.skills || []).length;
|
||||
(composition.agents || []).length +
|
||||
(composition.commands || []).length +
|
||||
(composition.skills || []).length +
|
||||
extensionReferences +
|
||||
implicitExtension;
|
||||
|
||||
return {
|
||||
dir,
|
||||
|
||||
+119
-133
@@ -5,10 +5,15 @@ import path from "path";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
import { readExternalPlugins } from "./external-plugin-validation.mjs";
|
||||
import { validateLicenseField } from "./lib/license.mjs";
|
||||
import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest } from "./agent-plugin-schema.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
|
||||
|
||||
const AGENT_PLUGINS_SCHEMA = AGENT_PLUGIN_SCHEMA_URL;
|
||||
const COPILOT_NAMESPACE = "com.github.copilot";
|
||||
const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot";
|
||||
|
||||
// Validation functions
|
||||
function validateName(name, folderName) {
|
||||
const errors = [];
|
||||
@@ -16,11 +21,11 @@ function validateName(name, folderName) {
|
||||
errors.push("name is required and must be a string");
|
||||
return errors;
|
||||
}
|
||||
if (name.length < 1 || name.length > 50) {
|
||||
errors.push("name must be between 1 and 50 characters");
|
||||
if (name.length < 1 || name.length > 64) {
|
||||
errors.push("name must be between 1 and 64 characters");
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
errors.push("name must contain only lowercase letters, numbers, and hyphens");
|
||||
if (!/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
errors.push("name must contain only lowercase letters, numbers, hyphens, and dots (spec §5.5)");
|
||||
}
|
||||
if (name !== folderName) {
|
||||
errors.push(`name "${name}" must match folder name "${folderName}"`);
|
||||
@@ -28,6 +33,14 @@ function validateName(name, folderName) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateSchema(parsed) {
|
||||
if (parsed["$schema"] !== AGENT_PLUGINS_SCHEMA) {
|
||||
return `$schema must be "${AGENT_PLUGINS_SCHEMA}"`;
|
||||
}
|
||||
const schemaErrors = validateAgentPluginManifest(parsed);
|
||||
return schemaErrors.length ? `manifest does not conform to Agent Plugins schema: ${schemaErrors.join("; ")}` : null;
|
||||
}
|
||||
|
||||
function validateDescription(description) {
|
||||
if (!description || typeof description !== "string") {
|
||||
return "description is required and must be a string";
|
||||
@@ -106,35 +119,40 @@ function validateSpecPaths(plugin) {
|
||||
const errors = [];
|
||||
const specs = {
|
||||
agents: { prefix: "./agents/", suffix: ".md", repoDir: "agents", repoSuffix: ".agent.md" },
|
||||
commands: { prefix: "./commands/", suffix: ".md", repoDir: "commands", repoSuffix: ".md" },
|
||||
hooks: { prefix: "./hooks/", suffix: "/", repoDir: "hooks", repoFile: "README.md" },
|
||||
skills: { prefix: "./skills/", suffix: "/", repoDir: "skills", repoFile: "SKILL.md" },
|
||||
};
|
||||
|
||||
for (const [field, spec] of Object.entries(specs)) {
|
||||
const arr = plugin[field];
|
||||
const arr = plugin.extensions?.[AWESOME_COPILOT_NAMESPACE]?.[field];
|
||||
if (arr === undefined) continue;
|
||||
if (!Array.isArray(arr)) {
|
||||
errors.push(`${field} must be an array`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must be an array`);
|
||||
continue;
|
||||
}
|
||||
if (!arraysEqual(arr, sortPluginEntries(arr))) {
|
||||
errors.push(`${field} must be sorted alphabetically`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must be sorted alphabetically`);
|
||||
}
|
||||
if (new Set(arr).size !== arr.length) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field} must not contain duplicate references`);
|
||||
}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const p = arr[i];
|
||||
if (typeof p !== "string") {
|
||||
errors.push(`${field}[${i}] must be a string`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must be a string`);
|
||||
continue;
|
||||
}
|
||||
if (!p.startsWith("./")) {
|
||||
errors.push(`${field}[${i}] must start with "./"`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must start with "./"`);
|
||||
continue;
|
||||
}
|
||||
if (!p.startsWith(spec.prefix)) {
|
||||
errors.push(`${field}[${i}] must start with "${spec.prefix}"`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must start with "${spec.prefix}"`);
|
||||
continue;
|
||||
}
|
||||
if (!p.endsWith(spec.suffix)) {
|
||||
errors.push(`${field}[${i}] must end with "${spec.suffix}"`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] must end with "${spec.suffix}"`);
|
||||
continue;
|
||||
}
|
||||
// Validate the source file exists at repo root
|
||||
@@ -143,12 +161,16 @@ function validateSpecPaths(plugin) {
|
||||
const skillDir = path.join(ROOT_FOLDER, spec.repoDir, basename);
|
||||
const skillFile = path.join(skillDir, spec.repoFile);
|
||||
if (!fs.existsSync(skillFile)) {
|
||||
errors.push(`${field}[${i}] source not found: ${spec.repoDir}/${basename}/SKILL.md`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found: ${spec.repoDir}/${basename}/SKILL.md`);
|
||||
}
|
||||
} else {
|
||||
const srcFile = path.join(ROOT_FOLDER, spec.repoDir, basename + spec.repoSuffix);
|
||||
const srcFile = spec.repoFile
|
||||
? path.join(ROOT_FOLDER, spec.repoDir, basename, spec.repoFile)
|
||||
: path.join(ROOT_FOLDER, spec.repoDir, basename + spec.repoSuffix);
|
||||
if (!fs.existsSync(srcFile)) {
|
||||
errors.push(`${field}[${i}] source not found: ${spec.repoDir}/${basename}${spec.repoSuffix}`);
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found`);
|
||||
} else if (field === "hooks" && !fs.existsSync(path.join(ROOT_FOLDER, spec.repoDir, basename, "hooks.json"))) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}[${i}] source not found: ${spec.repoDir}/${basename}/hooks.json`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,41 +178,61 @@ function validateSpecPaths(plugin) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateCuratedPluginExtensionRefs(plugin) {
|
||||
function validateExtensionReferences(plugin, pluginDir) {
|
||||
const errors = [];
|
||||
const extensionRefs = plugin?.["x-awesome-copilot"]?.extensions;
|
||||
if (extensionRefs === undefined) {
|
||||
const directories = plugin.extensions?.[AWESOME_COPILOT_NAMESPACE]?.extensions;
|
||||
if (directories === undefined) {
|
||||
return errors;
|
||||
}
|
||||
if (!Array.isArray(directories)) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must be an array`);
|
||||
return errors;
|
||||
}
|
||||
if (!arraysEqual(directories, sortPluginEntries(directories))) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions entries must be sorted alphabetically`);
|
||||
}
|
||||
if (new Set(directories).size !== directories.length) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions must not contain duplicate references`);
|
||||
}
|
||||
|
||||
for (const [index, directory] of directories.entries()) {
|
||||
const name = typeof directory === "string"
|
||||
? directory.replace(/^\.\/extensions\//, "").replace(/\/$/, "")
|
||||
: "";
|
||||
if (typeof directory !== "string" || !directory.startsWith("./extensions/") ||
|
||||
!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions[${index}] must be a valid ./extensions/<name> path`);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(path.join(EXTENSIONS_DIR, name, "extension.mjs"))) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].extensions[${index}] source not found: extensions/${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateCompositionNamespace(plugin) {
|
||||
const errors = [];
|
||||
const compositionFields = ["agents", "commands", "hooks", "mcpServers", "skills"];
|
||||
const extensions = plugin.extensions;
|
||||
const composition = extensions?.[AWESOME_COPILOT_NAMESPACE];
|
||||
|
||||
if (extensions !== undefined &&
|
||||
(typeof extensions !== "object" || extensions === null || Array.isArray(extensions))) {
|
||||
errors.push(`extensions must be an object containing "${AWESOME_COPILOT_NAMESPACE}"`);
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (!Array.isArray(extensionRefs)) {
|
||||
errors.push('x-awesome-copilot.extensions must be an array');
|
||||
if (composition !== undefined &&
|
||||
(typeof composition !== "object" || composition === null || Array.isArray(composition))) {
|
||||
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"] must be an object`);
|
||||
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}`);
|
||||
for (const field of compositionFields) {
|
||||
if (extensions?.[field] !== undefined) {
|
||||
errors.push(`extensions.${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +243,13 @@ function validatePlugin(folderName) {
|
||||
const pluginDir = path.join(PLUGINS_DIR, folderName);
|
||||
const errors = [];
|
||||
let parsedPlugin = null;
|
||||
const extensionDir = path.join(EXTENSIONS_DIR, folderName);
|
||||
const isExtensionPlugin = fs.existsSync(path.join(extensionDir, "extension.mjs"));
|
||||
|
||||
// Rule 1: Must have .github/plugin/plugin.json
|
||||
const pluginJsonPath = path.join(pluginDir, ".github/plugin", "plugin.json");
|
||||
// Rule 1: Must have plugin.json at the plugin root
|
||||
const pluginJsonPath = path.join(pluginDir, "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
errors.push("missing required file: .github/plugin/plugin.json");
|
||||
errors.push("missing required file: plugin.json");
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -226,7 +270,11 @@ function validatePlugin(folderName) {
|
||||
return { errors, plugin: parsedPlugin };
|
||||
}
|
||||
|
||||
// Rule 3 & 4: name, description, version
|
||||
// Rule 3: $schema required
|
||||
const schemaError = validateSchema(plugin);
|
||||
if (schemaError) errors.push(schemaError);
|
||||
|
||||
// Rule 4 & 5: name, description, version
|
||||
const nameErrors = validateName(plugin.name, folderName);
|
||||
errors.push(...nameErrors);
|
||||
|
||||
@@ -236,12 +284,18 @@ function validatePlugin(folderName) {
|
||||
const versionError = validateVersion(plugin.version);
|
||||
if (versionError) errors.push(versionError);
|
||||
|
||||
// Rule 5: keywords (or tags for backward compat)
|
||||
// Rule 6: keywords (or tags for backward compat)
|
||||
const keywordsError = validateKeywords(plugin.keywords ?? plugin.tags);
|
||||
if (keywordsError) errors.push(keywordsError);
|
||||
|
||||
// Rule 5b: license (shared with external plugins). Non-SPDX is a warning, not an error.
|
||||
const warnings = [];
|
||||
for (const field of ["agents", "commands", "hooks", "mcpServers", "skills"]) {
|
||||
if (plugin[field] !== undefined) {
|
||||
errors.push(`${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`);
|
||||
}
|
||||
}
|
||||
errors.push(...validateCompositionNamespace(plugin));
|
||||
const licenseResult = validateLicenseField(plugin.license, { required: false });
|
||||
errors.push(...licenseResult.errors);
|
||||
warnings.push(...licenseResult.warnings);
|
||||
@@ -250,9 +304,19 @@ function validatePlugin(folderName) {
|
||||
const specErrors = validateSpecPaths(plugin);
|
||||
errors.push(...specErrors);
|
||||
|
||||
const extensionRefErrors = validateCuratedPluginExtensionRefs(plugin);
|
||||
const extensionRefErrors = validateExtensionReferences(plugin, pluginDir);
|
||||
errors.push(...extensionRefErrors);
|
||||
|
||||
if (isExtensionPlugin) {
|
||||
const extension = plugin.extensions;
|
||||
const namespace = extension?.[COPILOT_NAMESPACE];
|
||||
if (!namespace || namespace.logo !== "assets/preview.png") {
|
||||
errors.push(`extensions["${COPILOT_NAMESPACE}"].logo must be exactly "assets/preview.png" for extension plugins`);
|
||||
} else {
|
||||
validateExtensionScreenshotPath(extensionDir, namespace.logo, `extensions["${COPILOT_NAMESPACE}"].logo`, errors);
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, warnings, plugin: parsedPlugin };
|
||||
}
|
||||
|
||||
@@ -269,62 +333,6 @@ function validateExtensionScreenshotPath(extensionDir, pathValue, fieldName, err
|
||||
}
|
||||
}
|
||||
|
||||
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)");
|
||||
}
|
||||
|
||||
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 "." in source manifests (extension convention)');
|
||||
}
|
||||
|
||||
return { errors, plugin: parsedPlugin };
|
||||
}
|
||||
|
||||
// Main validation function
|
||||
function validatePlugins() {
|
||||
const pluginDirs = fs.existsSync(PLUGINS_DIR)
|
||||
@@ -332,15 +340,12 @@ function validatePlugins() {
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
: [];
|
||||
const extensionDirs = getExtensionFolderNames();
|
||||
|
||||
if (pluginDirs.length === 0 && extensionDirs.length === 0) {
|
||||
console.log("No plugins or extension plugin manifests found - validation skipped");
|
||||
if (pluginDirs.length === 0) {
|
||||
console.log("No 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();
|
||||
@@ -374,30 +379,11 @@ function validatePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
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}`));
|
||||
for (const dir of getExtensionFolderNames()) {
|
||||
const pluginJsonPath = path.join(PLUGINS_DIR, dir, "plugin.json");
|
||||
if (!fs.existsSync(pluginJsonPath)) {
|
||||
console.error(`❌ extension ${dir}: missing plugin manifest at plugins/${dir}/plugin.json`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +404,7 @@ function validatePlugins() {
|
||||
}
|
||||
|
||||
if (!hasErrors) {
|
||||
console.log(`\n✅ All ${pluginDirs.length} plugins, ${extensionDirs.length} extensions, and the external catalog are valid`);
|
||||
console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`);
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
|
||||
Reference in New Issue
Block a user