Merge main into massive-terminal-upgrade

Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

View File

@@ -3,50 +3,21 @@
* Each feature is stored in .automaker/features/{featureId}/feature.json
*/
import path from "path";
import fs from "fs/promises";
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 "../lib/automaker-paths.js";
} from '@automaker/platform';
export interface Feature {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps?: string[];
passes?: boolean;
priority?: number;
status?: string;
dependencies?: string[];
spec?: string;
model?: string;
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
// Branch info - worktree path is derived at runtime from branchName
branchName?: string; // Name of the feature branch (undefined = use current worktree)
skipTests?: boolean;
thinkingLevel?: string;
planningMode?: 'skip' | 'lite' | 'spec' | 'full';
requirePlanApproval?: boolean;
planSpec?: {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string;
version: number;
generatedAt?: string;
approvedAt?: string;
reviewedByUser: boolean;
tasksCompleted?: number;
tasksTotal?: number;
};
error?: string;
summary?: string;
startedAt?: string;
[key: string]: unknown; // Keep catch-all for extensibility
}
const logger = createLogger('FeatureLoader');
// Re-export Feature type for convenience
export type { Feature };
export class FeatureLoader {
/**
@@ -76,26 +47,19 @@ export class FeatureLoader {
}
// 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))
);
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 fs.unlink(oldPath);
await secureFs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
console.warn(
`[FeatureLoader] Failed to delete image: ${oldPath}`,
error
);
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
}
}
}
@@ -108,23 +72,19 @@ export class FeatureLoader {
projectPath: string,
featureId: string,
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
): Promise<
Array<string | { path: string; [key: string]: unknown }> | undefined
> {
): Promise<Array<string | { path: string; [key: string]: unknown }> | undefined> {
if (!imagePaths || imagePaths.length === 0) {
return imagePaths;
}
const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId);
await fs.mkdir(featureImagesDir, { recursive: true });
await secureFs.mkdir(featureImagesDir, { recursive: true });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> =
[];
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
for (const imagePath of imagePaths) {
try {
const originalPath =
typeof imagePath === "string" ? imagePath : imagePath.path;
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/`)) {
@@ -139,11 +99,9 @@ export class FeatureLoader {
// Check if file exists
try {
await fs.access(fullOriginalPath);
await secureFs.access(fullOriginalPath);
} catch {
console.warn(
`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`
);
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
continue;
}
@@ -152,28 +110,27 @@ export class FeatureLoader {
const newPath = path.join(featureImagesDir, filename);
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
console.log(
`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`
);
await secureFs.copyFile(fullOriginalPath, newPath);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
// Try to delete the original temp file
try {
await fs.unlink(fullOriginalPath);
await secureFs.unlink(fullOriginalPath);
} catch {
// Ignore errors when deleting temp file
}
// Update the path in the result (use absolute path)
if (typeof imagePath === "string") {
if (typeof imagePath === 'string') {
updatedPaths.push(newPath);
} else {
updatedPaths.push({ ...imagePath, path: newPath });
}
} catch (error) {
console.error(`[FeatureLoader] Failed to migrate image:`, error);
// Keep original path if migration fails
updatedPaths.push(imagePath);
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;
}
}
@@ -191,14 +148,14 @@ export class FeatureLoader {
* 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");
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");
return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md');
}
/**
@@ -217,13 +174,15 @@ export class FeatureLoader {
// Check if features directory exists
try {
await fs.access(featuresDir);
await secureFs.access(featuresDir);
} catch {
return [];
}
// Read all feature directories
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
const entries = (await secureFs.readdir(featuresDir, {
withFileTypes: true,
})) as any[];
const featureDirs = entries.filter((entry) => entry.isDirectory());
// Load each feature
@@ -233,11 +192,11 @@ export class FeatureLoader {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
try {
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
const feature = JSON.parse(content);
if (!feature.id) {
console.warn(
logger.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
continue;
@@ -245,14 +204,14 @@ export class FeatureLoader {
features.push(feature);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
} else if (error instanceof SyntaxError) {
console.warn(
logger.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
} else {
console.error(
logger.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
@@ -262,14 +221,14 @@ export class FeatureLoader {
// 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;
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);
logger.error('Failed to get all features:', error);
return [];
}
}
@@ -280,16 +239,13 @@ export class FeatureLoader {
async get(projectPath: string, featureId: string): Promise<Feature | null> {
try {
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
const content = await fs.readFile(featureJsonPath, "utf-8");
const content = (await secureFs.readFile(featureJsonPath, 'utf-8')) as string;
return JSON.parse(content);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
console.error(
`[FeatureLoader] Failed to get feature ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
throw error;
}
}
@@ -297,10 +253,7 @@ export class FeatureLoader {
/**
* Create a new feature
*/
async create(
projectPath: string,
featureData: Partial<Feature>
): Promise<Feature> {
async create(projectPath: string, featureData: Partial<Feature>): Promise<Feature> {
const featureId = featureData.id || this.generateFeatureId();
const featureDir = this.getFeatureDir(projectPath, featureId);
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
@@ -309,7 +262,7 @@ export class FeatureLoader {
await ensureAutomakerDir(projectPath);
// Create feature directory
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
// Migrate images from temp directory to feature directory
const migratedImagePaths = await this.migrateImages(
@@ -320,21 +273,17 @@ export class FeatureLoader {
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || "Uncategorized",
description: featureData.description || "",
category: featureData.category || 'Uncategorized',
description: featureData.description || '',
...featureData,
id: featureId,
imagePaths: migratedImagePaths,
};
// Write feature.json
await fs.writeFile(
featureJsonPath,
JSON.stringify(feature, null, 2),
"utf-8"
);
await secureFs.writeFile(featureJsonPath, JSON.stringify(feature, null, 2), 'utf-8');
console.log(`[FeatureLoader] Created feature ${featureId}`);
logger.info(`Created feature ${featureId}`);
return feature;
}
@@ -355,38 +304,24 @@ export class FeatureLoader {
let updatedImagePaths = updates.imagePaths;
if (updates.imagePaths !== undefined) {
// Delete orphaned images (images that were removed)
await this.deleteOrphanedImages(
projectPath,
feature.imagePaths,
updates.imagePaths
);
await this.deleteOrphanedImages(projectPath, feature.imagePaths, updates.imagePaths);
// Migrate any new images
updatedImagePaths = await this.migrateImages(
projectPath,
featureId,
updates.imagePaths
);
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
}
// Merge updates
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined
? { imagePaths: updatedImagePaths }
: {}),
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
};
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
await fs.writeFile(
featureJsonPath,
JSON.stringify(updatedFeature, null, 2),
"utf-8"
);
await secureFs.writeFile(featureJsonPath, JSON.stringify(updatedFeature, null, 2), 'utf-8');
console.log(`[FeatureLoader] Updated feature ${featureId}`);
logger.info(`Updated feature ${featureId}`);
return updatedFeature;
}
@@ -396,14 +331,11 @@ export class FeatureLoader {
async delete(projectPath: string, featureId: string): Promise<boolean> {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.rm(featureDir, { recursive: true, force: true });
await secureFs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
return true;
} catch (error) {
console.error(
`[FeatureLoader] Failed to delete feature ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
return false;
}
}
@@ -411,22 +343,16 @@ export class FeatureLoader {
/**
* Get agent output for a feature
*/
async getAgentOutput(
projectPath: string,
featureId: string
): Promise<string | null> {
async getAgentOutput(projectPath: string, featureId: string): Promise<string | null> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
const content = await fs.readFile(agentOutputPath, "utf-8");
const content = (await secureFs.readFile(agentOutputPath, 'utf-8')) as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
console.error(
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
error
);
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
throw error;
}
}
@@ -434,30 +360,23 @@ export class FeatureLoader {
/**
* Save agent output for a feature
*/
async saveAgentOutput(
projectPath: string,
featureId: string,
content: string
): Promise<void> {
async saveAgentOutput(projectPath: string, featureId: string, content: string): Promise<void> {
const featureDir = this.getFeatureDir(projectPath, featureId);
await fs.mkdir(featureDir, { recursive: true });
await secureFs.mkdir(featureDir, { recursive: true });
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.writeFile(agentOutputPath, content, "utf-8");
await secureFs.writeFile(agentOutputPath, content, 'utf-8');
}
/**
* Delete agent output for a feature
*/
async deleteAgentOutput(
projectPath: string,
featureId: string
): Promise<void> {
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
try {
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
await fs.unlink(agentOutputPath);
await secureFs.unlink(agentOutputPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}