diff --git a/eng/extension-plugin-ownership.mjs b/eng/extension-plugin-ownership.mjs new file mode 100644 index 00000000..3a226f97 --- /dev/null +++ b/eng/extension-plugin-ownership.mjs @@ -0,0 +1,81 @@ +import fs from "fs"; +import path from "path"; + +const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot"; + +function extensionIdFromReference(reference) { + if (typeof reference !== "string" || !reference.startsWith("./extensions/")) { + return null; + } + + return reference.replace(/^\.\/extensions\//, "").replace(/\/$/, ""); +} + +export function buildExtensionPluginOwners(pluginEntries) { + const owners = new Map(); + const sortedEntries = [...pluginEntries].sort((a, b) => + a.directoryName.localeCompare(b.directoryName) + ); + + for (const { directoryName, manifest } of sortedEntries) { + const pluginName = + typeof manifest?.name === "string" && manifest.name.trim() + ? manifest.name.trim() + : directoryName; + const extensionIds = new Set([directoryName]); + const references = + manifest?.extensions?.[AWESOME_COPILOT_NAMESPACE]?.extensions; + + if (Array.isArray(references)) { + for (const reference of references) { + const extensionId = extensionIdFromReference(reference); + if (extensionId) { + extensionIds.add(extensionId); + } + } + } + + for (const extensionId of extensionIds) { + const pluginNames = owners.get(extensionId) ?? []; + if (!pluginNames.includes(pluginName)) { + pluginNames.push(pluginName); + } + owners.set(extensionId, pluginNames); + } + } + + return owners; +} + +export function readExtensionPluginOwners(pluginsDir) { + if (!fs.existsSync(pluginsDir)) { + return new Map(); + } + + const pluginEntries = fs + .readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const manifestPath = path.join(pluginsDir, entry.name, "plugin.json"); + if (!fs.existsSync(manifestPath)) { + return null; + } + + return { + directoryName: entry.name, + manifest: JSON.parse(fs.readFileSync(manifestPath, "utf-8")), + }; + }) + .filter(Boolean); + + return buildExtensionPluginOwners(pluginEntries); +} + +export function resolveExtensionPluginName(extensionId, owners) { + const pluginNames = owners.get(extensionId) ?? []; + return ( + pluginNames.find((pluginName) => pluginName === extensionId) ?? + pluginNames[0] ?? + extensionId + ); +} diff --git a/eng/extension-plugin-ownership.test.mjs b/eng/extension-plugin-ownership.test.mjs new file mode 100644 index 00000000..6f226631 --- /dev/null +++ b/eng/extension-plugin-ownership.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildExtensionPluginOwners, + resolveExtensionPluginName, +} from "./extension-plugin-ownership.mjs"; + +const namespace = "com.github.awesome-copilot"; + +test("resolves an extension bundled only by its parent plugin", () => { + const owners = buildExtensionPluginOwners([ + { + directoryName: "ember", + manifest: { + name: "ember", + extensions: { + [namespace]: { + extensions: ["./extensions/daily-focus-board"], + }, + }, + }, + }, + ]); + + assert.equal( + resolveExtensionPluginName("daily-focus-board", owners), + "ember" + ); +}); + +test("prefers a same-named standalone plugin over another owner", () => { + const owners = buildExtensionPluginOwners([ + { + directoryName: "parent-plugin", + manifest: { + name: "parent-plugin", + extensions: { + [namespace]: { + extensions: ["./extensions/daily-focus-board/"], + }, + }, + }, + }, + { + directoryName: "daily-focus-board", + manifest: { name: "daily-focus-board" }, + }, + ]); + + assert.equal( + resolveExtensionPluginName("daily-focus-board", owners), + "daily-focus-board" + ); +}); + +test("preserves the extension name when no plugin owns it", () => { + assert.equal( + resolveExtensionPluginName("daily-focus-board", new Map()), + "daily-focus-board" + ); +}); diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 93721cc0..21aeddd4 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -26,6 +26,10 @@ import { parseYamlFile, } from "./yaml-parser.mjs"; import { readExternalPlugins } from "./external-plugin-validation.mjs"; +import { + readExtensionPluginOwners, + resolveExtensionPluginName, +} from "./extension-plugin-ownership.mjs"; const __filename = fileURLToPath(import.meta.url); @@ -1177,6 +1181,7 @@ function generateCanvasManifest(gitDates, commitSha) { return { items: [], filters: { keywords: [] } }; } + const extensionPluginOwners = readExtensionPluginOwners(PLUGINS_DIR); const extensionDirs = fs .readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) .filter((entry) => { @@ -1212,6 +1217,7 @@ function generateCanvasManifest(gitDates, commitSha) { normalizeText(packageJson.description, "Canvas extension") ); const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name)); + const pluginName = resolveExtensionPluginName(dir.name, extensionPluginOwners); const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0")); const readmeFile = fs.existsSync(path.join(extensionDir, "README.md")) ? `${relPath}/README.md` @@ -1230,7 +1236,7 @@ function generateCanvasManifest(gitDates, commitSha) { /\\/g, "/" )}`; - const installCommand = `copilot plugin install ${extensionName}@awesome-copilot`; + const installCommand = `copilot plugin install ${pluginName}@awesome-copilot`; for (const canvas of canvasEntries) { const canvasId = normalizeText(canvas.id, dir.name); @@ -1241,7 +1247,7 @@ function generateCanvasManifest(gitDates, commitSha) { canvasId, extensionId: dir.name, extensionName, - pluginName: extensionName, + pluginName, name: canvasName, version: extensionVersion, readmeFile,