const path = require("path"); const fs = require("fs/promises"); /** * Feature Loader - Handles loading and managing features from individual feature folders * Each feature is stored in .automaker/features/{featureId}/feature.json */ class FeatureLoader { /** * Get the features directory path */ getFeaturesDir(projectPath) { return path.join(projectPath, ".automaker", "features"); } /** * Get the path to a specific feature folder */ getFeatureDir(projectPath, featureId) { return path.join(this.getFeaturesDir(projectPath), featureId); } /** * Get the path to a feature's feature.json file */ getFeatureJsonPath(projectPath, featureId) { return path.join( this.getFeatureDir(projectPath, featureId), "feature.json" ); } /** * Get the path to a feature's agent-output.md file */ getAgentOutputPath(projectPath, featureId) { return path.join( this.getFeatureDir(projectPath, featureId), "agent-output.md" ); } /** * Generate a new feature ID */ generateFeatureId() { return `feature-${Date.now()}-${Math.random() .toString(36) .substring(2, 11)}`; } /** * Ensure all image paths for a feature are stored within the feature directory */ async ensureFeatureImages(projectPath, featureId, feature) { if ( !feature || !Array.isArray(feature.imagePaths) || feature.imagePaths.length === 0 ) { return; } const featureDir = this.getFeatureDir(projectPath, featureId); const featureImagesDir = path.join(featureDir, "images"); await fs.mkdir(featureImagesDir, { recursive: true }); const updatedImagePaths = []; for (const entry of feature.imagePaths) { const isStringEntry = typeof entry === "string"; const currentPathValue = isStringEntry ? entry : entry.path; if (!currentPathValue) { updatedImagePaths.push(entry); continue; } let resolvedCurrentPath = currentPathValue; if (!path.isAbsolute(resolvedCurrentPath)) { resolvedCurrentPath = path.join(projectPath, resolvedCurrentPath); } resolvedCurrentPath = path.normalize(resolvedCurrentPath); // Skip if file doesn't exist try { await fs.access(resolvedCurrentPath); } catch { console.warn( `[FeatureLoader] Image file missing for ${featureId}: ${resolvedCurrentPath}` ); updatedImagePaths.push(entry); continue; } const relativeToFeatureImages = path.relative( featureImagesDir, resolvedCurrentPath ); const alreadyInFeatureDir = relativeToFeatureImages === "" || (!relativeToFeatureImages.startsWith("..") && !path.isAbsolute(relativeToFeatureImages)); let finalPath = resolvedCurrentPath; if (!alreadyInFeatureDir) { const originalName = path.basename(resolvedCurrentPath); let targetPath = path.join(featureImagesDir, originalName); // Avoid overwriting files by appending a counter if needed let counter = 1; while (true) { try { await fs.access(targetPath); const parsed = path.parse(originalName); targetPath = path.join( featureImagesDir, `${parsed.name}-${counter}${parsed.ext}` ); counter += 1; } catch { break; } } try { await fs.rename(resolvedCurrentPath, targetPath); finalPath = targetPath; } catch (error) { console.warn( `[FeatureLoader] Failed to move image ${resolvedCurrentPath}: ${error.message}` ); updatedImagePaths.push(entry); continue; } } updatedImagePaths.push( isStringEntry ? finalPath : { ...entry, path: finalPath } ); } feature.imagePaths = updatedImagePaths; } /** * Get all features for a project */ async getAll(projectPath) { try { const featuresDir = this.getFeaturesDir(projectPath); // Check if features directory exists try { await fs.access(featuresDir); } catch { // Directory doesn't exist, return empty array return []; } // Read all feature directories const entries = await fs.readdir(featuresDir, { withFileTypes: true }); const featureDirs = entries.filter((entry) => entry.isDirectory()); // Load each feature const features = []; for (const dir of featureDirs) { const featureId = dir.name; const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); try { const content = await fs.readFile(featureJsonPath, "utf-8"); const feature = JSON.parse(content); features.push(feature); } catch (error) { console.error( `[FeatureLoader] Failed to load feature ${featureId}:`, error ); // Continue loading other features } } // 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) { console.error("[FeatureLoader] Failed to get all features:", error); return []; } } /** * Get a single feature by ID */ async get(projectPath, featureId) { try { const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); const content = await fs.readFile(featureJsonPath, "utf-8"); return JSON.parse(content); } catch (error) { if (error.code === "ENOENT") { return null; } console.error( `[FeatureLoader] Failed to get feature ${featureId}:`, error ); throw error; } } /** * Create a new feature */ async create(projectPath, featureData) { const featureId = featureData.id || this.generateFeatureId(); const featureDir = this.getFeatureDir(projectPath, featureId); const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); // Ensure features directory exists const featuresDir = this.getFeaturesDir(projectPath); await fs.mkdir(featuresDir, { recursive: true }); // Create feature directory await fs.mkdir(featureDir, { recursive: true }); // Ensure feature has an ID const feature = { ...featureData, id: featureId }; // Move any uploaded images into the feature directory await this.ensureFeatureImages(projectPath, featureId, feature); // Write feature.json await fs.writeFile( featureJsonPath, JSON.stringify(feature, null, 2), "utf-8" ); console.log(`[FeatureLoader] Created feature ${featureId}`); return feature; } /** * Update a feature (partial updates supported) */ async update(projectPath, featureId, updates) { try { const feature = await this.get(projectPath, featureId); if (!feature) { throw new Error(`Feature ${featureId} not found`); } // Merge updates const updatedFeature = { ...feature, ...updates }; // Move any new images into the feature directory await this.ensureFeatureImages(projectPath, featureId, updatedFeature); // Write back to file const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); await fs.writeFile( featureJsonPath, JSON.stringify(updatedFeature, null, 2), "utf-8" ); console.log(`[FeatureLoader] Updated feature ${featureId}`); return updatedFeature; } catch (error) { console.error( `[FeatureLoader] Failed to update feature ${featureId}:`, error ); throw error; } } /** * Delete a feature and its entire folder */ async delete(projectPath, featureId) { try { const featureDir = this.getFeatureDir(projectPath, featureId); await fs.rm(featureDir, { recursive: true, force: true }); console.log(`[FeatureLoader] Deleted feature ${featureId}`); } catch (error) { if (error.code === "ENOENT") { // Feature doesn't exist, that's fine return; } console.error( `[FeatureLoader] Failed to delete feature ${featureId}:`, error ); throw error; } } /** * Get agent output for a feature */ async getAgentOutput(projectPath, featureId) { try { const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); const content = await fs.readFile(agentOutputPath, "utf-8"); return content; } catch (error) { if (error.code === "ENOENT") { return null; } console.error( `[FeatureLoader] Failed to get agent output for ${featureId}:`, error ); return null; } } // ============================================================================ // Legacy methods for backward compatibility (used by backend services) // ============================================================================ /** * Load all features for a project (legacy API) * Features are stored in .automaker/features/{id}/feature.json */ async loadFeatures(projectPath) { return await this.getAll(projectPath); } /** * Update feature status (legacy API) * Features are stored in .automaker/features/{id}/feature.json * @param {string} featureId - The ID of the feature to update * @param {string} status - The new status * @param {string} projectPath - Path to the project * @param {string} [summary] - Optional summary of what was done * @param {string} [error] - Optional error message if feature errored */ async updateFeatureStatus(featureId, status, projectPath, summary, error) { const updates = { status }; if (summary !== undefined) { updates.summary = summary; } if (error !== undefined) { updates.error = error; } else { // Clear error if not provided const feature = await this.get(projectPath, featureId); if (feature && feature.error) { updates.error = undefined; } } await this.update(projectPath, featureId, updates); console.log( `[FeatureLoader] Updated feature ${featureId}: status=${status}${ summary ? `, summary="${summary}"` : "" }` ); } /** * Select the next feature to implement * Prioritizes: earlier features in the list that are not verified or waiting_approval */ selectNextFeature(features) { // Find first feature that is in backlog or in_progress status // Skip verified and waiting_approval (which needs user input) return features.find( (f) => f.status !== "verified" && f.status !== "waiting_approval" ); } /** * Update worktree info for a feature (legacy API) * Features are stored in .automaker/features/{id}/feature.json * @param {string} featureId - The ID of the feature to update * @param {string} projectPath - Path to the project * @param {string|null} worktreePath - Path to the worktree (null to clear) * @param {string|null} branchName - Name of the feature branch (null to clear) */ async updateFeatureWorktree( featureId, projectPath, worktreePath, branchName ) { const updates = {}; if (worktreePath) { updates.worktreePath = worktreePath; updates.branchName = branchName; } else { updates.worktreePath = null; updates.branchName = null; } await this.update(projectPath, featureId, updates); console.log( `[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}` ); } } module.exports = new FeatureLoader();