mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
113
apps/server/src/routes/workspace.ts
Normal file
113
apps/server/src/routes/workspace.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user