refactor: streamline Electron API integration and enhance UI components

- Removed unused Electron API methods and simplified the main process.
- Introduced a new workspace picker modal for improved project selection.
- Enhanced error handling for authentication issues across various components.
- Updated UI styles for dark mode support and added new CSS variables.
- Refactored session management to utilize a centralized API access method.
- Added server routes for workspace management, including directory listing and configuration checks.
This commit is contained in:
Cody Seibert
2025-12-12 02:14:52 -05:00
parent 4b9bd2641f
commit 8e65f0b338
24 changed files with 1217 additions and 2390 deletions

View File

@@ -28,6 +28,7 @@ import { createSuggestionsRoutes } from "./routes/suggestions.js";
import { createModelsRoutes } from "./routes/models.js";
import { createSpecRegenerationRoutes } from "./routes/spec-regeneration.js";
import { createRunningAgentsRoutes } from "./routes/running-agents.js";
import { createWorkspaceRoutes } from "./routes/workspace.js";
import { AgentService } from "./services/agent-service.js";
import { FeatureLoader } from "./services/feature-loader.js";
@@ -47,7 +48,11 @@ if (!hasAnthropicKey) {
║ ⚠️ WARNING: ANTHROPIC_API_KEY not set ║
║ ║
║ The Claude Agent SDK requires ANTHROPIC_API_KEY to function. ║
${hasOAuthToken ? ' You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only.' : ''}
${
hasOAuthToken
? " You have CLAUDE_CODE_OAUTH_TOKEN set - this is for CLI auth only."
: ""
}
║ ║
║ Set your API key: ║
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
@@ -106,6 +111,7 @@ app.use("/api/suggestions", createSuggestionsRoutes(events));
app.use("/api/models", createModelsRoutes());
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
app.use("/api/running-agents", createRunningAgentsRoutes());
app.use("/api/workspace", createWorkspaceRoutes());
// Create HTTP server
const server = createServer(app);

View File

@@ -217,5 +217,103 @@ export function createFsRoutes(_events: EventEmitter): Router {
}
});
// Save image to .automaker/images directory
router.post("/save-image", async (req: Request, res: Response) => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};
if (!data || !filename || !projectPath) {
res.status(400).json({
success: false,
error: "data, filename, and projectPath are required",
});
return;
}
// Create .automaker/images directory if it doesn't exist
const imagesDir = path.join(projectPath, ".automaker", "images");
await fs.mkdir(imagesDir, { recursive: true });
// Decode base64 data (remove data URL prefix if present)
const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Generate unique filename with timestamp
const timestamp = Date.now();
const ext = path.extname(filename) || ".png";
const baseName = path.basename(filename, ext);
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
const filePath = path.join(imagesDir, uniqueFilename);
// Write file
await fs.writeFile(filePath, buffer);
// Add project path to allowed paths if not already
addAllowedPath(projectPath);
res.json({ success: true, path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// Serve image files
router.get("/image", async (req: Request, res: Response) => {
try {
const { path: imagePath, projectPath } = req.query as {
path?: string;
projectPath?: string;
};
if (!imagePath) {
res.status(400).json({ success: false, error: "path is required" });
return;
}
// Resolve full path
const fullPath = path.isAbsolute(imagePath)
? imagePath
: projectPath
? path.join(projectPath, imagePath)
: imagePath;
// Check if file exists
try {
await fs.access(fullPath);
} catch {
res.status(404).json({ success: false, error: "Image not found" });
return;
}
// Read the file
const buffer = await fs.readFile(fullPath);
// Determine MIME type from extension
const ext = path.extname(fullPath).toLowerCase();
const mimeTypes: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".bmp": "image/bmp",
};
res.setHeader("Content-Type", mimeTypes[ext] || "application/octet-stream");
res.setHeader("Cache-Control", "public, max-age=3600");
res.send(buffer);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -11,9 +11,46 @@ import fs from "fs/promises";
const execAsync = promisify(exec);
// Storage for API keys (in-memory for now, should be persisted)
// Storage for API keys (in-memory cache)
const apiKeys: Record<string, string> = {};
// Helper to persist API keys to .env file
async function persistApiKeyToEnv(key: string, value: string): Promise<void> {
const envPath = path.join(process.cwd(), ".env");
try {
let envContent = "";
try {
envContent = await fs.readFile(envPath, "utf-8");
} catch {
// .env file doesn't exist, we'll create it
}
// Parse existing env content
const lines = envContent.split("\n");
const keyRegex = new RegExp(`^${key}=`);
let found = false;
const newLines = lines.map((line) => {
if (keyRegex.test(line)) {
found = true;
return `${key}=${value}`;
}
return line;
});
if (!found) {
// Add the key at the end
newLines.push(`${key}=${value}`);
}
await fs.writeFile(envPath, newLines.join("\n"));
console.log(`[Setup] Persisted ${key} to .env file`);
} catch (error) {
console.error(`[Setup] Failed to persist ${key} to .env:`, error);
throw error;
}
}
export function createSetupRoutes(): Router {
const router = Router();
@@ -301,13 +338,16 @@ export function createSetupRoutes(): Router {
apiKeys[provider] = apiKey;
// Also set as environment variable
if (provider === "anthropic") {
// Also set as environment variable and persist to .env
if (provider === "anthropic" || provider === "anthropic_oauth_token") {
process.env.ANTHROPIC_API_KEY = apiKey;
await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey);
} else if (provider === "openai") {
process.env.OPENAI_API_KEY = apiKey;
await persistApiKeyToEnv("OPENAI_API_KEY", apiKey);
} else if (provider === "google") {
process.env.GOOGLE_API_KEY = apiKey;
await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey);
}
res.json({ success: true });

View File

@@ -0,0 +1,113 @@
/**
* Workspace routes
* Provides API endpoints for workspace directory management
*/
import { Router, type Request, type Response } from "express";
import fs from "fs/promises";
import path from "path";
import { addAllowedPath } from "../lib/security.js";
export function createWorkspaceRoutes(): Router {
const router = Router();
// Get workspace configuration status
router.get("/config", async (_req: Request, res: Response) => {
try {
const workspaceDir = process.env.WORKSPACE_DIR;
if (!workspaceDir) {
res.json({
success: true,
configured: false,
});
return;
}
// Check if the directory exists
try {
const stats = await fs.stat(workspaceDir);
if (!stats.isDirectory()) {
res.json({
success: true,
configured: false,
error: "WORKSPACE_DIR is not a valid directory",
});
return;
}
// Add workspace dir to allowed paths
addAllowedPath(workspaceDir);
res.json({
success: true,
configured: true,
workspaceDir,
});
} catch {
res.json({
success: true,
configured: false,
error: "WORKSPACE_DIR path does not exist",
});
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
// List directories in workspace
router.get("/directories", async (_req: Request, res: Response) => {
try {
const workspaceDir = process.env.WORKSPACE_DIR;
if (!workspaceDir) {
res.status(400).json({
success: false,
error: "WORKSPACE_DIR is not configured",
});
return;
}
// Check if directory exists
try {
await fs.stat(workspaceDir);
} catch {
res.status(400).json({
success: false,
error: "WORKSPACE_DIR path does not exist",
});
return;
}
// Add workspace dir to allowed paths
addAllowedPath(workspaceDir);
// Read directory contents
const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
// Filter to directories only and map to result format
const directories = entries
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.map((entry) => ({
name: entry.name,
path: path.join(workspaceDir, entry.name),
}))
.sort((a, b) => a.name.localeCompare(b.name));
// Add each directory to allowed paths
directories.forEach((dir) => addAllowedPath(dir.path));
res.json({
success: true,
directories,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -220,11 +220,17 @@ export class AutoModeService {
projectPath,
});
} else {
const errorMessage = (error as Error).message || "Unknown error";
const isAuthError = errorMessage.includes("Authentication failed") ||
errorMessage.includes("Invalid API key") ||
errorMessage.includes("authentication_failed");
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
await this.updateFeatureStatus(projectPath, featureId, "failed");
this.emitAutoModeEvent("auto_mode_error", {
featureId,
error: (error as Error).message,
error: errorMessage,
errorType: isAuthError ? "authentication" : "execution",
projectPath,
});
}
@@ -741,6 +747,17 @@ When done, summarize what you implemented and any notes for the developer.`;
for (const block of msg.message.content) {
if (block.type === "text") {
responseText = block.text;
// Check for authentication errors in the response
if (block.text.includes("Invalid API key") ||
block.text.includes("authentication_failed") ||
block.text.includes("Fix external API key")) {
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate."
);
}
this.emitAutoModeEvent("auto_mode_progress", {
featureId,
content: block.text,
@@ -753,7 +770,20 @@ When done, summarize what you implemented and any notes for the developer.`;
});
}
}
} else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") {
// Handle authentication error from the SDK
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
} else if (msg.type === "result" && msg.subtype === "success") {
// Check if result indicates an error
if (msg.is_error && msg.result?.includes("Invalid API key")) {
throw new Error(
"Authentication failed: Invalid or expired API key. " +
"Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate."
);
}
responseText = msg.result || responseText;
}
}

View File

@@ -25,6 +25,123 @@ export class FeatureLoader {
return path.join(projectPath, ".automaker", "features");
}
/**
* Get the images directory path for a feature
*/
getFeatureImagesDir(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), "images");
}
/**
* Delete images that were removed from a feature
*/
private async deleteOrphanedImages(
projectPath: string,
oldPaths: Array<string | { path: string; [key: string]: unknown }> | undefined,
newPaths: Array<string | { path: string; [key: string]: unknown }> | undefined
): Promise<void> {
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 {
const fullPath = path.isAbsolute(oldPath)
? oldPath
: path.join(projectPath, oldPath);
await fs.unlink(fullPath);
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);
}
}
}
}
/**
* Copy images from temp directory to feature directory and update paths
*/
private async migrateImages(
projectPath: string,
featureId: string,
imagePaths?: Array<string | { path: string; [key: string]: unknown }>
): 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 });
const updatedPaths: Array<string | { path: string; [key: string]: unknown }> = [];
for (const imagePath of imagePaths) {
try {
const originalPath = typeof imagePath === "string" ? imagePath : imagePath.path;
// Skip if already in feature directory
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 fs.access(fullOriginalPath);
} catch {
console.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
continue;
}
// Get filename and create new path
const filename = path.basename(originalPath);
const newPath = path.join(featureImagesDir, filename);
const relativePath = `.automaker/features/${featureId}/images/${filename}`;
// Copy the file
await fs.copyFile(fullOriginalPath, newPath);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${relativePath}`);
// Try to delete the original temp file
try {
await fs.unlink(fullOriginalPath);
} catch {
// Ignore errors when deleting temp file
}
// Update the path in the result
if (typeof imagePath === "string") {
updatedPaths.push(relativePath);
} else {
updatedPaths.push({ ...imagePath, path: relativePath });
}
} catch (error) {
console.error(`[FeatureLoader] Failed to migrate image:`, error);
// Keep original path if migration fails
updatedPaths.push(imagePath);
}
}
return updatedPaths;
}
/**
* Get the path to a specific feature folder
*/
@@ -151,12 +268,20 @@ export class FeatureLoader {
// Create feature directory
await fs.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
@@ -179,8 +304,30 @@ export class FeatureLoader {
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 };
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
};
// Write back to file
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);