mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
chore: update project management and API integration
- Added new scripts for server development and full application startup in package.json. - Enhanced project management by checking for existing projects to avoid duplicates. - Improved API integration with better error handling and connection checks in the Electron API. - Updated UI components to reflect changes in project and session management. - Refactored authentication status display to include more detailed information on methods used.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ node_modules/
|
||||
# Build outputs
|
||||
dist/
|
||||
.next/
|
||||
node_modules
|
||||
|
||||
241
apps/app/electron/main-simplified.js
Normal file
241
apps/app/electron/main-simplified.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Simplified Electron main process
|
||||
*
|
||||
* This version spawns the backend server and uses HTTP API for most operations.
|
||||
* Only native features (dialogs, shell) use IPC.
|
||||
*/
|
||||
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
// Load environment variables from .env file
|
||||
require("dotenv").config({ path: path.join(__dirname, "../.env") });
|
||||
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
|
||||
|
||||
let mainWindow = null;
|
||||
let serverProcess = null;
|
||||
const SERVER_PORT = 3008;
|
||||
|
||||
// Get icon path - works in both dev and production
|
||||
function getIconPath() {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "app", "public", "logo.png")
|
||||
: path.join(__dirname, "../public/logo.png");
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the backend server
|
||||
*/
|
||||
async function startServer() {
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
// Server entry point
|
||||
const serverPath = isDev
|
||||
? path.join(__dirname, "../../server/dist/index.js")
|
||||
: path.join(process.resourcesPath, "server", "index.js");
|
||||
|
||||
// Set environment variables for server
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: SERVER_PORT.toString(),
|
||||
DATA_DIR: app.getPath("userData"),
|
||||
};
|
||||
|
||||
console.log("[Electron] Starting backend server...");
|
||||
|
||||
serverProcess = spawn("node", [serverPath], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
console.log(`[Server] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
console.error(`[Server Error] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on("close", (code) => {
|
||||
console.log(`[Server] Process exited with code ${code}`);
|
||||
serverProcess = null;
|
||||
});
|
||||
|
||||
// Wait for server to be ready
|
||||
await waitForServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for server to be available
|
||||
*/
|
||||
async function waitForServer(maxAttempts = 30) {
|
||||
const http = require("http");
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${SERVER_PORT}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Status: ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Timeout"));
|
||||
});
|
||||
});
|
||||
console.log("[Electron] Server is ready");
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Server failed to start");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main window
|
||||
*/
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
icon: getIconPath(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload-simplified.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
titleBarStyle: "hiddenInset",
|
||||
backgroundColor: "#0a0a0a",
|
||||
});
|
||||
|
||||
// Load Next.js dev server in development or production build
|
||||
const isDev = !app.isPackaged;
|
||||
if (isDev) {
|
||||
mainWindow.loadURL("http://localhost:3007");
|
||||
if (process.env.OPEN_DEVTOOLS === "true") {
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, "../.next/server/app/index.html"));
|
||||
}
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
// App lifecycle
|
||||
app.whenReady().then(async () => {
|
||||
// Set app icon (dock icon on macOS)
|
||||
if (process.platform === "darwin" && app.dock) {
|
||||
app.dock.setIcon(getIconPath());
|
||||
}
|
||||
|
||||
try {
|
||||
// Start backend server
|
||||
await startServer();
|
||||
|
||||
// Create window
|
||||
createWindow();
|
||||
} catch (error) {
|
||||
console.error("[Electron] Failed to start:", error);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
// Kill server process
|
||||
if (serverProcess) {
|
||||
console.log("[Electron] Stopping server...");
|
||||
serverProcess.kill();
|
||||
serverProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// IPC Handlers - Only native features
|
||||
// ============================================
|
||||
|
||||
// Native file dialogs
|
||||
ipcMain.handle("dialog:openDirectory", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openDirectory", "createDirectory"],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:openFile", async (_, options = {}) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_, options = {}) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Shell operations
|
||||
ipcMain.handle("shell:openExternal", async (_, url) => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:openPath", async (_, filePath) => {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// App info
|
||||
ipcMain.handle("app:getPath", async (_, name) => {
|
||||
return app.getPath(name);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getVersion", async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isPackaged", async () => {
|
||||
return app.isPackaged;
|
||||
});
|
||||
|
||||
// Ping - for connection check
|
||||
ipcMain.handle("ping", async () => {
|
||||
return "pong";
|
||||
});
|
||||
|
||||
// Get server URL for HTTP client
|
||||
ipcMain.handle("server:getUrl", async () => {
|
||||
return `http://localhost:${SERVER_PORT}`;
|
||||
});
|
||||
37
apps/app/electron/preload-simplified.js
Normal file
37
apps/app/electron/preload-simplified.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Simplified Electron preload script
|
||||
*
|
||||
* Only exposes native features (dialogs, shell) and server URL.
|
||||
* All other operations go through HTTP API.
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Expose minimal API for native features
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// Platform info
|
||||
platform: process.platform,
|
||||
isElectron: true,
|
||||
|
||||
// Connection check
|
||||
ping: () => ipcRenderer.invoke("ping"),
|
||||
|
||||
// Get server URL for HTTP client
|
||||
getServerUrl: () => ipcRenderer.invoke("server:getUrl"),
|
||||
|
||||
// Native dialogs - better UX than prompt()
|
||||
openDirectory: () => ipcRenderer.invoke("dialog:openDirectory"),
|
||||
openFile: (options) => ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options) => ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// Shell operations
|
||||
openExternalLink: (url) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
openPath: (filePath) => ipcRenderer.invoke("shell:openPath", filePath),
|
||||
|
||||
// App info
|
||||
getPath: (name) => ipcRenderer.invoke("app:getPath", name),
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
isPackaged: () => ipcRenderer.invoke("app:isPackaged"),
|
||||
});
|
||||
|
||||
console.log("[Preload] Electron API exposed (simplified mode)");
|
||||
@@ -94,7 +94,7 @@ export default function Home() {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const result = await api.ping();
|
||||
setIpcConnected(result === "pong" || result === "pong (mock)");
|
||||
setIpcConnected(result === "pong");
|
||||
} catch (error) {
|
||||
console.error("IPC connection failed:", error);
|
||||
setIpcConnected(false);
|
||||
@@ -193,8 +193,8 @@ export default function Home() {
|
||||
<SetupView />
|
||||
{/* Environment indicator */}
|
||||
{isMounted && !isElectron() && (
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
|
||||
Web Mode (Mock IPC)
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
||||
Web Mode
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -210,8 +210,8 @@ export default function Home() {
|
||||
|
||||
{/* Environment indicator - only show after mount to prevent hydration issues */}
|
||||
{isMounted && !isElectron() && (
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-yellow-500/10 text-yellow-500 text-xs rounded-full border border-yellow-500/20 pointer-events-none">
|
||||
Web Mode (Mock IPC)
|
||||
<div className="fixed bottom-4 right-4 px-3 py-1.5 bg-blue-500/10 text-blue-500 text-xs rounded-full border border-blue-500/20 pointer-events-none">
|
||||
Web Mode
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -413,14 +413,33 @@ export function Sidebar() {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
// Check if project already exists (by path) to preserve theme and other settings
|
||||
const existingProject = projects.find((p) => p.path === path);
|
||||
|
||||
let project: Project;
|
||||
if (existingProject) {
|
||||
// Update existing project, preserving theme and other properties
|
||||
project = {
|
||||
...existingProject,
|
||||
name, // Update name in case it changed
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
// Update the project in the store (this will update the existing entry)
|
||||
const updatedProjects = projects.map((p) =>
|
||||
p.id === existingProject.id ? project : p
|
||||
);
|
||||
useAppStore.setState({ projects: updatedProjects });
|
||||
} else {
|
||||
// Create new project
|
||||
project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
addProject(project);
|
||||
}
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
|
||||
// Check if app_spec.txt exists
|
||||
@@ -455,7 +474,7 @@ export function Sidebar() {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [addProject, setCurrentProject]);
|
||||
}, [projects, addProject, setCurrentProject]);
|
||||
|
||||
const handleRestoreProject = useCallback(
|
||||
(projectId: string) => {
|
||||
|
||||
@@ -4,14 +4,13 @@ import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HotkeyButton } from "@/components/ui/hotkey-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Plus,
|
||||
MessageSquare,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SessionListItem } from "@/types/electron";
|
||||
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
// Random session name generator
|
||||
const adjectives = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImageIcon, X, Loader2 } from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -98,7 +98,7 @@ export function DescriptionImageDropZone({
|
||||
});
|
||||
};
|
||||
|
||||
const saveImageToTemp = async (
|
||||
const saveImageToTemp = useCallback(async (
|
||||
base64Data: string,
|
||||
filename: string,
|
||||
mimeType: string
|
||||
@@ -107,8 +107,8 @@ export function DescriptionImageDropZone({
|
||||
const api = getElectronAPI();
|
||||
// Check if saveImageToTemp method exists
|
||||
if (!api.saveImageToTemp) {
|
||||
// Fallback for mock API - return a mock path in .automaker/images
|
||||
console.log("[DescriptionImageDropZone] Using mock path for image");
|
||||
// Fallback path when saveImageToTemp is not available
|
||||
console.log("[DescriptionImageDropZone] Using fallback path for image");
|
||||
return `.automaker/images/${Date.now()}_${filename}`;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function DescriptionImageDropZone({
|
||||
console.error("[DescriptionImageDropZone] Error saving image:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}, [currentProject?.path]);
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: FileList) => {
|
||||
@@ -193,7 +193,7 @@ export function DescriptionImageDropZone({
|
||||
|
||||
setIsProcessing(false);
|
||||
},
|
||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages]
|
||||
[disabled, isProcessing, images, maxFiles, maxFileSize, onImagesChange, previewImages, saveImageToTemp]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
|
||||
@@ -149,12 +149,12 @@ export function AgentToolsView() {
|
||||
setTerminalResult(null);
|
||||
|
||||
try {
|
||||
// Simulate agent requesting terminal command execution
|
||||
console.log(`[Agent Tool] Requesting to run command: ${terminalCommand}`);
|
||||
// Terminal command simulation for demonstration purposes
|
||||
console.log(`[Agent Tool] Simulating command: ${terminalCommand}`);
|
||||
|
||||
// In mock mode, simulate terminal output
|
||||
// In real Electron mode, this would use child_process
|
||||
const mockOutputs: Record<string, string> = {
|
||||
// Simulated outputs for common commands (preview mode)
|
||||
// In production, the agent executes commands via Claude SDK
|
||||
const simulatedOutputs: Record<string, string> = {
|
||||
ls: "app_spec.txt\nfeatures\nnode_modules\npackage.json\nsrc\ntests\ntsconfig.json",
|
||||
pwd: currentProject?.path || "/Users/demo/project",
|
||||
"echo hello": "hello",
|
||||
@@ -168,8 +168,8 @@ export function AgentToolsView() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const output =
|
||||
mockOutputs[terminalCommand.toLowerCase()] ||
|
||||
`Command executed: ${terminalCommand}\n(Mock output - real execution requires Electron mode)`;
|
||||
simulatedOutputs[terminalCommand.toLowerCase()] ||
|
||||
`[Preview] ${terminalCommand}\n(Terminal commands are executed by the agent during feature implementation)`;
|
||||
|
||||
setTerminalResult({
|
||||
success: true,
|
||||
|
||||
@@ -394,22 +394,25 @@ export function BoardView() {
|
||||
}, []);
|
||||
|
||||
// Load features using features API
|
||||
// IMPORTANT: Do NOT add 'features' to dependency array - it would cause infinite reload loop
|
||||
const loadFeatures = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const currentPath = currentProject.path;
|
||||
const previousPath = prevProjectPathRef.current;
|
||||
const isProjectSwitch = previousPath !== null && currentPath !== previousPath;
|
||||
|
||||
// If project switched, clear features first to prevent cross-contamination
|
||||
// Also treat this as an initial load for the new project
|
||||
if (previousPath !== null && currentPath !== previousPath) {
|
||||
// Get cached features from store (without adding to dependencies)
|
||||
const cachedFeatures = useAppStore.getState().features;
|
||||
|
||||
// If project switched, mark it but don't clear features yet
|
||||
// We'll clear after successful API load to prevent data loss
|
||||
if (isProjectSwitch) {
|
||||
console.log(
|
||||
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}, clearing features`
|
||||
`[BoardView] Project switch detected: ${previousPath} -> ${currentPath}`
|
||||
);
|
||||
isSwitchingProjectRef.current = true;
|
||||
isInitialLoadRef.current = true;
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]); // Also clear categories
|
||||
}
|
||||
|
||||
// Update the ref to track current project
|
||||
@@ -424,6 +427,7 @@ export function BoardView() {
|
||||
const api = getElectronAPI();
|
||||
if (!api.features) {
|
||||
console.error("[BoardView] Features API not available");
|
||||
// Keep cached features if API is unavailable
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -441,10 +445,31 @@ export function BoardView() {
|
||||
thinkingLevel: f.thinkingLevel || "none",
|
||||
})
|
||||
);
|
||||
// Successfully loaded features - now safe to set them
|
||||
setFeatures(featuresWithIds);
|
||||
|
||||
// Only clear categories on project switch AFTER successful load
|
||||
if (isProjectSwitch) {
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} else if (!result.success && result.error) {
|
||||
console.error("[BoardView] API returned error:", result.error);
|
||||
// If it's a new project or the error indicates no features found,
|
||||
// that's expected - start with empty array
|
||||
if (isProjectSwitch) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
// Otherwise keep cached features
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load features:", error);
|
||||
// On error, keep existing cached features for the current project
|
||||
// Only clear on project switch if we have no features from server
|
||||
if (isProjectSwitch && cachedFeatures.length === 0) {
|
||||
setFeatures([]);
|
||||
setPersistedCategories([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isInitialLoadRef.current = false;
|
||||
@@ -1475,8 +1500,14 @@ export function BoardView() {
|
||||
if (isRunning) {
|
||||
map.in_progress.push(f);
|
||||
} else {
|
||||
// Otherwise, use the feature's status
|
||||
map[f.status].push(f);
|
||||
// Otherwise, use the feature's status (fallback to backlog for unknown statuses)
|
||||
const status = f.status as ColumnId;
|
||||
if (map[status]) {
|
||||
map[status].push(f);
|
||||
} else {
|
||||
// Unknown status, default to backlog
|
||||
map.backlog.push(f);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -61,12 +61,14 @@ export function AuthenticationStatusDisplay({
|
||||
{claudeAuthStatus.method === "oauth_token_env"
|
||||
? "Using CLAUDE_CODE_OAUTH_TOKEN"
|
||||
: claudeAuthStatus.method === "oauth_token"
|
||||
? "Using stored OAuth token"
|
||||
? "Using stored OAuth token (claude login)"
|
||||
: claudeAuthStatus.method === "api_key_env"
|
||||
? "Using ANTHROPIC_API_KEY"
|
||||
: claudeAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: "Unknown method"}
|
||||
: claudeAuthStatus.method === "credentials_file"
|
||||
? "Using credentials file"
|
||||
: `Using ${claudeAuthStatus.method || "detected"} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -107,14 +109,16 @@ export function AuthenticationStatusDisplay({
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Info className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens"
|
||||
{codexAuthStatus.method === "subscription"
|
||||
? "Using Codex subscription (Plus/Team)"
|
||||
: codexAuthStatus.method === "cli_verified" ||
|
||||
codexAuthStatus.method === "cli_tokens"
|
||||
? "Using CLI login (OpenAI account)"
|
||||
: codexAuthStatus.method === "api_key"
|
||||
? "Using stored API key"
|
||||
: codexAuthStatus.method === "env"
|
||||
? "Using OPENAI_API_KEY"
|
||||
: "Unknown method"}
|
||||
: `Using ${codexAuthStatus.method || "unknown"} authentication`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -68,19 +68,24 @@ export function useCliStatus() {
|
||||
try {
|
||||
const result = await api.setup.getClaudeStatus();
|
||||
if (result.success && result.auth) {
|
||||
const auth = result.auth;
|
||||
// Validate method is one of the expected values, default to "none"
|
||||
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "none"] as const;
|
||||
// Cast to extended type that includes server-added fields
|
||||
const auth = result.auth as typeof result.auth & {
|
||||
oauthTokenValid?: boolean;
|
||||
apiKeyValid?: boolean;
|
||||
};
|
||||
// Map server method names to client method types
|
||||
// Server returns: oauth_token_env, oauth_token, api_key_env, api_key, credentials_file, none
|
||||
const validMethods = ["oauth_token_env", "oauth_token", "api_key", "api_key_env", "credentials_file", "none"] as const;
|
||||
type AuthMethod = typeof validMethods[number];
|
||||
const method: AuthMethod = validMethods.includes(auth.method as AuthMethod)
|
||||
? (auth.method as AuthMethod)
|
||||
: "none";
|
||||
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key, not none
|
||||
const authStatus = {
|
||||
authenticated: auth.authenticated,
|
||||
method,
|
||||
hasCredentialsFile: auth.hasCredentialsFile ?? false,
|
||||
oauthTokenValid: auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
|
||||
apiKeyValid: auth.hasStoredApiKey || auth.hasEnvApiKey,
|
||||
oauthTokenValid: auth.oauthTokenValid || auth.hasStoredOAuthToken || auth.hasEnvOAuthToken,
|
||||
apiKeyValid: auth.apiKeyValid || auth.hasStoredApiKey || auth.hasEnvApiKey,
|
||||
hasEnvOAuthToken: auth.hasEnvOAuthToken,
|
||||
hasEnvApiKey: auth.hasEnvApiKey,
|
||||
};
|
||||
@@ -96,27 +101,30 @@ export function useCliStatus() {
|
||||
try {
|
||||
const result = await api.setup.getCodexStatus();
|
||||
if (result.success && result.auth) {
|
||||
const auth = result.auth;
|
||||
// Determine method - prioritize cli_verified and cli_tokens over auth_file
|
||||
const method =
|
||||
auth.method === "cli_verified" || auth.method === "cli_tokens"
|
||||
? auth.method === "cli_verified"
|
||||
? ("cli_verified" as const)
|
||||
: ("cli_tokens" as const)
|
||||
: auth.method === "auth_file"
|
||||
? ("api_key" as const)
|
||||
: auth.method === "env_var"
|
||||
? ("env" as const)
|
||||
: ("none" as const);
|
||||
// Cast to extended type that includes server-added fields
|
||||
const auth = result.auth as typeof result.auth & {
|
||||
hasSubscription?: boolean;
|
||||
cliLoggedIn?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
// Map server method names to client method types
|
||||
// Server returns: subscription, cli_verified, cli_tokens, api_key, env, none
|
||||
const validMethods = ["subscription", "cli_verified", "cli_tokens", "api_key", "env", "none"] as const;
|
||||
type CodexMethod = typeof validMethods[number];
|
||||
const method: CodexMethod = validMethods.includes(auth.method as CodexMethod)
|
||||
? (auth.method as CodexMethod)
|
||||
: auth.authenticated ? "api_key" : "none"; // Default authenticated to api_key
|
||||
|
||||
const authStatus = {
|
||||
authenticated: auth.authenticated,
|
||||
method,
|
||||
// Only set apiKeyValid for actual API key methods, not CLI login
|
||||
// Only set apiKeyValid for actual API key methods, not CLI login or subscription
|
||||
apiKeyValid:
|
||||
method === "cli_verified" || method === "cli_tokens"
|
||||
method === "cli_verified" || method === "cli_tokens" || method === "subscription"
|
||||
? undefined
|
||||
: auth.hasAuthFile || auth.hasEnvKey,
|
||||
: auth.hasAuthFile || auth.hasEnvKey || auth.hasEnvApiKey,
|
||||
hasSubscription: auth.hasSubscription,
|
||||
cliLoggedIn: auth.cliLoggedIn,
|
||||
};
|
||||
setCodexAuthStatus(authStatus);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getElectronAPI } from "@/lib/electron";
|
||||
import { getElectronAPI, type Project } from "@/lib/electron";
|
||||
import { initializeProject } from "@/lib/project-init";
|
||||
import {
|
||||
FolderOpen,
|
||||
@@ -105,14 +105,34 @@ export function WelcomeView() {
|
||||
return;
|
||||
}
|
||||
|
||||
const project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
// Check if project already exists (by path) to preserve theme and other settings
|
||||
const existingProject = projects.find((p) => p.path === path);
|
||||
|
||||
let project: Project;
|
||||
if (existingProject) {
|
||||
// Update existing project, preserving theme and other properties
|
||||
project = {
|
||||
...existingProject,
|
||||
name, // Update name in case it changed
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
// Update the project in the store (this will update the existing entry)
|
||||
const updatedProjects = projects.map((p) =>
|
||||
p.id === existingProject.id ? project : p
|
||||
);
|
||||
// We need to manually update projects since addProject would create a duplicate
|
||||
useAppStore.setState({ projects: updatedProjects });
|
||||
} else {
|
||||
// Create new project
|
||||
project = {
|
||||
id: `project-${Date.now()}`,
|
||||
name,
|
||||
path,
|
||||
lastOpened: new Date().toISOString(),
|
||||
};
|
||||
addProject(project);
|
||||
}
|
||||
|
||||
addProject(project);
|
||||
setCurrentProject(project);
|
||||
|
||||
// Show initialization dialog if files were created
|
||||
@@ -148,7 +168,7 @@ export function WelcomeView() {
|
||||
setIsOpening(false);
|
||||
}
|
||||
},
|
||||
[addProject, setCurrentProject, analyzeProject]
|
||||
[projects, addProject, setCurrentProject, analyzeProject]
|
||||
);
|
||||
|
||||
const handleOpenProject = useCallback(async () => {
|
||||
|
||||
@@ -443,13 +443,109 @@ export const isElectron = (): boolean => {
|
||||
return typeof window !== "undefined" && window.isElectron === true;
|
||||
};
|
||||
|
||||
// Get the Electron API or a mock for web development
|
||||
// Check if backend server is available
|
||||
let serverAvailable: boolean | null = null;
|
||||
let serverCheckPromise: Promise<boolean> | null = null;
|
||||
|
||||
export const checkServerAvailable = async (): Promise<boolean> => {
|
||||
if (serverAvailable !== null) return serverAvailable;
|
||||
if (serverCheckPromise) return serverCheckPromise;
|
||||
|
||||
serverCheckPromise = (async () => {
|
||||
try {
|
||||
const serverUrl =
|
||||
process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008";
|
||||
const response = await fetch(`${serverUrl}/api/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
serverAvailable = response.ok;
|
||||
} catch {
|
||||
serverAvailable = false;
|
||||
}
|
||||
return serverAvailable;
|
||||
})();
|
||||
|
||||
return serverCheckPromise;
|
||||
};
|
||||
|
||||
// Reset server check (useful for retrying connection)
|
||||
export const resetServerCheck = (): void => {
|
||||
serverAvailable = null;
|
||||
serverCheckPromise = null;
|
||||
};
|
||||
|
||||
// Cached HTTP client instance
|
||||
let httpClientInstance: ElectronAPI | null = null;
|
||||
|
||||
// Check if we're in simplified Electron mode (HTTP backend instead of IPC)
|
||||
const isSimplifiedElectronMode = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = window.electronAPI as any;
|
||||
// Simplified mode has isElectron flag and getServerUrl but NOT readFile
|
||||
return api?.isElectron === true &&
|
||||
typeof api?.getServerUrl === "function" &&
|
||||
typeof api?.readFile !== "function";
|
||||
};
|
||||
|
||||
// Get the Electron API or HTTP client for web mode
|
||||
// In simplified Electron mode and web mode, uses HTTP client
|
||||
export const getElectronAPI = (): ElectronAPI => {
|
||||
// Check if we're in simplified Electron mode (uses HTTP backend)
|
||||
if (isSimplifiedElectronMode()) {
|
||||
if (typeof window !== "undefined" && !httpClientInstance) {
|
||||
const { getHttpApiClient } = require("./http-api-client");
|
||||
httpClientInstance = getHttpApiClient();
|
||||
}
|
||||
return httpClientInstance!;
|
||||
}
|
||||
|
||||
// Full Electron API with IPC
|
||||
if (isElectron() && window.electronAPI) {
|
||||
return window.electronAPI;
|
||||
}
|
||||
|
||||
// Return mock API for web development
|
||||
// Web mode: use HTTP API client
|
||||
if (typeof window !== "undefined") {
|
||||
if (!httpClientInstance) {
|
||||
const { getHttpApiClient } = require("./http-api-client");
|
||||
httpClientInstance = getHttpApiClient();
|
||||
}
|
||||
return httpClientInstance!;
|
||||
}
|
||||
|
||||
// SSR fallback - this shouldn't be called during actual operation
|
||||
throw new Error("Cannot get Electron API during SSR");
|
||||
};
|
||||
|
||||
// Async version that checks server availability first
|
||||
export const getElectronAPIAsync = async (): Promise<ElectronAPI> => {
|
||||
// Simplified Electron mode or web mode: use HTTP client
|
||||
if (isSimplifiedElectronMode() || !isElectron()) {
|
||||
if (typeof window !== "undefined") {
|
||||
const { getHttpApiClient } = await import("./http-api-client");
|
||||
return getHttpApiClient();
|
||||
}
|
||||
}
|
||||
|
||||
// Full Electron API with IPC
|
||||
if (isElectron() && window.electronAPI) {
|
||||
return window.electronAPI;
|
||||
}
|
||||
|
||||
throw new Error("Cannot get Electron API during SSR");
|
||||
};
|
||||
|
||||
// Check if backend is connected (for showing connection status in UI)
|
||||
export const isBackendConnected = async (): Promise<boolean> => {
|
||||
// Full Electron mode: backend is built-in
|
||||
if (isElectron() && !isSimplifiedElectronMode()) return true;
|
||||
// Simplified Electron or web mode: check server availability
|
||||
return await checkServerAvailable();
|
||||
};
|
||||
|
||||
// Mock API for development/fallback when no backend is available
|
||||
const getMockElectronAPI = (): ElectronAPI => {
|
||||
return {
|
||||
ping: async () => "pong (mock)",
|
||||
|
||||
|
||||
664
apps/app/src/lib/http-api-client.ts
Normal file
664
apps/app/src/lib/http-api-client.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* HTTP API Client for web mode
|
||||
*
|
||||
* This client provides the same API as the Electron IPC bridge,
|
||||
* but communicates with the backend server via HTTP/WebSocket.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ElectronAPI,
|
||||
FileResult,
|
||||
WriteResult,
|
||||
ReaddirResult,
|
||||
StatResult,
|
||||
DialogResult,
|
||||
SaveImageResult,
|
||||
AutoModeAPI,
|
||||
FeaturesAPI,
|
||||
SuggestionsAPI,
|
||||
SpecRegenerationAPI,
|
||||
AutoModeEvent,
|
||||
SuggestionsEvent,
|
||||
SpecRegenerationEvent,
|
||||
FeatureSuggestion,
|
||||
SuggestionType,
|
||||
} from "./electron";
|
||||
import type { Feature } from "@/store/app-store";
|
||||
import type {
|
||||
WorktreeAPI,
|
||||
GitAPI,
|
||||
ModelDefinition,
|
||||
ProviderStatus,
|
||||
} from "@/types/electron";
|
||||
|
||||
// Check if we're in simplified Electron mode (Electron with HTTP backend)
|
||||
const isSimplifiedElectronMode = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = (window as any).electronAPI;
|
||||
// Simplified mode has isElectron flag but limited methods
|
||||
return api?.isElectron === true && typeof api?.getServerUrl === "function";
|
||||
};
|
||||
|
||||
// Check if native Electron dialogs are available
|
||||
const hasNativeDialogs = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const api = (window as any).electronAPI;
|
||||
return typeof api?.openDirectory === "function";
|
||||
};
|
||||
|
||||
// Server URL - configurable via environment variable or Electron
|
||||
const getServerUrl = async (): Promise<string> => {
|
||||
if (typeof window !== "undefined") {
|
||||
// In simplified Electron mode, get URL from main process
|
||||
const api = (window as any).electronAPI;
|
||||
if (api?.getServerUrl) {
|
||||
try {
|
||||
return await api.getServerUrl();
|
||||
} catch {
|
||||
// Fall through to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Check for environment variable
|
||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
|
||||
// Default to localhost for development
|
||||
return "http://localhost:3008";
|
||||
}
|
||||
return "http://localhost:3008";
|
||||
};
|
||||
|
||||
// Synchronous version for constructor (uses default, then updates)
|
||||
const getServerUrlSync = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const envUrl = process.env.NEXT_PUBLIC_SERVER_URL;
|
||||
if (envUrl) return envUrl;
|
||||
}
|
||||
return "http://localhost:3008";
|
||||
};
|
||||
|
||||
// Get API key from environment variable
|
||||
const getApiKey = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return process.env.NEXT_PUBLIC_AUTOMAKER_API_KEY || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
/**
|
||||
* HTTP API Client that implements ElectronAPI interface
|
||||
*/
|
||||
export class HttpApiClient implements ElectronAPI {
|
||||
private serverUrl: string;
|
||||
private ws: WebSocket | null = null;
|
||||
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private isConnecting = false;
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrlSync();
|
||||
// Update server URL asynchronously if in Electron
|
||||
this.initServerUrl();
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private async initServerUrl(): Promise<void> {
|
||||
const url = await getServerUrl();
|
||||
if (url !== this.serverUrl) {
|
||||
this.serverUrl = url;
|
||||
// Reconnect WebSocket with new URL
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
private connectWebSocket(): void {
|
||||
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
try {
|
||||
const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/api/events";
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("[HttpApiClient] WebSocket connected");
|
||||
this.isConnecting = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
const callbacks = this.eventCallbacks.get(data.type);
|
||||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(data.payload));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HttpApiClient] Failed to parse WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log("[HttpApiClient] WebSocket disconnected");
|
||||
this.isConnecting = false;
|
||||
this.ws = null;
|
||||
// Attempt to reconnect after 5 seconds
|
||||
if (!this.reconnectTimer) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connectWebSocket();
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("[HttpApiClient] WebSocket error:", error);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[HttpApiClient] Failed to create WebSocket:", error);
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribeToEvent(type: EventType, callback: EventCallback): () => void {
|
||||
if (!this.eventCallbacks.has(type)) {
|
||||
this.eventCallbacks.set(type, new Set());
|
||||
}
|
||||
this.eventCallbacks.get(type)!.add(callback);
|
||||
|
||||
// Ensure WebSocket is connected
|
||||
this.connectWebSocket();
|
||||
|
||||
return () => {
|
||||
const callbacks = this.eventCallbacks.get(type);
|
||||
if (callbacks) {
|
||||
callbacks.delete(callback);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers["X-API-Key"] = apiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: this.getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async get<T>(endpoint: string): Promise<T> {
|
||||
const headers = this.getHeaders();
|
||||
const response = await fetch(`${this.serverUrl}${endpoint}`, { headers });
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Basic operations
|
||||
async ping(): Promise<string> {
|
||||
const result = await this.get<{ status: string }>("/api/health");
|
||||
return result.status === "ok" ? "pong" : "error";
|
||||
}
|
||||
|
||||
async openExternalLink(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
// Use native Electron shell if available (better UX)
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
if (api.openExternalLink) {
|
||||
return api.openExternalLink(url);
|
||||
}
|
||||
}
|
||||
// Web mode: open in new tab
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// File picker - uses native Electron dialogs when available, otherwise prompt
|
||||
async openDirectory(): Promise<DialogResult> {
|
||||
// Use native Electron dialog if available
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
return api.openDirectory();
|
||||
}
|
||||
|
||||
// Web mode: show a modal to let user type/paste path
|
||||
const path = prompt("Enter project directory path:");
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// Validate with server
|
||||
const result = await this.post<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}>("/api/fs/validate-path", { filePath: path });
|
||||
|
||||
if (result.success && result.path) {
|
||||
return { canceled: false, filePaths: [result.path] };
|
||||
}
|
||||
|
||||
alert(result.error || "Invalid path");
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
async openFile(options?: object): Promise<DialogResult> {
|
||||
// Use native Electron dialog if available
|
||||
if (hasNativeDialogs()) {
|
||||
const api = (window as any).electronAPI;
|
||||
return api.openFile(options);
|
||||
}
|
||||
|
||||
// Web mode: prompt for file path
|
||||
const path = prompt("Enter file path:");
|
||||
if (!path) {
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath: path }
|
||||
);
|
||||
|
||||
if (result.success && result.exists) {
|
||||
return { canceled: false, filePaths: [path] };
|
||||
}
|
||||
|
||||
alert("File does not exist");
|
||||
return { canceled: true, filePaths: [] };
|
||||
}
|
||||
|
||||
// File system operations
|
||||
async readFile(filePath: string): Promise<FileResult> {
|
||||
return this.post("/api/fs/read", { filePath });
|
||||
}
|
||||
|
||||
async writeFile(filePath: string, content: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/write", { filePath, content });
|
||||
}
|
||||
|
||||
async mkdir(dirPath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/mkdir", { dirPath });
|
||||
}
|
||||
|
||||
async readdir(dirPath: string): Promise<ReaddirResult> {
|
||||
return this.post("/api/fs/readdir", { dirPath });
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
const result = await this.post<{ success: boolean; exists: boolean }>(
|
||||
"/api/fs/exists",
|
||||
{ filePath }
|
||||
);
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
async stat(filePath: string): Promise<StatResult> {
|
||||
return this.post("/api/fs/stat", { filePath });
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<WriteResult> {
|
||||
return this.post("/api/fs/delete", { filePath });
|
||||
}
|
||||
|
||||
async trashItem(filePath: string): Promise<WriteResult> {
|
||||
// In web mode, trash is just delete
|
||||
return this.deleteFile(filePath);
|
||||
}
|
||||
|
||||
async getPath(name: string): Promise<string> {
|
||||
// Server provides data directory
|
||||
if (name === "userData") {
|
||||
const result = await this.get<{ dataDir: string }>("/api/health/detailed");
|
||||
return result.dataDir || "/data";
|
||||
}
|
||||
return `/data/${name}`;
|
||||
}
|
||||
|
||||
async saveImageToTemp(
|
||||
data: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
projectPath?: string
|
||||
): Promise<SaveImageResult> {
|
||||
return this.post("/api/fs/save-image", { data, filename, mimeType, projectPath });
|
||||
}
|
||||
|
||||
// CLI checks - server-side
|
||||
async checkClaudeCli(): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
return this.get("/api/setup/claude-status");
|
||||
}
|
||||
|
||||
async checkCodexCli(): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
hasApiKey?: boolean;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
windows?: string;
|
||||
linux?: string;
|
||||
npm?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
return this.get("/api/setup/codex-status");
|
||||
}
|
||||
|
||||
// Model API
|
||||
model = {
|
||||
getAvailable: async (): Promise<{
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/available");
|
||||
},
|
||||
checkProviders: async (): Promise<{
|
||||
success: boolean;
|
||||
providers?: Record<string, ProviderStatus>;
|
||||
error?: string;
|
||||
}> => {
|
||||
return this.get("/api/models/providers");
|
||||
},
|
||||
};
|
||||
|
||||
async testOpenAIConnection(apiKey?: string): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
return this.post("/api/setup/test-openai", { apiKey });
|
||||
}
|
||||
|
||||
// Setup API
|
||||
setup = {
|
||||
getClaudeStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
installed?: boolean;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasCredentialsFile?: boolean;
|
||||
hasToken?: boolean;
|
||||
hasStoredOAuthToken?: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
hasEnvOAuthToken?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/claude-status"),
|
||||
|
||||
getCodexStatus: (): Promise<{
|
||||
success: boolean;
|
||||
status?: string;
|
||||
method?: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
hasAuthFile: boolean;
|
||||
hasEnvKey: boolean;
|
||||
hasStoredApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get("/api/setup/codex-status"),
|
||||
|
||||
installClaude: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-claude"),
|
||||
|
||||
installCodex: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/install-codex"),
|
||||
|
||||
authClaude: (): Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
requiresManualAuth?: boolean;
|
||||
terminalOpened?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
}> => this.post("/api/setup/auth-claude"),
|
||||
|
||||
authCodex: (apiKey?: string): Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/auth-codex", { apiKey }),
|
||||
|
||||
storeApiKey: (provider: string, apiKey: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/store-api-key", { provider, apiKey }),
|
||||
|
||||
getApiKeys: (): Promise<{
|
||||
success: boolean;
|
||||
hasAnthropicKey: boolean;
|
||||
hasOpenAIKey: boolean;
|
||||
hasGoogleKey: boolean;
|
||||
}> => this.get("/api/setup/api-keys"),
|
||||
|
||||
configureCodexMcp: (projectPath: string): Promise<{
|
||||
success: boolean;
|
||||
configPath?: string;
|
||||
error?: string;
|
||||
}> => this.post("/api/setup/configure-codex-mcp", { projectPath }),
|
||||
|
||||
getPlatform: (): Promise<{
|
||||
success: boolean;
|
||||
platform: string;
|
||||
arch: string;
|
||||
homeDir: string;
|
||||
isWindows: boolean;
|
||||
isMac: boolean;
|
||||
isLinux: boolean;
|
||||
}> => this.get("/api/setup/platform"),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
},
|
||||
|
||||
onAuthProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent("agent:stream", callback);
|
||||
},
|
||||
};
|
||||
|
||||
// Features API
|
||||
features: FeaturesAPI = {
|
||||
getAll: (projectPath: string) =>
|
||||
this.post("/api/features/list", { projectPath }),
|
||||
get: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/get", { projectPath, featureId }),
|
||||
create: (projectPath: string, feature: Feature) =>
|
||||
this.post("/api/features/create", { projectPath, feature }),
|
||||
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
|
||||
this.post("/api/features/update", { projectPath, featureId, updates }),
|
||||
delete: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/delete", { projectPath, featureId }),
|
||||
getAgentOutput: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/features/agent-output", { projectPath, featureId }),
|
||||
};
|
||||
|
||||
// Auto Mode API
|
||||
autoMode: AutoModeAPI = {
|
||||
start: (projectPath: string, maxConcurrency?: number) =>
|
||||
this.post("/api/auto-mode/start", { projectPath, maxConcurrency }),
|
||||
stop: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/stop", { projectPath }),
|
||||
stopFeature: (featureId: string) =>
|
||||
this.post("/api/auto-mode/stop-feature", { featureId }),
|
||||
status: (projectPath?: string) =>
|
||||
this.post("/api/auto-mode/status", { projectPath }),
|
||||
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) =>
|
||||
this.post("/api/auto-mode/run-feature", { projectPath, featureId, useWorktrees }),
|
||||
verifyFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/verify-feature", { projectPath, featureId }),
|
||||
resumeFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/resume-feature", { projectPath, featureId }),
|
||||
contextExists: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/context-exists", { projectPath, featureId }),
|
||||
analyzeProject: (projectPath: string) =>
|
||||
this.post("/api/auto-mode/analyze-project", { projectPath }),
|
||||
followUpFeature: (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[]
|
||||
) =>
|
||||
this.post("/api/auto-mode/follow-up-feature", {
|
||||
projectPath,
|
||||
featureId,
|
||||
prompt,
|
||||
imagePaths,
|
||||
}),
|
||||
commitFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/auto-mode/commit-feature", { projectPath, featureId }),
|
||||
onEvent: (callback: (event: AutoModeEvent) => void) => {
|
||||
return this.subscribeToEvent("auto-mode:event", callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Worktree API
|
||||
worktree: WorktreeAPI = {
|
||||
revertFeature: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/revert", { projectPath, featureId }),
|
||||
mergeFeature: (projectPath: string, featureId: string, options?: object) =>
|
||||
this.post("/api/worktree/merge", { projectPath, featureId, options }),
|
||||
getInfo: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/info", { projectPath, featureId }),
|
||||
getStatus: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/status", { projectPath, featureId }),
|
||||
list: (projectPath: string) =>
|
||||
this.post("/api/worktree/list", { projectPath }),
|
||||
getDiffs: (projectPath: string, featureId: string) =>
|
||||
this.post("/api/worktree/diffs", { projectPath, featureId }),
|
||||
getFileDiff: (projectPath: string, featureId: string, filePath: string) =>
|
||||
this.post("/api/worktree/file-diff", { projectPath, featureId, filePath }),
|
||||
};
|
||||
|
||||
// Git API
|
||||
git: GitAPI = {
|
||||
getDiffs: (projectPath: string) =>
|
||||
this.post("/api/git/diffs", { projectPath }),
|
||||
getFileDiff: (projectPath: string, filePath: string) =>
|
||||
this.post("/api/git/file-diff", { projectPath, filePath }),
|
||||
};
|
||||
|
||||
// Suggestions API
|
||||
suggestions: SuggestionsAPI = {
|
||||
generate: (projectPath: string, suggestionType?: SuggestionType) =>
|
||||
this.post("/api/suggestions/generate", { projectPath, suggestionType }),
|
||||
stop: () => this.post("/api/suggestions/stop"),
|
||||
status: () => this.get("/api/suggestions/status"),
|
||||
onEvent: (callback: (event: SuggestionsEvent) => void) => {
|
||||
return this.subscribeToEvent("suggestions:event", callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Spec Regeneration API
|
||||
specRegeneration: SpecRegenerationAPI = {
|
||||
create: (projectPath: string, projectOverview: string, generateFeatures?: boolean) =>
|
||||
this.post("/api/spec-regeneration/create", {
|
||||
projectPath,
|
||||
projectOverview,
|
||||
generateFeatures,
|
||||
}),
|
||||
generate: (projectPath: string, projectDefinition: string) =>
|
||||
this.post("/api/spec-regeneration/generate", { projectPath, projectDefinition }),
|
||||
generateFeatures: (projectPath: string) =>
|
||||
this.post("/api/spec-regeneration/generate-features", { projectPath }),
|
||||
stop: () => this.post("/api/spec-regeneration/stop"),
|
||||
status: () => this.get("/api/spec-regeneration/status"),
|
||||
onEvent: (callback: (event: SpecRegenerationEvent) => void) => {
|
||||
return this.subscribeToEvent(
|
||||
"spec-regeneration:event",
|
||||
callback as EventCallback
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Running Agents API
|
||||
runningAgents = {
|
||||
getAll: (): Promise<{
|
||||
success: boolean;
|
||||
runningAgents?: Array<{
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
}>;
|
||||
totalCount?: number;
|
||||
autoLoopRunning?: boolean;
|
||||
error?: string;
|
||||
}> => this.get("/api/running-agents"),
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let httpApiClientInstance: HttpApiClient | null = null;
|
||||
|
||||
export function getHttpApiClient(): HttpApiClient {
|
||||
if (!httpApiClientInstance) {
|
||||
httpApiClientInstance = new HttpApiClient();
|
||||
}
|
||||
return httpApiClientInstance;
|
||||
}
|
||||
@@ -1138,26 +1138,32 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
{
|
||||
name: "automaker-storage",
|
||||
partialize: (state) => ({
|
||||
// Project management
|
||||
projects: state.projects,
|
||||
currentProject: state.currentProject,
|
||||
trashedProjects: state.trashedProjects,
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
// Features - cached locally for faster hydration (authoritative source is server)
|
||||
features: state.features,
|
||||
// UI state
|
||||
currentView: state.currentView,
|
||||
theme: state.theme,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
apiKeys: state.apiKeys,
|
||||
chatSessions: state.chatSessions,
|
||||
chatHistoryOpen: state.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
// Settings
|
||||
apiKeys: state.apiKeys,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
autoModeByProject: state.autoModeByProject,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
// Profiles and sessions
|
||||
aiProfiles: state.aiProfiles,
|
||||
chatSessions: state.chatSessions,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -10,10 +10,19 @@ export interface CliStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Claude Auth Method - all possible authentication sources
|
||||
export type ClaudeAuthMethod =
|
||||
| "oauth_token_env" // CLAUDE_CODE_OAUTH_TOKEN environment variable
|
||||
| "oauth_token" // Stored OAuth token from claude login
|
||||
| "api_key_env" // ANTHROPIC_API_KEY environment variable
|
||||
| "api_key" // Manually stored API key
|
||||
| "credentials_file" // Generic credentials file detection
|
||||
| "none";
|
||||
|
||||
// Claude Auth Status
|
||||
export interface ClaudeAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: "oauth_token_env" | "oauth_token" | "api_key" | "api_key_env" | "none";
|
||||
method: ClaudeAuthMethod;
|
||||
hasCredentialsFile?: boolean;
|
||||
oauthTokenValid?: boolean;
|
||||
apiKeyValid?: boolean;
|
||||
@@ -22,12 +31,23 @@ export interface ClaudeAuthStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Codex Auth Method - all possible authentication sources
|
||||
export type CodexAuthMethod =
|
||||
| "subscription" // Codex/OpenAI Plus or Team subscription
|
||||
| "cli_verified" // CLI logged in with OpenAI account
|
||||
| "cli_tokens" // CLI with stored access tokens
|
||||
| "api_key" // Manually stored API key
|
||||
| "env" // OPENAI_API_KEY environment variable
|
||||
| "none";
|
||||
|
||||
// Codex Auth Status
|
||||
export interface CodexAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: "api_key" | "env" | "cli_verified" | "cli_tokens" | "none";
|
||||
method: CodexAuthMethod;
|
||||
apiKeyValid?: boolean;
|
||||
mcpConfigured?: boolean;
|
||||
hasSubscription?: boolean;
|
||||
cliLoggedIn?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
45
apps/server/.env.example
Normal file
45
apps/server/.env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# Automaker Server Configuration
|
||||
# Copy this file to .env and configure your settings
|
||||
|
||||
# ============================================
|
||||
# REQUIRED
|
||||
# ============================================
|
||||
|
||||
# Your Anthropic API key for Claude models
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Security
|
||||
# ============================================
|
||||
|
||||
# API key for authenticating requests (leave empty to disable auth)
|
||||
# If set, all API requests must include X-API-Key header
|
||||
AUTOMAKER_API_KEY=
|
||||
|
||||
# Restrict file operations to these directories (comma-separated)
|
||||
# Important for security in multi-tenant environments
|
||||
ALLOWED_PROJECT_DIRS=/home/user/projects,/var/www
|
||||
|
||||
# CORS origin - which domains can access the API
|
||||
# Use "*" for development, set specific origin for production
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Server
|
||||
# ============================================
|
||||
|
||||
# Port to run the server on
|
||||
PORT=3008
|
||||
|
||||
# Data directory for sessions and metadata
|
||||
DATA_DIR=./data
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Additional AI Providers
|
||||
# ============================================
|
||||
|
||||
# OpenAI API key (for Codex CLI support)
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Google API key (for future Gemini support)
|
||||
GOOGLE_API_KEY=
|
||||
55
apps/server/Dockerfile
Normal file
55
apps/server/Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# Automaker Backend Server
|
||||
# Multi-stage build for minimal production image
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --workspace=apps/server
|
||||
|
||||
# Copy source
|
||||
COPY apps/server ./apps/server
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build --workspace=apps/server
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# Copy built files and production dependencies
|
||||
COPY --from=builder /app/apps/server/dist ./dist
|
||||
COPY --from=builder /app/apps/server/package*.json ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data && chown automaker:automaker /data
|
||||
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Start server
|
||||
CMD ["node", "dist/index.js"]
|
||||
29
apps/server/package.json
Normal file
29
apps/server/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.1.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.61",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/node": "^20",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
165
apps/server/src/index.ts
Normal file
165
apps/server/src/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Automaker Backend Server
|
||||
*
|
||||
* Provides HTTP/WebSocket API for both web and Electron modes.
|
||||
* In Electron mode, this server runs locally.
|
||||
* In web mode, this server runs on a remote host.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { createServer } from "http";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from "./lib/events.js";
|
||||
import { initAllowedPaths } from "./lib/security.js";
|
||||
import { authMiddleware, getAuthStatus } from "./lib/auth.js";
|
||||
import { createFsRoutes } from "./routes/fs.js";
|
||||
import { createHealthRoutes } from "./routes/health.js";
|
||||
import { createAgentRoutes } from "./routes/agent.js";
|
||||
import { createSessionsRoutes } from "./routes/sessions.js";
|
||||
import { createFeaturesRoutes } from "./routes/features.js";
|
||||
import { createAutoModeRoutes } from "./routes/auto-mode.js";
|
||||
import { createWorktreeRoutes } from "./routes/worktree.js";
|
||||
import { createGitRoutes } from "./routes/git.js";
|
||||
import { createSetupRoutes } from "./routes/setup.js";
|
||||
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 { AgentService } from "./services/agent-service.js";
|
||||
import { FeatureLoader } from "./services/feature-loader.js";
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3008", 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || "./data";
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
const hasOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
console.warn(`
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 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.' : ''}
|
||||
║ ║
|
||||
║ Set your API key: ║
|
||||
║ export ANTHROPIC_API_KEY="sk-ant-..." ║
|
||||
║ ║
|
||||
║ Or add to apps/server/.env: ║
|
||||
║ ANTHROPIC_API_KEY=sk-ant-... ║
|
||||
╚═══════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
console.log("[Server] ✓ ANTHROPIC_API_KEY detected");
|
||||
}
|
||||
|
||||
// Initialize security
|
||||
initAllowedPaths();
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
// Create shared event emitter for streaming
|
||||
const events: EventEmitter = createEventEmitter();
|
||||
|
||||
// Create services
|
||||
const agentService = new AgentService(DATA_DIR, events);
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
console.log("[Server] Agent service initialized");
|
||||
})();
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
app.use("/api/health", createHealthRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use("/api", authMiddleware);
|
||||
|
||||
app.use("/api/fs", createFsRoutes(events));
|
||||
app.use("/api/agent", createAgentRoutes(agentService, events));
|
||||
app.use("/api/sessions", createSessionsRoutes(agentService));
|
||||
app.use("/api/features", createFeaturesRoutes(featureLoader));
|
||||
app.use("/api/auto-mode", createAutoModeRoutes(events));
|
||||
app.use("/api/worktree", createWorktreeRoutes());
|
||||
app.use("/api/git", createGitRoutes());
|
||||
app.use("/api/setup", createSetupRoutes());
|
||||
app.use("/api/suggestions", createSuggestionsRoutes(events));
|
||||
app.use("/api/models", createModelsRoutes());
|
||||
app.use("/api/spec-regeneration", createSpecRegenerationRoutes(events));
|
||||
app.use("/api/running-agents", createRunningAgentsRoutes());
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for streaming events
|
||||
const wss = new WebSocketServer({ server, path: "/api/events" });
|
||||
|
||||
wss.on("connection", (ws: WebSocket) => {
|
||||
console.log("[WebSocket] Client connected");
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
const unsubscribe = events.subscribe((type, payload) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("[WebSocket] Client disconnected");
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error("[WebSocket] Error:", error);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════════════╗
|
||||
║ Automaker Backend Server ║
|
||||
╠═══════════════════════════════════════════════════════╣
|
||||
║ HTTP API: http://localhost:${PORT} ║
|
||||
║ WebSocket: ws://localhost:${PORT}/api/events ║
|
||||
║ Health: http://localhost:${PORT}/api/health ║
|
||||
╚═══════════════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("SIGTERM received, shutting down...");
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("SIGINT received, shutting down...");
|
||||
server.close(() => {
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
62
apps/server/src/lib/auth.ts
Normal file
62
apps/server/src/lib/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Authentication middleware for API security
|
||||
*
|
||||
* Supports API key authentication via header or environment variable.
|
||||
*/
|
||||
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
// API key from environment (optional - if not set, auth is disabled)
|
||||
const API_KEY = process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
*
|
||||
* If AUTOMAKER_API_KEY is set, requires matching key in X-API-Key header.
|
||||
* If not set, allows all requests (development mode).
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// If no API key is configured, allow all requests
|
||||
if (!API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for API key in header
|
||||
const providedKey = req.headers["x-api-key"] as string | undefined;
|
||||
|
||||
if (!providedKey) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: "Authentication required. Provide X-API-Key header.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providedKey !== API_KEY) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: "Invalid API key.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled
|
||||
*/
|
||||
export function isAuthEnabled(): boolean {
|
||||
return !!API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authentication status for health endpoint
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
return {
|
||||
enabled: !!API_KEY,
|
||||
method: API_KEY ? "api_key" : "none",
|
||||
};
|
||||
}
|
||||
57
apps/server/src/lib/events.ts
Normal file
57
apps/server/src/lib/events.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Event emitter for streaming events to WebSocket clients
|
||||
*/
|
||||
|
||||
export type EventType =
|
||||
| "agent:stream"
|
||||
| "auto-mode:event"
|
||||
| "auto-mode:started"
|
||||
| "auto-mode:stopped"
|
||||
| "auto-mode:idle"
|
||||
| "auto-mode:error"
|
||||
| "feature:started"
|
||||
| "feature:completed"
|
||||
| "feature:stopped"
|
||||
| "feature:error"
|
||||
| "feature:progress"
|
||||
| "feature:tool-use"
|
||||
| "feature:follow-up-started"
|
||||
| "feature:follow-up-completed"
|
||||
| "feature:verified"
|
||||
| "feature:committed"
|
||||
| "project:analysis-started"
|
||||
| "project:analysis-progress"
|
||||
| "project:analysis-completed"
|
||||
| "project:analysis-error"
|
||||
| "suggestions:event"
|
||||
| "spec-regeneration:event";
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
export interface EventEmitter {
|
||||
emit: (type: EventType, payload: unknown) => void;
|
||||
subscribe: (callback: EventCallback) => () => void;
|
||||
}
|
||||
|
||||
export function createEventEmitter(): EventEmitter {
|
||||
const subscribers = new Set<EventCallback>();
|
||||
|
||||
return {
|
||||
emit(type: EventType, payload: unknown) {
|
||||
for (const callback of subscribers) {
|
||||
try {
|
||||
callback(type, payload);
|
||||
} catch (error) {
|
||||
console.error("Error in event subscriber:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
subscribe(callback: EventCallback) {
|
||||
subscribers.add(callback);
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
72
apps/server/src/lib/security.ts
Normal file
72
apps/server/src/lib/security.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Security utilities for path validation
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
|
||||
// Allowed project directories - loaded from environment
|
||||
const allowedPaths = new Set<string>();
|
||||
|
||||
/**
|
||||
* Initialize allowed paths from environment variable
|
||||
*/
|
||||
export function initAllowedPaths(): void {
|
||||
const dirs = process.env.ALLOWED_PROJECT_DIRS;
|
||||
if (dirs) {
|
||||
for (const dir of dirs.split(",")) {
|
||||
const trimmed = dir.trim();
|
||||
if (trimmed) {
|
||||
allowedPaths.add(path.resolve(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always allow the data directory
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
if (dataDir) {
|
||||
allowedPaths.add(path.resolve(dataDir));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a path to the allowed list
|
||||
*/
|
||||
export function addAllowedPath(filePath: string): void {
|
||||
allowedPaths.add(path.resolve(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is allowed
|
||||
*/
|
||||
export function isPathAllowed(filePath: string): boolean {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
// Check if the path is under any allowed directory
|
||||
for (const allowed of allowedPaths) {
|
||||
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a path and throw if not allowed
|
||||
*/
|
||||
export function validatePath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
|
||||
if (!isPathAllowed(resolved)) {
|
||||
throw new Error(`Access denied: ${filePath} is not in an allowed directory`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of allowed paths (for debugging)
|
||||
*/
|
||||
export function getAllowedPaths(): string[] {
|
||||
return Array.from(allowedPaths);
|
||||
}
|
||||
132
apps/server/src/routes/agent.ts
Normal file
132
apps/server/src/routes/agent.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Agent routes - HTTP API for Claude agent interactions
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { AgentService } from "../services/agent-service.js";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
export function createAgentRoutes(
|
||||
agentService: AgentService,
|
||||
_events: EventEmitter
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Start a conversation
|
||||
router.post("/start", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, workingDirectory } = req.body as {
|
||||
sessionId: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.startConversation({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a message
|
||||
router.post("/send", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId, message, workingDirectory, imagePaths } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
};
|
||||
|
||||
if (!sessionId || !message) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "sessionId and message are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the message processing (don't await - it streams via WebSocket)
|
||||
agentService
|
||||
.sendMessage({
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Agent Route] Error sending message:", error);
|
||||
});
|
||||
|
||||
// Return immediately - responses come via WebSocket
|
||||
res.json({ success: true, message: "Message sent" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get conversation history
|
||||
router.post("/history", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = agentService.getHistory(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop execution
|
||||
router.post("/stop", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.stopExecution(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear conversation
|
||||
router.post("/clear", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.body as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: "sessionId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await agentService.clearSession(sessionId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
263
apps/server/src/routes/auto-mode.ts
Normal file
263
apps/server/src/routes/auto-mode.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Auto Mode routes - HTTP API for autonomous feature implementation
|
||||
*
|
||||
* Uses the AutoModeService for real feature execution with Claude Agent SDK
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
import { AutoModeService } from "../services/auto-mode-service.js";
|
||||
|
||||
export function createAutoModeRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
const autoModeService = new AutoModeService(events);
|
||||
|
||||
// Start auto mode loop
|
||||
router.post("/start", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
await autoModeService.startAutoLoop(projectPath, maxConcurrency || 3);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop auto mode loop
|
||||
router.post("/stop", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const runningCount = await autoModeService.stopAutoLoop();
|
||||
res.json({ success: true, runningFeatures: runningCount });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop a specific feature
|
||||
router.post("/stop-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { featureId } = req.body as { featureId: string };
|
||||
|
||||
if (!featureId) {
|
||||
res.status(400).json({ success: false, error: "featureId is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const stopped = await autoModeService.stopFeature(featureId);
|
||||
res.json({ success: true, stopped });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get auto mode status
|
||||
router.post("/status", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const status = autoModeService.getStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Run a single feature
|
||||
router.post("/run-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start execution in background
|
||||
autoModeService
|
||||
.executeFeature(projectPath, featureId, useWorktrees ?? true, false)
|
||||
.catch((error) => {
|
||||
console.error(`[AutoMode] Feature ${featureId} error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify a feature
|
||||
router.post("/verify-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passes = await autoModeService.verifyFeature(projectPath, featureId);
|
||||
res.json({ success: true, passes });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Resume a feature
|
||||
router.post("/resume-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start resume in background
|
||||
autoModeService
|
||||
.resumeFeature(projectPath, featureId, useWorktrees ?? true)
|
||||
.catch((error) => {
|
||||
console.error(`[AutoMode] Resume feature ${featureId} error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Check if context exists for a feature
|
||||
router.post("/context-exists", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = await autoModeService.contextExists(projectPath, featureId);
|
||||
res.json({ success: true, exists });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze project
|
||||
router.post("/analyze-project", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start analysis in background
|
||||
autoModeService.analyzeProject(projectPath).catch((error) => {
|
||||
console.error(`[AutoMode] Project analysis error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Project analysis started" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Follow up on a feature
|
||||
router.post("/follow-up-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, prompt, imagePaths } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
prompt: string;
|
||||
imagePaths?: string[];
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !prompt) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath, featureId, and prompt are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start follow-up in background
|
||||
autoModeService
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths)
|
||||
.catch((error) => {
|
||||
console.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Commit feature changes
|
||||
router.post("/commit-feature", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commitHash = await autoModeService.commitFeature(projectPath, featureId);
|
||||
res.json({ success: true, commitHash });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
159
apps/server/src/routes/features.ts
Normal file
159
apps/server/src/routes/features.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Features routes - HTTP API for feature management
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { FeatureLoader, type Feature } from "../services/feature-loader.js";
|
||||
import { addAllowedPath } from "../lib/security.js";
|
||||
|
||||
export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
const router = Router();
|
||||
|
||||
// List all features for a project
|
||||
router.post("/list", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
res.json({ success: true, features });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single feature
|
||||
router.post("/get", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = await featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
res.status(404).json({ success: false, error: "Feature not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, feature });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new feature
|
||||
router.post("/create", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, feature } = req.body as {
|
||||
projectPath: string;
|
||||
feature: Partial<Feature>;
|
||||
};
|
||||
|
||||
if (!projectPath || !feature) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and feature are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add project path to allowed paths
|
||||
addAllowedPath(projectPath);
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a feature
|
||||
router.post("/update", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !updates) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath, featureId, and updates are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a feature
|
||||
router.post("/delete", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get agent output for a feature
|
||||
router.post("/agent-output", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await featureLoader.getAgentOutput(projectPath, featureId);
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
221
apps/server/src/routes/fs.ts
Normal file
221
apps/server/src/routes/fs.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* File system routes
|
||||
* Provides REST API equivalents for Electron IPC file operations
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
export function createFsRoutes(_events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
// Read file
|
||||
router.post("/read", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
const content = await fs.readFile(resolvedPath, "utf-8");
|
||||
|
||||
res.json({ success: true, content });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Write file
|
||||
router.post("/write", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath, content } = req.body as {
|
||||
filePath: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create directory
|
||||
router.post("/mkdir", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { dirPath } = req.body as { dirPath: string };
|
||||
|
||||
if (!dirPath) {
|
||||
res.status(400).json({ success: false, error: "dirPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(dirPath);
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Read directory
|
||||
router.post("/readdir", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { dirPath } = req.body as { dirPath: string };
|
||||
|
||||
if (!dirPath) {
|
||||
res.status(400).json({ success: false, error: "dirPath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(dirPath);
|
||||
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
|
||||
const result = entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory(),
|
||||
isFile: entry.isFile(),
|
||||
}));
|
||||
|
||||
res.json({ success: true, entries: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Check if file/directory exists
|
||||
router.post("/exists", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// For exists, we check but don't require the path to be pre-allowed
|
||||
// This allows the UI to validate user-entered paths
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
res.json({ success: true, exists: true });
|
||||
} catch {
|
||||
res.json({ success: true, exists: false });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get file stats
|
||||
router.post("/stat", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
isDirectory: stats.isDirectory(),
|
||||
isFile: stats.isFile(),
|
||||
size: stats.size,
|
||||
mtime: stats.mtime,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete file
|
||||
router.post("/delete", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = validatePath(filePath);
|
||||
await fs.rm(resolvedPath, { recursive: true });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Validate and add path to allowed list
|
||||
// This is the web equivalent of dialog:openDirectory
|
||||
router.post("/validate-path", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filePath } = req.body as { filePath: string };
|
||||
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, error: "filePath is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Check if path exists
|
||||
try {
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(400).json({ success: false, error: "Path is not a directory" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to allowed paths
|
||||
addAllowedPath(resolvedPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
path: resolvedPath,
|
||||
isAllowed: isPathAllowed(resolvedPath),
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({ success: false, error: "Path does not exist" });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
102
apps/server/src/routes/git.ts
Normal file
102
apps/server/src/routes/git.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Git routes - HTTP API for git operations (non-worktree)
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createGitRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get diffs for the main project
|
||||
router.post("/diffs", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: diff } = await execAsync("git diff HEAD", {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const files = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const statusChar = line[0];
|
||||
const filePath = line.slice(3);
|
||||
const statusMap: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
};
|
||||
return {
|
||||
status: statusChar,
|
||||
path: filePath,
|
||||
statusText: statusMap[statusChar] || "Unknown",
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
});
|
||||
} catch {
|
||||
res.json({ success: true, diff: "", files: [], hasChanges: false });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get diff for a specific file
|
||||
router.post("/file-diff", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !filePath) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and filePath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, {
|
||||
cwd: projectPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
res.json({ success: true, diff, filePath });
|
||||
} catch {
|
||||
res.json({ success: true, diff: "", filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
39
apps/server/src/routes/health.ts
Normal file
39
apps/server/src/routes/health.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Health check routes
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { getAuthStatus } from "../lib/auth.js";
|
||||
|
||||
export function createHealthRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Basic health check
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || "0.1.0",
|
||||
});
|
||||
});
|
||||
|
||||
// Detailed health check
|
||||
router.get("/detailed", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || "0.1.0",
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
dataDir: process.env.DATA_DIR || "./data",
|
||||
auth: getAuthStatus(),
|
||||
env: {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
128
apps/server/src/routes/models.ts
Normal file
128
apps/server/src/routes/models.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Models routes - HTTP API for model providers and availability
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
|
||||
interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow: number;
|
||||
maxOutputTokens: number;
|
||||
supportsVision: boolean;
|
||||
supportsTools: boolean;
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
available: boolean;
|
||||
hasApiKey: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createModelsRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get available models
|
||||
router.get("/available", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const models: ModelDefinition[] = [
|
||||
{
|
||||
id: "claude-opus-4-5-20251101",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-sonnet-20241022",
|
||||
name: "Claude 3.5 Sonnet",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "claude-3-5-haiku-20241022",
|
||||
name: "Claude 3.5 Haiku",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 8192,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
provider: "openai",
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "gpt-4o-mini",
|
||||
name: "GPT-4o Mini",
|
||||
provider: "openai",
|
||||
contextWindow: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
},
|
||||
{
|
||||
id: "o1",
|
||||
name: "o1",
|
||||
provider: "openai",
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 100000,
|
||||
supportsVision: true,
|
||||
supportsTools: false,
|
||||
},
|
||||
];
|
||||
|
||||
res.json({ success: true, models });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Check provider status
|
||||
router.get("/providers", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const providers: Record<string, ProviderStatus> = {
|
||||
anthropic: {
|
||||
available: !!process.env.ANTHROPIC_API_KEY,
|
||||
hasApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
},
|
||||
openai: {
|
||||
available: !!process.env.OPENAI_API_KEY,
|
||||
hasApiKey: !!process.env.OPENAI_API_KEY,
|
||||
},
|
||||
google: {
|
||||
available: !!process.env.GOOGLE_API_KEY,
|
||||
hasApiKey: !!process.env.GOOGLE_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({ success: true, providers });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
70
apps/server/src/routes/running-agents.ts
Normal file
70
apps/server/src/routes/running-agents.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Running Agents routes - HTTP API for tracking active agent executions
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import path from "path";
|
||||
|
||||
interface RunningAgent {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
isAutoMode: boolean;
|
||||
}
|
||||
|
||||
// In-memory tracking of running agents (shared with auto-mode service via reference)
|
||||
const runningAgentsMap = new Map<string, RunningAgent>();
|
||||
let autoLoopRunning = false;
|
||||
|
||||
export function createRunningAgentsRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get all running agents
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const runningAgents = Array.from(runningAgentsMap.values());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
runningAgents,
|
||||
totalCount: runningAgents.length,
|
||||
autoLoopRunning,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Export functions to update running agents from other services
|
||||
export function registerRunningAgent(
|
||||
featureId: string,
|
||||
projectPath: string,
|
||||
isAutoMode: boolean
|
||||
): void {
|
||||
runningAgentsMap.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
projectName: path.basename(projectPath),
|
||||
isAutoMode,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterRunningAgent(featureId: string): void {
|
||||
runningAgentsMap.delete(featureId);
|
||||
}
|
||||
|
||||
export function setAutoLoopRunning(running: boolean): void {
|
||||
autoLoopRunning = running;
|
||||
}
|
||||
|
||||
export function getRunningAgentsCount(): number {
|
||||
return runningAgentsMap.size;
|
||||
}
|
||||
|
||||
export function isAgentRunning(featureId: string): boolean {
|
||||
return runningAgentsMap.has(featureId);
|
||||
}
|
||||
126
apps/server/src/routes/sessions.ts
Normal file
126
apps/server/src/routes/sessions.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Sessions routes - HTTP API for session management
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { AgentService } from "../services/agent-service.js";
|
||||
|
||||
export function createSessionsRoutes(agentService: AgentService): Router {
|
||||
const router = Router();
|
||||
|
||||
// List all sessions
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const includeArchived = req.query.includeArchived === "true";
|
||||
const sessions = await agentService.listSessions(includeArchived);
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new session
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, projectPath, workingDirectory } = req.body as {
|
||||
name: string;
|
||||
projectPath?: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ success: false, error: "name is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await agentService.createSession(
|
||||
name,
|
||||
projectPath,
|
||||
workingDirectory
|
||||
);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a session
|
||||
router.put("/:sessionId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { name, tags } = req.body as {
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
const session = await agentService.updateSession(sessionId, { name, tags });
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive a session
|
||||
router.post("/:sessionId/archive", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const success = await agentService.archiveSession(sessionId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({ success: false, error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Unarchive a session
|
||||
router.post("/:sessionId/unarchive", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const success = await agentService.unarchiveSession(sessionId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({ success: false, error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a session
|
||||
router.delete("/:sessionId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const success = await agentService.deleteSession(sessionId);
|
||||
|
||||
if (!success) {
|
||||
res.status(404).json({ success: false, error: "Session not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
407
apps/server/src/routes/setup.ts
Normal file
407
apps/server/src/routes/setup.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* Setup routes - HTTP API for CLI detection, API keys, and platform info
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Storage for API keys (in-memory for now, should be persisted)
|
||||
const apiKeys: Record<string, string> = {};
|
||||
|
||||
export function createSetupRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get Claude CLI status
|
||||
router.get("/claude-status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
let installed = false;
|
||||
let version = "";
|
||||
let cliPath = "";
|
||||
let method = "none";
|
||||
|
||||
// Try to find Claude CLI
|
||||
try {
|
||||
const { stdout } = await execAsync("which claude || where claude 2>/dev/null");
|
||||
cliPath = stdout.trim();
|
||||
installed = true;
|
||||
method = "path";
|
||||
|
||||
// Get version
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync("claude --version");
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH, try common locations
|
||||
const commonPaths = [
|
||||
path.join(os.homedir(), ".local", "bin", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
path.join(os.homedir(), ".npm-global", "bin", "claude"),
|
||||
];
|
||||
|
||||
for (const p of commonPaths) {
|
||||
try {
|
||||
await fs.access(p);
|
||||
cliPath = p;
|
||||
installed = true;
|
||||
method = "local";
|
||||
break;
|
||||
} catch {
|
||||
// Not found at this path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication - detect all possible auth methods
|
||||
let auth = {
|
||||
authenticated: false,
|
||||
method: "none" as string,
|
||||
hasCredentialsFile: false,
|
||||
hasToken: false,
|
||||
hasStoredOAuthToken: false,
|
||||
hasStoredApiKey: !!apiKeys.anthropic,
|
||||
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY,
|
||||
hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
// Additional fields for detailed status
|
||||
oauthTokenValid: false,
|
||||
apiKeyValid: false,
|
||||
};
|
||||
|
||||
// Check for credentials file (OAuth tokens from claude login)
|
||||
const credentialsPath = path.join(os.homedir(), ".claude", "credentials.json");
|
||||
try {
|
||||
const credentialsContent = await fs.readFile(credentialsPath, "utf-8");
|
||||
const credentials = JSON.parse(credentialsContent);
|
||||
auth.hasCredentialsFile = true;
|
||||
|
||||
// Check what type of token is in credentials
|
||||
if (credentials.oauth_token || credentials.access_token) {
|
||||
auth.hasStoredOAuthToken = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "oauth_token"; // Stored OAuth token from credentials file
|
||||
} else if (credentials.api_key) {
|
||||
auth.apiKeyValid = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Stored API key in credentials file
|
||||
}
|
||||
} catch {
|
||||
// No credentials file or invalid format
|
||||
}
|
||||
|
||||
// Environment variables override stored credentials (higher priority)
|
||||
if (auth.hasEnvOAuthToken) {
|
||||
auth.authenticated = true;
|
||||
auth.oauthTokenValid = true;
|
||||
auth.method = "oauth_token_env"; // OAuth token from CLAUDE_CODE_OAUTH_TOKEN env var
|
||||
} else if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.apiKeyValid = true;
|
||||
auth.method = "api_key_env"; // API key from ANTHROPIC_API_KEY env var
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI)
|
||||
if (!auth.authenticated && apiKeys.anthropic) {
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Manually stored API key
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: installed ? "installed" : "not_installed",
|
||||
installed,
|
||||
method,
|
||||
version,
|
||||
path: cliPath,
|
||||
auth,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get Codex CLI status
|
||||
router.get("/codex-status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
let installed = false;
|
||||
let version = "";
|
||||
let cliPath = "";
|
||||
let method = "none";
|
||||
|
||||
// Try to find Codex CLI
|
||||
try {
|
||||
const { stdout } = await execAsync("which codex || where codex 2>/dev/null");
|
||||
cliPath = stdout.trim();
|
||||
installed = true;
|
||||
method = "path";
|
||||
|
||||
try {
|
||||
const { stdout: versionOut } = await execAsync("codex --version");
|
||||
version = versionOut.trim();
|
||||
} catch {
|
||||
// Version command might not be available
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
|
||||
// Check for OpenAI/Codex authentication
|
||||
let auth = {
|
||||
authenticated: false,
|
||||
method: "none" as string,
|
||||
hasAuthFile: false,
|
||||
hasEnvKey: !!process.env.OPENAI_API_KEY,
|
||||
hasStoredApiKey: !!apiKeys.openai,
|
||||
hasEnvApiKey: !!process.env.OPENAI_API_KEY,
|
||||
// Additional fields for subscription/account detection
|
||||
hasSubscription: false,
|
||||
cliLoggedIn: false,
|
||||
};
|
||||
|
||||
// Check for OpenAI CLI auth file (~/.codex/auth.json or similar)
|
||||
const codexAuthPaths = [
|
||||
path.join(os.homedir(), ".codex", "auth.json"),
|
||||
path.join(os.homedir(), ".openai", "credentials"),
|
||||
path.join(os.homedir(), ".config", "openai", "credentials.json"),
|
||||
];
|
||||
|
||||
for (const authPath of codexAuthPaths) {
|
||||
try {
|
||||
const authContent = await fs.readFile(authPath, "utf-8");
|
||||
const authData = JSON.parse(authContent);
|
||||
auth.hasAuthFile = true;
|
||||
|
||||
// Check for subscription/tokens
|
||||
if (authData.subscription || authData.plan || authData.account_type) {
|
||||
auth.hasSubscription = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "subscription"; // Codex subscription (Plus/Team)
|
||||
} else if (authData.access_token || authData.api_key) {
|
||||
auth.cliLoggedIn = true;
|
||||
auth.authenticated = true;
|
||||
auth.method = "cli_verified"; // CLI logged in with account
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Auth file not found at this path
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variable has highest priority
|
||||
if (auth.hasEnvApiKey) {
|
||||
auth.authenticated = true;
|
||||
auth.method = "env"; // OPENAI_API_KEY environment variable
|
||||
}
|
||||
|
||||
// In-memory stored API key (from settings UI)
|
||||
if (!auth.authenticated && apiKeys.openai) {
|
||||
auth.authenticated = true;
|
||||
auth.method = "api_key"; // Manually stored API key
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
status: installed ? "installed" : "not_installed",
|
||||
method,
|
||||
version,
|
||||
path: cliPath,
|
||||
auth,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Install Claude CLI
|
||||
router.post("/install-claude", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// In web mode, we can't install CLIs directly
|
||||
// Return instructions instead
|
||||
res.json({
|
||||
success: false,
|
||||
error:
|
||||
"CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Install Codex CLI
|
||||
router.post("/install-codex", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: false,
|
||||
error:
|
||||
"CLI installation requires terminal access. Please install manually using: npm install -g @openai/codex",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth Claude
|
||||
router.post("/auth-claude", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command: "claude login",
|
||||
message: "Please run 'claude login' in your terminal to authenticate",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth Codex
|
||||
router.post("/auth-codex", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
|
||||
if (apiKey) {
|
||||
apiKeys.openai = apiKey;
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command: "codex auth login",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Store API key
|
||||
router.post("/store-api-key", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { provider, apiKey } = req.body as { provider: string; apiKey: string };
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
res.status(400).json({ success: false, error: "provider and apiKey required" });
|
||||
return;
|
||||
}
|
||||
|
||||
apiKeys[provider] = apiKey;
|
||||
|
||||
// Also set as environment variable
|
||||
if (provider === "anthropic") {
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
} else if (provider === "openai") {
|
||||
process.env.OPENAI_API_KEY = apiKey;
|
||||
} else if (provider === "google") {
|
||||
process.env.GOOGLE_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get API keys status
|
||||
router.get("/api-keys", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY,
|
||||
hasOpenAIKey: !!apiKeys.openai || !!process.env.OPENAI_API_KEY,
|
||||
hasGoogleKey: !!apiKeys.google || !!process.env.GOOGLE_API_KEY,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Configure Codex MCP
|
||||
router.post("/configure-codex-mcp", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create .codex directory and config
|
||||
const codexDir = path.join(projectPath, ".codex");
|
||||
await fs.mkdir(codexDir, { recursive: true });
|
||||
|
||||
const configPath = path.join(codexDir, "config.toml");
|
||||
const config = `# Codex configuration
|
||||
[mcp]
|
||||
enabled = true
|
||||
`;
|
||||
await fs.writeFile(configPath, config);
|
||||
|
||||
res.json({ success: true, configPath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get platform info
|
||||
router.get("/platform", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const platform = os.platform();
|
||||
res.json({
|
||||
success: true,
|
||||
platform,
|
||||
arch: os.arch(),
|
||||
homeDir: os.homedir(),
|
||||
isWindows: platform === "win32",
|
||||
isMac: platform === "darwin",
|
||||
isLinux: platform === "linux",
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Test OpenAI connection
|
||||
router.post("/test-openai", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { apiKey } = req.body as { apiKey?: string };
|
||||
const key = apiKey || apiKeys.openai || process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!key) {
|
||||
res.json({ success: false, error: "No OpenAI API key provided" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Simple test - just verify the key format
|
||||
if (!key.startsWith("sk-")) {
|
||||
res.json({ success: false, error: "Invalid OpenAI API key format" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "API key format is valid" });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
412
apps/server/src/routes/spec-regeneration.ts
Normal file
412
apps/server/src/routes/spec-regeneration.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Spec Regeneration routes - HTTP API for AI-powered spec generation
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
export function createSpecRegenerationRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
// Create project spec from overview
|
||||
router.post("/create", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, projectOverview, generateFeatures } = req.body as {
|
||||
projectPath: string;
|
||||
projectOverview: string;
|
||||
generateFeatures?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !projectOverview) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectOverview required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
|
||||
// Start generation in background
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectOverview,
|
||||
events,
|
||||
currentAbortController,
|
||||
generateFeatures
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate from project definition
|
||||
router.post("/generate", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, projectDefinition } = req.body as {
|
||||
projectPath: string;
|
||||
projectDefinition: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !projectDefinition) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath and projectDefinition required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
res.json({ success: false, error: "Spec generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
|
||||
generateSpec(
|
||||
projectPath,
|
||||
projectDefinition,
|
||||
events,
|
||||
currentAbortController,
|
||||
false
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate features from existing spec
|
||||
router.post("/generate-features", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
res.json({ success: false, error: "Generation already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
|
||||
generateFeaturesFromSpec(projectPath, events, currentAbortController)
|
||||
.catch((error) => {
|
||||
console.error("[SpecRegeneration] Error:", error);
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop generation
|
||||
router.post("/stop", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
isRunning = false;
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get status
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
async function generateSpec(
|
||||
projectPath: string,
|
||||
projectOverview: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
generateFeatures?: boolean
|
||||
) {
|
||||
const prompt = `You are helping to define a software project specification.
|
||||
|
||||
Project Overview:
|
||||
${projectOverview}
|
||||
|
||||
Based on this overview, analyze the project and create a comprehensive specification that includes:
|
||||
|
||||
1. **Project Summary** - Brief description of what the project does
|
||||
2. **Core Features** - Main functionality the project needs
|
||||
3. **Technical Stack** - Recommended technologies and frameworks
|
||||
4. **Architecture** - High-level system design
|
||||
5. **Data Models** - Key entities and their relationships
|
||||
6. **API Design** - Main endpoints/interfaces needed
|
||||
7. **User Experience** - Key user flows and interactions
|
||||
|
||||
${generateFeatures ? `
|
||||
Also generate a list of features to implement. For each feature provide:
|
||||
- ID (lowercase-hyphenated)
|
||||
- Title
|
||||
- Description
|
||||
- Priority (1=high, 2=medium, 3=low)
|
||||
- Estimated complexity (simple, moderate, complex)
|
||||
` : ""}
|
||||
|
||||
Format your response as markdown. Be specific and actionable.`;
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_progress",
|
||||
content: "Starting spec generation...\n",
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
maxTurns: 10,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_progress",
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
// Save spec
|
||||
const specDir = path.join(projectPath, ".automaker");
|
||||
const specPath = path.join(specDir, "project-spec.md");
|
||||
|
||||
await fs.mkdir(specDir, { recursive: true });
|
||||
await fs.writeFile(specPath, responseText);
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "spec_complete",
|
||||
specPath,
|
||||
content: responseText,
|
||||
});
|
||||
|
||||
// If generate features was requested, parse and create them
|
||||
if (generateFeatures) {
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController
|
||||
) {
|
||||
// Read existing spec
|
||||
const specPath = path.join(projectPath, ".automaker", "project-spec.md");
|
||||
let spec: string;
|
||||
|
||||
try {
|
||||
spec = await fs.readFile(specPath, "utf-8");
|
||||
} catch {
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
error: "No project spec found. Generate spec first.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = `Based on this project specification:
|
||||
|
||||
${spec}
|
||||
|
||||
Generate a prioritized list of implementable features. For each feature provide:
|
||||
|
||||
1. **id**: A unique lowercase-hyphenated identifier
|
||||
2. **title**: Short descriptive title
|
||||
3. **description**: What this feature does (2-3 sentences)
|
||||
4. **priority**: 1 (high), 2 (medium), or 3 (low)
|
||||
5. **complexity**: "simple", "moderate", or "complex"
|
||||
6. **dependencies**: Array of feature IDs this depends on (can be empty)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "feature-id",
|
||||
"title": "Feature Title",
|
||||
"description": "What it does",
|
||||
"priority": 1,
|
||||
"complexity": "moderate",
|
||||
"dependencies": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generate 5-15 features that build on each other logically.`;
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_progress",
|
||||
content: "Analyzing spec and generating features...\n",
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_progress",
|
||||
content: block.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
}
|
||||
|
||||
async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
) {
|
||||
try {
|
||||
// Extract JSON from response
|
||||
const jsonMatch = content.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error("No valid JSON found in response");
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||
await fs.mkdir(featuresDir, { recursive: true });
|
||||
|
||||
const createdFeatures: Array<{ id: string; title: string }> = [];
|
||||
|
||||
for (const feature of parsed.features) {
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
id: feature.id,
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
status: "pending",
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || "moderate",
|
||||
dependencies: feature.dependencies || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, "feature.json"),
|
||||
JSON.stringify(featureData, null, 2)
|
||||
);
|
||||
|
||||
createdFeatures.push({ id: feature.id, title: feature.title });
|
||||
}
|
||||
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_complete",
|
||||
features: createdFeatures,
|
||||
count: createdFeatures.length,
|
||||
});
|
||||
} catch (error) {
|
||||
events.emit("spec-regeneration:event", {
|
||||
type: "features_error",
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
192
apps/server/src/routes/suggestions.ts
Normal file
192
apps/server/src/routes/suggestions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Suggestions routes - HTTP API for AI-powered feature suggestions
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
export function createSuggestionsRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
// Generate suggestions
|
||||
router.post("/generate", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, suggestionType = "features" } = req.body as {
|
||||
projectPath: string;
|
||||
suggestionType?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
res.json({ success: false, error: "Suggestions generation is already running" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(projectPath, suggestionType, events, currentAbortController)
|
||||
.catch((error) => {
|
||||
console.error("[Suggestions] Error:", error);
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_error",
|
||||
error: error.message,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
isRunning = false;
|
||||
currentAbortController = null;
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop suggestions generation
|
||||
router.post("/stop", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
isRunning = false;
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get status
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController
|
||||
) {
|
||||
const typePrompts: Record<string, string> = {
|
||||
features: "Analyze this project and suggest new features that would add value.",
|
||||
refactoring: "Analyze this project and identify refactoring opportunities.",
|
||||
security: "Analyze this project for security vulnerabilities and suggest fixes.",
|
||||
performance: "Analyze this project for performance issues and suggest optimizations.",
|
||||
};
|
||||
|
||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
||||
|
||||
Look at the codebase and provide 3-5 concrete suggestions.
|
||||
|
||||
For each suggestion, provide:
|
||||
1. A category (e.g., "User Experience", "Security", "Performance")
|
||||
2. A clear description of what to implement
|
||||
3. Concrete steps to implement it
|
||||
4. Priority (1=high, 2=medium, 3=low)
|
||||
5. Brief reasoning for why this would help
|
||||
|
||||
Format your response as JSON:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "suggestion-123",
|
||||
"category": "Category",
|
||||
"description": "What to implement",
|
||||
"steps": ["Step 1", "Step 2"],
|
||||
"priority": 1,
|
||||
"reasoning": "Why this helps"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_progress",
|
||||
content: `Starting ${suggestionType} analysis...\n`,
|
||||
});
|
||||
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let responseText = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_progress",
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_tool",
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse suggestions from response
|
||||
try {
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_complete",
|
||||
suggestions: parsed.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
throw new Error("No valid JSON found in response");
|
||||
}
|
||||
} catch (error) {
|
||||
// Return generic suggestions if parsing fails
|
||||
events.emit("suggestions:event", {
|
||||
type: "suggestions_complete",
|
||||
suggestions: [
|
||||
{
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: "Analysis",
|
||||
description: "Review the AI analysis output for insights",
|
||||
steps: ["Review the generated analysis"],
|
||||
priority: 1,
|
||||
reasoning: "The AI provided analysis but suggestions need manual review",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
355
apps/server/src/routes/worktree.ts
Normal file
355
apps/server/src/routes/worktree.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Worktree routes - HTTP API for git worktree operations
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createWorktreeRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Check if a path is a git repo
|
||||
async function isGitRepo(repoPath: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get worktree info
|
||||
router.post("/info", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
worktreePath,
|
||||
branchName: stdout.trim(),
|
||||
});
|
||||
} catch {
|
||||
res.json({ success: true, worktreePath: null, branchName: null });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get worktree status
|
||||
router.post("/status", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const files = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.slice(3));
|
||||
const { stdout: diffStat } = await execAsync("git diff --stat", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const { stdout: logOutput } = await execAsync(
|
||||
'git log --oneline -5 --format="%h %s"',
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
modifiedFiles: files.length,
|
||||
files,
|
||||
diffStat: diffStat.trim(),
|
||||
recentCommits: logOutput.trim().split("\n").filter(Boolean),
|
||||
});
|
||||
} catch {
|
||||
res.json({
|
||||
success: true,
|
||||
modifiedFiles: 0,
|
||||
files: [],
|
||||
diffStat: "",
|
||||
recentCommits: [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// List all worktrees
|
||||
router.post("/list", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: "projectPath required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isGitRepo(projectPath))) {
|
||||
res.json({ success: true, worktrees: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
const worktrees: Array<{ path: string; branch: string }> = [];
|
||||
const lines = stdout.split("\n");
|
||||
let current: { path?: string; branch?: string } = {};
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
current.path = line.slice(9);
|
||||
} else if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice(7).replace("refs/heads/", "");
|
||||
} else if (line === "") {
|
||||
if (current.path && current.branch) {
|
||||
worktrees.push({ path: current.path, branch: current.branch });
|
||||
}
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, worktrees });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get diffs for a worktree
|
||||
router.post("/diffs", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout: diff } = await execAsync("git diff HEAD", {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync("git status --porcelain", {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
const files = status
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const statusChar = line[0];
|
||||
const filePath = line.slice(3);
|
||||
const statusMap: Record<string, string> = {
|
||||
M: "Modified",
|
||||
A: "Added",
|
||||
D: "Deleted",
|
||||
R: "Renamed",
|
||||
C: "Copied",
|
||||
U: "Updated",
|
||||
"?": "Untracked",
|
||||
};
|
||||
return {
|
||||
status: statusChar,
|
||||
path: filePath,
|
||||
statusText: statusMap[statusChar] || "Unknown",
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
diff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
});
|
||||
} catch {
|
||||
res.json({ success: true, diff: "", files: [], hasChanges: false });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get diff for a specific file
|
||||
router.post("/file-diff", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !filePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: "projectPath, featureId, and filePath required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
const { stdout: diff } = await execAsync(`git diff HEAD -- "${filePath}"`, {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
res.json({ success: true, diff, filePath });
|
||||
} catch {
|
||||
res.json({ success: true, diff: "", filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Revert feature (remove worktree)
|
||||
router.post("/revert", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
|
||||
try {
|
||||
// Remove worktree
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Delete branch
|
||||
await execAsync(`git branch -D feature/${featureId}`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, removedPath: worktreePath });
|
||||
} catch (error) {
|
||||
// Worktree might not exist
|
||||
res.json({ success: true, removedPath: null });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
// Merge feature (merge worktree branch into main)
|
||||
router.post("/merge", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, featureId, options } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
options?: { squash?: boolean; message?: string };
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, error: "projectPath and featureId required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const branchName = `feature/${featureId}`;
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync(
|
||||
"git rev-parse --abbrev-ref HEAD",
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
// Merge the feature branch
|
||||
const mergeCmd = options?.squash
|
||||
? `git merge --squash ${branchName}`
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
|
||||
|
||||
await execAsync(mergeCmd, { cwd: projectPath });
|
||||
|
||||
// If squash merge, need to commit
|
||||
if (options?.squash) {
|
||||
await execAsync(
|
||||
`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up worktree and branch
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Cleanup errors are non-fatal
|
||||
}
|
||||
|
||||
res.json({ success: true, mergedBranch: branchName });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
562
apps/server/src/services/agent-service.ts
Normal file
562
apps/server/src/services/agent-service.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Agent Service - Runs Claude agents via the Claude Agent SDK
|
||||
* Manages conversation sessions and streams responses via WebSocket
|
||||
*/
|
||||
|
||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter } from "../lib/events.js";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
images?: Array<{
|
||||
data: string;
|
||||
mimeType: string;
|
||||
filename: string;
|
||||
}>;
|
||||
timestamp: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
messages: Message[];
|
||||
isRunning: boolean;
|
||||
abortController: AbortController | null;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
interface SessionMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
projectPath?: string;
|
||||
workingDirectory: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archived?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export class AgentService {
|
||||
private sessions = new Map<string, Session>();
|
||||
private stateDir: string;
|
||||
private metadataFile: string;
|
||||
private events: EventEmitter;
|
||||
|
||||
constructor(dataDir: string, events: EventEmitter) {
|
||||
this.stateDir = path.join(dataDir, "agent-sessions");
|
||||
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await fs.mkdir(this.stateDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume a conversation
|
||||
*/
|
||||
async startConversation({
|
||||
sessionId,
|
||||
workingDirectory,
|
||||
}: {
|
||||
sessionId: string;
|
||||
workingDirectory?: string;
|
||||
}) {
|
||||
if (!this.sessions.has(sessionId)) {
|
||||
const messages = await this.loadSession(sessionId);
|
||||
this.sessions.set(sessionId, {
|
||||
messages,
|
||||
isRunning: false,
|
||||
abortController: null,
|
||||
workingDirectory: workingDirectory || process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
const session = this.sessions.get(sessionId)!;
|
||||
return {
|
||||
success: true,
|
||||
messages: session.messages,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the agent and stream responses
|
||||
*/
|
||||
async sendMessage({
|
||||
sessionId,
|
||||
message,
|
||||
workingDirectory,
|
||||
imagePaths,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
workingDirectory?: string;
|
||||
imagePaths?: string[];
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.isRunning) {
|
||||
throw new Error("Agent is already processing a message");
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message["images"] = [];
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
images.push({
|
||||
data: base64Data,
|
||||
mimeType: mediaType,
|
||||
filename: path.basename(imagePath),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message
|
||||
const userMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: "user",
|
||||
content: message,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isRunning = true;
|
||||
session.abortController = new AbortController();
|
||||
|
||||
// Emit user message event
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "message",
|
||||
message: userMessage,
|
||||
});
|
||||
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
try {
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
systemPrompt: this.getSystemPrompt(),
|
||||
maxTurns: 20,
|
||||
cwd: workingDirectory || session.workingDirectory,
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController: session.abortController!,
|
||||
};
|
||||
|
||||
// Build prompt content
|
||||
let promptContent: string | Array<{ type: string; text?: string; source?: object }> =
|
||||
message;
|
||||
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
const contentBlocks: Array<{ type: string; text?: string; source?: object }> = [];
|
||||
|
||||
if (message && message.trim()) {
|
||||
contentBlocks.push({ type: "text", text: message });
|
||||
}
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
try {
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const base64Data = imageBuffer.toString("base64");
|
||||
const ext = path.extname(imagePath).toLowerCase();
|
||||
const mimeTypeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
const mediaType = mimeTypeMap[ext] || "image/png";
|
||||
|
||||
contentBlocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mediaType,
|
||||
data: base64Data,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") {
|
||||
promptContent = contentBlocks;
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload
|
||||
const promptPayload = Array.isArray(promptContent)
|
||||
? (async function* () {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
session_id: "",
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: promptContent,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
})()
|
||||
: promptContent;
|
||||
|
||||
const stream = query({ prompt: promptPayload, options });
|
||||
|
||||
let currentAssistantMessage: Message | null = null;
|
||||
let responseText = "";
|
||||
const toolUses: Array<{ name: string; input: unknown }> = [];
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant") {
|
||||
if (msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText += block.text;
|
||||
|
||||
if (!currentAssistantMessage) {
|
||||
currentAssistantMessage = {
|
||||
id: this.generateId(),
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
session.messages.push(currentAssistantMessage);
|
||||
} else {
|
||||
currentAssistantMessage.content = responseText;
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "stream",
|
||||
messageId: currentAssistantMessage.id,
|
||||
content: responseText,
|
||||
isComplete: false,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
const toolUse = {
|
||||
name: block.name,
|
||||
input: block.input,
|
||||
};
|
||||
toolUses.push(toolUse);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "tool_use",
|
||||
tool: toolUse,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result") {
|
||||
if (msg.subtype === "success" && msg.result) {
|
||||
if (currentAssistantMessage) {
|
||||
currentAssistantMessage.content = msg.result;
|
||||
responseText = msg.result;
|
||||
}
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "complete",
|
||||
messageId: currentAssistantMessage?.id,
|
||||
content: responseText,
|
||||
toolUses,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: currentAssistantMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
return { success: false, aborted: true };
|
||||
}
|
||||
|
||||
console.error("[AgentService] Error:", error);
|
||||
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: "assistant",
|
||||
content: `Error: ${(error as Error).message}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: "error",
|
||||
error: (error as Error).message,
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation history
|
||||
*/
|
||||
getHistory(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: session.messages,
|
||||
isRunning: session.isRunning,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop current agent execution
|
||||
*/
|
||||
async stopExecution(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
if (session.abortController) {
|
||||
session.abortController.abort();
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation history
|
||||
*/
|
||||
async clearSession(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.messages = [];
|
||||
session.isRunning = false;
|
||||
await this.saveSession(sessionId, []);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Session management
|
||||
|
||||
async loadSession(sessionId: string): Promise<Message[]> {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(sessionFile, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async saveSession(sessionId: string, messages: Message[]): Promise<void> {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(sessionFile, JSON.stringify(messages, null, 2), "utf-8");
|
||||
await this.updateSessionTimestamp(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[AgentService] Failed to save session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
|
||||
try {
|
||||
const data = await fs.readFile(this.metadataFile, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
|
||||
await fs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async updateSessionTimestamp(sessionId: string): Promise<void> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (metadata[sessionId]) {
|
||||
metadata[sessionId].updatedAt = new Date().toISOString();
|
||||
await this.saveMetadata(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(includeArchived = false): Promise<SessionMetadata[]> {
|
||||
const metadata = await this.loadMetadata();
|
||||
let sessions = Object.values(metadata);
|
||||
|
||||
if (!includeArchived) {
|
||||
sessions = sessions.filter((s) => !s.archived);
|
||||
}
|
||||
|
||||
return sessions.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
name: string,
|
||||
projectPath?: string,
|
||||
workingDirectory?: string
|
||||
): Promise<SessionMetadata> {
|
||||
const sessionId = this.generateId();
|
||||
const metadata = await this.loadMetadata();
|
||||
|
||||
const session: SessionMetadata = {
|
||||
id: sessionId,
|
||||
name,
|
||||
projectPath,
|
||||
workingDirectory: workingDirectory || projectPath || process.cwd(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
metadata[sessionId] = session;
|
||||
await this.saveMetadata(metadata);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async updateSession(
|
||||
sessionId: string,
|
||||
updates: Partial<SessionMetadata>
|
||||
): Promise<SessionMetadata | null> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (!metadata[sessionId]) return null;
|
||||
|
||||
metadata[sessionId] = {
|
||||
...metadata[sessionId],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.saveMetadata(metadata);
|
||||
return metadata[sessionId];
|
||||
}
|
||||
|
||||
async archiveSession(sessionId: string): Promise<boolean> {
|
||||
const result = await this.updateSession(sessionId, { archived: true });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async unarchiveSession(sessionId: string): Promise<boolean> {
|
||||
const result = await this.updateSession(sessionId, { archived: false });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
const metadata = await this.loadMetadata();
|
||||
if (!metadata[sessionId]) return false;
|
||||
|
||||
delete metadata[sessionId];
|
||||
await this.saveMetadata(metadata);
|
||||
|
||||
// Delete session file
|
||||
try {
|
||||
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
|
||||
await fs.unlink(sessionFile);
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
|
||||
// Clear from memory
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
|
||||
this.events.emit("agent:stream", { sessionId, ...data });
|
||||
}
|
||||
|
||||
private getSystemPrompt(): string {
|
||||
return `You are an AI assistant helping users build software. You are part of the Automaker application,
|
||||
which is designed to help developers plan, design, and implement software projects autonomously.
|
||||
|
||||
**Feature Storage:**
|
||||
Features are stored in .automaker/features/{id}/feature.json - each feature has its own folder.
|
||||
Use the UpdateFeatureStatus tool to manage features, not direct file edits.
|
||||
|
||||
Your role is to:
|
||||
- Help users define their project requirements and specifications
|
||||
- Ask clarifying questions to better understand their needs
|
||||
- Suggest technical approaches and architectures
|
||||
- Guide them through the development process
|
||||
- Be conversational and helpful
|
||||
- Write, edit, and modify code files as requested
|
||||
- Execute commands and tests
|
||||
- Search and analyze the codebase
|
||||
|
||||
When discussing projects, help users think through:
|
||||
- Core functionality and features
|
||||
- Technical stack choices
|
||||
- Data models and architecture
|
||||
- User experience considerations
|
||||
- Testing strategies
|
||||
|
||||
You have full access to the codebase and can:
|
||||
- Read files to understand existing code
|
||||
- Write new files
|
||||
- Edit existing files
|
||||
- Run bash commands
|
||||
- Search for code patterns
|
||||
- Execute tests and builds`;
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
}
|
||||
815
apps/server/src/services/auto-mode-service.ts
Normal file
815
apps/server/src/services/auto-mode-service.ts
Normal file
@@ -0,0 +1,815 @@
|
||||
/**
|
||||
* Auto Mode Service - Autonomous feature implementation using Claude Agent SDK
|
||||
*
|
||||
* Manages:
|
||||
* - Worktree creation for isolated development
|
||||
* - Feature execution with Claude
|
||||
* - Concurrent execution with max concurrency limits
|
||||
* - Progress streaming via events
|
||||
* - Verification and merge workflows
|
||||
*/
|
||||
|
||||
import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import type { EventEmitter, EventType } from "../lib/events.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface Feature {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority?: number;
|
||||
spec?: string;
|
||||
}
|
||||
|
||||
interface RunningFeature {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
worktreePath: string | null;
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
isAutoMode: boolean;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export class AutoModeService {
|
||||
private events: EventEmitter;
|
||||
private runningFeatures = new Map<string, RunningFeature>();
|
||||
private autoLoopRunning = false;
|
||||
private autoLoopAbortController: AbortController | null = null;
|
||||
private config: AutoModeConfig | null = null;
|
||||
|
||||
constructor(events: EventEmitter) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop - continuously picks and executes pending features
|
||||
*/
|
||||
async startAutoLoop(projectPath: string, maxConcurrency = 3): Promise<void> {
|
||||
if (this.autoLoopRunning) {
|
||||
throw new Error("Auto mode is already running");
|
||||
}
|
||||
|
||||
this.autoLoopRunning = true;
|
||||
this.autoLoopAbortController = new AbortController();
|
||||
this.config = {
|
||||
maxConcurrency,
|
||||
useWorktrees: true,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_complete", {
|
||||
message: `Auto mode started with max ${maxConcurrency} concurrent features`,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Run the loop in the background
|
||||
this.runAutoLoop().catch((error) => {
|
||||
console.error("[AutoMode] Loop error:", error);
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
error: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async runAutoLoop(): Promise<void> {
|
||||
while (this.autoLoopRunning && this.autoLoopAbortController && !this.autoLoopAbortController.signal.aborted) {
|
||||
try {
|
||||
// Check if we have capacity
|
||||
if (this.runningFeatures.size >= (this.config?.maxConcurrency || 3)) {
|
||||
await this.sleep(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load pending features
|
||||
const pendingFeatures = await this.loadPendingFeatures(this.config!.projectPath);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
this.emitAutoModeEvent("auto_mode_complete", {
|
||||
message: "No pending features - auto mode idle",
|
||||
});
|
||||
await this.sleep(10000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a feature not currently running
|
||||
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id));
|
||||
|
||||
if (nextFeature) {
|
||||
// Start feature execution in background
|
||||
this.executeFeature(
|
||||
this.config!.projectPath,
|
||||
nextFeature.id,
|
||||
this.config!.useWorktrees,
|
||||
true
|
||||
).catch((error) => {
|
||||
console.error(`[AutoMode] Feature ${nextFeature.id} error:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
} catch (error) {
|
||||
console.error("[AutoMode] Loop iteration error:", error);
|
||||
await this.sleep(5000);
|
||||
}
|
||||
}
|
||||
|
||||
this.autoLoopRunning = false;
|
||||
this.emitAutoModeEvent("auto_mode_complete", {
|
||||
message: "Auto mode stopped",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop
|
||||
*/
|
||||
async stopAutoLoop(): Promise<number> {
|
||||
this.autoLoopRunning = false;
|
||||
if (this.autoLoopAbortController) {
|
||||
this.autoLoopAbortController.abort();
|
||||
this.autoLoopAbortController = null;
|
||||
}
|
||||
|
||||
return this.runningFeatures.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single feature
|
||||
*/
|
||||
async executeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = true,
|
||||
isAutoMode = false
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const branchName = `feature/${featureId}`;
|
||||
let worktreePath: string | null = null;
|
||||
|
||||
// Setup worktree if enabled
|
||||
if (useWorktrees) {
|
||||
worktreePath = await this.setupWorktree(projectPath, featureId, branchName);
|
||||
}
|
||||
|
||||
const workDir = worktreePath || projectPath;
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath,
|
||||
branchName,
|
||||
abortController,
|
||||
isAutoMode,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
// Emit feature start event
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: { id: featureId, title: "Loading...", description: "Feature is starting" },
|
||||
});
|
||||
|
||||
try {
|
||||
// Load feature details
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Update feature status to in-progress
|
||||
await this.updateFeatureStatus(projectPath, featureId, "in-progress");
|
||||
|
||||
// Build the prompt
|
||||
const prompt = this.buildFeaturePrompt(feature);
|
||||
|
||||
// Run the agent
|
||||
await this.runAgent(workDir, featureId, prompt, abortController);
|
||||
|
||||
// Mark as completed
|
||||
await this.updateFeatureStatus(projectPath, featureId, "completed");
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Feature completed in ${Math.round((Date.now() - this.runningFeatures.get(featureId)!.startTime) / 1000)}s`,
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError || (error as Error)?.name === "AbortError") {
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: false,
|
||||
message: "Feature stopped by user",
|
||||
projectPath,
|
||||
});
|
||||
} else {
|
||||
console.error(`[AutoMode] Feature ${featureId} failed:`, error);
|
||||
await this.updateFeatureStatus(projectPath, featureId, "failed");
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
featureId,
|
||||
error: (error as Error).message,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific feature
|
||||
*/
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
const running = this.runningFeatures.get(featureId);
|
||||
if (!running) {
|
||||
return false;
|
||||
}
|
||||
|
||||
running.abortController.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a feature (continues from saved context)
|
||||
*/
|
||||
async resumeFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees = true
|
||||
): Promise<void> {
|
||||
// Check if context exists
|
||||
const contextPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"agent-output.md"
|
||||
);
|
||||
|
||||
let hasContext = false;
|
||||
try {
|
||||
await fs.access(contextPath);
|
||||
hasContext = true;
|
||||
} catch {
|
||||
// No context
|
||||
}
|
||||
|
||||
if (hasContext) {
|
||||
// Load previous context and continue
|
||||
const context = await fs.readFile(contextPath, "utf-8");
|
||||
return this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees);
|
||||
}
|
||||
|
||||
// No context, start fresh
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow up on a feature with additional instructions
|
||||
*/
|
||||
async followUpFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
imagePaths?: string[]
|
||||
): Promise<void> {
|
||||
if (this.runningFeatures.has(featureId)) {
|
||||
throw new Error(`Feature ${featureId} is already running`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Check if worktree exists
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
workDir = worktreePath;
|
||||
} catch {
|
||||
// No worktree, use project path
|
||||
}
|
||||
|
||||
this.runningFeatures.set(featureId, {
|
||||
featureId,
|
||||
projectPath,
|
||||
worktreePath: workDir !== projectPath ? worktreePath : null,
|
||||
branchName: `feature/${featureId}`,
|
||||
abortController,
|
||||
isAutoMode: false,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId,
|
||||
projectPath,
|
||||
feature: { id: featureId, title: "Follow-up", description: prompt.substring(0, 100) },
|
||||
});
|
||||
|
||||
try {
|
||||
await this.runAgent(workDir, featureId, prompt, abortController, imagePaths);
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: "Follow-up completed successfully",
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof AbortError)) {
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
featureId,
|
||||
error: (error as Error).message,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
this.runningFeatures.delete(featureId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a feature's implementation
|
||||
*/
|
||||
async verifyFeature(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
workDir = worktreePath;
|
||||
} catch {
|
||||
// No worktree
|
||||
}
|
||||
|
||||
// Run verification - check if tests pass, build works, etc.
|
||||
const verificationChecks = [
|
||||
{ cmd: "npm run lint", name: "Lint" },
|
||||
{ cmd: "npm run typecheck", name: "Type check" },
|
||||
{ cmd: "npm test", name: "Tests" },
|
||||
{ cmd: "npm run build", name: "Build" },
|
||||
];
|
||||
|
||||
let allPassed = true;
|
||||
const results: Array<{ check: string; passed: boolean; output?: string }> = [];
|
||||
|
||||
for (const check of verificationChecks) {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(check.cmd, {
|
||||
cwd: workDir,
|
||||
timeout: 120000,
|
||||
});
|
||||
results.push({ check: check.name, passed: true, output: stdout || stderr });
|
||||
} catch (error) {
|
||||
allPassed = false;
|
||||
results.push({
|
||||
check: check.name,
|
||||
passed: false,
|
||||
output: (error as Error).message,
|
||||
});
|
||||
break; // Stop on first failure
|
||||
}
|
||||
}
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: allPassed,
|
||||
message: allPassed
|
||||
? "All verification checks passed"
|
||||
: `Verification failed: ${results.find(r => !r.passed)?.check || "Unknown"}`,
|
||||
});
|
||||
|
||||
return allPassed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit feature changes
|
||||
*/
|
||||
async commitFeature(projectPath: string, featureId: string): Promise<string | null> {
|
||||
const worktreePath = path.join(projectPath, ".automaker", "worktrees", featureId);
|
||||
let workDir = projectPath;
|
||||
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
workDir = worktreePath;
|
||||
} catch {
|
||||
// No worktree
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for changes
|
||||
const { stdout: status } = await execAsync("git status --porcelain", { cwd: workDir });
|
||||
if (!status.trim()) {
|
||||
return null; // No changes
|
||||
}
|
||||
|
||||
// Load feature for commit message
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const commitMessage = feature
|
||||
? `feat: ${feature.title}\n\nImplemented by Automaker auto-mode`
|
||||
: `feat: Feature ${featureId}`;
|
||||
|
||||
// Stage and commit
|
||||
await execAsync("git add -A", { cwd: workDir });
|
||||
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
||||
cwd: workDir,
|
||||
});
|
||||
|
||||
// Get commit hash
|
||||
const { stdout: hash } = await execAsync("git rev-parse HEAD", { cwd: workDir });
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId,
|
||||
passes: true,
|
||||
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
||||
});
|
||||
|
||||
return hash.trim();
|
||||
} catch (error) {
|
||||
console.error(`[AutoMode] Commit failed for ${featureId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context exists for a feature
|
||||
*/
|
||||
async contextExists(projectPath: string, featureId: string): Promise<boolean> {
|
||||
const contextPath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"agent-output.md"
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.access(contextPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze project to gather context
|
||||
*/
|
||||
async analyzeProject(projectPath: string): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
|
||||
const analysisFeatureId = `analysis-${Date.now()}`;
|
||||
this.emitAutoModeEvent("auto_mode_feature_start", {
|
||||
featureId: analysisFeatureId,
|
||||
projectPath,
|
||||
feature: { id: analysisFeatureId, title: "Project Analysis", description: "Analyzing project structure" },
|
||||
});
|
||||
|
||||
const prompt = `Analyze this project and provide a summary of:
|
||||
1. Project structure and architecture
|
||||
2. Main technologies and frameworks used
|
||||
3. Key components and their responsibilities
|
||||
4. Build and test commands
|
||||
5. Any existing conventions or patterns
|
||||
|
||||
Format your response as a structured markdown document.`;
|
||||
|
||||
try {
|
||||
const options: Options = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
maxTurns: 5,
|
||||
cwd: projectPath,
|
||||
allowedTools: ["Read", "Glob", "Grep"],
|
||||
permissionMode: "acceptEdits",
|
||||
abortController,
|
||||
};
|
||||
|
||||
const stream = query({ prompt, options });
|
||||
let analysisResult = "";
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
analysisResult = block.text;
|
||||
this.emitAutoModeEvent("auto_mode_progress", {
|
||||
featureId: analysisFeatureId,
|
||||
content: block.text,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
analysisResult = msg.result || analysisResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Save analysis
|
||||
const analysisPath = path.join(projectPath, ".automaker", "project-analysis.md");
|
||||
await fs.mkdir(path.dirname(analysisPath), { recursive: true });
|
||||
await fs.writeFile(analysisPath, analysisResult);
|
||||
|
||||
this.emitAutoModeEvent("auto_mode_feature_complete", {
|
||||
featureId: analysisFeatureId,
|
||||
passes: true,
|
||||
message: "Project analysis completed",
|
||||
projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
this.emitAutoModeEvent("auto_mode_error", {
|
||||
featureId: analysisFeatureId,
|
||||
error: (error as Error).message,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status
|
||||
*/
|
||||
getStatus(): {
|
||||
isRunning: boolean;
|
||||
autoLoopRunning: boolean;
|
||||
runningFeatures: string[];
|
||||
runningCount: number;
|
||||
} {
|
||||
return {
|
||||
isRunning: this.autoLoopRunning || this.runningFeatures.size > 0,
|
||||
autoLoopRunning: this.autoLoopRunning,
|
||||
runningFeatures: Array.from(this.runningFeatures.keys()),
|
||||
runningCount: this.runningFeatures.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private async setupWorktree(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
branchName: string
|
||||
): Promise<string> {
|
||||
const worktreesDir = path.join(projectPath, ".automaker", "worktrees");
|
||||
const worktreePath = path.join(worktreesDir, featureId);
|
||||
|
||||
await fs.mkdir(worktreesDir, { recursive: true });
|
||||
|
||||
// Check if worktree already exists
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
return worktreePath;
|
||||
} catch {
|
||||
// Create new worktree
|
||||
}
|
||||
|
||||
// Create branch if it doesn't exist
|
||||
try {
|
||||
await execAsync(`git branch ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Branch may already exist
|
||||
}
|
||||
|
||||
// Create worktree
|
||||
try {
|
||||
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Worktree creation failed, fall back to direct execution
|
||||
console.error(`[AutoMode] Worktree creation failed:`, error);
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
return worktreePath;
|
||||
}
|
||||
|
||||
private async loadFeature(projectPath: string, featureId: string): Promise<Feature | null> {
|
||||
const featurePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"feature.json"
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(featurePath, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async updateFeatureStatus(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
const featurePath = path.join(
|
||||
projectPath,
|
||||
".automaker",
|
||||
"features",
|
||||
featureId,
|
||||
"feature.json"
|
||||
);
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(featurePath, "utf-8");
|
||||
const feature = JSON.parse(data);
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
await fs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
} catch {
|
||||
// Feature file may not exist
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPendingFeatures(projectPath: string): Promise<Feature[]> {
|
||||
const featuresDir = path.join(projectPath, ".automaker", "features");
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(featuresDir, { withFileTypes: true });
|
||||
const features: Feature[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const featurePath = path.join(featuresDir, entry.name, "feature.json");
|
||||
try {
|
||||
const data = await fs.readFile(featurePath, "utf-8");
|
||||
const feature = JSON.parse(data);
|
||||
if (feature.status === "pending" || feature.status === "ready") {
|
||||
features.push(feature);
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid features
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
return features.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private buildFeaturePrompt(feature: Feature): string {
|
||||
let prompt = `## Feature Implementation Task
|
||||
|
||||
**Feature ID:** ${feature.id}
|
||||
**Title:** ${feature.title}
|
||||
**Description:** ${feature.description}
|
||||
`;
|
||||
|
||||
if (feature.spec) {
|
||||
prompt += `
|
||||
**Specification:**
|
||||
${feature.spec}
|
||||
`;
|
||||
}
|
||||
|
||||
prompt += `
|
||||
## Instructions
|
||||
|
||||
Implement this feature by:
|
||||
1. First, explore the codebase to understand the existing structure
|
||||
2. Plan your implementation approach
|
||||
3. Write the necessary code changes
|
||||
4. Add or update tests as needed
|
||||
5. Ensure the code follows existing patterns and conventions
|
||||
|
||||
When done, summarize what you implemented and any notes for the developer.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private async runAgent(
|
||||
workDir: string,
|
||||
featureId: string,
|
||||
prompt: string,
|
||||
abortController: AbortController,
|
||||
imagePaths?: string[]
|
||||
): Promise<void> {
|
||||
const options: Options = {
|
||||
model: "claude-opus-4-5-20251101",
|
||||
maxTurns: 50,
|
||||
cwd: workDir,
|
||||
allowedTools: [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
],
|
||||
permissionMode: "acceptEdits",
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Build prompt - include image paths for the agent to read
|
||||
let finalPrompt = prompt;
|
||||
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`;
|
||||
}
|
||||
|
||||
const stream = query({ prompt: finalPrompt, options });
|
||||
let responseText = "";
|
||||
const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md");
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "assistant" && msg.message.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === "text") {
|
||||
responseText = block.text;
|
||||
this.emitAutoModeEvent("auto_mode_progress", {
|
||||
featureId,
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
this.emitAutoModeEvent("auto_mode_tool", {
|
||||
featureId,
|
||||
tool: block.name,
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.type === "result" && msg.subtype === "success") {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
// Save agent output
|
||||
try {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, responseText);
|
||||
} catch {
|
||||
// May fail if directory doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFeatureWithContext(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
context: string,
|
||||
useWorktrees: boolean
|
||||
): Promise<void> {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
const prompt = `## Continuing Feature Implementation
|
||||
|
||||
${this.buildFeaturePrompt(feature)}
|
||||
|
||||
## Previous Context
|
||||
The following is the output from a previous implementation attempt. Continue from where you left off:
|
||||
|
||||
${context}
|
||||
|
||||
## Instructions
|
||||
Review the previous work and continue the implementation. If the feature appears complete, verify it works correctly.`;
|
||||
|
||||
return this.executeFeature(projectPath, featureId, useWorktrees, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event wrapped in the correct format for the client.
|
||||
* All auto-mode events are sent as type "auto-mode:event" with the actual
|
||||
* event type and data in the payload.
|
||||
*/
|
||||
private emitAutoModeEvent(
|
||||
eventType: string,
|
||||
data: Record<string, unknown>
|
||||
): void {
|
||||
// Wrap the event in auto-mode:event format expected by the client
|
||||
this.events.emit("auto-mode:event", {
|
||||
type: eventType,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
263
apps/server/src/services/feature-loader.ts
Normal file
263
apps/server/src/services/feature-loader.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Feature Loader - Handles loading and managing features from individual feature folders
|
||||
* Each feature is stored in .automaker/features/{featureId}/feature.json
|
||||
*/
|
||||
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export interface Feature {
|
||||
id: string;
|
||||
category: string;
|
||||
description: string;
|
||||
steps?: string[];
|
||||
passes?: boolean;
|
||||
priority?: number;
|
||||
imagePaths?: Array<string | { path: string; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class FeatureLoader {
|
||||
/**
|
||||
* Get the features directory path
|
||||
*/
|
||||
getFeaturesDir(projectPath: string): string {
|
||||
return path.join(projectPath, ".automaker", "features");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to a specific feature folder
|
||||
*/
|
||||
getFeatureDir(projectPath: string, featureId: string): string {
|
||||
return path.join(this.getFeaturesDir(projectPath), featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new feature ID
|
||||
*/
|
||||
generateFeatureId(): string {
|
||||
return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all features for a project
|
||||
*/
|
||||
async getAll(projectPath: string): Promise<Feature[]> {
|
||||
try {
|
||||
const featuresDir = this.getFeaturesDir(projectPath);
|
||||
|
||||
// Check if features directory exists
|
||||
try {
|
||||
await fs.access(featuresDir);
|
||||
} catch {
|
||||
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: Feature[] = [];
|
||||
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);
|
||||
|
||||
if (!feature.id) {
|
||||
console.warn(
|
||||
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
features.push(feature);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
continue;
|
||||
} else if (error instanceof SyntaxError) {
|
||||
console.warn(
|
||||
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[FeatureLoader] Failed to load feature ${featureId}:`,
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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: string, featureId: string): Promise<Feature | null> {
|
||||
try {
|
||||
const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId);
|
||||
const content = await fs.readFile(featureJsonPath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
console.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new 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);
|
||||
|
||||
// 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 required fields
|
||||
const feature: Feature = {
|
||||
category: featureData.category || "Uncategorized",
|
||||
description: featureData.description || "",
|
||||
...featureData,
|
||||
id: featureId,
|
||||
};
|
||||
|
||||
// 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: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
const updatedFeature: Feature = { ...feature, ...updates };
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feature
|
||||
*/
|
||||
async delete(projectPath: string, featureId: string): Promise<boolean> {
|
||||
try {
|
||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||
await fs.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);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent output for a feature
|
||||
*/
|
||||
async getAgentOutput(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
||||
const content = await fs.readFile(agentOutputPath, "utf-8");
|
||||
return content;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
console.error(
|
||||
`[FeatureLoader] Failed to get agent output for ${featureId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save agent output for a feature
|
||||
*/
|
||||
async saveAgentOutput(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const featureDir = this.getFeatureDir(projectPath, featureId);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
||||
await fs.writeFile(agentOutputPath, content, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete agent output for a feature
|
||||
*/
|
||||
async deleteAgentOutput(projectPath: string, featureId: string): Promise<void> {
|
||||
try {
|
||||
const agentOutputPath = this.getAgentOutputPath(projectPath, featureId);
|
||||
await fs.unlink(agentOutputPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/server/tsconfig.json
Normal file
20
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Automaker Docker Compose
|
||||
# For self-hosting the Automaker backend server
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/server/Dockerfile
|
||||
container_name: automaker-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3008:3008"
|
||||
environment:
|
||||
# Required
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
|
||||
# Optional - authentication (leave empty to disable)
|
||||
- AUTOMAKER_API_KEY=${AUTOMAKER_API_KEY:-}
|
||||
|
||||
# Optional - restrict to specific directories (comma-separated)
|
||||
- ALLOWED_PROJECT_DIRS=${ALLOWED_PROJECT_DIRS:-/projects}
|
||||
|
||||
# Optional - data directory for sessions, etc.
|
||||
- DATA_DIR=/data
|
||||
|
||||
# Optional - CORS origin (default allows all)
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-*}
|
||||
|
||||
# Optional - additional API keys
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
volumes:
|
||||
# Persist data between restarts
|
||||
- automaker-data:/data
|
||||
|
||||
# Mount your projects directory (read-write access)
|
||||
- ${PROJECTS_DIR:-./projects}:/projects
|
||||
|
||||
volumes:
|
||||
automaker-data:
|
||||
2623
package-lock.json
generated
2623
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,13 @@
|
||||
"dev:electron:debug": "npm run dev:electron:debug --workspace=apps/app",
|
||||
"dev:electron:wsl": "npm run dev:electron:wsl --workspace=apps/app",
|
||||
"dev:electron:wsl:gpu": "npm run dev:electron:wsl:gpu --workspace=apps/app",
|
||||
"dev:server": "npm run dev --workspace=apps/server",
|
||||
"dev:full": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
||||
"build": "npm run build --workspace=apps/app",
|
||||
"build:electron": "npm run build:electron --workspace=apps/app",
|
||||
"build:server": "npm run build --workspace=apps/server",
|
||||
"build:electron": "npm run build --workspace=apps/app",
|
||||
"start": "npm run start --workspace=apps/app",
|
||||
"start:server": "npm run start --workspace=apps/server",
|
||||
"lint": "npm run lint --workspace=apps/app",
|
||||
"test": "npm run test --workspace=apps/app",
|
||||
"test:headed": "npm run test:headed --workspace=apps/app",
|
||||
|
||||
267
plan.md
Normal file
267
plan.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Plan: Full Web Support for Automaker
|
||||
|
||||
## Goal
|
||||
Make the app work fully in web browsers while keeping Electron support. Web mode connects to a backend server (self-hosted or cloud). Electron embeds the same server locally.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Next.js Frontend │
|
||||
│ (same code both modes) │
|
||||
└───────────────┬─────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
[Web Mode] [Electron Mode]
|
||||
│ │
|
||||
HTTP/WebSocket HTTP/WebSocket
|
||||
to remote server to localhost:3008
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌───────────────▼─────────────────────┐
|
||||
│ Backend Server │
|
||||
│ (apps/server) │
|
||||
│ - Express + WebSocket │
|
||||
│ - All services from electron/ │
|
||||
│ - Claude Agent SDK │
|
||||
│ - File ops, Git, PTY │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key insight**: Electron uses the same HTTP API - just connects to localhost instead of remote.
|
||||
|
||||
---
|
||||
|
||||
## New Package: `apps/server`
|
||||
|
||||
```
|
||||
apps/server/
|
||||
├── package.json
|
||||
├── src/
|
||||
│ ├── index.ts # Express server entry
|
||||
│ ├── routes/
|
||||
│ │ ├── fs.ts # File system routes
|
||||
│ │ ├── agent.ts # Agent routes
|
||||
│ │ ├── sessions.ts # Session routes
|
||||
│ │ ├── auto-mode.ts # Auto mode routes
|
||||
│ │ ├── features.ts # Features routes
|
||||
│ │ ├── worktree.ts # Git worktree routes
|
||||
│ │ ├── setup.ts # Setup/config routes
|
||||
│ │ └── suggestions.ts # Feature suggestions routes
|
||||
│ ├── services/ # Moved from electron/services/
|
||||
│ │ ├── agent-service.ts
|
||||
│ │ ├── auto-mode-service.ts
|
||||
│ │ ├── worktree-manager.ts
|
||||
│ │ ├── feature-loader.ts
|
||||
│ │ ├── feature-executor.ts
|
||||
│ │ └── ...
|
||||
│ └── lib/
|
||||
│ ├── events.ts # Event emitter for streaming
|
||||
│ └── security.ts # Path validation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/app/src/lib/electron.ts` | Add `HttpApiClient` class that implements `ElectronAPI` using fetch/WebSocket |
|
||||
| `apps/app/electron/main.js` | Simplify to: spawn server + create window (remove 1500+ lines of IPC handlers) |
|
||||
| `apps/app/electron/preload.js` | Simplify to just expose `isElectron` flag |
|
||||
| `apps/app/package.json` | Remove server-side deps (Claude SDK, pty) |
|
||||
| Root `package.json` | Add `apps/server` workspace |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Create Server Package (Foundation)
|
||||
1. Create `apps/server` with Express + TypeScript setup
|
||||
2. Add health check endpoint: `GET /api/health`
|
||||
3. Copy one simple service (feature-loader) and create route
|
||||
4. Test with curl/Postman
|
||||
|
||||
### Phase 2: File System API
|
||||
1. Create `POST /api/fs/read`, `POST /api/fs/write`, etc.
|
||||
2. Add path security (allowlist validation)
|
||||
3. Update `electron.ts` with `HttpApiClient` for fs operations
|
||||
4. Test: file operations work in web mode
|
||||
|
||||
### Phase 3: Agent API with Streaming
|
||||
1. Add WebSocket server for events (`/api/events`)
|
||||
2. Migrate `agent-service.js` to TypeScript
|
||||
3. Create routes: `POST /api/agent/send`, etc.
|
||||
4. Events stream via WebSocket instead of IPC
|
||||
5. Test: chat works in web mode
|
||||
|
||||
### Phase 4: Sessions & Features API
|
||||
1. Migrate session management routes
|
||||
2. Migrate features CRUD routes
|
||||
3. Test: project/feature management works
|
||||
|
||||
### Phase 5: Auto Mode & Worktree
|
||||
1. Migrate `auto-mode-service.js` (complex - has streaming)
|
||||
2. Migrate `worktree-manager.js`
|
||||
3. Test: auto mode runs features in web
|
||||
|
||||
### Phase 6: Remaining Services
|
||||
1. Spec regeneration
|
||||
2. Feature suggestions
|
||||
3. Setup/CLI detection
|
||||
4. Model provider checks
|
||||
|
||||
### Phase 7: Simplify Electron
|
||||
1. Update `main.js` to spawn server process + create window
|
||||
2. Remove all IPC handlers
|
||||
3. Electron app uses HTTP like web
|
||||
4. Test: Electron still works
|
||||
|
||||
### Phase 8: Production Ready
|
||||
1. Add authentication (API key header)
|
||||
2. Configure CORS for production
|
||||
3. Add `ALLOWED_PROJECT_DIRS` env for security
|
||||
4. Docker setup for deployment
|
||||
5. Update build scripts
|
||||
|
||||
---
|
||||
|
||||
## API Design Pattern
|
||||
|
||||
Convert IPC handlers to REST:
|
||||
|
||||
```
|
||||
IPC: dialog:openDirectory → Web: User types path, POST /api/fs/validate
|
||||
IPC: fs:readFile → POST /api/fs/read { filePath }
|
||||
IPC: agent:send → POST /api/agent/send { sessionId, message, ... }
|
||||
IPC: auto-mode:start → POST /api/auto-mode/start { projectPath }
|
||||
IPC: features:getAll → GET /api/projects/:path/features
|
||||
```
|
||||
|
||||
Streaming via WebSocket:
|
||||
```
|
||||
ws://server/api/events
|
||||
|
||||
Events: agent:stream, auto-mode:event, suggestions:event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web-Specific Handling
|
||||
|
||||
| Feature | Electron | Web |
|
||||
|---------|----------|-----|
|
||||
| File picker | Native dialog | Text input + server validation |
|
||||
| Open link | shell.openExternal | window.open() |
|
||||
| Data directory | app.getPath('userData') | Server's DATA_DIR env |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
**Server `.env`:**
|
||||
```
|
||||
PORT=3008
|
||||
DATA_DIR=/path/to/data
|
||||
ANTHROPIC_API_KEY=xxx
|
||||
ALLOWED_PROJECT_DIRS=/home/user/projects
|
||||
```
|
||||
|
||||
**Frontend `.env.local`:**
|
||||
```
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3008
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- New files: ~15-20 (server package)
|
||||
- Modified files: ~5 (electron.ts, main.js, preload.js, package.jsons)
|
||||
- Deleted lines: ~1500 (IPC handlers from main.js)
|
||||
- Services to migrate: ~10
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ ALL PHASES COMPLETE
|
||||
|
||||
- [x] **Phase 1**: Server package foundation (`apps/server`)
|
||||
- Express server with WebSocket support
|
||||
- Event emitter for streaming
|
||||
- Security module for path validation
|
||||
- Health check endpoint
|
||||
|
||||
- [x] **Phase 2**: HttpApiClient in frontend
|
||||
- `apps/app/src/lib/http-api-client.ts` - full implementation
|
||||
- Modified `electron.ts` to use HTTP client when not in Electron
|
||||
- No mocks - all calls go through HTTP
|
||||
|
||||
- [x] **Phase 3**: Agent API with streaming
|
||||
- `apps/server/src/services/agent-service.ts`
|
||||
- `apps/server/src/routes/agent.ts`
|
||||
- WebSocket streaming for responses
|
||||
|
||||
- [x] **Phase 4**: Sessions & Features API
|
||||
- `apps/server/src/routes/sessions.ts`
|
||||
- `apps/server/src/services/feature-loader.ts`
|
||||
- `apps/server/src/routes/features.ts`
|
||||
|
||||
- [x] **Phase 5**: Auto Mode & Worktree
|
||||
- `apps/server/src/services/auto-mode-service.ts` - full implementation with Claude SDK
|
||||
- `apps/server/src/routes/auto-mode.ts`
|
||||
- `apps/server/src/routes/worktree.ts`
|
||||
- `apps/server/src/routes/git.ts`
|
||||
|
||||
- [x] **Phase 6**: Remaining services
|
||||
- `apps/server/src/routes/setup.ts` - CLI detection, API keys, platform info
|
||||
- `apps/server/src/routes/suggestions.ts` - AI-powered feature suggestions
|
||||
- `apps/server/src/routes/spec-regeneration.ts` - spec generation from overview
|
||||
- `apps/server/src/routes/models.ts` - model providers and availability
|
||||
- `apps/server/src/routes/running-agents.ts` - active agent tracking
|
||||
|
||||
- [x] **Phase 7**: Simplify Electron
|
||||
- `apps/app/electron/main-simplified.js` - spawns server, minimal IPC
|
||||
- `apps/app/electron/preload-simplified.js` - only native features exposed
|
||||
- Updated `electron.ts` to detect simplified mode
|
||||
- Updated `http-api-client.ts` to use native dialogs when available
|
||||
|
||||
- [x] **Phase 8**: Production ready
|
||||
- `apps/server/src/lib/auth.ts` - API key authentication middleware
|
||||
- `apps/server/Dockerfile` - multi-stage Docker build
|
||||
- `docker-compose.yml` - easy deployment configuration
|
||||
- `apps/server/.env.example` - documented configuration
|
||||
|
||||
---
|
||||
|
||||
## Additional Fixes Applied
|
||||
|
||||
### State Persistence
|
||||
- Features now cached in localStorage via Zustand persist middleware
|
||||
- Board view properly handles API failures by keeping cached data
|
||||
- Theme and UI state properly persisted across refreshes
|
||||
|
||||
### Authentication Display
|
||||
- Server now returns proper auth method names: `oauth_token_env`, `oauth_token`, `api_key_env`, `api_key`
|
||||
- Settings view displays correct auth source (OAuth token, API key, subscription, etc.)
|
||||
- Added support for Codex subscription detection
|
||||
- Fixed "Unknown method" display issue
|
||||
|
||||
### Bug Fixes
|
||||
- Fixed board-view.tsx crash when feature status is unknown (defaults to backlog)
|
||||
- Removed "Mock IPC" label from web mode indicator
|
||||
- Fixed unused imports and dependency warnings
|
||||
- Updated API key authentication header support in HTTP client
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The architecture is simple: **one backend server, two ways to access it** (web browser or Electron shell).
|
||||
|
||||
- **Web users**: Connect browser to your cloud-hosted server
|
||||
- **Electron users**: App spawns server locally, connects to localhost
|
||||
- **Same codebase**: Frontend code unchanged, backend services extracted to standalone server
|
||||
Reference in New Issue
Block a user