UNPKG

automagik-genie

Version:

Universal AI development companion that can be initialized in any codebase

492 lines (435 loc) 15 kB
const fs = require('fs').promises; const path = require('path'); const https = require('https'); const { pipeline } = require('stream/promises'); const { createWriteStream, createReadStream } = require('fs'); const os = require('os'); // Optional tar dependency - will be loaded when needed let tar = null; try { tar = require('tar'); } catch (error) { // tar will be loaded dynamically when needed } /** * TemplateManager - Handles template downloading, caching, and version management * Downloads latest templates from GitHub releases and manages local cache */ class TemplateManager { constructor(cacheDir = null) { this.cacheDir = cacheDir || path.join(os.homedir(), '.automagik-genie', 'templates'); this.githubApi = 'https://api.github.com/repos/namastexlabs/automagik-genie'; this.githubRaw = 'https://raw.githubusercontent.com/namastexlabs/automagik-genie'; } /** * Fetch latest release information from GitHub * @returns {Object} Latest release information */ async fetchLatestRelease() { try { const response = await this.makeHttpRequest(`${this.githubApi}/releases/latest`); const release = JSON.parse(response); return { version: release.tag_name, name: release.name, publishedAt: release.published_at, downloadUrl: release.tarball_url, zipUrl: release.zipball_url, body: release.body }; } catch (error) { throw new Error(`Failed to fetch latest release: ${error.message}`); } } /** * Download and cache template files for a specific version * @param {string} version - Version to download (e.g., 'v1.1.7') * @param {boolean} force - Force re-download even if cached * @returns {string} Path to cached template directory */ async downloadTemplate(version, force = false) { const templatePath = path.join(this.cacheDir, version); // Return cached version if exists and not forcing if (!force && await this.fileExists(templatePath)) { return templatePath; } await fs.mkdir(this.cacheDir, { recursive: true }); try { // Download specific files we need rather than entire repository const templateFiles = await this.getTemplateFileList(version); const downloadPath = path.join(templatePath, 'files'); await fs.mkdir(downloadPath, { recursive: true }); // Download each template file for (const file of templateFiles) { const fileUrl = `${this.githubRaw}/${version}/${file.path}`; const localPath = path.join(downloadPath, file.path); // Ensure local directory exists await fs.mkdir(path.dirname(localPath), { recursive: true }); try { const content = await this.makeHttpRequest(fileUrl); await fs.writeFile(localPath, content, 'utf-8'); } catch (fileError) { console.warn(`Failed to download ${file.path}: ${fileError.message}`); } } // Create template manifest const manifest = { version, downloadedAt: new Date().toISOString(), files: templateFiles, path: templatePath }; await fs.writeFile( path.join(templatePath, 'manifest.json'), JSON.stringify(manifest, null, 2) ); return templatePath; } catch (error) { // Cleanup failed download try { await fs.rmdir(templatePath, { recursive: true }); } catch (cleanupError) { // Ignore cleanup errors } throw new Error(`Template download failed: ${error.message}`); } } /** * Get list of template files to download * @param {string} version - Version to get file list for * @returns {Array} List of template files */ async getTemplateFileList(version) { // Define the files we need to download for updates const templateFiles = [ // Agent templates '.claude/agents/genie-analyzer.md', '.claude/agents/genie-dev-planner.md', '.claude/agents/genie-dev-designer.md', '.claude/agents/genie-dev-coder.md', '.claude/agents/genie-dev-fixer.md', '.claude/agents/genie-testing-maker.md', '.claude/agents/genie-testing-fixer.md', '.claude/agents/genie-quality-ruff.md', '.claude/agents/genie-quality-mypy.md', '.claude/agents/genie-claudemd.md', '.claude/agents/genie-clone.md', '.claude/agents/genie-agent-creator.md', '.claude/agents/genie-agent-enhancer.md', // Hook examples '.claude/hooks/examples/pre-commit.yml', '.claude/hooks/examples/post-merge.yml', '.claude/hooks/examples/pre-push.yml', // Core templates 'templates/CLAUDE.md.template', // Documentation templates 'CLAUDE.md' ]; return templateFiles.map(filePath => ({ path: filePath, type: this.getFileType(filePath), category: this.getFileCategory(filePath) })); } /** * Determine file type from path * @param {string} filePath - File path * @returns {string} File type */ getFileType(filePath) { if (filePath.endsWith('.md')) return 'agent'; if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) return 'hook'; if (filePath.includes('template')) return 'template'; return 'other'; } /** * Determine file category from path * @param {string} filePath - File path * @returns {string} Category */ getFileCategory(filePath) { if (filePath.includes('/agents/')) return 'agents'; if (filePath.includes('/hooks/')) return 'hooks'; if (filePath.includes('/templates/')) return 'templates'; return 'core'; } /** * Compare current project files with template version * @param {string} projectPath - Path to project * @param {string} templateVersion - Template version to compare against * @returns {Object} Comparison results */ async compareWithTemplate(projectPath, templateVersion) { const templatePath = await this.getCachedTemplate(templateVersion); if (!templatePath) { throw new Error(`Template version ${templateVersion} not found in cache`); } const manifest = await this.loadTemplateManifest(templatePath); const comparison = { version: templateVersion, files: { identical: [], different: [], missing: [], extra: [] }, summary: { totalFiles: 0, identicalCount: 0, differentCount: 0, missingCount: 0, extraCount: 0 } }; // Check each template file against project for (const templateFile of manifest.files) { const projectFilePath = path.join(projectPath, templateFile.path); const templateFilePath = path.join(templatePath, 'files', templateFile.path); comparison.summary.totalFiles++; if (await this.fileExists(projectFilePath)) { const projectContent = await fs.readFile(projectFilePath, 'utf-8'); const templateContent = await fs.readFile(templateFilePath, 'utf-8'); if (this.calculateChecksum(projectContent) === this.calculateChecksum(templateContent)) { comparison.files.identical.push({ path: templateFile.path, type: templateFile.type, category: templateFile.category }); comparison.summary.identicalCount++; } else { comparison.files.different.push({ path: templateFile.path, type: templateFile.type, category: templateFile.category, hasChanges: true }); comparison.summary.differentCount++; } } else { comparison.files.missing.push({ path: templateFile.path, type: templateFile.type, category: templateFile.category }); comparison.summary.missingCount++; } } // Check for extra files in project (not in template) const extraFiles = await this.findExtraFiles(projectPath, manifest.files); comparison.files.extra = extraFiles; comparison.summary.extraCount = extraFiles.length; return comparison; } /** * Find files in project that don't exist in template * @param {string} projectPath - Project path * @param {Array} templateFiles - Template file list * @returns {Array} Extra files in project */ async findExtraFiles(projectPath, templateFiles) { const templatePaths = new Set(templateFiles.map(f => f.path)); const extraFiles = []; const checkDirectory = async (dir, relativePath = '') => { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); const relPath = path.join(relativePath, entry.name).replace(/\\/g, '/'); if (entry.isDirectory()) { // Skip certain directories if (!['node_modules', '.git', '.venv', '__pycache__'].includes(entry.name)) { await checkDirectory(fullPath, relPath); } } else if (entry.isFile()) { // Check if this file should be managed by templates if (this.isTemplateManaged(relPath) && !templatePaths.has(relPath)) { extraFiles.push({ path: relPath, type: this.getFileType(relPath), category: this.getFileCategory(relPath), isExtra: true }); } } } } catch (error) { // Ignore directories we can't read } }; await checkDirectory(projectPath); return extraFiles; } /** * Check if a file should be managed by templates * @param {string} filePath - File path to check * @returns {boolean} True if file should be managed by templates */ isTemplateManaged(filePath) { const managedPaths = [ '.claude/agents/', '.claude/hooks/examples/', 'templates/', 'CLAUDE.md' ]; return managedPaths.some(managedPath => filePath.startsWith(managedPath)); } /** * Get cached template directory * @param {string} version - Template version * @returns {string|null} Path to cached template or null if not cached */ async getCachedTemplate(version) { const templatePath = path.join(this.cacheDir, version); if (await this.fileExists(templatePath)) { return templatePath; } // Try to download if not cached try { return await this.downloadTemplate(version); } catch (error) { return null; } } /** * Load template manifest from cached template * @param {string} templatePath - Path to cached template * @returns {Object} Template manifest */ async loadTemplateManifest(templatePath) { const manifestPath = path.join(templatePath, 'manifest.json'); if (!await this.fileExists(manifestPath)) { throw new Error(`Template manifest not found at ${manifestPath}`); } const content = await fs.readFile(manifestPath, 'utf-8'); return JSON.parse(content); } /** * Validate template integrity * @param {string} version - Template version to validate * @returns {boolean} True if template is valid */ async validateTemplateIntegrity(version) { const templatePath = await this.getCachedTemplate(version); if (!templatePath) { return false; } try { const manifest = await this.loadTemplateManifest(templatePath); // Check if all files from manifest exist for (const file of manifest.files) { const filePath = path.join(templatePath, 'files', file.path); if (!await this.fileExists(filePath)) { return false; } } return true; } catch (error) { return false; } } /** * Clear template cache * @param {string} version - Specific version to clear, or null for all */ async clearCache(version = null) { if (version) { const templatePath = path.join(this.cacheDir, version); if (await this.fileExists(templatePath)) { await fs.rmdir(templatePath, { recursive: true }); } } else { if (await this.fileExists(this.cacheDir)) { await fs.rmdir(this.cacheDir, { recursive: true }); } } } /** * List cached template versions * @returns {Array} List of cached template versions */ async listCachedVersions() { try { const entries = await fs.readdir(this.cacheDir, { withFileTypes: true }); const versions = []; for (const entry of entries) { if (entry.isDirectory()) { const manifestPath = path.join(this.cacheDir, entry.name, 'manifest.json'); if (await this.fileExists(manifestPath)) { try { const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8')); versions.push({ version: entry.name, downloadedAt: manifest.downloadedAt, fileCount: manifest.files.length, valid: await this.validateTemplateIntegrity(entry.name) }); } catch (error) { versions.push({ version: entry.name, downloadedAt: null, fileCount: 0, valid: false, corrupted: true }); } } } } return versions.sort((a, b) => new Date(b.downloadedAt || 0) - new Date(a.downloadedAt || 0)); } catch (error) { return []; } } /** * Make HTTP request * @param {string} url - URL to request * @returns {Promise<string>} Response body */ makeHttpRequest(url) { return new Promise((resolve, reject) => { const request = https.get(url, { headers: { 'User-Agent': 'automagik-genie-updater/1.0' } }, (response) => { let data = ''; response.on('data', (chunk) => { data += chunk; }); response.on('end', () => { if (response.statusCode >= 200 && response.statusCode < 300) { resolve(data); } else { reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)); } }); }); request.on('error', reject); request.setTimeout(30000, () => { request.destroy(); reject(new Error('Request timeout')); }); }); } /** * Calculate SHA-256 checksum * @param {string} content - Content to hash */ calculateChecksum(content) { const crypto = require('crypto'); return crypto.createHash('sha256').update(content, 'utf-8').digest('hex'); } /** * Check if file exists * @param {string} filePath - Path to check */ async fileExists(filePath) { try { await fs.access(filePath); return true; } catch (error) { return false; } } } module.exports = { TemplateManager };