/** * Feature Loader - Handles loading and managing features from individual feature folders * Each feature is stored in .automaker/features/{featureId}/feature.json */ import path from 'path'; import type { Feature } from '@automaker/types'; import { createLogger } from '@automaker/utils'; import * as secureFs from '../lib/secure-fs.js'; import { getFeaturesDir, getFeatureDir, getFeatureImagesDir, ensureAutomakerDir, } from '@automaker/platform'; const logger = createLogger('FeatureLoader'); // Re-export Feature type for convenience export type { Feature }; export class FeatureLoader { /** * Get the features directory path */ getFeaturesDir(projectPath: string): string { return getFeaturesDir(projectPath); } /** * Get the images directory path for a feature */ getFeatureImagesDir(projectPath: string, featureId: string): string { return getFeatureImagesDir(projectPath, featureId); } /** * Delete images that were removed from a feature */ private async deleteOrphanedImages( projectPath: string, oldPaths: Array | undefined, newPaths: Array | undefined ): Promise { if (!oldPaths || oldPaths.length === 0) { return; } // Build sets of paths for comparison const oldPathSet = new Set(oldPaths.map((p) => (typeof p === 'string' ? p : p.path))); const newPathSet = new Set((newPaths || []).map((p) => (typeof p === 'string' ? p : p.path))); // Find images that were removed for (const oldPath of oldPathSet) { if (!newPathSet.has(oldPath)) { try { // Paths are now absolute await secureFs.unlink(oldPath); console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`); } catch (error) { // Ignore errors when deleting (file may already be gone) logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error); } } } } /** * Copy images from temp directory to feature directory and update paths */ private async migrateImages( projectPath: string, featureId: string, imagePaths?: Array ): Promise | undefined> { if (!imagePaths || imagePaths.length === 0) { return imagePaths; } const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); await secureFs.mkdir(featureImagesDir, { recursive: true }); const updatedPaths: Array = []; for (const imagePath of imagePaths) { try { const originalPath = typeof imagePath === 'string' ? imagePath : imagePath.path; // Skip if already in feature directory (already absolute path in external storage) if (originalPath.includes(`/features/${featureId}/images/`)) { updatedPaths.push(imagePath); continue; } // Resolve the full path const fullOriginalPath = path.isAbsolute(originalPath) ? originalPath : path.join(projectPath, originalPath); // Check if file exists try { await secureFs.access(fullOriginalPath); } catch { logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`); continue; } // Get filename and create new path in external storage const filename = path.basename(originalPath); const newPath = path.join(featureImagesDir, filename); // Copy the file await secureFs.copyFile(fullOriginalPath, newPath); console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`); // Try to delete the original temp file try { await secureFs.unlink(fullOriginalPath); } catch { // Ignore errors when deleting temp file } // Update the path in the result (use absolute path) if (typeof imagePath === 'string') { updatedPaths.push(newPath); } else { updatedPaths.push({ ...imagePath, path: newPath }); } } catch (error) { logger.error(`Failed to migrate image:`, error); // Rethrow error to let caller decide how to handle it // Keeping original path could lead to broken references throw error; } } return updatedPaths; } /** * Get the path to a specific feature folder */ getFeatureDir(projectPath: string, featureId: string): string { return getFeatureDir(projectPath, featureId); } /** * Get the path to a feature's feature.json file */ getFeatureJsonPath(projectPath: string, featureId: string): string { return path.join(this.getFeatureDir(projectPath, featureId), 'feature.json'); } /** * Get the path to a feature's agent-output.md file */ getAgentOutputPath(projectPath: string, featureId: string): string { return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md'); } /** * Generate a new feature ID */ generateFeatureId(): string { return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } /** * Get all features for a project */ async getAll(projectPath: string): Promise { try { const featuresDir = this.getFeaturesDir(projectPath); // Check if features directory exists try { await secureFs.access(featuresDir); } catch { return []; } // Read all feature directories const entries = (await secureFs.readdir(featuresDir, { withFileTypes: true, })) as any[]; const featureDirs = entries.filter((entry) => entry.isDirectory()); // Load each feature const features: Feature[] = []; for (const dir of featureDirs) { const featureId = dir.name; const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); try { const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; const feature = JSON.parse(content); if (!feature.id) { logger.warn( `[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping` ); continue; } features.push(feature); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { continue; } else if (error instanceof SyntaxError) { logger.warn( `[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}` ); } else { logger.error( `[FeatureLoader] Failed to load feature ${featureId}:`, (error as Error).message ); } } } // Sort by creation order (feature IDs contain timestamp) features.sort((a, b) => { const aTime = a.id ? parseInt(a.id.split('-')[1] || '0') : 0; const bTime = b.id ? parseInt(b.id.split('-')[1] || '0') : 0; return aTime - bTime; }); return features; } catch (error) { logger.error('Failed to get all features:', error); return []; } } /** * Get a single feature by ID */ async get(projectPath: string, featureId: string): Promise { try { const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string; return JSON.parse(content); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error); throw error; } } /** * Create a new feature */ async create(projectPath: string, featureData: Partial): Promise { const featureId = featureData.id || this.generateFeatureId(); const featureDir = this.getFeatureDir(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); // Ensure automaker directory exists await ensureAutomakerDir(projectPath); // Create feature directory await secureFs.mkdir(featureDir, { recursive: true }); // Migrate images from temp directory to feature directory const migratedImagePaths = await this.migrateImages( projectPath, featureId, featureData.imagePaths ); // Ensure feature has required fields const feature: Feature = { category: featureData.category || 'Uncategorized', description: featureData.description || '', ...featureData, id: featureId, imagePaths: migratedImagePaths, }; // Write feature.json await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8'); logger.info(`Created feature ${featureId}`); return feature; } /** * Update a feature (partial updates supported) */ async update( projectPath: string, featureId: string, updates: Partial ): Promise { const feature = await this.get(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } // Handle image path changes let updatedImagePaths = updates.imagePaths; if (updates.imagePaths !== undefined) { // Delete orphaned images (images that were removed) await this.deleteOrphanedImages(projectPath, feature.imagePaths, updates.imagePaths); // Migrate any new images updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths); } // Merge updates const updatedFeature: Feature = { ...feature, ...updates, ...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}), }; // Write back to file const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8'); logger.info(`Updated feature ${featureId}`); return updatedFeature; } /** * Delete a feature */ async delete(projectPath: string, featureId: string): Promise { try { const featureDir = this.getFeatureDir(projectPath, featureId); await secureFs.rm(featureDir, { recursive: true, force: true }); console.log(`[FeatureLoader] Deleted feature ${featureId}`); return true; } catch (error) { logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error); return false; } } /** * Get agent output for a feature */ async getAgentOutput(projectPath: string, featureId: string): Promise { try { const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); const content = (await secureFs.readFile(agentOutputPath, 'utf-8')) as string; return content; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error); throw error; } } /** * Save agent output for a feature */ async saveAgentOutput(projectPath: string, featureId: string, content: string): Promise { const featureDir = this.getFeatureDir(projectPath, featureId); await secureFs.mkdir(featureDir, { recursive: true }); const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); await secureFs.writeFile(agentOutputPath, content, 'utf-8'); } /** * Delete agent output for a feature */ async deleteAgentOutput(projectPath: string, featureId: string): Promise { try { const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); await secureFs.unlink(agentOutputPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; } } } }