Merge branch 'main' of github.com:webdevcody/automaker

This commit is contained in:
Cody Seibert
2025-12-10 11:47:43 -05:00
47 changed files with 9701 additions and 4060 deletions

View File

@@ -7,6 +7,7 @@ const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
const fs = require("fs/promises");
const agentService = require("./agent-service");
const autoModeService = require("./auto-mode-service");
const worktreeManager = require("./services/worktree-manager");
const featureSuggestionsService = require("./services/feature-suggestions-service");
const specRegenerationService = require("./services/spec-regeneration-service");
@@ -61,6 +62,21 @@ app.whenReady().then(async () => {
const appDataPath = app.getPath("userData");
await agentService.initialize(appDataPath);
// Pre-load allowed paths from agent history to prevent breaking "Recent Projects"
try {
const sessions = await agentService.listSessions({ includeArchived: true });
sessions.forEach((session) => {
if (session.projectPath) {
addAllowedPath(session.projectPath);
}
});
console.log(
`[Security] Pre-loaded ${allowedPaths.size} allowed paths from history`
);
} catch (error) {
console.error("Failed to load sessions for security whitelist:", error);
}
createWindow();
app.on("activate", () => {
@@ -76,6 +92,43 @@ app.on("window-all-closed", () => {
}
});
// Track allowed paths for file operations (security)
const allowedPaths = new Set();
/**
* Add a path to the allowed list
*/
function addAllowedPath(pathToAdd) {
if (!pathToAdd) return;
allowedPaths.add(path.resolve(pathToAdd));
console.log(`[Security] Added allowed path: ${pathToAdd}`);
}
/**
* Check if a file path is allowed (must be within an allowed directory)
*/
function isPathAllowed(filePath) {
const resolvedPath = path.resolve(filePath);
// Allow access to app data directory (for logs, temp images etc)
const appDataPath = app.getPath("userData");
if (resolvedPath.startsWith(appDataPath)) return true;
// Check against all allowed project paths
for (const allowedPath of allowedPaths) {
// Check if path starts with allowed directory
// Ensure we don't match "/foo/bar" against "/foo/b"
if (
resolvedPath === allowedPath ||
resolvedPath.startsWith(allowedPath + path.sep)
) {
return true;
}
}
return false;
}
// IPC Handlers
// Dialog handlers
@@ -83,6 +136,11 @@ ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
if (!result.canceled && result.filePaths.length > 0) {
result.filePaths.forEach((p) => addAllowedPath(p));
}
return result;
});
@@ -91,12 +149,26 @@ ipcMain.handle("dialog:openFile", async (_, options = {}) => {
properties: ["openFile"],
...options,
});
if (!result.canceled && result.filePaths.length > 0) {
// Allow reading the specific file selected
result.filePaths.forEach((p) => addAllowedPath(p));
}
return result;
});
// File system handlers
ipcMain.handle("fs:readFile", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const content = await fs.readFile(filePath, "utf-8");
return { success: true, content };
} catch (error) {
@@ -106,6 +178,14 @@ ipcMain.handle("fs:readFile", async (_, filePath) => {
ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.writeFile(filePath, content, "utf-8");
return { success: true };
} catch (error) {
@@ -115,6 +195,14 @@ ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
ipcMain.handle("fs:mkdir", async (_, dirPath) => {
try {
// Security check
if (!isPathAllowed(dirPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.mkdir(dirPath, { recursive: true });
return { success: true };
} catch (error) {
@@ -124,6 +212,14 @@ ipcMain.handle("fs:mkdir", async (_, dirPath) => {
ipcMain.handle("fs:readdir", async (_, dirPath) => {
try {
// Security check
if (!isPathAllowed(dirPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
@@ -138,6 +234,11 @@ ipcMain.handle("fs:readdir", async (_, dirPath) => {
ipcMain.handle("fs:exists", async (_, filePath) => {
try {
// Exists check is generally safe, but we can restrict it too for strict privacy
if (!isPathAllowed(filePath)) {
return false;
}
await fs.access(filePath);
return true;
} catch {
@@ -147,6 +248,14 @@ ipcMain.handle("fs:exists", async (_, filePath) => {
ipcMain.handle("fs:stat", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const stats = await fs.stat(filePath);
return {
success: true,
@@ -164,6 +273,14 @@ ipcMain.handle("fs:stat", async (_, filePath) => {
ipcMain.handle("fs:deleteFile", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.unlink(filePath);
return { success: true };
} catch (error) {
@@ -173,6 +290,14 @@ ipcMain.handle("fs:deleteFile", async (_, filePath) => {
ipcMain.handle("fs:trashItem", async (_, targetPath) => {
try {
// Security check
if (!isPathAllowed(targetPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await shell.trashItem(targetPath);
return { success: true };
} catch (error) {
@@ -352,6 +477,10 @@ ipcMain.handle(
"sessions:create",
async (_, { name, projectPath, workingDirectory }) => {
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
if (workingDirectory) addAllowedPath(workingDirectory);
return await agentService.createSession({
name,
projectPath,
@@ -423,6 +552,9 @@ ipcMain.handle(
"auto-mode:start",
async (_, { projectPath, maxConcurrency }) => {
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
@@ -470,7 +602,7 @@ ipcMain.handle("auto-mode:status", () => {
*/
ipcMain.handle(
"auto-mode:run-feature",
async (_, { projectPath, featureId }) => {
async (_, { projectPath, featureId, useWorktrees = false }) => {
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -482,6 +614,7 @@ ipcMain.handle(
projectPath,
featureId,
sendToRenderer,
useWorktrees,
});
} catch (error) {
console.error("[IPC] auto-mode:run-feature error:", error);
@@ -581,6 +714,9 @@ ipcMain.handle(
ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => {
console.log("[IPC] auto-mode:analyze-project called with:", { projectPath });
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
@@ -672,6 +808,111 @@ ipcMain.handle(
}
);
// ============================================================================
// Claude CLI Detection IPC Handlers
// ============================================================================
/**
* Check Claude Code CLI installation status
*/
ipcMain.handle("claude:check-cli", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const info = claudeCliDetector.getInstallationInfo();
return { success: true, ...info };
} catch (error) {
console.error("[IPC] claude:check-cli error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// Codex CLI Detection IPC Handlers
// ============================================================================
/**
* Check Codex CLI installation status
*/
ipcMain.handle("codex:check-cli", async () => {
try {
const codexCliDetector = require("./services/codex-cli-detector");
const info = codexCliDetector.getInstallationInfo();
return { success: true, ...info };
} catch (error) {
console.error("[IPC] codex:check-cli error:", error);
return { success: false, error: error.message };
}
});
/**
* Get all available models from all providers
*/
ipcMain.handle("model:get-available", async () => {
try {
const { ModelProviderFactory } = require("./services/model-provider");
const models = ModelProviderFactory.getAllModels();
return { success: true, models };
} catch (error) {
console.error("[IPC] model:get-available error:", error);
return { success: false, error: error.message };
}
});
/**
* Check all provider installation status
*/
ipcMain.handle("model:check-providers", async () => {
try {
const { ModelProviderFactory } = require("./services/model-provider");
const status = await ModelProviderFactory.checkAllProviders();
return { success: true, providers: status };
} catch (error) {
console.error("[IPC] model:check-providers error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// MCP Server IPC Handlers
// ============================================================================
/**
* Handle MCP server callback for updating feature status
* This can be called by the MCP server script via HTTP or other communication mechanism
* Note: The MCP server script runs as a separate process, so it can't directly use Electron IPC.
* For now, the MCP server calls featureLoader.updateFeatureStatus directly.
* This handler is here for future extensibility (e.g., HTTP endpoint bridge).
*/
ipcMain.handle(
"mcp:update-feature-status",
async (_, { featureId, status, projectPath, summary }) => {
try {
const featureLoader = require("./services/feature-loader");
await featureLoader.updateFeatureStatus(
featureId,
status,
projectPath,
summary
);
// Notify renderer if window is available
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("mcp:feature-status-updated", {
featureId,
status,
projectPath,
summary,
});
}
return { success: true };
} catch (error) {
console.error("[IPC] mcp:update-feature-status error:", error);
return { success: false, error: error.message };
}
}
);
// ============================================================================
// Feature Suggestions IPC Handlers
// ============================================================================
@@ -682,53 +923,53 @@ let suggestionsExecution = null;
/**
* Generate feature suggestions by analyzing the project
*/
ipcMain.handle(
"suggestions:generate",
async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
return { success: false, error: "Suggestions generation is already running" };
}
// Create execution context
suggestionsExecution = {
abortController: null,
query: null,
isActive: () => suggestionsExecution !== null,
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
return {
success: false,
error: "Suggestions generation is already running",
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("suggestions:event", data);
}
};
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
suggestionsExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:generate error:", error);
suggestionsExecution = null;
return { success: false, error: error.message };
}
// Create execution context
suggestionsExecution = {
abortController: null,
query: null,
isActive: () => suggestionsExecution !== null,
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("suggestions:event", data);
}
};
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
suggestionsExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:generate error:", error);
suggestionsExecution = null;
return { success: false, error: error.message };
}
);
});
/**
* Stop the current suggestions generation
@@ -757,6 +998,79 @@ ipcMain.handle("suggestions:status", () => {
};
});
// ============================================================================
// OpenAI API Handlers
// ============================================================================
/**
* Test OpenAI API connection
*/
ipcMain.handle("openai:test-connection", async (_, { apiKey }) => {
try {
// Simple test using fetch to OpenAI API
const response = await fetch("https://api.openai.com/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
return {
success: true,
message: `Connected successfully. Found ${
data.data?.length || 0
} models.`,
};
} else {
const error = await response.json();
return {
success: false,
error: error.error?.message || "Failed to connect to OpenAI API",
};
}
} catch (error) {
console.error("[IPC] openai:test-connection error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// Worktree Management IPC Handlers
// ============================================================================
/**
* Revert feature changes by removing the worktree
* This effectively discards all changes made by the agent
*/
ipcMain.handle(
"worktree:revert-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] worktree:revert-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
}
};
return await autoModeService.revertFeature({
projectPath,
featureId,
sendToRenderer,
});
} catch (error) {
console.error("[IPC] worktree:revert-feature error:", error);
return { success: false, error: error.message };
}
}
);
// ============================================================================
// Spec Regeneration IPC Handlers
// ============================================================================
@@ -770,12 +1084,20 @@ let specRegenerationExecution = null;
ipcMain.handle(
"spec-regeneration:generate",
async (_, { projectPath, projectDefinition }) => {
console.log("[IPC] spec-regeneration:generate called with:", { projectPath });
console.log("[IPC] spec-regeneration:generate called with:", {
projectPath,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec regeneration is already running" };
return {
success: false,
error: "Spec regeneration is already running",
};
}
// Create execution context
@@ -793,9 +1115,17 @@ ipcMain.handle(
// Start regenerating spec (runs in background)
specRegenerationService
.regenerateSpec(projectPath, projectDefinition, sendToRenderer, specRegenerationExecution)
.regenerateSpec(
projectPath,
projectDefinition,
sendToRenderer,
specRegenerationExecution
)
.catch((error) => {
console.error("[IPC] spec-regeneration:generate background error:", error);
console.error(
"[IPC] spec-regeneration:generate background error:",
error
);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
@@ -821,7 +1151,10 @@ ipcMain.handle(
ipcMain.handle("spec-regeneration:stop", async () => {
console.log("[IPC] spec-regeneration:stop called");
try {
if (specRegenerationExecution && specRegenerationExecution.abortController) {
if (
specRegenerationExecution &&
specRegenerationExecution.abortController
) {
specRegenerationExecution.abortController.abort();
}
specRegenerationExecution = null;
@@ -838,7 +1171,9 @@ ipcMain.handle("spec-regeneration:stop", async () => {
ipcMain.handle("spec-regeneration:status", () => {
return {
success: true,
isRunning: specRegenerationExecution !== null && specRegenerationExecution.isActive(),
isRunning:
specRegenerationExecution !== null &&
specRegenerationExecution.isActive(),
};
});
@@ -848,9 +1183,15 @@ ipcMain.handle("spec-regeneration:status", () => {
ipcMain.handle(
"spec-regeneration:create",
async (_, { projectPath, projectOverview, generateFeatures = true }) => {
console.log("[IPC] spec-regeneration:create called with:", { projectPath, generateFeatures });
console.log("[IPC] spec-regeneration:create called with:", {
projectPath,
generateFeatures,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec creation is already running" };
@@ -871,9 +1212,18 @@ ipcMain.handle(
// Start creating spec (runs in background)
specRegenerationService
.createInitialSpec(projectPath, projectOverview, sendToRenderer, specRegenerationExecution, generateFeatures)
.createInitialSpec(
projectPath,
projectOverview,
sendToRenderer,
specRegenerationExecution,
generateFeatures
)
.catch((error) => {
console.error("[IPC] spec-regeneration:create background error:", error);
console.error(
"[IPC] spec-regeneration:create background error:",
error
);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
@@ -892,3 +1242,124 @@ ipcMain.handle(
}
}
);
/**
* Merge feature worktree changes back to main branch
*/
ipcMain.handle(
"worktree:merge-feature",
async (_, { projectPath, featureId, options }) => {
console.log("[IPC] worktree:merge-feature called with:", {
projectPath,
featureId,
options,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
}
};
return await autoModeService.mergeFeature({
projectPath,
featureId,
options,
sendToRenderer,
});
} catch (error) {
console.error("[IPC] worktree:merge-feature error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Get worktree info for a feature
*/
ipcMain.handle("worktree:get-info", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getWorktreeInfo({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-info error:", error);
return { success: false, error: error.message };
}
});
/**
* Get worktree status (changed files, commits)
*/
ipcMain.handle("worktree:get-status", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getWorktreeStatus({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-status error:", error);
return { success: false, error: error.message };
}
});
/**
* List all feature worktrees
*/
ipcMain.handle("worktree:list", async (_, { projectPath }) => {
try {
return await autoModeService.listWorktrees({ projectPath });
} catch (error) {
console.error("[IPC] worktree:list error:", error);
return { success: false, error: error.message };
}
});
/**
* Get file diffs for a worktree
*/
ipcMain.handle("worktree:get-diffs", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getFileDiffs({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-diffs error:", error);
return { success: false, error: error.message };
}
});
/**
* Get diff for a specific file in a worktree
*/
ipcMain.handle(
"worktree:get-file-diff",
async (_, { projectPath, featureId, filePath }) => {
try {
return await autoModeService.getFileDiff({
projectPath,
featureId,
filePath,
});
} catch (error) {
console.error("[IPC] worktree:get-file-diff error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Get file diffs for the main project (non-worktree)
*/
ipcMain.handle("git:get-diffs", async (_, { projectPath }) => {
try {
return await worktreeManager.getFileDiffs(projectPath);
} catch (error) {
console.error("[IPC] git:get-diffs error:", error);
return { success: false, error: error.message };
}
});
/**
* Get diff for a specific file in the main project (non-worktree)
*/
ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
try {
return await worktreeManager.getFileDiff(projectPath, filePath);
} catch (error) {
console.error("[IPC] git:get-file-diff error:", error);
return { success: false, error: error.message };
}
});