From 6b30271441bcec899e925dbeb4ede78068f69de4 Mon Sep 17 00:00:00 2001 From: Cody Seibert Date: Sun, 14 Dec 2025 17:53:21 -0500 Subject: [PATCH] refactoring the api endpoints to be separate files to reduce context usage --- apps/server/src/index.ts | 30 +- apps/server/src/routes/agent.ts | 155 ----- apps/server/src/routes/agent/common.ts | 21 + apps/server/src/routes/agent/index.ts | 29 + apps/server/src/routes/agent/routes/clear.ts | 28 + .../server/src/routes/agent/routes/history.ts | 28 + apps/server/src/routes/agent/routes/model.ts | 31 + apps/server/src/routes/agent/routes/send.ts | 54 ++ apps/server/src/routes/agent/routes/start.ts | 38 ++ apps/server/src/routes/agent/routes/stop.ts | 28 + apps/server/src/routes/auto-mode.ts | 261 -------- apps/server/src/routes/auto-mode/common.ts | 21 + apps/server/src/routes/auto-mode/index.ts | 40 ++ .../auto-mode/routes/analyze-project.ts | 35 + .../routes/auto-mode/routes/commit-feature.ts | 37 + .../routes/auto-mode/routes/context-exists.ts | 37 + .../auto-mode/routes/follow-up-feature.ts | 46 ++ .../routes/auto-mode/routes/resume-feature.ts | 44 ++ .../routes/auto-mode/routes/run-feature.ts | 44 ++ .../src/routes/auto-mode/routes/start.ts | 31 + .../src/routes/auto-mode/routes/status.ts | 22 + .../routes/auto-mode/routes/stop-feature.ts | 28 + .../src/routes/auto-mode/routes/stop.ts | 19 + .../routes/auto-mode/routes/verify-feature.ts | 37 + apps/server/src/routes/features.ts | 159 ----- apps/server/src/routes/features/common.ts | 21 + apps/server/src/routes/features/index.ts | 25 + .../routes/features/routes/agent-output.ts | 37 + .../src/routes/features/routes/create.ts | 41 ++ .../src/routes/features/routes/delete.ts | 34 + apps/server/src/routes/features/routes/get.ts | 39 ++ .../server/src/routes/features/routes/list.ts | 32 + .../src/routes/features/routes/update.ts | 40 ++ apps/server/src/routes/fs.ts | 631 ------------------ apps/server/src/routes/fs/common.ts | 21 + apps/server/src/routes/fs/index.ts | 42 ++ apps/server/src/routes/fs/routes/browse.ts | 107 +++ .../fs/routes/delete-board-background.ts | 43 ++ apps/server/src/routes/fs/routes/delete.ts | 29 + apps/server/src/routes/fs/routes/exists.ts | 35 + apps/server/src/routes/fs/routes/image.ts | 64 ++ apps/server/src/routes/fs/routes/mkdir.ts | 34 + apps/server/src/routes/fs/routes/read.ts | 29 + apps/server/src/routes/fs/routes/readdir.ts | 35 + .../src/routes/fs/routes/resolve-directory.ts | 126 ++++ .../routes/fs/routes/save-board-background.ts | 56 ++ .../server/src/routes/fs/routes/save-image.ts | 56 ++ apps/server/src/routes/fs/routes/stat.ts | 37 + .../src/routes/fs/routes/validate-path.ts | 50 ++ apps/server/src/routes/fs/routes/write.ts | 36 + apps/server/src/routes/git/common.ts | 21 + apps/server/src/routes/git/index.ts | 16 + .../routes/{git.ts => git/routes/diffs.ts} | 51 +- .../server/src/routes/git/routes/file-diff.ts | 45 ++ apps/server/src/routes/health.ts | 39 -- apps/server/src/routes/health/common.ts | 14 + apps/server/src/routes/health/index.ts | 16 + .../src/routes/health/routes/detailed.ts | 25 + apps/server/src/routes/health/routes/index.ts | 15 + apps/server/src/routes/models.ts | 101 --- apps/server/src/routes/models/common.ts | 21 + apps/server/src/routes/models/index.ts | 16 + .../src/routes/models/routes/available.ts | 66 ++ .../src/routes/models/routes/providers.ts | 34 + apps/server/src/routes/running-agents.ts | 30 - .../src/routes/running-agents/common.ts | 21 + .../server/src/routes/running-agents/index.ts | 17 + .../src/routes/running-agents/routes/index.ts | 26 + apps/server/src/routes/sessions.ts | 152 ----- apps/server/src/routes/sessions/common.ts | 21 + apps/server/src/routes/sessions/index.ts | 25 + .../src/routes/sessions/routes/archive.ts | 26 + .../src/routes/sessions/routes/create.ts | 36 + .../src/routes/sessions/routes/delete.ts | 26 + .../src/routes/sessions/routes/index.ts | 43 ++ .../src/routes/sessions/routes/unarchive.ts | 26 + .../src/routes/sessions/routes/update.ts | 35 + apps/server/src/routes/setup.ts | 336 ---------- apps/server/src/routes/setup/common.ts | 68 ++ .../src/routes/setup/get-claude-status.ts | 183 +++++ apps/server/src/routes/setup/index.ts | 24 + .../src/routes/setup/routes/api-keys.ts | 21 + .../src/routes/setup/routes/auth-claude.ts | 22 + .../src/routes/setup/routes/claude-status.ts | 22 + .../src/routes/setup/routes/install-claude.ts | 23 + .../src/routes/setup/routes/platform.ts | 27 + .../src/routes/setup/routes/store-api-key.ts | 58 ++ apps/server/src/routes/suggestions.ts | 192 ------ apps/server/src/routes/suggestions/common.ts | 36 + .../suggestions/generate-suggestions.ts | 125 ++++ apps/server/src/routes/suggestions/index.ts | 19 + .../src/routes/suggestions/routes/generate.ts | 62 ++ .../src/routes/suggestions/routes/status.ts | 17 + .../src/routes/suggestions/routes/stop.ts | 26 + apps/server/src/routes/templates/common.ts | 21 + apps/server/src/routes/templates/index.ts | 15 + .../routes/clone.ts} | 70 +- apps/server/src/routes/terminal.ts | 312 --------- apps/server/src/routes/terminal/common.ts | 137 ++++ apps/server/src/routes/terminal/index.ts | 44 ++ .../server/src/routes/terminal/routes/auth.ts | 66 ++ .../src/routes/terminal/routes/logout.ts | 20 + .../routes/terminal/routes/session-delete.ts | 26 + .../routes/terminal/routes/session-resize.ts | 36 + .../src/routes/terminal/routes/sessions.ts | 55 ++ .../src/routes/terminal/routes/status.ts | 24 + apps/server/src/routes/workspace.ts | 113 ---- apps/server/src/routes/workspace/common.ts | 21 + apps/server/src/routes/workspace/index.ts | 17 + .../src/routes/workspace/routes/config.ts | 55 ++ .../routes/workspace/routes/directories.ts | 62 ++ apps/server/src/routes/worktree.ts | 355 ---------- apps/server/src/routes/worktree/common.ts | 36 + apps/server/src/routes/worktree/index.ts | 26 + .../src/routes/worktree/routes/diffs.ts | 85 +++ .../src/routes/worktree/routes/file-diff.ts | 57 ++ .../server/src/routes/worktree/routes/info.ts | 57 ++ .../server/src/routes/worktree/routes/list.ts | 54 ++ .../src/routes/worktree/routes/merge.ts | 81 +++ .../src/routes/worktree/routes/revert.ts | 58 ++ .../src/routes/worktree/routes/status.ts | 77 +++ 121 files changed, 4281 insertions(+), 2927 deletions(-) delete mode 100644 apps/server/src/routes/agent.ts create mode 100644 apps/server/src/routes/agent/common.ts create mode 100644 apps/server/src/routes/agent/index.ts create mode 100644 apps/server/src/routes/agent/routes/clear.ts create mode 100644 apps/server/src/routes/agent/routes/history.ts create mode 100644 apps/server/src/routes/agent/routes/model.ts create mode 100644 apps/server/src/routes/agent/routes/send.ts create mode 100644 apps/server/src/routes/agent/routes/start.ts create mode 100644 apps/server/src/routes/agent/routes/stop.ts delete mode 100644 apps/server/src/routes/auto-mode.ts create mode 100644 apps/server/src/routes/auto-mode/common.ts create mode 100644 apps/server/src/routes/auto-mode/index.ts create mode 100644 apps/server/src/routes/auto-mode/routes/analyze-project.ts create mode 100644 apps/server/src/routes/auto-mode/routes/commit-feature.ts create mode 100644 apps/server/src/routes/auto-mode/routes/context-exists.ts create mode 100644 apps/server/src/routes/auto-mode/routes/follow-up-feature.ts create mode 100644 apps/server/src/routes/auto-mode/routes/resume-feature.ts create mode 100644 apps/server/src/routes/auto-mode/routes/run-feature.ts create mode 100644 apps/server/src/routes/auto-mode/routes/start.ts create mode 100644 apps/server/src/routes/auto-mode/routes/status.ts create mode 100644 apps/server/src/routes/auto-mode/routes/stop-feature.ts create mode 100644 apps/server/src/routes/auto-mode/routes/stop.ts create mode 100644 apps/server/src/routes/auto-mode/routes/verify-feature.ts delete mode 100644 apps/server/src/routes/features.ts create mode 100644 apps/server/src/routes/features/common.ts create mode 100644 apps/server/src/routes/features/index.ts create mode 100644 apps/server/src/routes/features/routes/agent-output.ts create mode 100644 apps/server/src/routes/features/routes/create.ts create mode 100644 apps/server/src/routes/features/routes/delete.ts create mode 100644 apps/server/src/routes/features/routes/get.ts create mode 100644 apps/server/src/routes/features/routes/list.ts create mode 100644 apps/server/src/routes/features/routes/update.ts delete mode 100644 apps/server/src/routes/fs.ts create mode 100644 apps/server/src/routes/fs/common.ts create mode 100644 apps/server/src/routes/fs/index.ts create mode 100644 apps/server/src/routes/fs/routes/browse.ts create mode 100644 apps/server/src/routes/fs/routes/delete-board-background.ts create mode 100644 apps/server/src/routes/fs/routes/delete.ts create mode 100644 apps/server/src/routes/fs/routes/exists.ts create mode 100644 apps/server/src/routes/fs/routes/image.ts create mode 100644 apps/server/src/routes/fs/routes/mkdir.ts create mode 100644 apps/server/src/routes/fs/routes/read.ts create mode 100644 apps/server/src/routes/fs/routes/readdir.ts create mode 100644 apps/server/src/routes/fs/routes/resolve-directory.ts create mode 100644 apps/server/src/routes/fs/routes/save-board-background.ts create mode 100644 apps/server/src/routes/fs/routes/save-image.ts create mode 100644 apps/server/src/routes/fs/routes/stat.ts create mode 100644 apps/server/src/routes/fs/routes/validate-path.ts create mode 100644 apps/server/src/routes/fs/routes/write.ts create mode 100644 apps/server/src/routes/git/common.ts create mode 100644 apps/server/src/routes/git/index.ts rename apps/server/src/routes/{git.ts => git/routes/diffs.ts} (52%) create mode 100644 apps/server/src/routes/git/routes/file-diff.ts delete mode 100644 apps/server/src/routes/health.ts create mode 100644 apps/server/src/routes/health/common.ts create mode 100644 apps/server/src/routes/health/index.ts create mode 100644 apps/server/src/routes/health/routes/detailed.ts create mode 100644 apps/server/src/routes/health/routes/index.ts delete mode 100644 apps/server/src/routes/models.ts create mode 100644 apps/server/src/routes/models/common.ts create mode 100644 apps/server/src/routes/models/index.ts create mode 100644 apps/server/src/routes/models/routes/available.ts create mode 100644 apps/server/src/routes/models/routes/providers.ts delete mode 100644 apps/server/src/routes/running-agents.ts create mode 100644 apps/server/src/routes/running-agents/common.ts create mode 100644 apps/server/src/routes/running-agents/index.ts create mode 100644 apps/server/src/routes/running-agents/routes/index.ts delete mode 100644 apps/server/src/routes/sessions.ts create mode 100644 apps/server/src/routes/sessions/common.ts create mode 100644 apps/server/src/routes/sessions/index.ts create mode 100644 apps/server/src/routes/sessions/routes/archive.ts create mode 100644 apps/server/src/routes/sessions/routes/create.ts create mode 100644 apps/server/src/routes/sessions/routes/delete.ts create mode 100644 apps/server/src/routes/sessions/routes/index.ts create mode 100644 apps/server/src/routes/sessions/routes/unarchive.ts create mode 100644 apps/server/src/routes/sessions/routes/update.ts delete mode 100644 apps/server/src/routes/setup.ts create mode 100644 apps/server/src/routes/setup/common.ts create mode 100644 apps/server/src/routes/setup/get-claude-status.ts create mode 100644 apps/server/src/routes/setup/index.ts create mode 100644 apps/server/src/routes/setup/routes/api-keys.ts create mode 100644 apps/server/src/routes/setup/routes/auth-claude.ts create mode 100644 apps/server/src/routes/setup/routes/claude-status.ts create mode 100644 apps/server/src/routes/setup/routes/install-claude.ts create mode 100644 apps/server/src/routes/setup/routes/platform.ts create mode 100644 apps/server/src/routes/setup/routes/store-api-key.ts delete mode 100644 apps/server/src/routes/suggestions.ts create mode 100644 apps/server/src/routes/suggestions/common.ts create mode 100644 apps/server/src/routes/suggestions/generate-suggestions.ts create mode 100644 apps/server/src/routes/suggestions/index.ts create mode 100644 apps/server/src/routes/suggestions/routes/generate.ts create mode 100644 apps/server/src/routes/suggestions/routes/status.ts create mode 100644 apps/server/src/routes/suggestions/routes/stop.ts create mode 100644 apps/server/src/routes/templates/common.ts create mode 100644 apps/server/src/routes/templates/index.ts rename apps/server/src/routes/{templates.ts => templates/routes/clone.ts} (72%) delete mode 100644 apps/server/src/routes/terminal.ts create mode 100644 apps/server/src/routes/terminal/common.ts create mode 100644 apps/server/src/routes/terminal/index.ts create mode 100644 apps/server/src/routes/terminal/routes/auth.ts create mode 100644 apps/server/src/routes/terminal/routes/logout.ts create mode 100644 apps/server/src/routes/terminal/routes/session-delete.ts create mode 100644 apps/server/src/routes/terminal/routes/session-resize.ts create mode 100644 apps/server/src/routes/terminal/routes/sessions.ts create mode 100644 apps/server/src/routes/terminal/routes/status.ts delete mode 100644 apps/server/src/routes/workspace.ts create mode 100644 apps/server/src/routes/workspace/common.ts create mode 100644 apps/server/src/routes/workspace/index.ts create mode 100644 apps/server/src/routes/workspace/routes/config.ts create mode 100644 apps/server/src/routes/workspace/routes/directories.ts delete mode 100644 apps/server/src/routes/worktree.ts create mode 100644 apps/server/src/routes/worktree/common.ts create mode 100644 apps/server/src/routes/worktree/index.ts create mode 100644 apps/server/src/routes/worktree/routes/diffs.ts create mode 100644 apps/server/src/routes/worktree/routes/file-diff.ts create mode 100644 apps/server/src/routes/worktree/routes/info.ts create mode 100644 apps/server/src/routes/worktree/routes/list.ts create mode 100644 apps/server/src/routes/worktree/routes/merge.ts create mode 100644 apps/server/src/routes/worktree/routes/revert.ts create mode 100644 apps/server/src/routes/worktree/routes/status.ts 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..82cc0648 --- /dev/null +++ b/apps/server/src/routes/agent/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for agent routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Agent"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..dbaa2204 --- /dev/null +++ b/apps/server/src/routes/agent/routes/send.ts @@ -0,0 +1,54 @@ +/** + * 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) => { + logger.error("[Agent Route] Error sending message:", error); + }); + + // 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/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..caff463e --- /dev/null +++ b/apps/server/src/routes/auto-mode/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for auto-mode routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("AutoMode"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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/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..faf0d6b6 --- /dev/null +++ b/apps/server/src/routes/features/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for features routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Features"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..aa32e602 --- /dev/null +++ b/apps/server/src/routes/fs/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for fs routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("FS"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..00e35c2f --- /dev/null +++ b/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -0,0 +1,126 @@ +/** + * 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); + 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) { + 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..da9f8dcc --- /dev/null +++ b/apps/server/src/routes/git/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for git routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Git"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..3d8071a2 --- /dev/null +++ b/apps/server/src/routes/health/common.ts @@ -0,0 +1,14 @@ +/** + * Common utilities for health routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Health"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} 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..bd8b899c --- /dev/null +++ b/apps/server/src/routes/models/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for models routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Models"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..24f1e4a7 --- /dev/null +++ b/apps/server/src/routes/running-agents/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for running-agents routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("RunningAgents"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..69a123d9 --- /dev/null +++ b/apps/server/src/routes/sessions/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for sessions routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Sessions"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..a1103007 --- /dev/null +++ b/apps/server/src/routes/setup/common.ts @@ -0,0 +1,68 @@ +/** + * Common utilities and state for setup routes + */ + +import { createLogger } from "../../lib/logger.js"; +import path from "path"; +import fs from "fs/promises"; + +const logger = createLogger("Setup"); + +// Storage for API keys (in-memory cache) +export const apiKeys: Record = {}; + +/** + * 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; + } +} + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..e9492822 --- /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 { apiKeys } 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: !!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 + } + + 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..1fbeb886 --- /dev/null +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -0,0 +1,21 @@ +/** + * GET /api-keys endpoint - Get API keys status + */ + +import type { Request, Response } from "express"; +import { apiKeys, getErrorMessage, logError } from "../common.js"; + +export function createApiKeysHandler() { + return async (_req: Request, res: Response): Promise => { + try { + res.json({ + success: true, + hasAnthropicKey: !!apiKeys.anthropic || !!process.env.ANTHROPIC_API_KEY, + hasGoogleKey: !!apiKeys.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..5f0e6b44 --- /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 { + apiKeys, + 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; + } + + 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); + 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..7dcc8e54 --- /dev/null +++ b/apps/server/src/routes/suggestions/common.ts @@ -0,0 +1,36 @@ +/** + * Common utilities and state for suggestions routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Suggestions"); + +// Shared state for tracking generation status +export let isRunning = false; +export let currentAbortController: AbortController | null = null; + +/** + * Set the running state and abort controller + */ +export function setRunningState( + running: boolean, + controller: AbortController | null = null +): void { + isRunning = running; + currentAbortController = controller; +} + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..11c7b9b1 --- /dev/null +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -0,0 +1,125 @@ +/** + * 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) { + // 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..d02fd3fe --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/generate.ts @@ -0,0 +1,62 @@ +/** + * 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 { + isRunning, + 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; + } + + 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) => { + logger.error("[Suggestions] Error:", error); + 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..72981b6d --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/status.ts @@ -0,0 +1,17 @@ +/** + * GET /status endpoint - Get status + */ + +import type { Request, Response } from "express"; +import { isRunning, getErrorMessage, logError } from "../common.js"; + +export function createStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + 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..6871b3da --- /dev/null +++ b/apps/server/src/routes/suggestions/routes/stop.ts @@ -0,0 +1,26 @@ +/** + * POST /stop endpoint - Stop suggestions generation + */ + +import type { Request, Response } from "express"; +import { + currentAbortController, + setRunningState, + getErrorMessage, + logError, +} from "../common.js"; + +export function createStopHandler() { + return async (_req: Request, res: Response): Promise => { + try { + 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..d2337a42 --- /dev/null +++ b/apps/server/src/routes/templates/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for templates routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Templates"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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 72% rename from apps/server/src/routes/templates.ts rename to apps/server/src/routes/templates/routes/clone.ts index b3b62622..fe7b0460 100644 --- a/apps/server/src/routes/templates.ts +++ b/apps/server/src/routes/templates/routes/clone.ts @@ -1,23 +1,19 @@ /** - * 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 { createLogger } from "../../../lib/logger.js"; +import { getErrorMessage, logError } from "../common.js"; -export function createTemplatesRoutes(): Router { - const router = Router(); +const logger = createLogger("Templates"); - /** - * 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 +30,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 +47,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}` ); } @@ -83,27 +81,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 +117,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 +165,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 +178,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 +191,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 +199,9 @@ 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 }); + logger.error("[Templates] Clone error:", error); + 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..0b401113 --- /dev/null +++ b/apps/server/src/routes/terminal/common.ts @@ -0,0 +1,137 @@ +/** + * 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) +export const validTokens: Map = + new Map(); +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * 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; +} + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} 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..d1e18ae5 --- /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, + validTokens, + 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(); + validTokens.set(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..919fc28e --- /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 { validTokens } 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) { + validTokens.delete(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..7325e681 --- /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 } 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) { + logger.error("[Terminal] Error creating session:", error); + 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..5219ca77 --- /dev/null +++ b/apps/server/src/routes/workspace/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for workspace routes + */ + +import { createLogger } from "../../lib/logger.js"; + +const logger = createLogger("Workspace"); + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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..7cf981ca --- /dev/null +++ b/apps/server/src/routes/worktree/common.ts @@ -0,0 +1,36 @@ +/** + * Common utilities for worktree routes + */ + +import { createLogger } from "../../lib/logger.js"; +import { exec } from "child_process"; +import { promisify } from "util"; + +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; + } +} + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error"; +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`, error); +} 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) }); + } + }; +}