diff --git a/README.md b/README.md index 29e61eb1..424abf06 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > > Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks. > -> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/). +> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker). # Automaker diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 65971d15..d0a770f7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -16,26 +16,26 @@ 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 { createRunningAgentsRoutes } from "./routes/running-agents.js"; -import { createWorkspaceRoutes } from "./routes/workspace.js"; -import { createTemplatesRoutes } from "./routes/templates.js"; +import { createFsRoutes } from "./routes/fs/index.js"; +import { createHealthRoutes } from "./routes/health/index.js"; +import { createAgentRoutes } from "./routes/agent/index.js"; +import { createSessionsRoutes } from "./routes/sessions/index.js"; +import { createFeaturesRoutes } from "./routes/features/index.js"; +import { createAutoModeRoutes } from "./routes/auto-mode/index.js"; +import { createWorktreeRoutes } from "./routes/worktree/index.js"; +import { createGitRoutes } from "./routes/git/index.js"; +import { createSetupRoutes } from "./routes/setup/index.js"; +import { createSuggestionsRoutes } from "./routes/suggestions/index.js"; +import { createModelsRoutes } from "./routes/models/index.js"; +import { createRunningAgentsRoutes } from "./routes/running-agents/index.js"; +import { createWorkspaceRoutes } from "./routes/workspace/index.js"; +import { createTemplatesRoutes } from "./routes/templates/index.js"; import { createTerminalRoutes, validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired, -} from "./routes/terminal.js"; +} from "./routes/terminal/index.js"; import { AgentService } from "./services/agent-service.js"; import { FeatureLoader } from "./services/feature-loader.js"; import { AutoModeService } from "./services/auto-mode-service.js"; diff --git a/apps/server/src/routes/agent.ts b/apps/server/src/routes/agent.ts deleted file mode 100644 index 7315125f..00000000 --- a/apps/server/src/routes/agent.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 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, model } = req.body as { - sessionId: string; - message: string; - workingDirectory?: string; - imagePaths?: string[]; - model?: 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, - model, - }) - .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 }); - } - }); - - // Set session model - router.post("/model", async (req: Request, res: Response) => { - try { - const { sessionId, model } = req.body as { - sessionId: string; - model: string; - }; - - if (!sessionId || !model) { - res.status(400).json({ success: false, error: "sessionId and model are required" }); - return; - } - - const result = await agentService.setSessionModel(sessionId, model); - res.json({ success: result }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - return router; -} diff --git a/apps/server/src/routes/agent/common.ts b/apps/server/src/routes/agent/common.ts new file mode 100644 index 00000000..4257bee1 --- /dev/null +++ b/apps/server/src/routes/agent/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for agent routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Agent"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts new file mode 100644 index 00000000..ed12e296 --- /dev/null +++ b/apps/server/src/routes/agent/index.ts @@ -0,0 +1,29 @@ +/** + * Agent routes - HTTP API for Claude agent interactions + */ + +import { Router } from "express"; +import { AgentService } from "../../services/agent-service.js"; +import type { EventEmitter } from "../../lib/events.js"; +import { createStartHandler } from "./routes/start.js"; +import { createSendHandler } from "./routes/send.js"; +import { createHistoryHandler } from "./routes/history.js"; +import { createStopHandler } from "./routes/stop.js"; +import { createClearHandler } from "./routes/clear.js"; +import { createModelHandler } from "./routes/model.js"; + +export function createAgentRoutes( + agentService: AgentService, + _events: EventEmitter +): Router { + const router = Router(); + + router.post("/start", createStartHandler(agentService)); + router.post("/send", createSendHandler(agentService)); + router.post("/history", createHistoryHandler(agentService)); + router.post("/stop", createStopHandler(agentService)); + router.post("/clear", createClearHandler(agentService)); + router.post("/model", createModelHandler(agentService)); + + return router; +} diff --git a/apps/server/src/routes/agent/routes/clear.ts b/apps/server/src/routes/agent/routes/clear.ts new file mode 100644 index 00000000..42418331 --- /dev/null +++ b/apps/server/src/routes/agent/routes/clear.ts @@ -0,0 +1,28 @@ +/** + * POST /clear endpoint - Clear conversation + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createClearHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Clear session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/history.ts b/apps/server/src/routes/agent/routes/history.ts new file mode 100644 index 00000000..c2b23be8 --- /dev/null +++ b/apps/server/src/routes/agent/routes/history.ts @@ -0,0 +1,28 @@ +/** + * POST /history endpoint - Get conversation history + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createHistoryHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get history failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/model.ts b/apps/server/src/routes/agent/routes/model.ts new file mode 100644 index 00000000..2e1b933e --- /dev/null +++ b/apps/server/src/routes/agent/routes/model.ts @@ -0,0 +1,31 @@ +/** + * POST /model endpoint - Set session model + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createModelHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, model } = req.body as { + sessionId: string; + model: string; + }; + + if (!sessionId || !model) { + res + .status(400) + .json({ success: false, error: "sessionId and model are required" }); + return; + } + + const result = await agentService.setSessionModel(sessionId, model); + res.json({ success: result }); + } catch (error) { + logError(error, "Set session model failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/send.ts b/apps/server/src/routes/agent/routes/send.ts new file mode 100644 index 00000000..fa012e89 --- /dev/null +++ b/apps/server/src/routes/agent/routes/send.ts @@ -0,0 +1,52 @@ +/** + * POST /send endpoint - Send a message + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("Agent"); + +export function createSendHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, workingDirectory, imagePaths, model } = + req.body as { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + model?: 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, + model, + }) + .catch((error) => { + logError(error, "Send message failed (background)"); + }); + + // Return immediately - responses come via WebSocket + res.json({ success: true, message: "Message sent" }); + } catch (error) { + logError(error, "Send message failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/start.ts b/apps/server/src/routes/agent/routes/start.ts new file mode 100644 index 00000000..3686bad5 --- /dev/null +++ b/apps/server/src/routes/agent/routes/start.ts @@ -0,0 +1,38 @@ +/** + * POST /start endpoint - Start a conversation + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("Agent"); + +export function createStartHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Start conversation failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/agent/routes/stop.ts b/apps/server/src/routes/agent/routes/stop.ts new file mode 100644 index 00000000..204c7d4a --- /dev/null +++ b/apps/server/src/routes/agent/routes/stop.ts @@ -0,0 +1,28 @@ +/** + * POST /stop endpoint - Stop execution + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStopHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Stop execution failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/app-spec/common.ts b/apps/server/src/routes/app-spec/common.ts index 79decd80..3ee78009 100644 --- a/apps/server/src/routes/app-spec/common.ts +++ b/apps/server/src/routes/app-spec/common.ts @@ -6,9 +6,19 @@ import { createLogger } from "../../lib/logger.js"; const logger = createLogger("SpecRegeneration"); -// Shared state for tracking generation status -export let isRunning = false; -export let currentAbortController: AbortController | null = null; +// Shared state for tracking generation status - private +let isRunning = false; +let currentAbortController: AbortController | null = null; + +/** + * Get the current running state + */ +export function getSpecRegenerationStatus(): { + isRunning: boolean; + currentAbortController: AbortController | null; +} { + return { isRunning, currentAbortController }; +} /** * Set the running state and abort controller @@ -65,9 +75,7 @@ export function logError(error: unknown, context: string): void { ); } -/** - * Get error message from error object - */ -export function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Unknown error"; -} +import { getErrorMessage as getErrorMessageShared } from "../common.js"; + +// Re-export shared utility +export { getErrorMessageShared as getErrorMessage }; diff --git a/apps/server/src/routes/app-spec/routes/create.ts b/apps/server/src/routes/app-spec/routes/create.ts index 5ed1583f..89d6430e 100644 --- a/apps/server/src/routes/app-spec/routes/create.ts +++ b/apps/server/src/routes/app-spec/routes/create.ts @@ -6,7 +6,7 @@ import type { Request, Response } from "express"; import type { EventEmitter } from "../../../lib/events.js"; import { createLogger } from "../../../lib/logger.js"; import { - isRunning, + getSpecRegenerationStatus, setRunningState, logAuthStatus, logError, @@ -48,6 +48,7 @@ export function createCreateHandler(events: EventEmitter) { return; } + const { isRunning } = getSpecRegenerationStatus(); if (isRunning) { logger.warn("Generation already running, rejecting request"); res.json({ success: false, error: "Spec generation already running" }); @@ -87,8 +88,7 @@ export function createCreateHandler(events: EventEmitter) { ); res.json({ success: true }); } catch (error) { - logger.error("❌ Route handler exception:"); - logger.error("Error:", error); + logError(error, "Create spec route handler failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/app-spec/routes/generate-features.ts b/apps/server/src/routes/app-spec/routes/generate-features.ts index 4bb8535a..91f3b9b5 100644 --- a/apps/server/src/routes/app-spec/routes/generate-features.ts +++ b/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -6,7 +6,7 @@ import type { Request, Response } from "express"; import type { EventEmitter } from "../../../lib/events.js"; import { createLogger } from "../../../lib/logger.js"; import { - isRunning, + getSpecRegenerationStatus, setRunningState, logAuthStatus, logError, @@ -32,6 +32,7 @@ export function createGenerateFeaturesHandler(events: EventEmitter) { return; } + const { isRunning } = getSpecRegenerationStatus(); if (isRunning) { logger.warn("Generation already running, rejecting request"); res.json({ success: false, error: "Generation already running" }); @@ -62,8 +63,7 @@ export function createGenerateFeaturesHandler(events: EventEmitter) { ); res.json({ success: true }); } catch (error) { - logger.error("❌ Route handler exception:"); - logger.error("Error:", error); + logError(error, "Generate features route handler failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/app-spec/routes/generate.ts b/apps/server/src/routes/app-spec/routes/generate.ts index 3cd6b889..428de409 100644 --- a/apps/server/src/routes/app-spec/routes/generate.ts +++ b/apps/server/src/routes/app-spec/routes/generate.ts @@ -6,7 +6,7 @@ import type { Request, Response } from "express"; import type { EventEmitter } from "../../../lib/events.js"; import { createLogger } from "../../../lib/logger.js"; import { - isRunning, + getSpecRegenerationStatus, setRunningState, logAuthStatus, logError, @@ -52,6 +52,7 @@ export function createGenerateHandler(events: EventEmitter) { return; } + const { isRunning } = getSpecRegenerationStatus(); if (isRunning) { logger.warn("Generation already running, rejecting request"); res.json({ success: false, error: "Spec generation already running" }); @@ -90,8 +91,7 @@ export function createGenerateHandler(events: EventEmitter) { ); res.json({ success: true }); } catch (error) { - logger.error("❌ Route handler exception:"); - logger.error("Error:", error); + logError(error, "Generate spec route handler failed"); res.status(500).json({ success: false, error: getErrorMessage(error) }); } }; diff --git a/apps/server/src/routes/app-spec/routes/status.ts b/apps/server/src/routes/app-spec/routes/status.ts index a39a3ebe..a3c1aac1 100644 --- a/apps/server/src/routes/app-spec/routes/status.ts +++ b/apps/server/src/routes/app-spec/routes/status.ts @@ -3,11 +3,12 @@ */ import type { Request, Response } from "express"; -import { isRunning, getErrorMessage } from "../common.js"; +import { getSpecRegenerationStatus, getErrorMessage } from "../common.js"; export function createStatusHandler() { return async (_req: Request, res: Response): Promise => { try { + const { isRunning } = getSpecRegenerationStatus(); res.json({ success: true, isRunning }); } catch (error) { res.status(500).json({ success: false, error: getErrorMessage(error) }); diff --git a/apps/server/src/routes/app-spec/routes/stop.ts b/apps/server/src/routes/app-spec/routes/stop.ts index e9221b92..7c3bd5ca 100644 --- a/apps/server/src/routes/app-spec/routes/stop.ts +++ b/apps/server/src/routes/app-spec/routes/stop.ts @@ -4,7 +4,7 @@ import type { Request, Response } from "express"; import { - currentAbortController, + getSpecRegenerationStatus, setRunningState, getErrorMessage, } from "../common.js"; @@ -12,6 +12,7 @@ import { export function createStopHandler() { return async (_req: Request, res: Response): Promise => { try { + const { currentAbortController } = getSpecRegenerationStatus(); if (currentAbortController) { currentAbortController.abort(); } diff --git a/apps/server/src/routes/auto-mode.ts b/apps/server/src/routes/auto-mode.ts deleted file mode 100644 index cd6bfcb0..00000000 --- a/apps/server/src/routes/auto-mode.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * 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 { AutoModeService } from "../services/auto-mode-service.js"; - -export function createAutoModeRoutes(autoModeService: AutoModeService): Router { - const router = Router(); - - // 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; -} diff --git a/apps/server/src/routes/auto-mode/common.ts b/apps/server/src/routes/auto-mode/common.ts new file mode 100644 index 00000000..77082852 --- /dev/null +++ b/apps/server/src/routes/auto-mode/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for auto-mode routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("AutoMode"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/auto-mode/index.ts b/apps/server/src/routes/auto-mode/index.ts new file mode 100644 index 00000000..93253c47 --- /dev/null +++ b/apps/server/src/routes/auto-mode/index.ts @@ -0,0 +1,40 @@ +/** + * Auto Mode routes - HTTP API for autonomous feature implementation + * + * Uses the AutoModeService for real feature execution with Claude Agent SDK + */ + +import { Router } from "express"; +import type { AutoModeService } from "../../services/auto-mode-service.js"; +import { createStartHandler } from "./routes/start.js"; +import { createStopHandler } from "./routes/stop.js"; +import { createStopFeatureHandler } from "./routes/stop-feature.js"; +import { createStatusHandler } from "./routes/status.js"; +import { createRunFeatureHandler } from "./routes/run-feature.js"; +import { createVerifyFeatureHandler } from "./routes/verify-feature.js"; +import { createResumeFeatureHandler } from "./routes/resume-feature.js"; +import { createContextExistsHandler } from "./routes/context-exists.js"; +import { createAnalyzeProjectHandler } from "./routes/analyze-project.js"; +import { createFollowUpFeatureHandler } from "./routes/follow-up-feature.js"; +import { createCommitFeatureHandler } from "./routes/commit-feature.js"; + +export function createAutoModeRoutes(autoModeService: AutoModeService): Router { + const router = Router(); + + router.post("/start", createStartHandler(autoModeService)); + router.post("/stop", createStopHandler(autoModeService)); + router.post("/stop-feature", createStopFeatureHandler(autoModeService)); + router.post("/status", createStatusHandler(autoModeService)); + router.post("/run-feature", createRunFeatureHandler(autoModeService)); + router.post("/verify-feature", createVerifyFeatureHandler(autoModeService)); + router.post("/resume-feature", createResumeFeatureHandler(autoModeService)); + router.post("/context-exists", createContextExistsHandler(autoModeService)); + router.post("/analyze-project", createAnalyzeProjectHandler(autoModeService)); + router.post( + "/follow-up-feature", + createFollowUpFeatureHandler(autoModeService) + ); + router.post("/commit-feature", createCommitFeatureHandler(autoModeService)); + + return router; +} diff --git a/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/apps/server/src/routes/auto-mode/routes/analyze-project.ts new file mode 100644 index 00000000..28a2d489 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/analyze-project.ts @@ -0,0 +1,35 @@ +/** + * POST /analyze-project endpoint - Analyze project + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("AutoMode"); + +export function createAnalyzeProjectHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) => { + logger.error(`[AutoMode] Project analysis error:`, error); + }); + + res.json({ success: true, message: "Project analysis started" }); + } catch (error) { + logError(error, "Analyze project failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/apps/server/src/routes/auto-mode/routes/commit-feature.ts new file mode 100644 index 00000000..f0297102 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -0,0 +1,37 @@ +/** + * POST /commit-feature endpoint - Commit feature changes + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createCommitFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Commit feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/context-exists.ts b/apps/server/src/routes/auto-mode/routes/context-exists.ts new file mode 100644 index 00000000..32ebb4ce --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/context-exists.ts @@ -0,0 +1,37 @@ +/** + * POST /context-exists endpoint - Check if context exists for a feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createContextExistsHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Check context exists failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts new file mode 100644 index 00000000..06741d17 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -0,0 +1,46 @@ +/** + * POST /follow-up-feature endpoint - Follow up on a feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("AutoMode"); + +export function createFollowUpFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) => { + logger.error( + `[AutoMode] Follow up feature ${featureId} error:`, + error + ); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, "Follow up feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/apps/server/src/routes/auto-mode/routes/resume-feature.ts new file mode 100644 index 00000000..73007d91 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -0,0 +1,44 @@ +/** + * POST /resume-feature endpoint - Resume a feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("AutoMode"); + +export function createResumeFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) => { + logger.error(`[AutoMode] Resume feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, "Resume feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/run-feature.ts b/apps/server/src/routes/auto-mode/routes/run-feature.ts new file mode 100644 index 00000000..f3d258c2 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -0,0 +1,44 @@ +/** + * POST /run-feature endpoint - Run a single feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; + +const logger = createLogger("AutoMode"); + +export function createRunFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) => { + logger.error(`[AutoMode] Feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, "Run feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/start.ts b/apps/server/src/routes/auto-mode/routes/start.ts new file mode 100644 index 00000000..9868cd1a --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/start.ts @@ -0,0 +1,31 @@ +/** + * POST /start endpoint - Start auto mode loop + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStartHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Start auto loop failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/status.ts b/apps/server/src/routes/auto-mode/routes/status.ts new file mode 100644 index 00000000..ba0ee8a1 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/status.ts @@ -0,0 +1,22 @@ +/** + * POST /status endpoint - Get auto mode status + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStatusHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + try { + const status = autoModeService.getStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, "Get status failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/apps/server/src/routes/auto-mode/routes/stop-feature.ts new file mode 100644 index 00000000..0468e9d3 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/stop-feature.ts @@ -0,0 +1,28 @@ +/** + * POST /stop-feature endpoint - Stop a specific feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStopFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Stop feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/stop.ts b/apps/server/src/routes/auto-mode/routes/stop.ts new file mode 100644 index 00000000..69f21fc3 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/stop.ts @@ -0,0 +1,19 @@ +/** + * POST /stop endpoint - Stop auto mode loop + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStopHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + try { + const runningCount = await autoModeService.stopAutoLoop(); + res.json({ success: true, runningFeatures: runningCount }); + } catch (error) { + logError(error, "Stop auto loop failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/apps/server/src/routes/auto-mode/routes/verify-feature.ts new file mode 100644 index 00000000..456eecb2 --- /dev/null +++ b/apps/server/src/routes/auto-mode/routes/verify-feature.ts @@ -0,0 +1,37 @@ +/** + * POST /verify-feature endpoint - Verify a feature + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createVerifyFeatureHandler(autoModeService: AutoModeService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Verify feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/common.ts b/apps/server/src/routes/common.ts new file mode 100644 index 00000000..8a1fcc68 --- /dev/null +++ b/apps/server/src/routes/common.ts @@ -0,0 +1,24 @@ +/** + * Common utilities shared across all route modules + */ + +import { createLogger } from "../lib/logger.js"; + +type Logger = ReturnType; + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Create a logError function for a specific logger + * This ensures consistent error logging format across all routes + */ +export function createLogError(logger: Logger) { + return (error: unknown, context: string): void => { + logger.error(`❌ ${context}:`, error); + }; +} diff --git a/apps/server/src/routes/features.ts b/apps/server/src/routes/features.ts deleted file mode 100644 index 2878ef08..00000000 --- a/apps/server/src/routes/features.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * 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; - }; - - 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; - }; - - 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; -} diff --git a/apps/server/src/routes/features/common.ts b/apps/server/src/routes/features/common.ts new file mode 100644 index 00000000..172008d6 --- /dev/null +++ b/apps/server/src/routes/features/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for features routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Features"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts new file mode 100644 index 00000000..735d8812 --- /dev/null +++ b/apps/server/src/routes/features/index.ts @@ -0,0 +1,25 @@ +/** + * Features routes - HTTP API for feature management + */ + +import { Router } from "express"; +import { FeatureLoader } from "../../services/feature-loader.js"; +import { createListHandler } from "./routes/list.js"; +import { createGetHandler } from "./routes/get.js"; +import { createCreateHandler } from "./routes/create.js"; +import { createUpdateHandler } from "./routes/update.js"; +import { createDeleteHandler } from "./routes/delete.js"; +import { createAgentOutputHandler } from "./routes/agent-output.js"; + +export function createFeaturesRoutes(featureLoader: FeatureLoader): Router { + const router = Router(); + + router.post("/list", createListHandler(featureLoader)); + router.post("/get", createGetHandler(featureLoader)); + router.post("/create", createCreateHandler(featureLoader)); + router.post("/update", createUpdateHandler(featureLoader)); + router.post("/delete", createDeleteHandler(featureLoader)); + router.post("/agent-output", createAgentOutputHandler(featureLoader)); + + return router; +} diff --git a/apps/server/src/routes/features/routes/agent-output.ts b/apps/server/src/routes/features/routes/agent-output.ts new file mode 100644 index 00000000..62f8f50a --- /dev/null +++ b/apps/server/src/routes/features/routes/agent-output.ts @@ -0,0 +1,37 @@ +/** + * POST /agent-output endpoint - Get agent output for a feature + */ + +import type { Request, Response } from "express"; +import { FeatureLoader } from "../../../services/feature-loader.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createAgentOutputHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get agent output failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts new file mode 100644 index 00000000..fda12589 --- /dev/null +++ b/apps/server/src/routes/features/routes/create.ts @@ -0,0 +1,41 @@ +/** + * POST /create endpoint - Create a new feature + */ + +import type { Request, Response } from "express"; +import { + FeatureLoader, + type Feature, +} from "../../../services/feature-loader.js"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createCreateHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, feature } = req.body as { + projectPath: string; + feature: Partial; + }; + + 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) { + logError(error, "Create feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/delete.ts b/apps/server/src/routes/features/routes/delete.ts new file mode 100644 index 00000000..bf5408d5 --- /dev/null +++ b/apps/server/src/routes/features/routes/delete.ts @@ -0,0 +1,34 @@ +/** + * POST /delete endpoint - Delete a feature + */ + +import type { Request, Response } from "express"; +import { FeatureLoader } from "../../../services/feature-loader.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Delete feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/get.ts b/apps/server/src/routes/features/routes/get.ts new file mode 100644 index 00000000..17900bb0 --- /dev/null +++ b/apps/server/src/routes/features/routes/get.ts @@ -0,0 +1,39 @@ +/** + * POST /get endpoint - Get a single feature + */ + +import type { Request, Response } from "express"; +import { FeatureLoader } from "../../../services/feature-loader.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createGetHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts new file mode 100644 index 00000000..33dc68b6 --- /dev/null +++ b/apps/server/src/routes/features/routes/list.ts @@ -0,0 +1,32 @@ +/** + * POST /list endpoint - List all features for a project + */ + +import type { Request, Response } from "express"; +import { FeatureLoader } from "../../../services/feature-loader.js"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createListHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "List features failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts new file mode 100644 index 00000000..68be887b --- /dev/null +++ b/apps/server/src/routes/features/routes/update.ts @@ -0,0 +1,40 @@ +/** + * POST /update endpoint - Update a feature + */ + +import type { Request, Response } from "express"; +import { + FeatureLoader, + type Feature, +} from "../../../services/feature-loader.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createUpdateHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, updates } = req.body as { + projectPath: string; + featureId: string; + updates: Partial; + }; + + 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) { + logError(error, "Update feature failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts deleted file mode 100644 index 65268790..00000000 --- a/apps/server/src/routes/fs.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * 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 os from "os"; -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 = path.resolve(dirPath); - - await fs.mkdir(resolvedPath, { recursive: true }); - - // Add the new directory to allowed paths for tracking - addAllowedPath(resolvedPath); - - 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 }); - } - }); - - // Resolve directory path from directory name and file structure - // Used when browser file picker only provides directory name (not full path) - router.post("/resolve-directory", async (req: Request, res: Response) => { - try { - const { directoryName, sampleFiles, fileCount } = req.body as { - directoryName: string; - sampleFiles?: string[]; - fileCount?: number; - }; - - if (!directoryName) { - res - .status(400) - .json({ success: false, error: "directoryName is required" }); - return; - } - - // If directoryName looks like an absolute path, try validating it directly - if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) { - try { - const resolvedPath = path.resolve(directoryName); - const stats = await fs.stat(resolvedPath); - if (stats.isDirectory()) { - addAllowedPath(resolvedPath); - return res.json({ - success: true, - path: resolvedPath, - }); - } - } catch { - // Not a valid absolute path, continue to search - } - } - - // Search for directory in common locations - const searchPaths: string[] = [ - process.cwd(), // Current working directory - process.env.HOME || process.env.USERPROFILE || "", // User home - path.join( - process.env.HOME || process.env.USERPROFILE || "", - "Documents" - ), - path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"), - // Common project locations - path.join( - process.env.HOME || process.env.USERPROFILE || "", - "Projects" - ), - ].filter(Boolean); - - // Also check parent of current working directory - try { - const parentDir = path.dirname(process.cwd()); - if (!searchPaths.includes(parentDir)) { - searchPaths.push(parentDir); - } - } catch { - // Ignore - } - - // Search for directory matching the name and file structure - for (const searchPath of searchPaths) { - try { - const candidatePath = path.join(searchPath, directoryName); - const stats = await fs.stat(candidatePath); - - if (stats.isDirectory()) { - // Verify it matches by checking for sample files - if (sampleFiles && sampleFiles.length > 0) { - let matches = 0; - for (const sampleFile of sampleFiles.slice(0, 5)) { - // Remove directory name prefix from sample file path - const relativeFile = sampleFile.startsWith(directoryName + "/") - ? sampleFile.substring(directoryName.length + 1) - : sampleFile.split("/").slice(1).join("/") || - sampleFile.split("/").pop() || - sampleFile; - - try { - const filePath = path.join(candidatePath, relativeFile); - await fs.access(filePath); - matches++; - } catch { - // File doesn't exist, continue checking - } - } - - // If at least one file matches, consider it a match - if (matches === 0 && sampleFiles.length > 0) { - continue; // Try next candidate - } - } - - // Found matching directory - addAllowedPath(candidatePath); - return res.json({ - success: true, - path: candidatePath, - }); - } - } catch { - // Directory doesn't exist at this location, continue searching - continue; - } - } - - // Directory not found - res.status(404).json({ - success: false, - error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // Save image to .automaker/images directory - router.post("/save-image", async (req: Request, res: Response) => { - try { - const { data, filename, mimeType, projectPath } = req.body as { - data: string; - filename: string; - mimeType: string; - projectPath: string; - }; - - if (!data || !filename || !projectPath) { - res.status(400).json({ - success: false, - error: "data, filename, and projectPath are required", - }); - return; - } - - // Create .automaker/images directory if it doesn't exist - const imagesDir = path.join(projectPath, ".automaker", "images"); - await fs.mkdir(imagesDir, { recursive: true }); - - // Decode base64 data (remove data URL prefix if present) - const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Data, "base64"); - - // Generate unique filename with timestamp - const timestamp = Date.now(); - const ext = path.extname(filename) || ".png"; - const baseName = path.basename(filename, ext); - const uniqueFilename = `${baseName}-${timestamp}${ext}`; - const filePath = path.join(imagesDir, uniqueFilename); - - // Write file - await fs.writeFile(filePath, buffer); - - // Add project path to allowed paths if not already - addAllowedPath(projectPath); - - res.json({ success: true, path: filePath }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // Browse directories - for file browser UI - router.post("/browse", async (req: Request, res: Response) => { - try { - const { dirPath } = req.body as { dirPath?: string }; - - // Default to home directory if no path provided - const targetPath = dirPath ? path.resolve(dirPath) : os.homedir(); - - // Detect available drives on Windows - const detectDrives = async (): Promise => { - if (os.platform() !== "win32") { - return []; - } - - const drives: string[] = []; - const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - - for (const letter of letters) { - const drivePath = `${letter}:\\`; - try { - await fs.access(drivePath); - drives.push(drivePath); - } catch { - // Drive doesn't exist, skip it - } - } - - return drives; - }; - - // Get parent directory - const parentPath = path.dirname(targetPath); - const hasParent = parentPath !== targetPath; - - // Get available drives - const drives = await detectDrives(); - - try { - const stats = await fs.stat(targetPath); - - if (!stats.isDirectory()) { - res - .status(400) - .json({ success: false, error: "Path is not a directory" }); - return; - } - - // Read directory contents - const entries = await fs.readdir(targetPath, { withFileTypes: true }); - - // Filter for directories only and add parent directory option - const directories = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .map((entry) => ({ - name: entry.name, - path: path.join(targetPath, entry.name), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - res.json({ - success: true, - currentPath: targetPath, - parentPath: hasParent ? parentPath : null, - directories, - drives, - }); - } catch (error) { - // Handle permission errors gracefully - still return path info so user can navigate away - const errorMessage = - error instanceof Error ? error.message : "Failed to read directory"; - const isPermissionError = - errorMessage.includes("EPERM") || errorMessage.includes("EACCES"); - - if (isPermissionError) { - // Return success with empty directories so user can still navigate to parent - res.json({ - success: true, - currentPath: targetPath, - parentPath: hasParent ? parentPath : null, - directories: [], - drives, - warning: - "Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security", - }); - } else { - res.status(400).json({ - success: false, - error: errorMessage, - }); - } - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // Serve image files - router.get("/image", async (req: Request, res: Response) => { - try { - const { path: imagePath, projectPath } = req.query as { - path?: string; - projectPath?: string; - }; - - if (!imagePath) { - res.status(400).json({ success: false, error: "path is required" }); - return; - } - - // Resolve full path - const fullPath = path.isAbsolute(imagePath) - ? imagePath - : projectPath - ? path.join(projectPath, imagePath) - : imagePath; - - // Check if file exists - try { - await fs.access(fullPath); - } catch { - res.status(404).json({ success: false, error: "Image not found" }); - return; - } - - // Read the file - const buffer = await fs.readFile(fullPath); - - // Determine MIME type from extension - const ext = path.extname(fullPath).toLowerCase(); - const mimeTypes: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - }; - - res.setHeader( - "Content-Type", - mimeTypes[ext] || "application/octet-stream" - ); - res.setHeader("Cache-Control", "public, max-age=3600"); - res.send(buffer); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // Save board background image to .automaker/board directory - router.post("/save-board-background", async (req: Request, res: Response) => { - try { - const { data, filename, mimeType, projectPath } = req.body as { - data: string; - filename: string; - mimeType: string; - projectPath: string; - }; - - if (!data || !filename || !projectPath) { - res.status(400).json({ - success: false, - error: "data, filename, and projectPath are required", - }); - return; - } - - // Create .automaker/board directory if it doesn't exist - const boardDir = path.join(projectPath, ".automaker", "board"); - await fs.mkdir(boardDir, { recursive: true }); - - // Decode base64 data (remove data URL prefix if present) - const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); - const buffer = Buffer.from(base64Data, "base64"); - - // Use a fixed filename for the board background (overwrite previous) - const ext = path.extname(filename) || ".png"; - const uniqueFilename = `background${ext}`; - const filePath = path.join(boardDir, uniqueFilename); - - // Write file - await fs.writeFile(filePath, buffer); - - // Add project path to allowed paths if not already - addAllowedPath(projectPath); - - // Return the relative path for storage - const relativePath = `.automaker/board/${uniqueFilename}`; - res.json({ success: true, path: relativePath }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // Delete board background image - router.post( - "/delete-board-background", - 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; - } - - const boardDir = path.join(projectPath, ".automaker", "board"); - - try { - // Try to remove all files in the board directory - const files = await fs.readdir(boardDir); - for (const file of files) { - if (file.startsWith("background")) { - await fs.unlink(path.join(boardDir, file)); - } - } - } catch { - // Directory may not exist, that's fine - } - - 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; -} diff --git a/apps/server/src/routes/fs/common.ts b/apps/server/src/routes/fs/common.ts new file mode 100644 index 00000000..49649571 --- /dev/null +++ b/apps/server/src/routes/fs/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for fs routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("FS"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/fs/index.ts b/apps/server/src/routes/fs/index.ts new file mode 100644 index 00000000..6fc67dad --- /dev/null +++ b/apps/server/src/routes/fs/index.ts @@ -0,0 +1,42 @@ +/** + * File system routes + * Provides REST API equivalents for Electron IPC file operations + */ + +import { Router } from "express"; +import type { EventEmitter } from "../../lib/events.js"; +import { createReadHandler } from "./routes/read.js"; +import { createWriteHandler } from "./routes/write.js"; +import { createMkdirHandler } from "./routes/mkdir.js"; +import { createReaddirHandler } from "./routes/readdir.js"; +import { createExistsHandler } from "./routes/exists.js"; +import { createStatHandler } from "./routes/stat.js"; +import { createDeleteHandler } from "./routes/delete.js"; +import { createValidatePathHandler } from "./routes/validate-path.js"; +import { createResolveDirectoryHandler } from "./routes/resolve-directory.js"; +import { createSaveImageHandler } from "./routes/save-image.js"; +import { createBrowseHandler } from "./routes/browse.js"; +import { createImageHandler } from "./routes/image.js"; +import { createSaveBoardBackgroundHandler } from "./routes/save-board-background.js"; +import { createDeleteBoardBackgroundHandler } from "./routes/delete-board-background.js"; + +export function createFsRoutes(_events: EventEmitter): Router { + const router = Router(); + + router.post("/read", createReadHandler()); + router.post("/write", createWriteHandler()); + router.post("/mkdir", createMkdirHandler()); + router.post("/readdir", createReaddirHandler()); + router.post("/exists", createExistsHandler()); + router.post("/stat", createStatHandler()); + router.post("/delete", createDeleteHandler()); + router.post("/validate-path", createValidatePathHandler()); + router.post("/resolve-directory", createResolveDirectoryHandler()); + router.post("/save-image", createSaveImageHandler()); + router.post("/browse", createBrowseHandler()); + router.get("/image", createImageHandler()); + router.post("/save-board-background", createSaveBoardBackgroundHandler()); + router.post("/delete-board-background", createDeleteBoardBackgroundHandler()); + + return router; +} diff --git a/apps/server/src/routes/fs/routes/browse.ts b/apps/server/src/routes/fs/routes/browse.ts new file mode 100644 index 00000000..7579fb34 --- /dev/null +++ b/apps/server/src/routes/fs/routes/browse.ts @@ -0,0 +1,107 @@ +/** + * POST /browse endpoint - Browse directories for file browser UI + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +export function createBrowseHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { dirPath } = req.body as { dirPath?: string }; + + // Default to home directory if no path provided + const targetPath = dirPath ? path.resolve(dirPath) : os.homedir(); + + // Detect available drives on Windows + const detectDrives = async (): Promise => { + if (os.platform() !== "win32") { + return []; + } + + const drives: string[] = []; + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + for (const letter of letters) { + const drivePath = `${letter}:\\`; + try { + await fs.access(drivePath); + drives.push(drivePath); + } catch { + // Drive doesn't exist, skip it + } + } + + return drives; + }; + + // Get parent directory + const parentPath = path.dirname(targetPath); + const hasParent = parentPath !== targetPath; + + // Get available drives + const drives = await detectDrives(); + + try { + const stats = await fs.stat(targetPath); + + if (!stats.isDirectory()) { + res + .status(400) + .json({ success: false, error: "Path is not a directory" }); + return; + } + + // Read directory contents + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + + // Filter for directories only and add parent directory option + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => ({ + name: entry.name, + path: path.join(targetPath, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + success: true, + currentPath: targetPath, + parentPath: hasParent ? parentPath : null, + directories, + drives, + }); + } catch (error) { + // Handle permission errors gracefully - still return path info so user can navigate away + const errorMessage = + error instanceof Error ? error.message : "Failed to read directory"; + const isPermissionError = + errorMessage.includes("EPERM") || errorMessage.includes("EACCES"); + + if (isPermissionError) { + // Return success with empty directories so user can still navigate to parent + res.json({ + success: true, + currentPath: targetPath, + parentPath: hasParent ? parentPath : null, + directories: [], + drives, + warning: + "Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security", + }); + } else { + res.status(400).json({ + success: false, + error: errorMessage, + }); + } + } + } catch (error) { + logError(error, "Browse directories failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/delete-board-background.ts b/apps/server/src/routes/fs/routes/delete-board-background.ts new file mode 100644 index 00000000..a9dacc9d --- /dev/null +++ b/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -0,0 +1,43 @@ +/** + * POST /delete-board-background endpoint - Delete board background image + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +export function createDeleteBoardBackgroundHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: "projectPath is required", + }); + return; + } + + const boardDir = path.join(projectPath, ".automaker", "board"); + + try { + // Try to remove all files in the board directory + const files = await fs.readdir(boardDir); + for (const file of files) { + if (file.startsWith("background")) { + await fs.unlink(path.join(boardDir, file)); + } + } + } catch { + // Directory may not exist, that's fine + } + + res.json({ success: true }); + } catch (error) { + logError(error, "Delete board background failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/delete.ts b/apps/server/src/routes/fs/routes/delete.ts new file mode 100644 index 00000000..0f0604f1 --- /dev/null +++ b/apps/server/src/routes/fs/routes/delete.ts @@ -0,0 +1,29 @@ +/** + * POST /delete endpoint - Delete file + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import { validatePath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createDeleteHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Delete file failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/exists.ts b/apps/server/src/routes/fs/routes/exists.ts new file mode 100644 index 00000000..2ca33bee --- /dev/null +++ b/apps/server/src/routes/fs/routes/exists.ts @@ -0,0 +1,35 @@ +/** + * POST /exists endpoint - Check if file/directory exists + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +export function createExistsHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Check exists failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/image.ts b/apps/server/src/routes/fs/routes/image.ts new file mode 100644 index 00000000..eddf5aed --- /dev/null +++ b/apps/server/src/routes/fs/routes/image.ts @@ -0,0 +1,64 @@ +/** + * GET /image endpoint - Serve image files + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +export function createImageHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { path: imagePath, projectPath } = req.query as { + path?: string; + projectPath?: string; + }; + + if (!imagePath) { + res.status(400).json({ success: false, error: "path is required" }); + return; + } + + // Resolve full path + const fullPath = path.isAbsolute(imagePath) + ? imagePath + : projectPath + ? path.join(projectPath, imagePath) + : imagePath; + + // Check if file exists + try { + await fs.access(fullPath); + } catch { + res.status(404).json({ success: false, error: "Image not found" }); + return; + } + + // Read the file + const buffer = await fs.readFile(fullPath); + + // Determine MIME type from extension + const ext = path.extname(fullPath).toLowerCase(); + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + }; + + res.setHeader( + "Content-Type", + mimeTypes[ext] || "application/octet-stream" + ); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(buffer); + } catch (error) { + logError(error, "Serve image failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/mkdir.ts b/apps/server/src/routes/fs/routes/mkdir.ts new file mode 100644 index 00000000..51634544 --- /dev/null +++ b/apps/server/src/routes/fs/routes/mkdir.ts @@ -0,0 +1,34 @@ +/** + * POST /mkdir endpoint - Create directory + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createMkdirHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { dirPath } = req.body as { dirPath: string }; + + if (!dirPath) { + res.status(400).json({ success: false, error: "dirPath is required" }); + return; + } + + const resolvedPath = path.resolve(dirPath); + + await fs.mkdir(resolvedPath, { recursive: true }); + + // Add the new directory to allowed paths for tracking + addAllowedPath(resolvedPath); + + res.json({ success: true }); + } catch (error) { + logError(error, "Create directory failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/read.ts b/apps/server/src/routes/fs/routes/read.ts new file mode 100644 index 00000000..2c7f08eb --- /dev/null +++ b/apps/server/src/routes/fs/routes/read.ts @@ -0,0 +1,29 @@ +/** + * POST /read endpoint - Read file + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import { validatePath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createReadHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Read file failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/readdir.ts b/apps/server/src/routes/fs/routes/readdir.ts new file mode 100644 index 00000000..c30fa6b2 --- /dev/null +++ b/apps/server/src/routes/fs/routes/readdir.ts @@ -0,0 +1,35 @@ +/** + * POST /readdir endpoint - Read directory + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import { validatePath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createReaddirHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Read directory failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/resolve-directory.ts b/apps/server/src/routes/fs/routes/resolve-directory.ts new file mode 100644 index 00000000..9b165c42 --- /dev/null +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -0,0 +1,128 @@ +/** + * POST /resolve-directory endpoint - Resolve directory path from directory name + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createResolveDirectoryHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { directoryName, sampleFiles, fileCount } = req.body as { + directoryName: string; + sampleFiles?: string[]; + fileCount?: number; + }; + + if (!directoryName) { + res + .status(400) + .json({ success: false, error: "directoryName is required" }); + return; + } + + // If directoryName looks like an absolute path, try validating it directly + if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) { + try { + const resolvedPath = path.resolve(directoryName); + const stats = await fs.stat(resolvedPath); + if (stats.isDirectory()) { + addAllowedPath(resolvedPath); + res.json({ + success: true, + path: resolvedPath, + }); + return; + } + } catch { + // Not a valid absolute path, continue to search + } + } + + // Search for directory in common locations + const searchPaths: string[] = [ + process.cwd(), // Current working directory + process.env.HOME || process.env.USERPROFILE || "", // User home + path.join( + process.env.HOME || process.env.USERPROFILE || "", + "Documents" + ), + path.join(process.env.HOME || process.env.USERPROFILE || "", "Desktop"), + // Common project locations + path.join( + process.env.HOME || process.env.USERPROFILE || "", + "Projects" + ), + ].filter(Boolean); + + // Also check parent of current working directory + try { + const parentDir = path.dirname(process.cwd()); + if (!searchPaths.includes(parentDir)) { + searchPaths.push(parentDir); + } + } catch { + // Ignore + } + + // Search for directory matching the name and file structure + for (const searchPath of searchPaths) { + try { + const candidatePath = path.join(searchPath, directoryName); + const stats = await fs.stat(candidatePath); + + if (stats.isDirectory()) { + // Verify it matches by checking for sample files + if (sampleFiles && sampleFiles.length > 0) { + let matches = 0; + for (const sampleFile of sampleFiles.slice(0, 5)) { + // Remove directory name prefix from sample file path + const relativeFile = sampleFile.startsWith(directoryName + "/") + ? sampleFile.substring(directoryName.length + 1) + : sampleFile.split("/").slice(1).join("/") || + sampleFile.split("/").pop() || + sampleFile; + + try { + const filePath = path.join(candidatePath, relativeFile); + await fs.access(filePath); + matches++; + } catch { + // File doesn't exist, continue checking + } + } + + // If at least one file matches, consider it a match + if (matches === 0 && sampleFiles.length > 0) { + continue; // Try next candidate + } + } + + // Found matching directory + addAllowedPath(candidatePath); + res.json({ + success: true, + path: candidatePath, + }); + return; + } + } catch { + // Directory doesn't exist at this location, continue searching + continue; + } + } + + // Directory not found + res.status(404).json({ + success: false, + error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`, + }); + } catch (error) { + logError(error, "Resolve directory failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/save-board-background.ts b/apps/server/src/routes/fs/routes/save-board-background.ts new file mode 100644 index 00000000..3697e4a8 --- /dev/null +++ b/apps/server/src/routes/fs/routes/save-board-background.ts @@ -0,0 +1,56 @@ +/** + * POST /save-board-background endpoint - Save board background image + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createSaveBoardBackgroundHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { data, filename, mimeType, projectPath } = req.body as { + data: string; + filename: string; + mimeType: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: "data, filename, and projectPath are required", + }); + return; + } + + // Create .automaker/board directory if it doesn't exist + const boardDir = path.join(projectPath, ".automaker", "board"); + await fs.mkdir(boardDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Use a fixed filename for the board background (overwrite previous) + const ext = path.extname(filename) || ".png"; + const uniqueFilename = `background${ext}`; + const filePath = path.join(boardDir, uniqueFilename); + + // Write file + await fs.writeFile(filePath, buffer); + + // Add project path to allowed paths if not already + addAllowedPath(projectPath); + + // Return the relative path for storage + const relativePath = `.automaker/board/${uniqueFilename}`; + res.json({ success: true, path: relativePath }); + } catch (error) { + logError(error, "Save board background failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/save-image.ts b/apps/server/src/routes/fs/routes/save-image.ts new file mode 100644 index 00000000..c6987d05 --- /dev/null +++ b/apps/server/src/routes/fs/routes/save-image.ts @@ -0,0 +1,56 @@ +/** + * POST /save-image endpoint - Save image to .automaker/images directory + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createSaveImageHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { data, filename, mimeType, projectPath } = req.body as { + data: string; + filename: string; + mimeType: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: "data, filename, and projectPath are required", + }); + return; + } + + // Create .automaker/images directory if it doesn't exist + const imagesDir = path.join(projectPath, ".automaker", "images"); + await fs.mkdir(imagesDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Generate unique filename with timestamp + const timestamp = Date.now(); + const ext = path.extname(filename) || ".png"; + const baseName = path.basename(filename, ext); + const uniqueFilename = `${baseName}-${timestamp}${ext}`; + const filePath = path.join(imagesDir, uniqueFilename); + + // Write file + await fs.writeFile(filePath, buffer); + + // Add project path to allowed paths if not already + addAllowedPath(projectPath); + + res.json({ success: true, path: filePath }); + } catch (error) { + logError(error, "Save image failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/stat.ts b/apps/server/src/routes/fs/routes/stat.ts new file mode 100644 index 00000000..b92ed00c --- /dev/null +++ b/apps/server/src/routes/fs/routes/stat.ts @@ -0,0 +1,37 @@ +/** + * POST /stat endpoint - Get file stats + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import { validatePath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createStatHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get file stats failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/validate-path.ts b/apps/server/src/routes/fs/routes/validate-path.ts new file mode 100644 index 00000000..69bb3eaa --- /dev/null +++ b/apps/server/src/routes/fs/routes/validate-path.ts @@ -0,0 +1,50 @@ +/** + * POST /validate-path endpoint - Validate and add path to allowed list + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath, isPathAllowed } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createValidatePathHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Validate path failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/fs/routes/write.ts b/apps/server/src/routes/fs/routes/write.ts new file mode 100644 index 00000000..81336104 --- /dev/null +++ b/apps/server/src/routes/fs/routes/write.ts @@ -0,0 +1,36 @@ +/** + * POST /write endpoint - Write file + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { validatePath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createWriteHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Write file failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/git/common.ts b/apps/server/src/routes/git/common.ts new file mode 100644 index 00000000..1bde9f82 --- /dev/null +++ b/apps/server/src/routes/git/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for git routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Git"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/git/index.ts b/apps/server/src/routes/git/index.ts new file mode 100644 index 00000000..eb8ce590 --- /dev/null +++ b/apps/server/src/routes/git/index.ts @@ -0,0 +1,16 @@ +/** + * Git routes - HTTP API for git operations (non-worktree) + */ + +import { Router } from "express"; +import { createDiffsHandler } from "./routes/diffs.js"; +import { createFileDiffHandler } from "./routes/file-diff.js"; + +export function createGitRoutes(): Router { + const router = Router(); + + router.post("/diffs", createDiffsHandler()); + router.post("/file-diff", createFileDiffHandler()); + + return router; +} diff --git a/apps/server/src/routes/git.ts b/apps/server/src/routes/git/routes/diffs.ts similarity index 52% rename from apps/server/src/routes/git.ts rename to apps/server/src/routes/git/routes/diffs.ts index e6a65ba4..dd0e809f 100644 --- a/apps/server/src/routes/git.ts +++ b/apps/server/src/routes/git/routes/diffs.ts @@ -1,18 +1,16 @@ /** - * Git routes - HTTP API for git operations (non-worktree) + * POST /diffs endpoint - Get diffs for the main project */ -import { Router, type Request, type Response } from "express"; +import type { Request, Response } from "express"; import { exec } from "child_process"; import { promisify } from "util"; +import { getErrorMessage, logError } from "../common.js"; 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) => { +export function createDiffsHandler() { + return async (req: Request, res: Response): Promise => { try { const { projectPath } = req.body as { projectPath: string }; @@ -62,41 +60,8 @@ export function createGitRoutes(): Router { 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 }); + logError(error, "Get diffs failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); } - }); - - // 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; + }; } diff --git a/apps/server/src/routes/git/routes/file-diff.ts b/apps/server/src/routes/git/routes/file-diff.ts new file mode 100644 index 00000000..7f480a6f --- /dev/null +++ b/apps/server/src/routes/git/routes/file-diff.ts @@ -0,0 +1,45 @@ +/** + * POST /file-diff endpoint - Get diff for a specific file + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createFileDiffHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get file diff failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts deleted file mode 100644 index 78111004..00000000 --- a/apps/server/src/routes/health.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 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; -} diff --git a/apps/server/src/routes/health/common.ts b/apps/server/src/routes/health/common.ts new file mode 100644 index 00000000..c4104e3f --- /dev/null +++ b/apps/server/src/routes/health/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for health routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Health"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/health/index.ts b/apps/server/src/routes/health/index.ts new file mode 100644 index 00000000..6ec62532 --- /dev/null +++ b/apps/server/src/routes/health/index.ts @@ -0,0 +1,16 @@ +/** + * Health check routes + */ + +import { Router } from "express"; +import { createIndexHandler } from "./routes/index.js"; +import { createDetailedHandler } from "./routes/detailed.js"; + +export function createHealthRoutes(): Router { + const router = Router(); + + router.get("/", createIndexHandler()); + router.get("/detailed", createDetailedHandler()); + + return router; +} diff --git a/apps/server/src/routes/health/routes/detailed.ts b/apps/server/src/routes/health/routes/detailed.ts new file mode 100644 index 00000000..22deba78 --- /dev/null +++ b/apps/server/src/routes/health/routes/detailed.ts @@ -0,0 +1,25 @@ +/** + * GET /detailed endpoint - Detailed health check + */ + +import type { Request, Response } from "express"; +import { getAuthStatus } from "../../../lib/auth.js"; + +export function createDetailedHandler() { + return (_req: Request, res: Response): void => { + 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, + }, + }); + }; +} diff --git a/apps/server/src/routes/health/routes/index.ts b/apps/server/src/routes/health/routes/index.ts new file mode 100644 index 00000000..e571b78e --- /dev/null +++ b/apps/server/src/routes/health/routes/index.ts @@ -0,0 +1,15 @@ +/** + * GET / endpoint - Basic health check + */ + +import type { Request, Response } from "express"; + +export function createIndexHandler() { + return (_req: Request, res: Response): void => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "0.1.0", + }); + }; +} diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts deleted file mode 100644 index 0345b54a..00000000 --- a/apps/server/src/routes/models.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Models routes - HTTP API for model providers and availability - */ - -import { Router, type Request, type Response } from "express"; -import { ProviderFactory } from "../providers/provider-factory.js"; - -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, - }, - ]; - - 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 { - // Get installation status from all providers - const statuses = await ProviderFactory.checkAllProviders(); - - const providers: Record = { - anthropic: { - available: statuses.claude?.installed || false, - hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN, - }, - 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; -} diff --git a/apps/server/src/routes/models/common.ts b/apps/server/src/routes/models/common.ts new file mode 100644 index 00000000..06364bfc --- /dev/null +++ b/apps/server/src/routes/models/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for models routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Models"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/models/index.ts b/apps/server/src/routes/models/index.ts new file mode 100644 index 00000000..4ed1fda2 --- /dev/null +++ b/apps/server/src/routes/models/index.ts @@ -0,0 +1,16 @@ +/** + * Models routes - HTTP API for model providers and availability + */ + +import { Router } from "express"; +import { createAvailableHandler } from "./routes/available.js"; +import { createProvidersHandler } from "./routes/providers.js"; + +export function createModelsRoutes(): Router { + const router = Router(); + + router.get("/available", createAvailableHandler()); + router.get("/providers", createProvidersHandler()); + + return router; +} diff --git a/apps/server/src/routes/models/routes/available.ts b/apps/server/src/routes/models/routes/available.ts new file mode 100644 index 00000000..3e26b690 --- /dev/null +++ b/apps/server/src/routes/models/routes/available.ts @@ -0,0 +1,66 @@ +/** + * GET /available endpoint - Get available models + */ + +import type { Request, Response } from "express"; +import { getErrorMessage, logError } from "../common.js"; + +interface ModelDefinition { + id: string; + name: string; + provider: string; + contextWindow: number; + maxOutputTokens: number; + supportsVision: boolean; + supportsTools: boolean; +} + +export function createAvailableHandler() { + return async (_req: Request, res: Response): Promise => { + 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, + }, + ]; + + res.json({ success: true, models }); + } catch (error) { + logError(error, "Get available models failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/models/routes/providers.ts b/apps/server/src/routes/models/routes/providers.ts new file mode 100644 index 00000000..b5bfcd9a --- /dev/null +++ b/apps/server/src/routes/models/routes/providers.ts @@ -0,0 +1,34 @@ +/** + * GET /providers endpoint - Check provider status + */ + +import type { Request, Response } from "express"; +import { ProviderFactory } from "../../../providers/provider-factory.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createProvidersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Get installation status from all providers + const statuses = await ProviderFactory.checkAllProviders(); + + const providers: Record = { + anthropic: { + available: statuses.claude?.installed || false, + hasApiKey: + !!process.env.ANTHROPIC_API_KEY || + !!process.env.CLAUDE_CODE_OAUTH_TOKEN, + }, + google: { + available: !!process.env.GOOGLE_API_KEY, + hasApiKey: !!process.env.GOOGLE_API_KEY, + }, + }; + + res.json({ success: true, providers }); + } catch (error) { + logError(error, "Get providers failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/running-agents.ts b/apps/server/src/routes/running-agents.ts deleted file mode 100644 index 116a5b00..00000000 --- a/apps/server/src/routes/running-agents.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Running Agents routes - HTTP API for tracking active agent executions - */ - -import { Router, type Request, type Response } from "express"; -import type { AutoModeService } from "../services/auto-mode-service.js"; - -export function createRunningAgentsRoutes(autoModeService: AutoModeService): Router { - const router = Router(); - - // Get all running agents - router.get("/", async (_req: Request, res: Response) => { - try { - const runningAgents = autoModeService.getRunningAgents(); - const status = autoModeService.getStatus(); - - res.json({ - success: true, - runningAgents, - totalCount: runningAgents.length, - autoLoopRunning: status.autoLoopRunning, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - return router; -} diff --git a/apps/server/src/routes/running-agents/common.ts b/apps/server/src/routes/running-agents/common.ts new file mode 100644 index 00000000..2518453a --- /dev/null +++ b/apps/server/src/routes/running-agents/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for running-agents routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("RunningAgents"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/running-agents/index.ts b/apps/server/src/routes/running-agents/index.ts new file mode 100644 index 00000000..cef82fea --- /dev/null +++ b/apps/server/src/routes/running-agents/index.ts @@ -0,0 +1,17 @@ +/** + * Running Agents routes - HTTP API for tracking active agent executions + */ + +import { Router } from "express"; +import type { AutoModeService } from "../../services/auto-mode-service.js"; +import { createIndexHandler } from "./routes/index.js"; + +export function createRunningAgentsRoutes( + autoModeService: AutoModeService +): Router { + const router = Router(); + + router.get("/", createIndexHandler(autoModeService)); + + return router; +} diff --git a/apps/server/src/routes/running-agents/routes/index.ts b/apps/server/src/routes/running-agents/routes/index.ts new file mode 100644 index 00000000..8d1f8760 --- /dev/null +++ b/apps/server/src/routes/running-agents/routes/index.ts @@ -0,0 +1,26 @@ +/** + * GET / endpoint - Get all running agents + */ + +import type { Request, Response } from "express"; +import type { AutoModeService } from "../../../services/auto-mode-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createIndexHandler(autoModeService: AutoModeService) { + return async (_req: Request, res: Response): Promise => { + try { + const runningAgents = autoModeService.getRunningAgents(); + const status = autoModeService.getStatus(); + + res.json({ + success: true, + runningAgents, + totalCount: runningAgents.length, + autoLoopRunning: status.autoLoopRunning, + }); + } catch (error) { + logError(error, "Get running agents failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts deleted file mode 100644 index 587c88c1..00000000 --- a/apps/server/src/routes/sessions.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * 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 sessionsRaw = await agentService.listSessions(includeArchived); - - // Transform to match frontend SessionListItem interface - const sessions = await Promise.all( - sessionsRaw.map(async (s) => { - const messages = await agentService.loadSession(s.id); - const lastMessage = messages[messages.length - 1]; - const preview = lastMessage?.content?.slice(0, 100) || ""; - - return { - id: s.id, - name: s.name, - projectPath: s.projectPath || s.workingDirectory, - workingDirectory: s.workingDirectory, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - isArchived: s.archived || false, - tags: s.tags || [], - messageCount: messages.length, - preview, - }; - }) - ); - - 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, model } = req.body as { - name: string; - projectPath?: string; - workingDirectory?: string; - model?: string; - }; - - if (!name) { - res.status(400).json({ success: false, error: "name is required" }); - return; - } - - const session = await agentService.createSession( - name, - projectPath, - workingDirectory, - model - ); - 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, model } = req.body as { - name?: string; - tags?: string[]; - model?: string; - }; - - const session = await agentService.updateSession(sessionId, { name, tags, model }); - 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; -} diff --git a/apps/server/src/routes/sessions/common.ts b/apps/server/src/routes/sessions/common.ts new file mode 100644 index 00000000..6e2a3171 --- /dev/null +++ b/apps/server/src/routes/sessions/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for sessions routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Sessions"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/sessions/index.ts b/apps/server/src/routes/sessions/index.ts new file mode 100644 index 00000000..1cae202d --- /dev/null +++ b/apps/server/src/routes/sessions/index.ts @@ -0,0 +1,25 @@ +/** + * Sessions routes - HTTP API for session management + */ + +import { Router } from "express"; +import { AgentService } from "../../services/agent-service.js"; +import { createIndexHandler } from "./routes/index.js"; +import { createCreateHandler } from "./routes/create.js"; +import { createUpdateHandler } from "./routes/update.js"; +import { createArchiveHandler } from "./routes/archive.js"; +import { createUnarchiveHandler } from "./routes/unarchive.js"; +import { createDeleteHandler } from "./routes/delete.js"; + +export function createSessionsRoutes(agentService: AgentService): Router { + const router = Router(); + + router.get("/", createIndexHandler(agentService)); + router.post("/", createCreateHandler(agentService)); + router.put("/:sessionId", createUpdateHandler(agentService)); + router.post("/:sessionId/archive", createArchiveHandler(agentService)); + router.post("/:sessionId/unarchive", createUnarchiveHandler(agentService)); + router.delete("/:sessionId", createDeleteHandler(agentService)); + + return router; +} diff --git a/apps/server/src/routes/sessions/routes/archive.ts b/apps/server/src/routes/sessions/routes/archive.ts new file mode 100644 index 00000000..dd9b6aa0 --- /dev/null +++ b/apps/server/src/routes/sessions/routes/archive.ts @@ -0,0 +1,26 @@ +/** + * POST /:sessionId/archive endpoint - Archive a session + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createArchiveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Archive session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions/routes/create.ts b/apps/server/src/routes/sessions/routes/create.ts new file mode 100644 index 00000000..7faf9e36 --- /dev/null +++ b/apps/server/src/routes/sessions/routes/create.ts @@ -0,0 +1,36 @@ +/** + * POST / endpoint - Create a new session + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createCreateHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { name, projectPath, workingDirectory, model } = req.body as { + name: string; + projectPath?: string; + workingDirectory?: string; + model?: string; + }; + + if (!name) { + res.status(400).json({ success: false, error: "name is required" }); + return; + } + + const session = await agentService.createSession( + name, + projectPath, + workingDirectory, + model + ); + res.json({ success: true, session }); + } catch (error) { + logError(error, "Create session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions/routes/delete.ts b/apps/server/src/routes/sessions/routes/delete.ts new file mode 100644 index 00000000..2d4c9f4c --- /dev/null +++ b/apps/server/src/routes/sessions/routes/delete.ts @@ -0,0 +1,26 @@ +/** + * DELETE /:sessionId endpoint - Delete a session + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createDeleteHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Delete session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions/routes/index.ts b/apps/server/src/routes/sessions/routes/index.ts new file mode 100644 index 00000000..64b891db --- /dev/null +++ b/apps/server/src/routes/sessions/routes/index.ts @@ -0,0 +1,43 @@ +/** + * GET / endpoint - List all sessions + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createIndexHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const includeArchived = req.query.includeArchived === "true"; + const sessionsRaw = await agentService.listSessions(includeArchived); + + // Transform to match frontend SessionListItem interface + const sessions = await Promise.all( + sessionsRaw.map(async (s) => { + const messages = await agentService.loadSession(s.id); + const lastMessage = messages[messages.length - 1]; + const preview = lastMessage?.content?.slice(0, 100) || ""; + + return { + id: s.id, + name: s.name, + projectPath: s.projectPath || s.workingDirectory, + workingDirectory: s.workingDirectory, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + isArchived: s.archived || false, + tags: s.tags || [], + messageCount: messages.length, + preview, + }; + }) + ); + + res.json({ success: true, sessions }); + } catch (error) { + logError(error, "List sessions failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions/routes/unarchive.ts b/apps/server/src/routes/sessions/routes/unarchive.ts new file mode 100644 index 00000000..07e4be17 --- /dev/null +++ b/apps/server/src/routes/sessions/routes/unarchive.ts @@ -0,0 +1,26 @@ +/** + * POST /:sessionId/unarchive endpoint - Unarchive a session + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createUnarchiveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Unarchive session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/sessions/routes/update.ts b/apps/server/src/routes/sessions/routes/update.ts new file mode 100644 index 00000000..2dbea431 --- /dev/null +++ b/apps/server/src/routes/sessions/routes/update.ts @@ -0,0 +1,35 @@ +/** + * PUT /:sessionId endpoint - Update a session + */ + +import type { Request, Response } from "express"; +import { AgentService } from "../../../services/agent-service.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createUpdateHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + const { name, tags, model } = req.body as { + name?: string; + tags?: string[]; + model?: string; + }; + + const session = await agentService.updateSession(sessionId, { + name, + tags, + model, + }); + if (!session) { + res.status(404).json({ success: false, error: "Session not found" }); + return; + } + + res.json({ success: true, session }); + } catch (error) { + logError(error, "Update session failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts deleted file mode 100644 index 6c16f6fd..00000000 --- a/apps/server/src/routes/setup.ts +++ /dev/null @@ -1,336 +0,0 @@ -/** - * 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 cache) -const apiKeys: Record = {}; - -// Helper to persist API keys to .env file -async function persistApiKeyToEnv(key: string, value: string): Promise { - const envPath = path.join(process.cwd(), ".env"); - - try { - let envContent = ""; - try { - envContent = await fs.readFile(envPath, "utf-8"); - } catch { - // .env file doesn't exist, we'll create it - } - - // Parse existing env content - const lines = envContent.split("\n"); - const keyRegex = new RegExp(`^${key}=`); - let found = false; - const newLines = lines.map((line) => { - if (keyRegex.test(line)) { - found = true; - return `${key}=${value}`; - } - return line; - }); - - if (!found) { - // Add the key at the end - newLines.push(`${key}=${value}`); - } - - await fs.writeFile(envPath, newLines.join("\n")); - console.log(`[Setup] Persisted ${key} to .env file`); - } catch (error) { - console.error(`[Setup] Failed to persist ${key} to .env:`, error); - throw error; - } -} - -export function createSetupRoutes(): Router { - const router = Router(); - - // 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"), - path.join(os.homedir(), ".claude", "local", "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"; - - // Get version from this path - try { - const { stdout: versionOut } = await execAsync(`"${p}" --version`); - version = versionOut.trim(); - } catch { - // Version command might not be available - } - break; - } catch { - // Not found at this path - } - } - } - - // Check authentication - detect all possible auth methods - // Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth - // apiKeys.anthropic stores direct API keys for pay-per-use - let auth = { - authenticated: false, - method: "none" as string, - hasCredentialsFile: false, - hasToken: false, - hasStoredOAuthToken: !!apiKeys.anthropic_oauth_token, - 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, - hasCliAuth: false, - hasRecentActivity: false, - }; - - const claudeDir = path.join(os.homedir(), ".claude"); - - // Check for recent Claude CLI activity - indicates working authentication - // The stats-cache.json file is only populated when the CLI is working properly - const statsCachePath = path.join(claudeDir, "stats-cache.json"); - try { - const statsContent = await fs.readFile(statsCachePath, "utf-8"); - const stats = JSON.parse(statsContent); - - // Check if there's any activity (which means the CLI is authenticated and working) - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - auth.hasRecentActivity = true; - auth.hasCliAuth = true; - auth.authenticated = true; - auth.method = "cli_authenticated"; - } - } catch { - // Stats file doesn't exist or is invalid - } - - // Check for settings.json - indicates CLI has been set up - const settingsPath = path.join(claudeDir, "settings.json"); - try { - await fs.access(settingsPath); - // If settings exist but no activity, CLI might be set up but not authenticated - if (!auth.hasCliAuth) { - // Try to check for other indicators of auth - const sessionsDir = path.join(claudeDir, "projects"); - try { - const sessions = await fs.readdir(sessionsDir); - if (sessions.length > 0) { - auth.hasCliAuth = true; - auth.authenticated = true; - auth.method = "cli_authenticated"; - } - } catch { - // Sessions directory doesn't exist - } - } - } catch { - // Settings file doesn't exist - } - - // Check for credentials file (OAuth tokens from claude login) - legacy/alternative auth - const credentialsPath = path.join(claudeDir, "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 OAuth token (from setup wizard - subscription auth) - if (!auth.authenticated && apiKeys.anthropic_oauth_token) { - auth.authenticated = true; - auth.oauthTokenValid = true; - auth.method = "oauth_token"; // Stored OAuth token from setup wizard - } - - // In-memory stored API key (from settings UI - pay-per-use) - if (!auth.authenticated && apiKeys.anthropic) { - auth.authenticated = true; - auth.apiKeyValid = 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 }); - } - }); - - // 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 }); - } - }); - - // 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 }); - } - }); - - // 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 and persist to .env - // IMPORTANT: OAuth tokens and API keys must be stored separately - // - OAuth tokens (subscription auth) -> CLAUDE_CODE_OAUTH_TOKEN - // - API keys (pay-per-use) -> ANTHROPIC_API_KEY - if (provider === "anthropic_oauth_token") { - // OAuth token from claude setup-token (subscription-based auth) - process.env.CLAUDE_CODE_OAUTH_TOKEN = apiKey; - await persistApiKeyToEnv("CLAUDE_CODE_OAUTH_TOKEN", apiKey); - console.log("[Setup] Stored OAuth token as CLAUDE_CODE_OAUTH_TOKEN"); - } else if (provider === "anthropic") { - // Direct API key (pay-per-use) - process.env.ANTHROPIC_API_KEY = apiKey; - await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey); - console.log("[Setup] Stored API key as ANTHROPIC_API_KEY"); - } else if (provider === "google") { - process.env.GOOGLE_API_KEY = apiKey; - await persistApiKeyToEnv("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, - 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 }); - } - }); - - // 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 }); - } - }); - - return router; -} diff --git a/apps/server/src/routes/setup/common.ts b/apps/server/src/routes/setup/common.ts new file mode 100644 index 00000000..5ea3a584 --- /dev/null +++ b/apps/server/src/routes/setup/common.ts @@ -0,0 +1,83 @@ +/** + * Common utilities and state for setup routes + */ + +import { createLogger } from "../../lib/logger.js"; +import path from "path"; +import fs from "fs/promises"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Setup"); + +// Storage for API keys (in-memory cache) - private +const apiKeys: Record = {}; + +/** + * Get an API key for a provider + */ +export function getApiKey(provider: string): string | undefined { + return apiKeys[provider]; +} + +/** + * Set an API key for a provider + */ +export function setApiKey(provider: string, key: string): void { + apiKeys[provider] = key; +} + +/** + * Get all API keys (for read-only access) + */ +export function getAllApiKeys(): Record { + return { ...apiKeys }; +} + +/** + * Helper to persist API keys to .env file + */ +export async function persistApiKeyToEnv( + key: string, + value: string +): Promise { + const envPath = path.join(process.cwd(), ".env"); + + try { + let envContent = ""; + try { + envContent = await fs.readFile(envPath, "utf-8"); + } catch { + // .env file doesn't exist, we'll create it + } + + // Parse existing env content + const lines = envContent.split("\n"); + const keyRegex = new RegExp(`^${key}=`); + let found = false; + const newLines = lines.map((line) => { + if (keyRegex.test(line)) { + found = true; + return `${key}=${value}`; + } + return line; + }); + + if (!found) { + // Add the key at the end + newLines.push(`${key}=${value}`); + } + + await fs.writeFile(envPath, newLines.join("\n")); + logger.info(`[Setup] Persisted ${key} to .env file`); + } catch (error) { + logger.error(`[Setup] Failed to persist ${key} to .env:`, error); + throw error; + } +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/setup/get-claude-status.ts b/apps/server/src/routes/setup/get-claude-status.ts new file mode 100644 index 00000000..f999625a --- /dev/null +++ b/apps/server/src/routes/setup/get-claude-status.ts @@ -0,0 +1,183 @@ +/** + * Business logic for getting Claude CLI status + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import os from "os"; +import path from "path"; +import fs from "fs/promises"; +import { getApiKey } from "./common.js"; + +const execAsync = promisify(exec); + +export async function getClaudeStatus() { + 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"), + path.join(os.homedir(), ".claude", "local", "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"; + + // Get version from this path + try { + const { stdout: versionOut } = await execAsync(`"${p}" --version`); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + break; + } catch { + // Not found at this path + } + } + } + + // Check authentication - detect all possible auth methods + // Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth + // apiKeys.anthropic stores direct API keys for pay-per-use + let auth = { + authenticated: false, + method: "none" as string, + hasCredentialsFile: false, + hasToken: false, + hasStoredOAuthToken: !!getApiKey("anthropic_oauth_token"), + hasStoredApiKey: !!getApiKey("anthropic"), + hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY, + hasEnvOAuthToken: !!process.env.CLAUDE_CODE_OAUTH_TOKEN, + // Additional fields for detailed status + oauthTokenValid: false, + apiKeyValid: false, + hasCliAuth: false, + hasRecentActivity: false, + }; + + const claudeDir = path.join(os.homedir(), ".claude"); + + // Check for recent Claude CLI activity - indicates working authentication + // The stats-cache.json file is only populated when the CLI is working properly + const statsCachePath = path.join(claudeDir, "stats-cache.json"); + try { + const statsContent = await fs.readFile(statsCachePath, "utf-8"); + const stats = JSON.parse(statsContent); + + // Check if there's any activity (which means the CLI is authenticated and working) + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + auth.hasRecentActivity = true; + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = "cli_authenticated"; + } + } catch { + // Stats file doesn't exist or is invalid + } + + // Check for settings.json - indicates CLI has been set up + const settingsPath = path.join(claudeDir, "settings.json"); + try { + await fs.access(settingsPath); + // If settings exist but no activity, CLI might be set up but not authenticated + if (!auth.hasCliAuth) { + // Try to check for other indicators of auth + const sessionsDir = path.join(claudeDir, "projects"); + try { + const sessions = await fs.readdir(sessionsDir); + if (sessions.length > 0) { + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = "cli_authenticated"; + } + } catch { + // Sessions directory doesn't exist + } + } + } catch { + // Settings file doesn't exist + } + + // Check for credentials file (OAuth tokens from claude login) - legacy/alternative auth + const credentialsPath = path.join(claudeDir, "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 OAuth token (from setup wizard - subscription auth) + if (!auth.authenticated && getApiKey("anthropic_oauth_token")) { + auth.authenticated = true; + auth.oauthTokenValid = true; + auth.method = "oauth_token"; // Stored OAuth token from setup wizard + } + + // In-memory stored API key (from settings UI - pay-per-use) + if (!auth.authenticated && getApiKey("anthropic")) { + auth.authenticated = true; + auth.apiKeyValid = true; + auth.method = "api_key"; // Manually stored API key + } + + return { + status: installed ? "installed" : "not_installed", + installed, + method, + version, + path: cliPath, + auth, + }; +} diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts new file mode 100644 index 00000000..f1caf6ac --- /dev/null +++ b/apps/server/src/routes/setup/index.ts @@ -0,0 +1,24 @@ +/** + * Setup routes - HTTP API for CLI detection, API keys, and platform info + */ + +import { Router } from "express"; +import { createClaudeStatusHandler } from "./routes/claude-status.js"; +import { createInstallClaudeHandler } from "./routes/install-claude.js"; +import { createAuthClaudeHandler } from "./routes/auth-claude.js"; +import { createStoreApiKeyHandler } from "./routes/store-api-key.js"; +import { createApiKeysHandler } from "./routes/api-keys.js"; +import { createPlatformHandler } from "./routes/platform.js"; + +export function createSetupRoutes(): Router { + const router = Router(); + + router.get("/claude-status", createClaudeStatusHandler()); + router.post("/install-claude", createInstallClaudeHandler()); + router.post("/auth-claude", createAuthClaudeHandler()); + router.post("/store-api-key", createStoreApiKeyHandler()); + router.get("/api-keys", createApiKeysHandler()); + router.get("/platform", createPlatformHandler()); + + return router; +} diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts new file mode 100644 index 00000000..e292503a --- /dev/null +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -0,0 +1,22 @@ +/** + * GET /api-keys endpoint - Get API keys status + */ + +import type { Request, Response } from "express"; +import { getApiKey, getErrorMessage, logError } from "../common.js"; + +export function createApiKeysHandler() { + return async (_req: Request, res: Response): Promise => { + try { + res.json({ + success: true, + hasAnthropicKey: + !!getApiKey("anthropic") || !!process.env.ANTHROPIC_API_KEY, + hasGoogleKey: !!getApiKey("google") || !!process.env.GOOGLE_API_KEY, + }); + } catch (error) { + logError(error, "Get API keys failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/auth-claude.ts b/apps/server/src/routes/setup/routes/auth-claude.ts new file mode 100644 index 00000000..2ab8401d --- /dev/null +++ b/apps/server/src/routes/setup/routes/auth-claude.ts @@ -0,0 +1,22 @@ +/** + * POST /auth-claude endpoint - Auth Claude + */ + +import type { Request, Response } from "express"; +import { getErrorMessage, logError } from "../common.js"; + +export function createAuthClaudeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + res.json({ + success: true, + requiresManualAuth: true, + command: "claude login", + message: "Please run 'claude login' in your terminal to authenticate", + }); + } catch (error) { + logError(error, "Auth Claude failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/claude-status.ts b/apps/server/src/routes/setup/routes/claude-status.ts new file mode 100644 index 00000000..232a47bd --- /dev/null +++ b/apps/server/src/routes/setup/routes/claude-status.ts @@ -0,0 +1,22 @@ +/** + * GET /claude-status endpoint - Get Claude CLI status + */ + +import type { Request, Response } from "express"; +import { getClaudeStatus } from "../get-claude-status.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createClaudeStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = await getClaudeStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, "Get Claude status failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/install-claude.ts b/apps/server/src/routes/setup/routes/install-claude.ts new file mode 100644 index 00000000..c471fc6c --- /dev/null +++ b/apps/server/src/routes/setup/routes/install-claude.ts @@ -0,0 +1,23 @@ +/** + * POST /install-claude endpoint - Install Claude CLI + */ + +import type { Request, Response } from "express"; +import { getErrorMessage, logError } from "../common.js"; + +export function createInstallClaudeHandler() { + return async (_req: Request, res: Response): Promise => { + 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) { + logError(error, "Install Claude CLI failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/platform.ts b/apps/server/src/routes/setup/routes/platform.ts new file mode 100644 index 00000000..40788d0b --- /dev/null +++ b/apps/server/src/routes/setup/routes/platform.ts @@ -0,0 +1,27 @@ +/** + * GET /platform endpoint - Get platform info + */ + +import type { Request, Response } from "express"; +import os from "os"; +import { getErrorMessage, logError } from "../common.js"; + +export function createPlatformHandler() { + return async (_req: Request, res: Response): Promise => { + 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) { + logError(error, "Get platform info failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/store-api-key.ts b/apps/server/src/routes/setup/routes/store-api-key.ts new file mode 100644 index 00000000..1f34f22d --- /dev/null +++ b/apps/server/src/routes/setup/routes/store-api-key.ts @@ -0,0 +1,58 @@ +/** + * POST /store-api-key endpoint - Store API key + */ + +import type { Request, Response } from "express"; +import { + setApiKey, + persistApiKeyToEnv, + getErrorMessage, + logError, +} from "../common.js"; +import { createLogger } from "../../../lib/logger.js"; + +const logger = createLogger("Setup"); + +export function createStoreApiKeyHandler() { + return async (req: Request, res: Response): Promise => { + 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; + } + + setApiKey(provider, apiKey); + + // Also set as environment variable and persist to .env + // IMPORTANT: OAuth tokens and API keys must be stored separately + // - OAuth tokens (subscription auth) -> CLAUDE_CODE_OAUTH_TOKEN + // - API keys (pay-per-use) -> ANTHROPIC_API_KEY + if (provider === "anthropic_oauth_token") { + // OAuth token from claude setup-token (subscription-based auth) + process.env.CLAUDE_CODE_OAUTH_TOKEN = apiKey; + await persistApiKeyToEnv("CLAUDE_CODE_OAUTH_TOKEN", apiKey); + logger.info("[Setup] Stored OAuth token as CLAUDE_CODE_OAUTH_TOKEN"); + } else if (provider === "anthropic") { + // Direct API key (pay-per-use) + process.env.ANTHROPIC_API_KEY = apiKey; + await persistApiKeyToEnv("ANTHROPIC_API_KEY", apiKey); + logger.info("[Setup] Stored API key as ANTHROPIC_API_KEY"); + } else if (provider === "google") { + process.env.GOOGLE_API_KEY = apiKey; + await persistApiKeyToEnv("GOOGLE_API_KEY", apiKey); + } + + res.json({ success: true }); + } catch (error) { + logError(error, "Store API key failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/suggestions.ts b/apps/server/src/routes/suggestions.ts deleted file mode 100644 index 578d1328..00000000 --- a/apps/server/src/routes/suggestions.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 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 = { - 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, 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", - }, - ], - }); - } -} diff --git a/apps/server/src/routes/suggestions/common.ts b/apps/server/src/routes/suggestions/common.ts new file mode 100644 index 00000000..b291c5ae --- /dev/null +++ b/apps/server/src/routes/suggestions/common.ts @@ -0,0 +1,40 @@ +/** + * Common utilities and state for suggestions routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Suggestions"); + +// Shared state for tracking generation status - private +let isRunning = false; +let currentAbortController: AbortController | null = null; + +/** + * Get the current running state + */ +export function getSuggestionsStatus(): { + isRunning: boolean; + currentAbortController: AbortController | null; +} { + return { isRunning, currentAbortController }; +} + +/** + * Set the running state and abort controller + */ +export function setRunningState( + running: boolean, + controller: AbortController | null = null +): void { + isRunning = running; + currentAbortController = controller; +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts new file mode 100644 index 00000000..14c02abe --- /dev/null +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -0,0 +1,127 @@ +/** + * Business logic for generating suggestions + */ + +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import type { EventEmitter } from "../../lib/events.js"; +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Suggestions"); + +export async function generateSuggestions( + projectPath: string, + suggestionType: string, + events: EventEmitter, + abortController: AbortController +): Promise { + const typePrompts: Record = { + 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, i: number) => ({ + ...s, + id: s.id || `suggestion-${Date.now()}-${i}`, + }) + ), + }); + } else { + throw new Error("No valid JSON found in response"); + } + } catch (error) { + // Log the parsing error for debugging + logger.error("Failed to parse suggestions JSON from AI response:", 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", + }, + ], + }); + } +} diff --git a/apps/server/src/routes/suggestions/index.ts b/apps/server/src/routes/suggestions/index.ts new file mode 100644 index 00000000..176ac5c2 --- /dev/null +++ b/apps/server/src/routes/suggestions/index.ts @@ -0,0 +1,19 @@ +/** + * Suggestions routes - HTTP API for AI-powered feature suggestions + */ + +import { Router } from "express"; +import type { EventEmitter } from "../../lib/events.js"; +import { createGenerateHandler } from "./routes/generate.js"; +import { createStopHandler } from "./routes/stop.js"; +import { createStatusHandler } from "./routes/status.js"; + +export function createSuggestionsRoutes(events: EventEmitter): Router { + const router = Router(); + + router.post("/generate", createGenerateHandler(events)); + router.post("/stop", createStopHandler()); + router.get("/status", createStatusHandler()); + + return router; +} diff --git a/apps/server/src/routes/suggestions/routes/generate.ts b/apps/server/src/routes/suggestions/routes/generate.ts new file mode 100644 index 00000000..beafd10f --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/generate.ts @@ -0,0 +1,63 @@ +/** + * POST /generate endpoint - Generate suggestions + */ + +import type { Request, Response } from "express"; +import type { EventEmitter } from "../../../lib/events.js"; +import { createLogger } from "../../../lib/logger.js"; +import { + getSuggestionsStatus, + setRunningState, + getErrorMessage, + logError, +} from "../common.js"; +import { generateSuggestions } from "../generate-suggestions.js"; + +const logger = createLogger("Suggestions"); + +export function createGenerateHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, suggestionType = "features" } = req.body as { + projectPath: string; + suggestionType?: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: "projectPath required" }); + return; + } + + const { isRunning } = getSuggestionsStatus(); + if (isRunning) { + res.json({ + success: false, + error: "Suggestions generation is already running", + }); + return; + } + + setRunningState(true); + const abortController = new AbortController(); + setRunningState(true, abortController); + + // Start generation in background + generateSuggestions(projectPath, suggestionType, events, abortController) + .catch((error) => { + logError(error, "Generate suggestions failed (background)"); + events.emit("suggestions:event", { + type: "suggestions_error", + error: getErrorMessage(error), + }); + }) + .finally(() => { + setRunningState(false, null); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, "Generate suggestions failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/suggestions/routes/status.ts b/apps/server/src/routes/suggestions/routes/status.ts new file mode 100644 index 00000000..d62dfa17 --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/status.ts @@ -0,0 +1,18 @@ +/** + * GET /status endpoint - Get status + */ + +import type { Request, Response } from "express"; +import { getSuggestionsStatus, getErrorMessage, logError } from "../common.js"; + +export function createStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const { isRunning } = getSuggestionsStatus(); + res.json({ success: true, isRunning }); + } catch (error) { + logError(error, "Get status failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/suggestions/routes/stop.ts b/apps/server/src/routes/suggestions/routes/stop.ts new file mode 100644 index 00000000..3a18a0be --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/stop.ts @@ -0,0 +1,27 @@ +/** + * POST /stop endpoint - Stop suggestions generation + */ + +import type { Request, Response } from "express"; +import { + getSuggestionsStatus, + setRunningState, + getErrorMessage, + logError, +} from "../common.js"; + +export function createStopHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const { currentAbortController } = getSuggestionsStatus(); + if (currentAbortController) { + currentAbortController.abort(); + } + setRunningState(false, null); + res.json({ success: true }); + } catch (error) { + logError(error, "Stop suggestions failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/templates/common.ts b/apps/server/src/routes/templates/common.ts new file mode 100644 index 00000000..b4c06132 --- /dev/null +++ b/apps/server/src/routes/templates/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for templates routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +export const logger = createLogger("Templates"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/templates/index.ts b/apps/server/src/routes/templates/index.ts new file mode 100644 index 00000000..4e7462fe --- /dev/null +++ b/apps/server/src/routes/templates/index.ts @@ -0,0 +1,15 @@ +/** + * Templates routes + * Provides API for cloning GitHub starter templates + */ + +import { Router } from "express"; +import { createCloneHandler } from "./routes/clone.js"; + +export function createTemplatesRoutes(): Router { + const router = Router(); + + router.post("/clone", createCloneHandler()); + + return router; +} diff --git a/apps/server/src/routes/templates.ts b/apps/server/src/routes/templates/routes/clone.ts similarity index 71% rename from apps/server/src/routes/templates.ts rename to apps/server/src/routes/templates/routes/clone.ts index b3b62622..11e9bf45 100644 --- a/apps/server/src/routes/templates.ts +++ b/apps/server/src/routes/templates/routes/clone.ts @@ -1,23 +1,16 @@ /** - * Templates routes - * Provides API for cloning GitHub starter templates + * POST /clone endpoint - Clone a GitHub template to a new project directory */ -import { Router, type Request, type Response } from "express"; +import type { Request, Response } from "express"; import { spawn } from "child_process"; import path from "path"; import fs from "fs/promises"; -import { addAllowedPath } from "../lib/security.js"; +import { addAllowedPath } from "../../../lib/security.js"; +import { logger, getErrorMessage, logError } from "../common.js"; -export function createTemplatesRoutes(): Router { - const router = Router(); - - /** - * Clone a GitHub template to a new project directory - * POST /api/templates/clone - * Body: { repoUrl: string, projectName: string, parentDir: string } - */ - router.post("/clone", async (req: Request, res: Response) => { +export function createCloneHandler() { + return async (req: Request, res: Response): Promise => { try { const { repoUrl, projectName, parentDir } = req.body as { repoUrl: string; @@ -34,7 +27,9 @@ export function createTemplatesRoutes(): Router { return; } - console.log(`[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}`); + logger.info( + `[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}` + ); // Validate repo URL is a valid GitHub URL const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/; @@ -49,7 +44,7 @@ export function createTemplatesRoutes(): Router { // Sanitize project name (allow alphanumeric, dash, underscore) const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, "-"); if (sanitizedName !== projectName) { - console.log( + logger.info( `[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}` ); } @@ -61,10 +56,11 @@ export function createTemplatesRoutes(): Router { const resolvedProject = path.resolve(projectPath); const relativePath = path.relative(resolvedParent, resolvedProject); if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - return res.status(400).json({ + res.status(400).json({ success: false, error: "Invalid project name; potential path traversal attempt.", }); + return; } // Check if directory already exists @@ -83,27 +79,35 @@ export function createTemplatesRoutes(): Router { try { // Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /) const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir); - const isUnixRoot = parentDir === '/' || parentDir === ''; + const isUnixRoot = parentDir === "/" || parentDir === ""; const isRoot = isWindowsRoot || isUnixRoot; if (isRoot) { // Root paths always exist, just verify access - console.log(`[Templates] Using root path: ${parentDir}`); + logger.info(`[Templates] Using root path: ${parentDir}`); await fs.access(parentDir); } else { // Check if parent directory exists - const parentExists = await fs.access(parentDir).then(() => true).catch(() => false); + const parentExists = await fs + .access(parentDir) + .then(() => true) + .catch(() => false); if (!parentExists) { - console.log(`[Templates] Creating parent directory: ${parentDir}`); + logger.info(`[Templates] Creating parent directory: ${parentDir}`); await fs.mkdir(parentDir, { recursive: true }); } else { - console.log(`[Templates] Parent directory exists: ${parentDir}`); + logger.info(`[Templates] Parent directory exists: ${parentDir}`); } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error("[Templates] Failed to access parent directory:", parentDir, error); + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error( + "[Templates] Failed to access parent directory:", + parentDir, + error + ); res.status(500).json({ success: false, error: `Failed to access parent directory: ${errorMessage}`, @@ -111,7 +115,7 @@ export function createTemplatesRoutes(): Router { return; } - console.log(`[Templates] Cloning ${repoUrl} to ${projectPath}`); + logger.info(`[Templates] Cloning ${repoUrl} to ${projectPath}`); // Clone the repository const cloneResult = await new Promise<{ @@ -159,9 +163,9 @@ export function createTemplatesRoutes(): Router { try { const gitDir = path.join(projectPath, ".git"); await fs.rm(gitDir, { recursive: true, force: true }); - console.log("[Templates] Removed .git directory"); + logger.info("[Templates] Removed .git directory"); } catch (error) { - console.warn("[Templates] Could not remove .git directory:", error); + logger.warn("[Templates] Could not remove .git directory:", error); // Continue anyway - not critical } @@ -172,12 +176,12 @@ export function createTemplatesRoutes(): Router { }); gitInit.on("close", () => { - console.log("[Templates] Initialized fresh git repository"); + logger.info("[Templates] Initialized fresh git repository"); resolve(); }); gitInit.on("error", () => { - console.warn("[Templates] Could not initialize git"); + logger.warn("[Templates] Could not initialize git"); resolve(); }); }); @@ -185,7 +189,7 @@ export function createTemplatesRoutes(): Router { // Add to allowed paths addAllowedPath(projectPath); - console.log(`[Templates] Successfully cloned template to ${projectPath}`); + logger.info(`[Templates] Successfully cloned template to ${projectPath}`); res.json({ success: true, @@ -193,11 +197,8 @@ export function createTemplatesRoutes(): Router { projectName: sanitizedName, }); } catch (error) { - console.error("[Templates] Clone error:", error); - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); + logError(error, "Clone template failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); } - }); - - return router; + }; } diff --git a/apps/server/src/routes/terminal.ts b/apps/server/src/routes/terminal.ts deleted file mode 100644 index 622935b8..00000000 --- a/apps/server/src/routes/terminal.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * Terminal routes with password protection - * - * Provides REST API for terminal session management and authentication. - * WebSocket connections for real-time I/O are handled separately in index.ts. - */ - -import { Router, Request, Response, NextFunction } from "express"; -import { getTerminalService } from "../services/terminal-service.js"; - -// Read env variables lazily to ensure dotenv has loaded them -function getTerminalPassword(): string | undefined { - return process.env.TERMINAL_PASSWORD; -} - -function getTerminalEnabledConfig(): boolean { - return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default -} - -// In-memory session tokens (would use Redis in production) -const validTokens: Map = new Map(); -const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours - -/** - * Generate a secure random token - */ -function generateToken(): string { - return `term-${Date.now()}-${Math.random().toString(36).substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; -} - -/** - * Clean up expired tokens - */ -function cleanupExpiredTokens(): void { - const now = new Date(); - validTokens.forEach((data, token) => { - if (data.expiresAt < now) { - validTokens.delete(token); - } - }); -} - -// Clean up expired tokens every 5 minutes -setInterval(cleanupExpiredTokens, 5 * 60 * 1000); - -/** - * Validate a terminal session token - */ -export function validateTerminalToken(token: string | undefined): boolean { - if (!token) return false; - - const tokenData = validTokens.get(token); - if (!tokenData) return false; - - if (tokenData.expiresAt < new Date()) { - validTokens.delete(token); - return false; - } - - return true; -} - -/** - * Check if terminal requires password - */ -export function isTerminalPasswordRequired(): boolean { - return !!getTerminalPassword(); -} - -/** - * Check if terminal is enabled - */ -export function isTerminalEnabled(): boolean { - return getTerminalEnabledConfig(); -} - -/** - * Terminal authentication middleware - * Checks for valid session token if password is configured - */ -export function terminalAuthMiddleware( - req: Request, - res: Response, - next: NextFunction -): void { - // Check if terminal is enabled - if (!getTerminalEnabledConfig()) { - res.status(403).json({ - success: false, - error: "Terminal access is disabled", - }); - return; - } - - // If no password configured, allow all requests - if (!getTerminalPassword()) { - next(); - return; - } - - // Check for session token - const token = - (req.headers["x-terminal-token"] as string) || - (req.query.token as string); - - if (!validateTerminalToken(token)) { - res.status(401).json({ - success: false, - error: "Terminal authentication required", - passwordRequired: true, - }); - return; - } - - next(); -} - -export function createTerminalRoutes(): Router { - const router = Router(); - const terminalService = getTerminalService(); - - /** - * GET /api/terminal/status - * Get terminal status (enabled, password required, platform info) - */ - router.get("/status", (_req, res) => { - res.json({ - success: true, - data: { - enabled: getTerminalEnabledConfig(), - passwordRequired: !!getTerminalPassword(), - platform: terminalService.getPlatformInfo(), - }, - }); - }); - - /** - * POST /api/terminal/auth - * Authenticate with password to get a session token - */ - router.post("/auth", (req, res) => { - if (!getTerminalEnabledConfig()) { - res.status(403).json({ - success: false, - error: "Terminal access is disabled", - }); - return; - } - - const terminalPassword = getTerminalPassword(); - - // If no password required, return immediate success - if (!terminalPassword) { - res.json({ - success: true, - data: { - authenticated: true, - passwordRequired: false, - }, - }); - return; - } - - const { password } = req.body; - - if (!password || password !== terminalPassword) { - res.status(401).json({ - success: false, - error: "Invalid password", - }); - return; - } - - // Generate session token - const token = generateToken(); - const now = new Date(); - validTokens.set(token, { - createdAt: now, - expiresAt: new Date(now.getTime() + TOKEN_EXPIRY_MS), - }); - - res.json({ - success: true, - data: { - authenticated: true, - token, - expiresIn: TOKEN_EXPIRY_MS, - }, - }); - }); - - /** - * POST /api/terminal/logout - * Invalidate a session token - */ - router.post("/logout", (req, res) => { - const token = - (req.headers["x-terminal-token"] as string) || - req.body.token; - - if (token) { - validTokens.delete(token); - } - - res.json({ - success: true, - }); - }); - - // Apply terminal auth middleware to all routes below - router.use(terminalAuthMiddleware); - - /** - * GET /api/terminal/sessions - * List all active terminal sessions - */ - router.get("/sessions", (_req, res) => { - const sessions = terminalService.getAllSessions(); - res.json({ - success: true, - data: sessions, - }); - }); - - /** - * POST /api/terminal/sessions - * Create a new terminal session - */ - router.post("/sessions", (req, res) => { - try { - const { cwd, cols, rows, shell } = req.body; - - const session = terminalService.createSession({ - cwd, - cols: cols || 80, - rows: rows || 24, - shell, - }); - - res.json({ - success: true, - data: { - id: session.id, - cwd: session.cwd, - shell: session.shell, - createdAt: session.createdAt, - }, - }); - } catch (error) { - console.error("[Terminal] Error creating session:", error); - res.status(500).json({ - success: false, - error: "Failed to create terminal session", - details: error instanceof Error ? error.message : "Unknown error", - }); - } - }); - - /** - * DELETE /api/terminal/sessions/:id - * Kill a terminal session - */ - router.delete("/sessions/:id", (req, res) => { - const { id } = req.params; - const killed = terminalService.killSession(id); - - if (!killed) { - res.status(404).json({ - success: false, - error: "Session not found", - }); - return; - } - - res.json({ - success: true, - }); - }); - - /** - * POST /api/terminal/sessions/:id/resize - * Resize a terminal session - */ - router.post("/sessions/:id/resize", (req, res) => { - const { id } = req.params; - const { cols, rows } = req.body; - - if (!cols || !rows) { - res.status(400).json({ - success: false, - error: "cols and rows are required", - }); - return; - } - - const resized = terminalService.resize(id, cols, rows); - - if (!resized) { - res.status(404).json({ - success: false, - error: "Session not found", - }); - return; - } - - res.json({ - success: true, - }); - }); - - return router; -} diff --git a/apps/server/src/routes/terminal/common.ts b/apps/server/src/routes/terminal/common.ts new file mode 100644 index 00000000..80b3a496 --- /dev/null +++ b/apps/server/src/routes/terminal/common.ts @@ -0,0 +1,165 @@ +/** + * Common utilities and state for terminal routes + */ + +import { createLogger } from "../../lib/logger.js"; +import type { Request, Response, NextFunction } from "express"; +import { getTerminalService } from "../../services/terminal-service.js"; + +const logger = createLogger("Terminal"); + +// Read env variables lazily to ensure dotenv has loaded them +function getTerminalPassword(): string | undefined { + return process.env.TERMINAL_PASSWORD; +} + +function getTerminalEnabledConfig(): boolean { + return process.env.TERMINAL_ENABLED !== "false"; // Enabled by default +} + +// In-memory session tokens (would use Redis in production) - private +const validTokens: Map = + new Map(); +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Add a token to the valid tokens map + */ +export function addToken( + token: string, + data: { createdAt: Date; expiresAt: Date } +): void { + validTokens.set(token, data); +} + +/** + * Delete a token from the valid tokens map + */ +export function deleteToken(token: string): void { + validTokens.delete(token); +} + +/** + * Get token data for a given token + */ +export function getTokenData( + token: string +): { createdAt: Date; expiresAt: Date } | undefined { + return validTokens.get(token); +} + +/** + * Generate a secure random token + */ +export function generateToken(): string { + return `term-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 15)}${Math.random().toString(36).substr(2, 15)}`; +} + +/** + * Clean up expired tokens + */ +export function cleanupExpiredTokens(): void { + const now = new Date(); + validTokens.forEach((data, token) => { + if (data.expiresAt < now) { + validTokens.delete(token); + } + }); +} + +// Clean up expired tokens every 5 minutes +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); + +/** + * Validate a terminal session token + */ +export function validateTerminalToken(token: string | undefined): boolean { + if (!token) return false; + + const tokenData = validTokens.get(token); + if (!tokenData) return false; + + if (tokenData.expiresAt < new Date()) { + validTokens.delete(token); + return false; + } + + return true; +} + +/** + * Check if terminal requires password + */ +export function isTerminalPasswordRequired(): boolean { + return !!getTerminalPassword(); +} + +/** + * Check if terminal is enabled + */ +export function isTerminalEnabled(): boolean { + return getTerminalEnabledConfig(); +} + +/** + * Terminal authentication middleware + * Checks for valid session token if password is configured + */ +export function terminalAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + // Check if terminal is enabled + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + // If no password configured, allow all requests + if (!getTerminalPassword()) { + next(); + return; + } + + // Check for session token + const token = + (req.headers["x-terminal-token"] as string) || (req.query.token as string); + + if (!validateTerminalToken(token)) { + res.status(401).json({ + success: false, + error: "Terminal authentication required", + passwordRequired: true, + }); + return; + } + + next(); +} + +export function getTerminalPasswordConfig(): string | undefined { + return getTerminalPassword(); +} + +export function getTerminalEnabledConfigValue(): boolean { + return getTerminalEnabledConfig(); +} + +export function getTokenExpiryMs(): number { + return TOKEN_EXPIRY_MS; +} + +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/terminal/index.ts b/apps/server/src/routes/terminal/index.ts new file mode 100644 index 00000000..7ee0e978 --- /dev/null +++ b/apps/server/src/routes/terminal/index.ts @@ -0,0 +1,44 @@ +/** + * Terminal routes with password protection + * + * Provides REST API for terminal session management and authentication. + * WebSocket connections for real-time I/O are handled separately in index.ts. + */ + +import { Router } from "express"; +import { + terminalAuthMiddleware, + validateTerminalToken, + isTerminalEnabled, + isTerminalPasswordRequired, +} from "./common.js"; +import { createStatusHandler } from "./routes/status.js"; +import { createAuthHandler } from "./routes/auth.js"; +import { createLogoutHandler } from "./routes/logout.js"; +import { + createSessionsListHandler, + createSessionsCreateHandler, +} from "./routes/sessions.js"; +import { createSessionDeleteHandler } from "./routes/session-delete.js"; +import { createSessionResizeHandler } from "./routes/session-resize.js"; + +// Re-export for use in main index.ts +export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; + +export function createTerminalRoutes(): Router { + const router = Router(); + + router.get("/status", createStatusHandler()); + router.post("/auth", createAuthHandler()); + router.post("/logout", createLogoutHandler()); + + // Apply terminal auth middleware to all routes below + router.use(terminalAuthMiddleware); + + router.get("/sessions", createSessionsListHandler()); + router.post("/sessions", createSessionsCreateHandler()); + router.delete("/sessions/:id", createSessionDeleteHandler()); + router.post("/sessions/:id/resize", createSessionResizeHandler()); + + return router; +} diff --git a/apps/server/src/routes/terminal/routes/auth.ts b/apps/server/src/routes/terminal/routes/auth.ts new file mode 100644 index 00000000..234d4572 --- /dev/null +++ b/apps/server/src/routes/terminal/routes/auth.ts @@ -0,0 +1,66 @@ +/** + * POST /auth endpoint - Authenticate with password to get a session token + */ + +import type { Request, Response } from "express"; +import { + getTerminalEnabledConfigValue, + getTerminalPasswordConfig, + generateToken, + addToken, + getTokenExpiryMs, + getErrorMessage, +} from "../common.js"; + +export function createAuthHandler() { + return (req: Request, res: Response): void => { + if (!getTerminalEnabledConfigValue()) { + res.status(403).json({ + success: false, + error: "Terminal access is disabled", + }); + return; + } + + const terminalPassword = getTerminalPasswordConfig(); + + // If no password required, return immediate success + if (!terminalPassword) { + res.json({ + success: true, + data: { + authenticated: true, + passwordRequired: false, + }, + }); + return; + } + + const { password } = req.body; + + if (!password || password !== terminalPassword) { + res.status(401).json({ + success: false, + error: "Invalid password", + }); + return; + } + + // Generate session token + const token = generateToken(); + const now = new Date(); + addToken(token, { + createdAt: now, + expiresAt: new Date(now.getTime() + getTokenExpiryMs()), + }); + + res.json({ + success: true, + data: { + authenticated: true, + token, + expiresIn: getTokenExpiryMs(), + }, + }); + }; +} diff --git a/apps/server/src/routes/terminal/routes/logout.ts b/apps/server/src/routes/terminal/routes/logout.ts new file mode 100644 index 00000000..9e3c8fa3 --- /dev/null +++ b/apps/server/src/routes/terminal/routes/logout.ts @@ -0,0 +1,20 @@ +/** + * POST /logout endpoint - Invalidate a session token + */ + +import type { Request, Response } from "express"; +import { deleteToken } from "../common.js"; + +export function createLogoutHandler() { + return (req: Request, res: Response): void => { + const token = (req.headers["x-terminal-token"] as string) || req.body.token; + + if (token) { + deleteToken(token); + } + + res.json({ + success: true, + }); + }; +} diff --git a/apps/server/src/routes/terminal/routes/session-delete.ts b/apps/server/src/routes/terminal/routes/session-delete.ts new file mode 100644 index 00000000..aa3f96cb --- /dev/null +++ b/apps/server/src/routes/terminal/routes/session-delete.ts @@ -0,0 +1,26 @@ +/** + * DELETE /sessions/:id endpoint - Kill a terminal session + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; + +export function createSessionDeleteHandler() { + return (req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const { id } = req.params; + const killed = terminalService.killSession(id); + + if (!killed) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }; +} diff --git a/apps/server/src/routes/terminal/routes/session-resize.ts b/apps/server/src/routes/terminal/routes/session-resize.ts new file mode 100644 index 00000000..a6a8a70d --- /dev/null +++ b/apps/server/src/routes/terminal/routes/session-resize.ts @@ -0,0 +1,36 @@ +/** + * POST /sessions/:id/resize endpoint - Resize a terminal session + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; + +export function createSessionResizeHandler() { + return (req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const { id } = req.params; + const { cols, rows } = req.body; + + if (!cols || !rows) { + res.status(400).json({ + success: false, + error: "cols and rows are required", + }); + return; + } + + const resized = terminalService.resize(id, cols, rows); + + if (!resized) { + res.status(404).json({ + success: false, + error: "Session not found", + }); + return; + } + + res.json({ + success: true, + }); + }; +} diff --git a/apps/server/src/routes/terminal/routes/sessions.ts b/apps/server/src/routes/terminal/routes/sessions.ts new file mode 100644 index 00000000..1c1138c0 --- /dev/null +++ b/apps/server/src/routes/terminal/routes/sessions.ts @@ -0,0 +1,55 @@ +/** + * GET /sessions endpoint - List all active terminal sessions + * POST /sessions endpoint - Create a new terminal session + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; +import { getErrorMessage, logError } from "../common.js"; +import { createLogger } from "../../../lib/logger.js"; + +const logger = createLogger("Terminal"); + +export function createSessionsListHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const sessions = terminalService.getAllSessions(); + res.json({ + success: true, + data: sessions, + }); + }; +} + +export function createSessionsCreateHandler() { + return (req: Request, res: Response): void => { + try { + const terminalService = getTerminalService(); + const { cwd, cols, rows, shell } = req.body; + + const session = terminalService.createSession({ + cwd, + cols: cols || 80, + rows: rows || 24, + shell, + }); + + res.json({ + success: true, + data: { + id: session.id, + cwd: session.cwd, + shell: session.shell, + createdAt: session.createdAt, + }, + }); + } catch (error) { + logError(error, "Create terminal session failed"); + res.status(500).json({ + success: false, + error: "Failed to create terminal session", + details: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/terminal/routes/status.ts b/apps/server/src/routes/terminal/routes/status.ts new file mode 100644 index 00000000..014c482a --- /dev/null +++ b/apps/server/src/routes/terminal/routes/status.ts @@ -0,0 +1,24 @@ +/** + * GET /status endpoint - Get terminal status + */ + +import type { Request, Response } from "express"; +import { getTerminalService } from "../../../services/terminal-service.js"; +import { + getTerminalEnabledConfigValue, + isTerminalPasswordRequired, +} from "../common.js"; + +export function createStatusHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + enabled: getTerminalEnabledConfigValue(), + passwordRequired: isTerminalPasswordRequired(), + platform: terminalService.getPlatformInfo(), + }, + }); + }; +} diff --git a/apps/server/src/routes/workspace.ts b/apps/server/src/routes/workspace.ts deleted file mode 100644 index 6cac419c..00000000 --- a/apps/server/src/routes/workspace.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Workspace routes - * Provides API endpoints for workspace directory management - */ - -import { Router, type Request, type Response } from "express"; -import fs from "fs/promises"; -import path from "path"; -import { addAllowedPath } from "../lib/security.js"; - -export function createWorkspaceRoutes(): Router { - const router = Router(); - - // Get workspace configuration status - router.get("/config", async (_req: Request, res: Response) => { - try { - const workspaceDir = process.env.WORKSPACE_DIR; - - if (!workspaceDir) { - res.json({ - success: true, - configured: false, - }); - return; - } - - // Check if the directory exists - try { - const stats = await fs.stat(workspaceDir); - if (!stats.isDirectory()) { - res.json({ - success: true, - configured: false, - error: "WORKSPACE_DIR is not a valid directory", - }); - return; - } - - // Add workspace dir to allowed paths - addAllowedPath(workspaceDir); - - res.json({ - success: true, - configured: true, - workspaceDir, - }); - } catch { - res.json({ - success: true, - configured: false, - error: "WORKSPACE_DIR path does not exist", - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - // List directories in workspace - router.get("/directories", async (_req: Request, res: Response) => { - try { - const workspaceDir = process.env.WORKSPACE_DIR; - - if (!workspaceDir) { - res.status(400).json({ - success: false, - error: "WORKSPACE_DIR is not configured", - }); - return; - } - - // Check if directory exists - try { - await fs.stat(workspaceDir); - } catch { - res.status(400).json({ - success: false, - error: "WORKSPACE_DIR path does not exist", - }); - return; - } - - // Add workspace dir to allowed paths - addAllowedPath(workspaceDir); - - // Read directory contents - const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); - - // Filter to directories only and map to result format - const directories = entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) - .map((entry) => ({ - name: entry.name, - path: path.join(workspaceDir, entry.name), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - - // Add each directory to allowed paths - directories.forEach((dir) => addAllowedPath(dir.path)); - - res.json({ - success: true, - directories, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - res.status(500).json({ success: false, error: message }); - } - }); - - return router; -} diff --git a/apps/server/src/routes/workspace/common.ts b/apps/server/src/routes/workspace/common.ts new file mode 100644 index 00000000..80c1f99b --- /dev/null +++ b/apps/server/src/routes/workspace/common.ts @@ -0,0 +1,15 @@ +/** + * Common utilities for workspace routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Workspace"); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/workspace/index.ts b/apps/server/src/routes/workspace/index.ts new file mode 100644 index 00000000..ec247a89 --- /dev/null +++ b/apps/server/src/routes/workspace/index.ts @@ -0,0 +1,17 @@ +/** + * Workspace routes + * Provides API endpoints for workspace directory management + */ + +import { Router } from "express"; +import { createConfigHandler } from "./routes/config.js"; +import { createDirectoriesHandler } from "./routes/directories.js"; + +export function createWorkspaceRoutes(): Router { + const router = Router(); + + router.get("/config", createConfigHandler()); + router.get("/directories", createDirectoriesHandler()); + + return router; +} diff --git a/apps/server/src/routes/workspace/routes/config.ts b/apps/server/src/routes/workspace/routes/config.ts new file mode 100644 index 00000000..19f3c661 --- /dev/null +++ b/apps/server/src/routes/workspace/routes/config.ts @@ -0,0 +1,55 @@ +/** + * GET /config endpoint - Get workspace configuration status + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createConfigHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const workspaceDir = process.env.WORKSPACE_DIR; + + if (!workspaceDir) { + res.json({ + success: true, + configured: false, + }); + return; + } + + // Check if the directory exists + try { + const stats = await fs.stat(workspaceDir); + if (!stats.isDirectory()) { + res.json({ + success: true, + configured: false, + error: "WORKSPACE_DIR is not a valid directory", + }); + return; + } + + // Add workspace dir to allowed paths + addAllowedPath(workspaceDir); + + res.json({ + success: true, + configured: true, + workspaceDir, + }); + } catch { + res.json({ + success: true, + configured: false, + error: "WORKSPACE_DIR path does not exist", + }); + } + } catch (error) { + logError(error, "Get workspace config failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/workspace/routes/directories.ts b/apps/server/src/routes/workspace/routes/directories.ts new file mode 100644 index 00000000..6c780fb6 --- /dev/null +++ b/apps/server/src/routes/workspace/routes/directories.ts @@ -0,0 +1,62 @@ +/** + * GET /directories endpoint - List directories in workspace + */ + +import type { Request, Response } from "express"; +import fs from "fs/promises"; +import path from "path"; +import { addAllowedPath } from "../../../lib/security.js"; +import { getErrorMessage, logError } from "../common.js"; + +export function createDirectoriesHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const workspaceDir = process.env.WORKSPACE_DIR; + + if (!workspaceDir) { + res.status(400).json({ + success: false, + error: "WORKSPACE_DIR is not configured", + }); + return; + } + + // Check if directory exists + try { + await fs.stat(workspaceDir); + } catch { + res.status(400).json({ + success: false, + error: "WORKSPACE_DIR path does not exist", + }); + return; + } + + // Add workspace dir to allowed paths + addAllowedPath(workspaceDir); + + // Read directory contents + const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); + + // Filter to directories only and map to result format + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => ({ + name: entry.name, + path: path.join(workspaceDir, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Add each directory to allowed paths + directories.forEach((dir) => addAllowedPath(dir.path)); + + res.json({ + success: true, + directories, + }); + } catch (error) { + logError(error, "List workspace directories failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree.ts b/apps/server/src/routes/worktree.ts deleted file mode 100644 index 9d57e3d3..00000000 --- a/apps/server/src/routes/worktree.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * 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 { - 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 = { - 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; -} diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts new file mode 100644 index 00000000..60a81038 --- /dev/null +++ b/apps/server/src/routes/worktree/common.ts @@ -0,0 +1,30 @@ +/** + * Common utilities for worktree routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { + getErrorMessage as getErrorMessageShared, + createLogError, +} from "../common.js"; + +const logger = createLogger("Worktree"); +const execAsync = promisify(exec); + +/** + * Check if a path is a git repo + */ +export async function isGitRepo(repoPath: string): Promise { + try { + await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath }); + return true; + } catch { + return false; + } +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts new file mode 100644 index 00000000..bfa2f585 --- /dev/null +++ b/apps/server/src/routes/worktree/index.ts @@ -0,0 +1,26 @@ +/** + * Worktree routes - HTTP API for git worktree operations + */ + +import { Router } from "express"; +import { createInfoHandler } from "./routes/info.js"; +import { createStatusHandler } from "./routes/status.js"; +import { createListHandler } from "./routes/list.js"; +import { createDiffsHandler } from "./routes/diffs.js"; +import { createFileDiffHandler } from "./routes/file-diff.js"; +import { createRevertHandler } from "./routes/revert.js"; +import { createMergeHandler } from "./routes/merge.js"; + +export function createWorktreeRoutes(): Router { + const router = Router(); + + router.post("/info", createInfoHandler()); + router.post("/status", createStatusHandler()); + router.post("/list", createListHandler()); + router.post("/diffs", createDiffsHandler()); + router.post("/file-diff", createFileDiffHandler()); + router.post("/revert", createRevertHandler()); + router.post("/merge", createMergeHandler()); + + return router; +} diff --git a/apps/server/src/routes/worktree/routes/diffs.ts b/apps/server/src/routes/worktree/routes/diffs.ts new file mode 100644 index 00000000..cc2c6cf4 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/diffs.ts @@ -0,0 +1,85 @@ +/** + * POST /diffs endpoint - Get diffs for a worktree + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createDiffsHandler() { + return async (req: Request, res: Response): Promise => { + 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 = { + 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) { + logError(error, "Get worktree diffs failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/file-diff.ts b/apps/server/src/routes/worktree/routes/file-diff.ts new file mode 100644 index 00000000..27fafba5 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/file-diff.ts @@ -0,0 +1,57 @@ +/** + * POST /file-diff endpoint - Get diff for a specific file + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createFileDiffHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get worktree file diff failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/info.ts b/apps/server/src/routes/worktree/routes/info.ts new file mode 100644 index 00000000..e1175262 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/info.ts @@ -0,0 +1,57 @@ +/** + * POST /info endpoint - Get worktree info + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createInfoHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get worktree info failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts new file mode 100644 index 00000000..2f99206b --- /dev/null +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -0,0 +1,54 @@ +/** + * POST /list endpoint - List all worktrees + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { isGitRepo, getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createListHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "List worktrees failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/merge.ts b/apps/server/src/routes/worktree/routes/merge.ts new file mode 100644 index 00000000..57ae2c96 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/merge.ts @@ -0,0 +1,81 @@ +/** + * POST /merge endpoint - Merge feature (merge worktree branch into main) + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createMergeHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Merge worktree failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/revert.ts b/apps/server/src/routes/worktree/routes/revert.ts new file mode 100644 index 00000000..95f5a33a --- /dev/null +++ b/apps/server/src/routes/worktree/routes/revert.ts @@ -0,0 +1,58 @@ +/** + * POST /revert endpoint - Revert feature (remove worktree) + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createRevertHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Revert worktree failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/worktree/routes/status.ts b/apps/server/src/routes/worktree/routes/status.ts new file mode 100644 index 00000000..5d3a330b --- /dev/null +++ b/apps/server/src/routes/worktree/routes/status.ts @@ -0,0 +1,77 @@ +/** + * POST /status endpoint - Get worktree status + */ + +import type { Request, Response } from "express"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fs from "fs/promises"; +import { getErrorMessage, logError } from "../common.js"; + +const execAsync = promisify(exec); + +export function createStatusHandler() { + return async (req: Request, res: Response): Promise => { + 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) { + logError(error, "Get worktree status failed"); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/tests/unit/routes/app-spec/common.test.ts b/apps/server/tests/unit/routes/app-spec/common.test.ts index 39df4e70..14ec98d1 100644 --- a/apps/server/tests/unit/routes/app-spec/common.test.ts +++ b/apps/server/tests/unit/routes/app-spec/common.test.ts @@ -2,8 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { setRunningState, getErrorMessage, - isRunning, - currentAbortController, + getSpecRegenerationStatus, } from "@/routes/app-spec/common.js"; describe("app-spec/common.ts", () => { @@ -15,33 +14,35 @@ describe("app-spec/common.ts", () => { describe("setRunningState", () => { it("should set isRunning to true when running is true", () => { setRunningState(true); - expect(isRunning).toBe(true); + expect(getSpecRegenerationStatus().isRunning).toBe(true); }); it("should set isRunning to false when running is false", () => { setRunningState(true); setRunningState(false); - expect(isRunning).toBe(false); + expect(getSpecRegenerationStatus().isRunning).toBe(false); }); it("should set currentAbortController when provided", () => { const controller = new AbortController(); setRunningState(true, controller); - expect(currentAbortController).toBe(controller); + expect(getSpecRegenerationStatus().currentAbortController).toBe( + controller + ); }); it("should set currentAbortController to null when not provided", () => { const controller = new AbortController(); setRunningState(true, controller); setRunningState(false); - expect(currentAbortController).toBe(null); + expect(getSpecRegenerationStatus().currentAbortController).toBe(null); }); it("should set currentAbortController to null when explicitly passed null", () => { const controller = new AbortController(); setRunningState(true, controller); setRunningState(true, null); - expect(currentAbortController).toBe(null); + expect(getSpecRegenerationStatus().currentAbortController).toBe(null); }); it("should update state multiple times correctly", () => { @@ -49,16 +50,20 @@ describe("app-spec/common.ts", () => { const controller2 = new AbortController(); setRunningState(true, controller1); - expect(isRunning).toBe(true); - expect(currentAbortController).toBe(controller1); + expect(getSpecRegenerationStatus().isRunning).toBe(true); + expect(getSpecRegenerationStatus().currentAbortController).toBe( + controller1 + ); setRunningState(true, controller2); - expect(isRunning).toBe(true); - expect(currentAbortController).toBe(controller2); + expect(getSpecRegenerationStatus().isRunning).toBe(true); + expect(getSpecRegenerationStatus().currentAbortController).toBe( + controller2 + ); setRunningState(false, null); - expect(isRunning).toBe(false); - expect(currentAbortController).toBe(null); + expect(getSpecRegenerationStatus().isRunning).toBe(false); + expect(getSpecRegenerationStatus().currentAbortController).toBe(null); }); }); diff --git a/docs/pr-comment-fix-agent.md b/docs/pr-comment-fix-agent.md new file mode 100644 index 00000000..46099277 --- /dev/null +++ b/docs/pr-comment-fix-agent.md @@ -0,0 +1,152 @@ +# PR Comment Fix Agent Instructions + +## Overview + +This agent automatically reviews a GitHub Pull Request, analyzes all comments, and systematically addresses each comment by making the necessary code changes. + +## Workflow + +### Step 1: Fetch PR Information + +1. Use the GitHub CLI command: `gh pr view --comments --json number,title,body,comments,headRefName,baseRefName` +2. Parse the JSON output to extract: + - PR number and title + - PR description/body + - All comments (including review comments and inline comments) + - Branch information (head and base branches) + +### Step 2: Analyze Comments + +For each comment, identify: + +- **Type**: Review comment, inline comment, or general comment +- **File path**: If it's an inline comment, extract the file path +- **Line number**: If it's an inline comment, extract the line number(s) +- **Intent**: What change is being requested? + - Bug fix + - Code style/formatting + - Performance improvement + - Refactoring + - Missing functionality + - Documentation update + - Test addition/modification +- **Priority**: Determine if it's blocking (must fix) or non-blocking (nice to have) + +### Step 3: Checkout PR Branch + +1. Ensure you're in the correct repository +2. Fetch the latest changes: `git fetch origin` +3. Checkout the PR branch: `git checkout ` +4. Pull latest changes: `git pull origin ` + +### Step 4: Address Each Comment Systematically + +For each comment, follow this process: + +#### 4.1 Read Relevant Files + +- If the comment references a specific file, read that file first +- If the comment is general, read related files based on context +- Understand the current implementation + +#### 4.2 Understand the Request + +- Parse what specific change is needed +- Identify the root cause or issue +- Consider edge cases and implications + +#### 4.3 Make the Fix + +- Implement the requested change +- Ensure the fix addresses the exact concern raised +- Maintain code consistency with the rest of the codebase +- Follow existing code style and patterns + +#### 4.4 Verify the Fix + +- Check that the change resolves the comment +- Ensure no new issues are introduced +- Run relevant tests if available +- Check for linting errors + +### Step 5: Document Changes + +For each comment addressed: + +- Add a comment or commit message referencing the PR comment +- If multiple comments are addressed, group related changes logically + +### Step 6: Commit Changes + +1. Stage all changes: `git add -A` +2. Create a commit with a descriptive message: + + ``` + fix: address PR review comments + + - [Brief description of fix 1] (addresses comment #X) + - [Brief description of fix 2] (addresses comment #Y) + - ... + ``` + +3. Push changes: `git push origin ` + +## Comment Types and Handling + +### Inline Code Comments + +- **Location**: Specific file and line number +- **Action**: Read the file, locate the exact line, understand context, make targeted fix +- **Example**: "This function should handle null values" → Add null check + +### Review Comments + +- **Location**: May reference multiple files or general patterns +- **Action**: Read all referenced files, understand the pattern, apply fix consistently +- **Example**: "We should use async/await instead of promises" → Refactor all instances + +### General Comments + +- **Location**: PR-level, not file-specific +- **Action**: Understand the broader concern, identify affected areas, make comprehensive changes +- **Example**: "Add error handling" → Review entire PR for missing error handling + +## Best Practices + +1. **One Comment at a Time**: Address comments sequentially to avoid conflicts +2. **Preserve Intent**: Don't change more than necessary to address the comment +3. **Test Changes**: Run tests after each significant change +4. **Ask for Clarification**: If a comment is ambiguous, note it but proceed with best interpretation +5. **Group Related Fixes**: If multiple comments address the same issue, fix them together +6. **Maintain Style**: Follow existing code style, formatting, and patterns +7. **Check Dependencies**: Ensure fixes don't break other parts of the codebase + +## Error Handling + +- If a comment references a file that doesn't exist, note it and skip +- If a line number is out of range (file changed), search for similar code nearby +- If a fix introduces breaking changes, revert and try a different approach +- If tests fail after a fix, investigate and adjust the implementation + +## Completion Criteria + +The agent has successfully completed when: + +1. All comments have been analyzed +2. All actionable comments have been addressed with code changes +3. All changes have been committed and pushed +4. A summary of addressed comments is provided + +## Example Output Summary + +``` +PR #123 Review Comments - Addressed + +✅ Comment #1: Fixed null handling in getUserData() (line 45) +✅ Comment #2: Added error handling for API calls +✅ Comment #3: Refactored to use async/await pattern +⚠️ Comment #4: Requires clarification - noted in commit message +✅ Comment #5: Fixed typo in documentation + +Total: 5 comments, 4 addressed, 1 requires clarification +``` diff --git a/docs/pr-comment-fix-prompt.md b/docs/pr-comment-fix-prompt.md new file mode 100644 index 00000000..51bd6b64 --- /dev/null +++ b/docs/pr-comment-fix-prompt.md @@ -0,0 +1,51 @@ +# PR Comment Fix Agent Prompt + +Use this prompt directly with an AI agent (like Claude Code) to automatically fix PR review comments. + +## Direct Prompt + +``` +You are an AI agent tasked with reviewing and fixing GitHub Pull Request review comments. + +Your task: +1. Fetch PR information: Run `gh pr view --comments --json number,title,body,comments,headRefName,baseRefName` +2. Parse all comments from the JSON output +3. Checkout the PR branch: `git checkout ` and `git pull origin ` +4. For each comment: + - Identify the file and line number (if applicable) + - Understand what change is being requested + - Read the relevant file(s) + - Make the necessary code changes to address the comment + - Verify the fix doesn't break existing functionality +5. Commit all changes with a descriptive message referencing the PR comments +6. Push changes back to the PR branch + +Guidelines: +- Address comments one at a time, but group related fixes +- Preserve existing code style and patterns +- Don't change more than necessary to address each comment +- Run tests if available after making changes +- If a comment is unclear, make your best interpretation and note it +- Create a summary of all comments addressed + +Start by asking for the PR number, then proceed with the workflow above. +``` + +## Usage Example + +``` +I need you to fix all review comments on PR #123. + +[Paste the prompt above] + +PR number: 123 +``` + +## Alternative: One-Liner Version + +``` +Review PR # comments using `gh pr view --comments`, checkout the PR branch, +read each comment, understand what needs to be fixed, make the code changes to address each +comment, commit with descriptive messages, and push back to the branch. Provide a summary of +all comments addressed. +```