Files
awesome-copilot/eng/clean-materialized-plugins.mjs
T
Aaron Powell 26fe2d126b Fix extension materialization to move bundles into container (#2339)
* Migrate extension plugin materialization layout

Materialize extension plugins into a dedicated extensions/ container, validate the new manifest convention, and bump extension plugin versions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Keep extension manifests source-compatible

Restore source extension manifests to "extensions": "." while preserving materialization-time rewrite to "extensions" in distribution output.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Validate canvas extension layout for external submissions

Add intake and quality-gate checks for canvas-tagged external plugins so they must include extensions/extension.mjs and optional manifest extensions is validated when present.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Fix plugin clean extension pass and typo guard text

Declare EXTENSIONS_DIR in clean-materialized-plugins and run extension cleanup once after plugin cleanup. Also normalize misspelled-key detection strings to satisfy spelling checks without changing validation behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Proper codespell fix

* Separate canvas structure quality gate status

Track canvas structure as its own gate status and output, include it in aggregate summaries, and enforce Git object types so extensions/ is a tree and extensions/extension.mjs is a blob.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Move extension bundles during materialization

Change extension-plugin materialization to move root bundle entries into extensions/ instead of copying them, and assert originals are removed in test coverage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Nest materialized extension bundles by plugin name

Materialize extension plugins into extensions/<plugin-name>/... (moved entries) so resulting paths are duplicated by design, and bump extension plugin versions to the next patch release.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Fix extension materialization publish and cleanup

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1

* Preserve root extension logo asset

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: d26008fb-9928-4ba7-b7c8-36f35320f7c1
2026-07-18 10:49:27 +10:00

305 lines
9.0 KiB
JavaScript

#!/usr/bin/env node
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");
const MATERIALIZED_SPECS = {
agents: {
path: "agents",
restore(dirPath) {
return collectFiles(dirPath).map((relativePath) => `./agents/${relativePath}`);
},
},
commands: {
path: "commands",
restore(dirPath) {
return collectFiles(dirPath).map((relativePath) => `./commands/${relativePath}`);
},
},
skills: {
path: "skills",
restore(dirPath) {
return collectSkillDirectories(dirPath).map((relativePath) => `./skills/${relativePath}/`);
},
},
};
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)) {
return false;
}
let plugin;
try {
plugin = JSON.parse(fs.readFileSync(pluginJsonPath, "utf8"));
} catch (error) {
throw new Error(`Failed to parse ${pluginJsonPath}: ${error.message}`);
}
let changed = false;
for (const [field, spec] of Object.entries(MATERIALIZED_SPECS)) {
if (Array.isArray(plugin[field])) {
const sortedEntries = sortPluginEntries(plugin[field]);
if (!arraysEqual(plugin[field], sortedEntries)) {
plugin[field] = sortedEntries;
changed = true;
}
}
const materializedPath = path.join(pluginPath, spec.path);
if (!fs.existsSync(materializedPath) || !fs.statSync(materializedPath).isDirectory()) {
continue;
}
const restored = spec.restore(materializedPath);
if (!arraysEqual(plugin[field], restored)) {
plugin[field] = restored;
changed = true;
}
}
if (changed) {
fs.writeFileSync(pluginJsonPath, JSON.stringify(plugin, null, 2) + "\n", "utf8");
}
return changed;
}
function cleanPlugin(pluginPath) {
const manifestUpdated = restoreManifestFromMaterializedFiles(pluginPath);
if (manifestUpdated) {
console.log(` Updated ${path.basename(pluginPath)}/.github/plugin/plugin.json`);
}
let removed = 0;
for (const { path: subdir } of Object.values(MATERIALIZED_SPECS)) {
const target = path.join(pluginPath, subdir);
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
const count = countFiles(target);
fs.rmSync(target, { recursive: true, force: true });
removed += count;
console.log(` Removed ${path.basename(pluginPath)}/${subdir}/ (${count} files)`);
}
}
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 })) {
if (entry.isDirectory()) {
count += countFiles(path.join(dir, entry.name));
} else {
count++;
}
}
return count;
}
function collectFiles(dir, rootDir = dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectFiles(entryPath, rootDir));
} else {
files.push(toPosixPath(path.relative(rootDir, entryPath)));
}
}
return files.sort();
}
function collectSkillDirectories(dir, rootDir = dir) {
const skillDirs = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const entryPath = path.join(dir, entry.name);
if (fs.existsSync(path.join(entryPath, "SKILL.md"))) {
skillDirs.push(toPosixPath(path.relative(rootDir, entryPath)));
continue;
}
skillDirs.push(...collectSkillDirectories(entryPath, rootDir));
}
return skillDirs.sort();
}
function arraysEqual(left, right) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
return left.every((value, index) => value === right[index]);
}
function sortPluginEntries(entries) {
return [...entries].sort((left, right) => left.localeCompare(right));
}
function toPosixPath(filePath) {
return filePath.split(path.sep).join("/");
}
function main() {
console.log("Cleaning materialized files from plugins...\n");
if (!fs.existsSync(PLUGINS_DIR)) {
console.error(`Error: plugins directory not found at ${PLUGINS_DIR}`);
process.exit(1);
}
const pluginDirs = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
let total = 0;
let manifestsUpdated = 0;
for (const dirName of pluginDirs) {
const { removed, manifestUpdated } = cleanPlugin(path.join(PLUGINS_DIR, dirName));
total += removed;
if (manifestUpdated) {
manifestsUpdated++;
}
}
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.");
} else {
console.log(`✅ Removed ${total} materialized file(s) from plugins.`);
if (manifestsUpdated > 0) {
console.log(`✅ Updated ${manifestsUpdated} plugin manifest(s) to restore and normalize spec entries.`);
}
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}