397 lines
12 KiB
JavaScript
Executable File
397 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { parseCollectionYaml } = require("./yaml-parser");
|
|
|
|
/**
|
|
* Simple YAML parser for configuration files with enhanced error handling
|
|
*/
|
|
function parseConfigYamlContent(content) {
|
|
const lines = content.split("\n");
|
|
const result = {};
|
|
let currentSection = null;
|
|
let lineNumber = 0;
|
|
|
|
try {
|
|
for (const line of lines) {
|
|
lineNumber++;
|
|
const trimmed = line.trim();
|
|
|
|
// Skip comments and empty lines
|
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
|
|
if (!trimmed.includes(":")) {
|
|
continue;
|
|
}
|
|
|
|
const colonIndex = trimmed.indexOf(":");
|
|
const key = trimmed.substring(0, colonIndex).trim();
|
|
let value = trimmed.substring(colonIndex + 1).trim();
|
|
|
|
// Validate key format
|
|
if (!key || key.includes(" ") && !currentSection) {
|
|
throw new Error(`Invalid key format on line ${lineNumber}: "${key}"`);
|
|
}
|
|
|
|
// Remove quotes if present
|
|
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
// Handle sections (no value)
|
|
if (!value) {
|
|
currentSection = key;
|
|
if (!result[currentSection]) {
|
|
result[currentSection] = {};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Handle boolean values
|
|
if (value === "true") value = true;
|
|
else if (value === "false") value = false;
|
|
|
|
if (currentSection) {
|
|
result[currentSection][key] = value;
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
throw new Error(`YAML parsing error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate configuration structure and content
|
|
*/
|
|
function validateConfig(config) {
|
|
const errors = [];
|
|
|
|
if (!config || typeof config !== 'object') {
|
|
errors.push("Configuration must be a valid object");
|
|
return errors;
|
|
}
|
|
|
|
// Check version
|
|
if (!config.version) {
|
|
errors.push("Configuration must have a 'version' field");
|
|
}
|
|
|
|
// Check project structure
|
|
if (config.project && typeof config.project !== 'object') {
|
|
errors.push("'project' field must be an object");
|
|
}
|
|
|
|
// Validate sections
|
|
const validSections = ['prompts', 'instructions', 'chatmodes', 'collections'];
|
|
validSections.forEach(section => {
|
|
if (config[section] && typeof config[section] !== 'object') {
|
|
errors.push(`'${section}' field must be an object`);
|
|
} else if (config[section]) {
|
|
// Validate section items
|
|
Object.entries(config[section]).forEach(([key, value]) => {
|
|
if (typeof value !== 'boolean') {
|
|
errors.push(`${section}.${key} must be a boolean value (true/false)`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for unknown top-level fields
|
|
const knownFields = ['version', 'project', ...validSections];
|
|
Object.keys(config).forEach(key => {
|
|
if (!knownFields.includes(key)) {
|
|
errors.push(`Unknown configuration field: '${key}'`);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
function parseConfigYaml(filePath) {
|
|
try {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
const config = parseConfigYamlContent(content);
|
|
|
|
// Validate the parsed configuration
|
|
const validationErrors = validateConfig(config);
|
|
if (validationErrors.length > 0) {
|
|
console.error(`Configuration validation errors in ${filePath}:`);
|
|
validationErrors.forEach(error => {
|
|
console.error(` ❌ ${error}`);
|
|
});
|
|
throw new Error("Configuration validation failed");
|
|
}
|
|
|
|
return config;
|
|
} catch (error) {
|
|
if (error.message === "Configuration validation failed") {
|
|
throw error;
|
|
}
|
|
console.error(`Error parsing config file ${filePath}: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply configuration and copy enabled files to project
|
|
*/
|
|
async function applyConfig(configPath = "awesome-copilot.config.yml") {
|
|
if (!fs.existsSync(configPath)) {
|
|
console.error(`Configuration file not found: ${configPath}`);
|
|
console.log("Run 'node generate-config.js' to create a configuration file first.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = parseConfigYaml(configPath);
|
|
if (!config) {
|
|
console.error("Failed to parse configuration file");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Applying awesome-copilot configuration...");
|
|
|
|
const rootDir = __dirname;
|
|
const outputDir = config.project?.output_directory || ".awesome-copilot";
|
|
|
|
// Create output directory structure
|
|
ensureDirectoryExists(outputDir);
|
|
ensureDirectoryExists(path.join(outputDir, "prompts"));
|
|
ensureDirectoryExists(path.join(outputDir, "instructions"));
|
|
ensureDirectoryExists(path.join(outputDir, "chatmodes"));
|
|
|
|
// Check if this is a subsequent run by looking for existing state
|
|
const stateFilePath = path.join(outputDir, ".awesome-copilot-state.json");
|
|
let previousState = {};
|
|
if (fs.existsSync(stateFilePath)) {
|
|
try {
|
|
previousState = JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
|
|
} catch (error) {
|
|
// If state file is corrupted, treat as first run
|
|
previousState = {};
|
|
}
|
|
}
|
|
|
|
let copiedCount = 0;
|
|
let removedCount = 0;
|
|
let skippedCount = 0;
|
|
const summary = {
|
|
prompts: 0,
|
|
instructions: 0,
|
|
chatmodes: 0,
|
|
collections: 0
|
|
};
|
|
|
|
// Process collections first (they can enable individual items)
|
|
const enabledItems = new Set();
|
|
if (config.collections) {
|
|
for (const [collectionName, enabled] of Object.entries(config.collections)) {
|
|
if (enabled) {
|
|
const collectionPath = path.join(rootDir, "collections", `${collectionName}.collection.yml`);
|
|
if (fs.existsSync(collectionPath)) {
|
|
const collection = parseCollectionYaml(collectionPath);
|
|
if (collection && collection.items) {
|
|
collection.items.forEach(item => {
|
|
enabledItems.add(item.path);
|
|
});
|
|
summary.collections++;
|
|
console.log(`✓ Enabled collection: ${collectionName} (${collection.items.length} items)`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to process section files
|
|
function processSection(sectionName, sourceDir, destDir, fileExtension) {
|
|
const enabledInSection = new Set();
|
|
|
|
if (config[sectionName]) {
|
|
for (const [itemName, enabled] of Object.entries(config[sectionName])) {
|
|
const sourcePath = path.join(rootDir, sourceDir, `${itemName}${fileExtension}`);
|
|
const destPath = path.join(outputDir, destDir, `${itemName}${fileExtension}`);
|
|
|
|
if (enabled && fs.existsSync(sourcePath)) {
|
|
const copyResult = copyFileWithTracking(sourcePath, destPath);
|
|
if (copyResult.copied) {
|
|
copiedCount++;
|
|
summary[sectionName]++;
|
|
} else if (copyResult.skipped) {
|
|
skippedCount++;
|
|
}
|
|
enabledInSection.add(itemName);
|
|
} else if (!enabled && fs.existsSync(destPath)) {
|
|
// Remove file if it's disabled
|
|
fs.unlinkSync(destPath);
|
|
console.log(`✗ Removed: ${itemName}${fileExtension}`);
|
|
removedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove any files in destination that are not enabled
|
|
const destDirPath = path.join(outputDir, destDir);
|
|
if (fs.existsSync(destDirPath)) {
|
|
const existingFiles = fs.readdirSync(destDirPath)
|
|
.filter(file => file.endsWith(fileExtension));
|
|
|
|
existingFiles.forEach(file => {
|
|
const itemName = file.replace(fileExtension, '');
|
|
if (!enabledInSection.has(itemName) && !isItemInEnabledCollection(file, enabledItems)) {
|
|
const filePath = path.join(destDirPath, file);
|
|
fs.unlinkSync(filePath);
|
|
console.log(`✗ Removed orphaned: ${file}`);
|
|
removedCount++;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Helper function to copy files with tracking
|
|
function copyFileWithTracking(sourcePath, destPath) {
|
|
// Check if file already exists and is identical
|
|
if (fs.existsSync(destPath)) {
|
|
try {
|
|
const sourceContent = fs.readFileSync(sourcePath);
|
|
const destContent = fs.readFileSync(destPath);
|
|
|
|
if (sourceContent.equals(destContent)) {
|
|
// Files are identical, no need to copy
|
|
console.log(`⚡ Skipped (up to date): ${path.basename(sourcePath)}`);
|
|
return { copied: false, skipped: true };
|
|
}
|
|
} catch (error) {
|
|
// If we can't read files for comparison, just proceed with copy
|
|
}
|
|
}
|
|
|
|
fs.copyFileSync(sourcePath, destPath);
|
|
console.log(`✓ Copied: ${path.basename(sourcePath)}`);
|
|
return { copied: true, skipped: false };
|
|
}
|
|
|
|
// Helper function to check if an item is in an enabled collection
|
|
function isItemInEnabledCollection(filename, enabledItems) {
|
|
for (const itemPath of enabledItems) {
|
|
if (path.basename(itemPath) === filename) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Process prompts
|
|
processSection("prompts", "prompts", "prompts", ".prompt.md");
|
|
|
|
// Process instructions
|
|
processSection("instructions", "instructions", "instructions", ".instructions.md");
|
|
|
|
// Process chat modes
|
|
processSection("chatmodes", "chatmodes", "chatmodes", ".chatmode.md");
|
|
|
|
// Process items from enabled collections, but respect individual overrides
|
|
for (const itemPath of enabledItems) {
|
|
const sourcePath = path.join(rootDir, itemPath);
|
|
if (fs.existsSync(sourcePath)) {
|
|
const fileName = path.basename(itemPath);
|
|
const itemName = fileName.replace(/\.(prompt|instructions|chatmode)\.md$/, '');
|
|
let destPath;
|
|
let section;
|
|
|
|
if (fileName.endsWith('.prompt.md')) {
|
|
destPath = path.join(outputDir, "prompts", fileName);
|
|
section = "prompts";
|
|
} else if (fileName.endsWith('.chatmode.md')) {
|
|
destPath = path.join(outputDir, "chatmodes", fileName);
|
|
section = "chatmodes";
|
|
} else if (fileName.endsWith('.instructions.md')) {
|
|
destPath = path.join(outputDir, "instructions", fileName);
|
|
section = "instructions";
|
|
}
|
|
|
|
// Only copy if not explicitly disabled in individual settings
|
|
const isExplicitlyDisabled = config[section] && config[section][itemName] === false;
|
|
|
|
if (destPath && !isExplicitlyDisabled) {
|
|
const copyResult = copyFileWithTracking(sourcePath, destPath);
|
|
if (copyResult.copied) {
|
|
copiedCount++;
|
|
} else if (copyResult.skipped) {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate summary
|
|
console.log("\n" + "=".repeat(50));
|
|
console.log("Configuration applied successfully!");
|
|
console.log("=".repeat(50));
|
|
console.log(`📂 Output directory: ${outputDir}`);
|
|
console.log(`📝 Total files copied: ${copiedCount}`);
|
|
if (skippedCount > 0) {
|
|
console.log(`⚡ Files skipped (up to date): ${skippedCount}`);
|
|
}
|
|
if (removedCount > 0) {
|
|
console.log(`🗑️ Total files removed: ${removedCount}`);
|
|
}
|
|
console.log(`🎯 Prompts: ${summary.prompts}`);
|
|
console.log(`📋 Instructions: ${summary.instructions}`);
|
|
console.log(`💭 Chat modes: ${summary.chatmodes}`);
|
|
console.log(`📦 Collections: ${summary.collections}`);
|
|
|
|
if (config.project?.name) {
|
|
console.log(`🏷️ Project: ${config.project.name}`);
|
|
}
|
|
|
|
// Save current state for future idempotency checks
|
|
const currentState = {
|
|
lastApplied: new Date().toISOString(),
|
|
configHash: Buffer.from(JSON.stringify(config)).toString('base64'),
|
|
outputDir: outputDir
|
|
};
|
|
|
|
try {
|
|
fs.writeFileSync(stateFilePath, JSON.stringify(currentState, null, 2));
|
|
} catch (error) {
|
|
// State saving failure is not critical
|
|
console.log("⚠️ Warning: Could not save state file for future optimization");
|
|
}
|
|
|
|
console.log("\nNext steps:");
|
|
console.log("1. Add the files to your version control system");
|
|
console.log("2. Use prompts with /awesome-copilot command in GitHub Copilot Chat");
|
|
console.log("3. Instructions will automatically apply to your coding");
|
|
console.log("4. Import chat modes in VS Code settings");
|
|
}
|
|
|
|
/**
|
|
* Ensure directory exists, create if it doesn't
|
|
*/
|
|
function ensureDirectoryExists(dirPath) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
console.log(`📁 Created directory: ${dirPath}`);
|
|
}
|
|
}
|
|
|
|
// CLI usage
|
|
if (require.main === module) {
|
|
const configPath = process.argv[2] || "awesome-copilot.config.yml";
|
|
applyConfig(configPath).catch(error => {
|
|
console.error("Error applying configuration:", error.message);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
applyConfig,
|
|
parseConfigYaml,
|
|
parseConfigYamlContent
|
|
};
|